Skip to content

unstable_cache: encode the synthetic cache item name so non-ASCII query params / callback names don't silently break caching #2895

Description

@github-actions

Track Next.js fix for non-ASCII characters in the unstable_cache cache item name breaking caching.

Upstream commit: vercel/next.js@4286a42 (#96937)
Fixes: vercel/next.js#76286
Related refactor: vercel/next.js@1f0cd93 (#96936) — renames encodeCacheTagencodeHeaderSafe

Related (already closed) issue: #1138 encodes non-ASCII in cache tags. This is a distinct code path: the cache item name built by unstable_cache, not the tags or the cache key.

Problem

unstable_cache assembles a synthetic cache item name (its fetchUrl) from two inputs:

`unstable_cache ${fetchUrlPrefix} ${cb.name ? ` ${cb.name}` : cacheKey}`

fetchUrlPrefix is built by getFetchUrlPrefix, which reads the pathname and search params out of the request URL. The pathname stays percent-encoded, but URLSearchParams returns decoded keys/values, so a non-ASCII query parameter lands unencoded in the name. The callback name (cb.name) can also hold a non-ASCII character.

A cache implementation may serialize this item name into an HTTP request header, whose values are limited to Latin-1. When the name holds a character above U+00FF, the conversion throws before the request is dispatched, so:

  • the read never reaches the cache (nothing is found),
  • the write that follows fails the same way (nothing is stored),
  • the entry falls back to the origin on every render, with no reported error.

Reachable cases:

  • Any dynamic route calling unstable_cache with a non-ASCII query parameter (whether or not the route reads searchParams, and including a param a caller appends).
  • A cached callback whose name holds a non-ASCII character (usually only observable in dev; a production build renames the binding).

Fix shape (upstream)

Encode the assembled name with encodeHeaderSafe (the renamed encodeCacheTag), after normalizing lone surrogates:

const fetchUrl = encodeHeaderSafe(
  `unstable_cache ${fetchUrlPrefix} ${cb.name ? ` ${cb.name}` : cacheKey}`.toWellFormed()
)
  • encodeHeaderSafe only replaces characters outside Node's valid header-value class, so the separating spaces and URL punctuation are preserved and the name keeps its documented shape.
  • toWellFormed() replaces lone surrogates (which cb.name can hold and which encodeURIComponent rejects) with the replacement character — acceptable because the item name is only a debug label, not the cache key.
  • Every name representable today is returned unchanged, so this is inert for existing entries.

The item name is a label: it is not the cache key (derived separately from the callback key parts and args), and the Suspense Cache API neither parses nor matches on it.

Relevance to vinext

vinext reimplements unstable_cache. If our implementation constructs a comparable item name and any cache backend (e.g. a KV/data adapter, or Node-compatible post-processing) serializes it into a header, we hit the same silent cache-never-hits failure class. We should apply the same header-safe encoding (plus toWellFormed) to the item name at construction.

Verification (upstream test)

test/e2e/app-dir/non-ascii-cache-item-name/:

  • /[slug] requested with a non-ASCII segment + non-ASCII query param (anonymous callback)
  • /named-callback requested under a pure-ASCII URL with a non-ASCII callback name (dev only)
  • /lone-surrogate for the lone-surrogate case
  • A deployment/cache-handler check asserts the entry is not recomputed on every request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    nextjs-trackingTracking issue for a Next.js canary change relevant to vinext

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions