Encode the cache item name built by unstable_cache - #96937
Open
unstubbable wants to merge 2 commits into
Open
Conversation
Cache metadata reaches a cache implementation through HTTP request headers, whose values are limited to Latin-1. Every tag, soft tag, and cache item name has to be representable there. A value that is not gets lost before it reaches the cache, so the entry is never found and never stored, and the route calls the origin on every render without reporting an error. `unstable_cache` assembles its item name from the request URL and the name of the cached callback, and the fixture covers those two parts on separate routes so that a failure names the part it comes from. `/[slug]` holds an anonymous callback and is requested with a non-ASCII segment and a non-ASCII query parameter. The pathname arrives percent-encoded, but `URLSearchParams` returns decoded keys and values, so the query is the part that cannot be carried. `/named-callback` is requested under a pure ASCII URL and holds a callback whose name is non-ASCII, which leaves the name as the only candidate there. The `/[slug]` route also renders a tagged `fetch` as a control. It derives its item name from an already percent-encoded URL, so it has to keep caching for the very request that breaks the entry beside it, which separates a broken item name from a cache that is unavailable. A deployment exercises the constraint through the real cache implementation, where an item name it cannot carry surfaces as a cache that never returns anything, so the assertion there is that the rendered values stay stable across requests. Everywhere else the fixture registers a cache handler that applies the same conversion and reports the values that fail it. Each branch guards against passing for the wrong reason. The local one requires the handler to have been consulted, and every read asserts that both values are numbers, so a render that produced neither cannot read as two equal results. The callback name is only asserted in development: a production build renames the binding, and the constraint would hold there for a reason unrelated to the encoding.
A cache implementation may serialize cache metadata into HTTP request headers, whose values are limited to Latin-1. `unstable_cache` assembles a cache item name from the request URL and the name of the cached callback, and neither part was encoded. When that name holds a character above U+00FF the conversion throws before the request is dispatched, so the read never reaches the cache and the write that follows it fails the same way. Nothing is stored, nothing is found, and the entry falls back to the origin on every render. The name is built by `getFetchUrlPrefix`, which reads the pathname and the search parameters out of the request URL. The pathname stays percent-encoded, but `URLSearchParams` returns decoded keys and values, so a non-ASCII query parameter is the reachable case: it applies to any dynamic route that calls `unstable_cache`, whether or not the route reads `searchParams`, and therefore also to a parameter a caller appends. A callback whose name holds such a character is affected too, though a production build usually renames the binding. This change encodes the assembled name with `encodeHeaderSafe`. That helper only replaces characters outside the class Node accepts in a header value, so the separating spaces and the URL punctuation are preserved and the name keeps its documented shape. Every name that is representable today is returned unchanged, so this is inert for existing entries. The item name is a label: it is not the cache key, which is derived separately from the callback's key parts and arguments, and the Suspense Cache API neither parses nor matches on it. The test covers the two parts of the name on separate routes so a failure names the part it comes from, and asserts the constraint rather than either input, since which inputs are live depends on the bundler and on minification. A deployment checks it through the real cache handler implementation, where the failure shows up as an entry that is recomputed on every request.
Contributor
Tests PassedCommit: 40c7eca |
Contributor
Stats from current PR🟢 1 improvement
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles
Server Middleware
Build DetailsBuild Manifests
Build Cache
📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📝 Changed Files (12 files)Files with changes:
View diffsapp-page-exp..ntime.dev.jsDiff too large to display app-page-tur..ntime.dev.jsDiff too large to display app-page-tur..ntime.dev.jsDiff too large to display app-page.runtime.dev.jsDiff too large to display app-route-ex..ntime.dev.jsDiff too large to display app-route-tu..ntime.dev.jsDiff too large to display app-route-tu..ntime.dev.jsDiff too large to display app-route.runtime.dev.jsDiff too large to display pages-api-tu..ntime.dev.jsDiff too large to display pages-api.runtime.dev.jsDiff too large to display pages-turbo...ntime.dev.jsDiff too large to display pages.runtime.dev.jsDiff too large to display 📎 Tarball URLCommit: 40c7eca |
unstubbable
marked this pull request as ready for review
August 7, 2026 18:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A cache implementation may serialize cache metadata into HTTP request headers, whose values are limited to Latin-1.
unstable_cacheassembles a cache item name from the request URL and the name of the cached callback, and neither part was encoded. When that name holds a character above U+00FF the conversion throws before the request is dispatched, so the read never reaches the cache and the write that follows it fails the same way. Nothing is stored, nothing is found, and the entry falls back to the origin on every render.The name is built by
getFetchUrlPrefix, which reads the pathname and the search parameters out of the request URL. The pathname stays percent-encoded, butURLSearchParamsreturns decoded keys and values, so a non-ASCII query parameter is the reachable case: it applies to any dynamic route that callsunstable_cache, whether or not the route readssearchParams, and therefore also to a parameter a caller appends. A callback whose name holds such a character is affected too, though a production build usually renames the binding.This change encodes the assembled name with
encodeHeaderSafe. That helper only replaces characters outside the class Node accepts in a header value, so the separating spaces and the URL punctuation are preserved and the name keeps its documented shape. Every name that is representable today is returned unchanged, so this is inert for existing entries. The item name is a label: it is not the cache key, which is derived separately from the callback's key parts and arguments, and the Suspense Cache API neither parses nor matches on it.The test covers the two parts of the name on separate routes so a failure names the part it comes from, and asserts the constraint rather than either input, since which inputs are live depends on the bundler and on minification. A deployment checks it through the real cache handler implementation, where the failure shows up as an entry that is recomputed on every request.
fixes #76286