Track Next.js fix for non-ASCII cache tags crashing ISR responses with ERR_INVALID_CHAR.
Upstream commit: vercel/next.js@9e18303 (#93601)
Fixes: vercel/next.js#93142
Closes: #93139, #93167
Problem
When a cache tag contains a non-ASCII character (Hebrew, Arabic, CJK, emoji, …), it 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 platforms with stale-if-error (Vercel), the 500 is masked from clients but revalidation itself keeps failing and the cache stops refreshing for affected routes.
On Cloudflare Workers, Headers setters are more permissive, but if vinext writes a x-next-cache-tags header anywhere that is later parsed by Node-compatible code (e.g. KV cache handler post-processing, or a downstream Worker that uses validateHeaderValue semantics), we have the same crash class.
Fix shape
New helper packages/next/src/server/lib/encode-cache-tag.ts:
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
}
Properties:
- Applied at every public boundary so storage, comparison, and the wire all see the same canonical ASCII-safe form.
- Idempotent on already-encoded
%xx input (the fast-path returns unchanged input).
- Matches runs of out-of-class code units so surrogate pairs (emoji) are handed to
encodeURIComponent as a complete code point — a per-code-unit regex would split the pair and throw URIError.
Call sites updated
server/lib/implicit-tags.ts — getImplicitTags (path-derived _N_T_… tags)
server/lib/patch-fetch.ts — validateTags (funnels cacheTag(), unstable_cache(), fetch tags)
server/web/spec-extension/revalidate.ts — revalidatePath, revalidateTag, updateTag
PR #93139 attempted to encode at construction but missed user-supplied tag entry points and used a decodeURIComponent round-trip that mangled literal %xx characters. PR #93167 encoded only at setHeader sites, which left storage and invalidation diverging. The canonical-form-at-the-boundary approach in #93601 covers all entry points uniformly.
Action for vinext
- Port
encode-cache-tag.ts into packages/vinext/src/server/ (or shims/ if shared with the runtime).
- Apply it in vinext's equivalents:
shims/cache.ts — cacheTag(...tags) (currently at shims/cache.ts:733)
shims/cache.ts / shims/next-cache.ts — revalidateTag, revalidatePath, updateTag (whichever exist)
server/* — wherever fetch tags are validated and stored
- Any path-derived tag construction (
_N_T_/[slug]/page shape)
- Audit any
Headers.set("x-next-cache-tags", …) (or equivalent) call site for places where un-encoded tags could land in a header.
- Port the e2e test at
test/e2e/app-dir/non-ascii-cache-tags/ — at minimum: a page that calls cacheTag('שלום-עולם') and a route that calls revalidateTag with the same tag, asserting both ISR storage and revalidation work end to end.
Existing related issues (different concern): #708 tracks the new two-argument revalidateTag(tag, profile) signature and is independent of encoding.
Track Next.js fix for non-ASCII cache tags crashing ISR responses with
ERR_INVALID_CHAR.Upstream commit: vercel/next.js@9e18303 (#93601)
Fixes: vercel/next.js#93142
Closes: #93139, #93167
Problem
When a cache tag contains a non-ASCII character (Hebrew, Arabic, CJK, emoji, …), it gets written into the internal
x-next-cache-tagsHTTP header on ISR responses. Node'svalidateHeaderValuerejects any byte outside\t\x20-\x7e, so the response crashes withERR_INVALID_CHAR. On platforms with stale-if-error (Vercel), the 500 is masked from clients but revalidation itself keeps failing and the cache stops refreshing for affected routes.On Cloudflare Workers,
Headerssetters are more permissive, but if vinext writes ax-next-cache-tagsheader anywhere that is later parsed by Node-compatible code (e.g. KV cache handler post-processing, or a downstream Worker that usesvalidateHeaderValuesemantics), we have the same crash class.Fix shape
New helper
packages/next/src/server/lib/encode-cache-tag.ts:Properties:
%xxinput (the fast-path returns unchanged input).encodeURIComponentas a complete code point — a per-code-unit regex would split the pair and throwURIError.Call sites updated
server/lib/implicit-tags.ts—getImplicitTags(path-derived_N_T_…tags)server/lib/patch-fetch.ts—validateTags(funnelscacheTag(),unstable_cache(), fetchtags)server/web/spec-extension/revalidate.ts—revalidatePath,revalidateTag,updateTagPR #93139 attempted to encode at construction but missed user-supplied tag entry points and used a
decodeURIComponentround-trip that mangled literal%xxcharacters. PR #93167 encoded only atsetHeadersites, which left storage and invalidation diverging. The canonical-form-at-the-boundary approach in #93601 covers all entry points uniformly.Action for vinext
encode-cache-tag.tsintopackages/vinext/src/server/(orshims/if shared with the runtime).shims/cache.ts—cacheTag(...tags)(currently atshims/cache.ts:733)shims/cache.ts/shims/next-cache.ts—revalidateTag,revalidatePath,updateTag(whichever exist)server/*— wherever fetchtagsare validated and stored_N_T_/[slug]/pageshape)Headers.set("x-next-cache-tags", …)(or equivalent) call site for places where un-encoded tags could land in a header.test/e2e/app-dir/non-ascii-cache-tags/— at minimum: a page that callscacheTag('שלום-עולם')and a route that callsrevalidateTagwith the same tag, asserting both ISR storage and revalidation work end to end.Existing related issues (different concern): #708 tracks the new two-argument
revalidateTag(tag, profile)signature and is independent of encoding.