Link to the code that reproduces this issue
https://github.com/nlindema/segment-cache-shell-poisoning-repro
To Reproduce
npm install, npm run build, npm run start (production — the bug needs the prerender manifest; it is not a next dev scenario)
- Open
/ in a fresh browser profile (the poisoned cache entry persists for the session once written)
- Wait ~3 seconds for the four
/item/* link prefetches to settle
- Click alpha
The app is five small files: next.config.js with cacheComponents: true, partialPrefetching: true, and htmlLimitedBots: /.*/ (blocking metadata for every UA — an SEO policy choice; this is what arms the bug), a static listing page with four <Link>s to /item/alpha … /item/delta, and app/item/[...slug]/page.js where a 'use cache' data read feeds both the page body and generateMetadata. The build emits /item/[...slug] as ◐ (Partial Prerender).
Current vs. Expected behavior
Current (16.3.3, 3/3 across fresh browser contexts): the URL is /item/alpha, but <title> and <h1> render Item delta (or Item charlie — whichever sibling happened to be prefetched last). The wrong content is never corrected after render. All four /item/* prefetch responses arrive without x-nextjs-postponed. Direct loads and reloads are always correct; only soft navigation is affected.
Expected: navigating to /item/alpha renders Item alpha, regardless of which sibling routes were prefetched.
Control (same app, one line): comment out htmlLimitedBots: /.*/, rebuild — every prefetch response now carries x-nextjs-postponed: 2 and the click is always correct. So the trigger is the htmlLimitedBots catch-all disabling PPR shell serving; the analysis below is why that turns into cross-URL cache poisoning on the client.
Canary: does not reproduce on 16.4.0-canary.0 (or .10) — prefetches still fire without x-nextjs-postponed, but the navigation issues a dynamic request for the clicked URL and renders correctly, with soft navigation intact. Verified boundary with the same harness: 16.3.3 = red 2/2, 16.4.0-canary.0 = green. The range v16.3.3...v16.4.0-canary.0 is 293 commits; candidates by title include #96827 ("Use Cache Components as the internal PPR signal"), #96406 ("Unify RouteTree and CacheNodeSeedData on the client") and #97128 ("Fix: Optimistic routing bugs leading to repeated prefetch loops"). Since 16.3.3 is the latest stable, this is effectively a request to confirm the fix was intentional and consider a 16.3.x backport.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.5.0: Tue Jun 9 22:28:29 PDT 2026; root:xnu-12377.121.10~1/RELEASE_ARM64_T6030
Available memory (MB): 36864
Available CPU cores: 12
Binaries:
Node: 26.4.0
npm: 11.17.0
Yarn: N/A
pnpm: 11.13.1
Relevant Packages:
next: 16.3.3 // Latest available version is detected (16.3.3).
eslint-config-next: N/A
react: 19.2.8
react-dom: 19.2.8
typescript: N/A
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
cacheComponents, Partial Prerendering (PPR), Linking and Navigating, Use Cache
Which stage(s) are affected? (Select all that apply)
next start (local), Other (Deployed), next dev (local)
Additional context
Mechanism, from instrumenting the 16.3.3 client segment cache (why the catch-all turns into cross-URL poisoning):
htmlLimitedBots becomes a prerender-manifest bypass rule for PPR routes (build/index.js, bypassFor: [{ key: 'user-agent', value: … }]); with /.*/ every request bypasses the shell, including RSC prefetches, which are then served by the legacy dynamic flow (full concrete page, no x-nextjs-postponed: 2).
- The client scheduler still picks
FetchStrategy.PPR — the SubtreeHasPartialPrefetching hint branch wins before route.supportsPerSegmentPrefetching (false) is consulted (segment-cache/scheduler.js).
- The Shell phase's RuntimeShell request therefore gets a full concrete page instead of an App Shell, and
fulfillEntrySpawnedByRuntimePrefetch re-keys it at tree.shellVaryPath — every non-root param replaced by Fallback (segment-cache/cache.js). Each sibling prefetch overwrites the same param-blind keys (page segment, _head, param layout).
- Lookups fall through to
Fallback entries (cache-map.js, getEntryWithFallbackImpl), so navigating to slug A reads slug B's full page, and nothing triggers a corrective fetch.
Two suggestions, independent of the canary fix:
- The client should not store a response under the param-blind shell vary path when it already knows the route does not support per-segment prefetching (
routeIsPPREnabled === false was computed for the same route's /_tree response).
- The
htmlLimitedBots bypass guards blocking metadata in the HTML response, but it also applies to RSC segment prefetch requests, which carry no HTML head. Serving per-segment prefetches from the segment cache regardless of the UA bypass would let apps that need blocking metadata for every UA still use Instant Navigations — today the two features are silently mutually exclusive.
Deployment: reproduces locally (next build && next start) and on a Docker output: "standalone" deployment identically; browser-independent (Chromium via Playwright and regular Chrome).
Link to the code that reproduces this issue
https://github.com/nlindema/segment-cache-shell-poisoning-repro
To Reproduce
npm install,npm run build,npm run start(production — the bug needs the prerender manifest; it is not anext devscenario)/in a fresh browser profile (the poisoned cache entry persists for the session once written)/item/*link prefetches to settleThe app is five small files:
next.config.jswithcacheComponents: true,partialPrefetching: true, andhtmlLimitedBots: /.*/(blocking metadata for every UA — an SEO policy choice; this is what arms the bug), a static listing page with four<Link>s to/item/alpha…/item/delta, andapp/item/[...slug]/page.jswhere a'use cache'data read feeds both the page body andgenerateMetadata. The build emits/item/[...slug]as◐ (Partial Prerender).Current vs. Expected behavior
Current (16.3.3, 3/3 across fresh browser contexts): the URL is
/item/alpha, but<title>and<h1>renderItem delta(orItem charlie— whichever sibling happened to be prefetched last). The wrong content is never corrected after render. All four/item/*prefetch responses arrive withoutx-nextjs-postponed. Direct loads and reloads are always correct; only soft navigation is affected.Expected: navigating to
/item/alpharendersItem alpha, regardless of which sibling routes were prefetched.Control (same app, one line): comment out
htmlLimitedBots: /.*/, rebuild — every prefetch response now carriesx-nextjs-postponed: 2and the click is always correct. So the trigger is thehtmlLimitedBotscatch-all disabling PPR shell serving; the analysis below is why that turns into cross-URL cache poisoning on the client.Canary: does not reproduce on
16.4.0-canary.0(or.10) — prefetches still fire withoutx-nextjs-postponed, but the navigation issues a dynamic request for the clicked URL and renders correctly, with soft navigation intact. Verified boundary with the same harness:16.3.3= red 2/2,16.4.0-canary.0= green. The rangev16.3.3...v16.4.0-canary.0is 293 commits; candidates by title include #96827 ("Use Cache Components as the internal PPR signal"), #96406 ("Unify RouteTree and CacheNodeSeedData on the client") and #97128 ("Fix: Optimistic routing bugs leading to repeated prefetch loops"). Since 16.3.3 is the latest stable, this is effectively a request to confirm the fix was intentional and consider a 16.3.x backport.Provide environment information
Operating System: Platform: darwin Arch: arm64 Version: Darwin Kernel Version 25.5.0: Tue Jun 9 22:28:29 PDT 2026; root:xnu-12377.121.10~1/RELEASE_ARM64_T6030 Available memory (MB): 36864 Available CPU cores: 12 Binaries: Node: 26.4.0 npm: 11.17.0 Yarn: N/A pnpm: 11.13.1 Relevant Packages: next: 16.3.3 // Latest available version is detected (16.3.3). eslint-config-next: N/A react: 19.2.8 react-dom: 19.2.8 typescript: N/A Next.js Config: output: N/AWhich area(s) are affected? (Select all that apply)
cacheComponents, Partial Prerendering (PPR), Linking and Navigating, Use Cache
Which stage(s) are affected? (Select all that apply)
next start (local), Other (Deployed), next dev (local)
Additional context
Mechanism, from instrumenting the 16.3.3 client segment cache (why the catch-all turns into cross-URL poisoning):
htmlLimitedBotsbecomes a prerender-manifest bypass rule for PPR routes (build/index.js,bypassFor: [{ key: 'user-agent', value: … }]); with/.*/every request bypasses the shell, including RSC prefetches, which are then served by the legacy dynamic flow (full concrete page, nox-nextjs-postponed: 2).FetchStrategy.PPR— theSubtreeHasPartialPrefetchinghint branch wins beforeroute.supportsPerSegmentPrefetching(false) is consulted (segment-cache/scheduler.js).fulfillEntrySpawnedByRuntimePrefetchre-keys it attree.shellVaryPath— every non-root param replaced byFallback(segment-cache/cache.js). Each sibling prefetch overwrites the same param-blind keys (page segment,_head, param layout).Fallbackentries (cache-map.js,getEntryWithFallbackImpl), so navigating to slug A reads slug B's full page, and nothing triggers a corrective fetch.Two suggestions, independent of the canary fix:
routeIsPPREnabled === falsewas computed for the same route's/_treeresponse).htmlLimitedBotsbypass guards blocking metadata in the HTML response, but it also applies to RSC segment prefetch requests, which carry no HTML head. Serving per-segment prefetches from the segment cache regardless of the UA bypass would let apps that need blocking metadata for every UA still use Instant Navigations — today the two features are silently mutually exclusive.Deployment: reproduces locally (
next build && next start) and on a Dockeroutput: "standalone"deployment identically; browser-independent (Chromium via Playwright and regular Chrome).