Code Diff
diff --git a/packages/next/src/server/lib/encode-cache-tag.ts b/packages/next/src/server/lib/encode-cache-tag.ts
new file mode 100644
index 000000000000..1938c9c7e425
--- /dev/null
+++ b/packages/next/src/server/lib/encode-cache-tag.ts
@@ -0,0 +1,37 @@
+/**
+ * Percent-encode every character outside printable ASCII so a tag value can be
+ * safely serialized as part of the `x-next-cache-tags` HTTP header.
+ *
+ * Node's `validateHeaderValue` rejects any code unit outside `\t\x20-\x7e`, so
+ * a matched route path or user-supplied tag containing a non-ASCII character
+ * (Hebrew, Arabic, Chinese, emoji, …) would otherwise throw `ERR_INVALID_CHAR`
+ * and crash ISR on every affected request.
+ *
+ * This is applied at the public boundaries — tag construction
+ * (`getImplicitTags`, `validateTags`) and invalidation input (`revalidatePath`,
+ * `revalidateTag`, `updateTag`) — so storage, comparison, and the wire all see
+ * the same canonical ASCII-safe form.
+ *
+ * The character class `[\t\x20-\x7e]` mirrors Node's `validHdrChars` table —
+ * `\t` plus printable ASCII through `~`. Anything outside that is rejected
+ * by `validateHeaderValue`, so we encode runs of those characters and leave
+ * everything else (`,`, `/`, `%`, `[`, `]`, `_`, `-`, `\t`, …) byte-for-byte
+ * unchanged. This preserves the comma-separated header format and the
+ * dynamic-segment markers in derived tags (`_N_T_/[slug]/page`).
+ *
+ * Properties:
+ * - Fast-path: input that already fits the validation class is returned
+ * unchanged. This makes the encoder idempotent on already-encoded `%xx`
+ * sequences.
+ * - Matches *runs* of out-of-class code units so surrogate pairs (e.g. an
+ * emoji) are handed to `encodeURIComponent` as a complete code point — a
+ * per-code-unit regex would split the pair and throw `URIError`.
+ */
+const OUT_OF_CLASS_CHAR = /[^\t\x20-\x7e]/
+const OUT_OF_CLASS_RUN = /[^\t\x20-\x7e]+/g
+
+export function encodeCacheTag(tag: string): string {
+ return OUT_OF_CLASS_CHAR.test(tag)
+ ? tag.replace(OUT_OF_CLASS_RUN, (run) => encodeURIComponent(run))
+ : tag
+}
diff --git a/packages/next/src/server/lib/implicit-tags.test.ts b/packages/next/src/server/lib/implicit-tags.test.ts
index 31e6ef04d2fa..6c51cd2983c4 100644
--- a/packages/next/src/server/lib/implicit-tags.test.ts
+++ b/packages/next/src/server/lib/implicit-tags.test.ts
@@ -73,6 +73,34 @@ describe('getImplicitTags()', () => {
'_N_T_/foo/bar/baz',
],
},
+ {
+ // Non-ASCII pathname must be percent-encoded so it can be safely
+ // serialized into the `x-next-cache-tags` HTTP header. Surrogate-pair
+ // emoji exercises run-based replacement (a per-code-unit regex would
+ // throw `URIError`).
+ page: '/[slug]/page',
+ pathname: '/🎉',
+ fallbackRouteParams: null,
+ expectedTags: [
+ '_N_T_/layout',
+ '_N_T_/[slug]/layout',
+ '_N_T_/[slug]/page',
+ '_N_T_/%F0%9F%8E%89',
+ ],
+ },
+ {
+ // Already-encoded pathname must not be double-encoded. The encoder
+ // is idempotent on ASCII input including `%xx` sequences.
+ page: '/[slug]/page',
+ pathname: '/%F0%9F%8E%89',
+ fallbackRouteParams: null,
+ expectedTags: [
+ '_N_T_/layout',
+ '_N_T_/[slug]/layout',
+ '_N_T_/[slug]/page',
+ '_N_T_/%F0%9F%8E%89',
+ ],
+ },
])(
'for page $page with pathname $pathname',
async ({ page, pathname, fallbackRouteParams, expectedTags }) => {
diff --git a/packages/next/src/server/lib/implicit-tags.ts b/packages/next/src/server/lib/implicit-tags.ts
index 3e808e808f26..c983fbcaf33a 100644
--- a/packages/next/src/server/lib/implicit-tags.ts
+++ b/packages/next/src/server/lib/implicit-tags.ts
@@ -1,6 +1,7 @@
import { NEXT_CACHE_IMPLICIT_TAG_ID } from '../../lib/constants'
import type { OpaqueFallbackRouteParams } from '../request/fallback-params'
import { getCacheHandlerEntries } from '../use-cache/handlers'
+import { encodeCacheTag } from './encode-cache-tag'
import { createLazyResult, type LazyResult } from './lazy-result'
export interface ImplicitTags {
@@ -78,17 +79,19 @@ export async function getImplicitTags(
): Promise<ImplicitTags> {
const tags = new Set<string>()
- // Add the derived tags from the page.
+ // Add the derived tags from the page. Encode each tag so a non-ASCII
+ // pathname doesn't trip header validation when written to
+ // `x-next-cache-tags`. Idempotent on already-ASCII input.
const derivedTags = getDerivedTags(page)
for (let tag of derivedTags) {
- tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`
+ tag = encodeCacheTag(`${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`)
tags.add(tag)
}
// Add the tags from the pathname. If the route has unknown params, we don't
// want to add the pathname as a tag, as it will be invalid.
if (pathname && (!fallbackRouteParams || fallbackRouteParams.size === 0)) {
- const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}`
+ const tag = encodeCacheTag(`${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}`)
tags.add(tag)
}
diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts
index 39ed601e153a..60bbb7185298 100644
--- a/packages/next/src/server/lib/patch-fetch.ts
+++ b/packages/next/src/server/lib/patch-fetch.ts
@@ -30,6 +30,7 @@ import {
import { cloneResponse } from './clone-response'
import type { IncrementalCache } from './incremental-cache'
import { RenderStage } from '../app-render/staged-rendering'
+import { encodeCacheTag } from './encode-cache-tag'
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
@@ -95,7 +96,10 @@ export function validateTags(tags: any[], description: string) {
reason: `exceeded max length of ${NEXT_CACHE_TAG_MAX_LENGTH}`,
})
} else {
- validTags.push(tag)
+ // Encode so a non-ASCII tag can be safely serialized into the
+ // `x-next-cache-tags` HTTP header without tripping Node's header
+ // validation. Length is checked on the raw input above.
+ validTags.push(encodeCacheTag(tag))
}
if (validTags.length > NEXT_CACHE_TAG_MAX_ITEMS) {
diff --git a/packages/next/src/server/web/spec-extension/revalidate.ts b/packages/next/src/server/web/spec-extension/revalidate.ts
index 8737f0ee2b4a..0955c36fb146 100644
--- a/packages/next/src/server/web/spec-extension/revalidate.ts
+++ b/packages/next/src/server/web/spec-extension/revalidate.ts
@@ -16,6 +16,7 @@ import {
ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate,
} from '../../../shared/lib/action-revalidation-kind'
import { removeTrailingSlash } from '../../../shared/lib/router/utils/remove-trailing-slash'
+import { encodeCacheTag } from '../../lib/encode-cache-tag'
type CacheLifeConfig = {
expire?: number
@@ -36,7 +37,7 @@ export function revalidateTag(tag: string, profile: string | CacheLifeConfig) {
'"revalidateTag" without the second argument is now deprecated, add second argument of "max" or use "updateTag". See more info here: https://nextjs.org/docs/messages/revalidate-tag-single-arg'
)
}
- return revalidate([tag], `revalidateTag ${tag}`, profile)
+ return revalidate([encodeCacheTag(tag)], `revalidateTag ${tag}`, profile)
}
/**
@@ -58,7 +59,7 @@ export function updateTag(tag: string) {
)
}
// updateTag uses immediate expiration (no profile) without deprecation warning
- return revalidate([tag], `updateTag ${tag}`, undefined)
+ return revalidate([encodeCacheTag(tag)], `updateTag ${tag}`, undefined)
}
/**
@@ -101,7 +102,7 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') {
return
}
- let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${removeTrailingSlash(originalPath)}`
+ let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${encodeCacheTag(removeTrailingSlash(originalPath))}`
if (type) {
normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}`
diff --git a/test/e2e/app-dir/non-ascii-cache-tags/app/[slug]/page.tsx b/test/e2e/app-dir/non-ascii-cache-tags/app/[slug]/page.tsx
new file mode 100644
index 000000000000..71ce2dfd3dc4
--- /dev/null
+++ b/test/e2e/app-dir/non-ascii-cache-tags/app/[slug]/page.tsx
@@ -0,0 +1,76 @@
+import { cacheTag, unstable_cache, updateTag } from 'next/cache'
+import { connection } from 'next/server'
+import { Suspense } from 'react'
+
+export function generateStaticParams() {
+ return [{ slug: '🎉' }]
+}
+
+async function Cached({ params }: { params: Promise<{ slug: string }> }) {
+ 'use cache'
+ const { slug } = await params
+ cacheTag('🎂')
+ return (
+ <>
+ <p id="slug">{slug}</p>
+ <p>
+ Cached: <span id="cached-time">{new Date().toISOString()}</span>
+ </p>
+ </>
+ )
+}
+
+async function Dynamic() {
+ await connection()
+
+ return (
+ <p>
+ Dynamic: <span id="dynamic-time">{new Date().toISOString()}</span>
+ </p>
+ )
+}
+
+const getUnstableCached = unstable_cache(
+ async () => new Date().toISOString(),
+ ['unstable-cache-time'],
+ { tags: ['🌶'], revalidate: false }
+)
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const fetched = await fetch(
+ 'https://next-data-api-endpoint.vercel.app/api/random',
+ { next: { tags: ['🌮'], revalidate: false } }
+ ).then((r) => r.text())
+
+ const unstableCached = await getUnstableCached()
+
+ return (
+ <main>
+ <Cached params={params} />
+ <Suspense fallback={<p>Loading...</p>}>
+ <Dynamic />
+ </Suspense>
+ <p>
+ Fetched: <span id="fetched">{fetched}</span>
+ </p>
+ <p>
+ Unstable cached: <span id="unstable-cached-time">{unstableCached}</span>
+ </p>
+ <form>
+ <button
+ id="update-tag"
+ formAction={async () => {
+ 'use server'
+ updateTag('🎂')
+ }}
+ >
+ updateTag
+ </button>
+ </form>
+ </main>
+ )
+}
diff --git a/test/e2e/app-dir/non-ascii-cache-tags/app/api/revalidate/route.ts b/test/e2e/app-dir/non-ascii-cache-tags/app/api/revalidate/route.ts
new file mode 100644
index 000000000000..e007c2137033
--- /dev/null
+++ b/test/e2e/app-dir/non-ascii-cache-tags/app/api/revalidate/route.ts
@@ -0,0 +1,16 @@
+import { revalidatePath, revalidateTag } from 'next/cache'
+
+export async function POST(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const path = searchParams.get('path')
+ const tag = searchParams.get('tag')
+
+ if (path) {
+ revalidatePath(path)
+ }
+ if (tag) {
+ revalidateTag(tag, 'max')
+ }
+
+ return Response.json({ ok: true, path, tag })
+}
diff --git a/test/e2e/app-dir/non-ascii-cache-tags/app/layout.tsx b/test/e2e/app-dir/non-ascii-cache-tags/app/layout.tsx
new file mode 100644
index 000000000000..888614deda3b
--- /dev/null
+++ b/test/e2e/app-dir/non-ascii-cache-tags/app/layout.tsx
@@ -0,0 +1,8 @@
+import { ReactNode } from 'react'
+export default function Root({ children }: { children: ReactNode }) {
+ return (
+ <html>
+ <body>{children}</body>
+ </html>
+ )
+}
diff --git a/test/e2e/app-dir/non-ascii-cache-tags/next.config.js b/test/e2e/app-dir/non-ascii-cache-tags/next.config.js
new file mode 100644
index 000000000000..e64bae22d658
--- /dev/null
+++ b/test/e2e/app-dir/non-ascii-cache-tags/next.config.js
@@ -0,0 +1,8 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+}
+
+module.exports = nextConfig
diff --git a/test/e2e/app-dir/non-ascii-cache-tags/non-ascii-cache-tags.test.ts b/test/e2e/app-dir/non-ascii-cache-tags/non-ascii-cache-tags.test.ts
new file mode 100644
index 000000000000..d93bd1ce182b
--- /dev/null
+++ b/test/e2e/app-dir/non-ascii-cache-tags/non-ascii-cache-tags.test.ts
@@ -0,0 +1,116 @@
+import { nextTestSetup } from 'e2e-utils'
+import { retry } from 'next-test-utils'
+
+// Regression test for https://github.com/vercel/next.js/issues/93142
+//
+// Any non-ASCII character (Hebrew, Arabic, CJK, emoji, …) in a cache tag —
+// whether it's a path-derived implicit tag or a user-supplied tag from
+// `cacheTag()`, `unstable_cache({tags})`, or `fetch({next:{tags}})` — gets
+// written into the internal `x-next-cache-tags` HTTP header on ISR responses.
+// Node's `validateHeaderValue` rejects any byte outside `\t\x20-\x7e`, so the
+// response crashes with `ERR_INVALID_CHAR`.
+//
+// On Vercel deploy stale-if-error masks the 500 from clients, but revalidation
+// itself keeps failing and the cache stops refreshing for affected routes. The
+// revalidate / updateTag cases here cover that round-trip: a cached entry keyed
+// under a non-ASCII path / tag must actually be invalidated by
+// `revalidatePath`, `revalidateTag`, or `updateTag` against each cache backend.
+describe('non-ASCII cache tags', () => {
+ const { next, isNextDeploy } = nextTestSetup({
+ files: __dirname,
+ })
+
+ const SLUG = '🎉'
+ const TAG = '🎂'
+ const FETCH_TAG = '🌮'
+ const UNSTABLE_TAG = '🌶'
+ const PATH = `/${encodeURIComponent(SLUG)}`
+
+ it('serves a non-ASCII slug ISR page without ERR_INVALID_CHAR', async () => {
+ const res = await next.fetch(PATH)
+ expect(res.status).toBe(200)
+
+ if (isNextDeploy) {
+ const tags = res.headers.get('x-next-cache-tags')
+ if (tags !== null) {
+ // Anything outside the validation character class would have
+ // crashed `setHeader` on the way out, so reaching the client at
+ // all is itself a signal — but assert explicitly to guard format.
+ expect(tags).toMatch(/^[\t\x20-\x7e]+$/)
+ }
+ }
+ })
+
+ it('invalidates a cached entry via revalidatePath with a non-ASCII path', async () => {
+ const initial = (await next.render$(PATH))('#cached-time').text()
+
+ const res = await next.fetch(
+ `/api/revalidate?path=${encodeURIComponent(`/${SLUG}`)}`,
+ { method: 'POST' }
+ )
+ expect(res.status).toBe(200)
+
+ // Revalidation may take a moment to propagate.
+ await retry(async () => {
+ const after = (await next.render$(PATH))('#cached-time').text()
+ expect(after).not.toBe(initial)
+ })
+ })
+
+ it('invalidates a cached entry via revalidateTag with a non-ASCII tag', async () => {
+ const initial = (await next.render$(PATH))('#cached-time').text()
+
+ const res = await next.fetch(
+ `/api/revalidate?tag=${encodeURIComponent(TAG)}`,
+ { method: 'POST' }
+ )
+ expect(res.status).toBe(200)
+
+ await retry(async () => {
+ const after = (await next.render$(PATH))('#cached-time').text()
+ expect(after).not.toBe(initial)
+ })
+ })
+
+ it('invalidates a fetch entry tagged with a non-ASCII tag via revalidateTag', async () => {
+ const initial = (await next.render$(PATH))('#fetched').text()
+
+ const res = await next.fetch(
+ `/api/revalidate?tag=${encodeURIComponent(FETCH_TAG)}`,
+
... [truncated]