Skip to content

feat(app-router): gate SSR useSearchParams() in cache-candidate renders - #3455

Draft
james-elicx wants to merge 6 commits into
isr-query/04-dynamic-latchfrom
isr-query/05-candidate-ssr-gates
Draft

james-elicx wants to merge 6 commits into
isr-query/04-dynamic-latchfrom
isr-query/05-candidate-ssr-gates

Conversation

@james-elicx

@james-elicx james-elicx commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3454. Second part of plan PR 1 (cache-candidate SSR mode). This fixes the poisoning bug for SSR useSearchParams(), where a query-bearing miss wrote the first visitor's query into the shared ISR entry.

Problem

In production, SSR useSearchParams() always returned the request's query, untracked, and the navigation payload serialized it into the HTML. On a static page the render was then stored under the query-free key, so every later visitor got the first visitor's query. Next.js never puts the query into static HTML: a call inside Suspense client-renders that boundary, and a call outside Suspense fails the build (a 500 at runtime for on-demand paths).

Change

A render is a cache candidate when core would read the cache for it: production, a route Next.js classifies static or SSG (#3451), no draft mode, no nonce on HTML, not a server action, not an unverified interception or a cacheability probe. PPR fallback shells keep cacheComponents' model. Regeneration and prerender already render with isStaticGeneration, so they bail out at once, as before.

For candidate HTML renders:

  • Gate. SSR useSearchParams() waits on a per-request gate (shims/search-params-gate.ts, started in handleSsr by server/app-ssr-search-params-gate.ts). It's decided once, at whichever comes first:
    • the render uses a dynamic API: the sticky latch from feat(app-router): track dynamic usage in a per-request sticky latch #3454 fires, and every call reads the real query, as in a Next.js dynamic render. A candidate render whose request latched is never stored, even when the dynamic API ran in a child scope (the layout probe, SSR) that doesn't reach the render's own flag, so real values never reach a stored response;
    • the render settles: SSR has read the Flight stream to its end, so no server component can still mark it dynamic. Every call throws BailoutToCSRError, and React client-renders the nearest Suspense boundary, as in Next.js's static HTML. The page is stored.
  • Unwrapped calls. A bail-out that reaches the shell returns a 500 and logs Next.js's useSearchParams() should be wrapped in a suspense boundary at page "<route>", even when the route has an error.tsx. Next.js rethrows the bail-out there too (app-render.tsx:3477-3488). The 500 keeps middleware headers and is never cacheable.
  • Navigation payload. The HTML sets searchParamsFromBrowser and serializes an empty searchParams, so the browser reads its own URL. When the gate has already opened by the time the head is written, the payload keeps the effective query (a rewrite may have changed it) instead. force-static keeps its current empty payload and never gates.
  • Cache status. Candidate misses report x-vinext-cache: MISS with or without a query.

Dev is unchanged: it still renders real values.

Behaviour change for the release notes: an App page that calls useSearchParams() outside Suspense on a static route rendered before (and cached the first visitor's query). It now returns a 500 in production with Next.js's message in the log, as Next.js does for an on-demand path. Wrapping the call in <Suspense> fixes it on both.

Tests

  • tests/candidate-search-params-gate.test.ts renders with Fizz and covers the plan's gate unit tests: wrapped (fallback, nothing dynamic), unwrapped (shell error), dynamic before the gate, dynamic while waiting, dynamic usage consumed at the shell, dynamic usage in an isolated scope, opening marks the render dynamic, dynamic after the settle point (fallback kept), a call after the decision, nested boundaries, and a cancelled Flight stream.
  • tests/app-page-render.test.ts: an unwrapped bail-out in a candidate render is a 500 with Next.js's message and middleware headers, skips the error boundary and writes nothing; outside candidate mode the boundary still renders. A candidate render that latched dynamic only in an isolated scope is sent no-store and never stored.
  • tests/app-page-dispatch.test.ts: the query-bearing store test now asserts the candidate flag reaches SSR and the response reports MISS; draft mode and dev render outside candidate mode.
  • Response Store e2e (examples/response-store-demo, new /search-params/* routes): ?q=<uuid> on a static page with a wrapped call is stored and re-served with the fallback, a browser-sourced payload and no trace of the uuid; an unwrapped call on an on-demand path is a 500 and never stored; a page that reads headers() renders the real query, serializes it in the payload, and isn't stored.
  • The deployed spec runs the same three checks against every backend.

Known residual

A gate that opens after the head has been flushed can't change the browser-sourced payload that's already been sent. This only matters when a rewrite changes the query and a client useSearchParams() reads it. React's hydration then recovers with the browser query.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head ed4513c186c516c483da5420cb517e57b9993705 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@pkg-pr-new

pkg-pr-new Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3455

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3455

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3455

vinext

npm i https://pkg.pr.new/vinext@3455

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3455

commit: fe36ba1

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a4d2a5a against base ea8e596 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.5 KB 142.6 KB ⚫ +0.0%
Client entry size (gzip) vinext 129.9 KB 130.7 KB ⚫ +0.6%
Dev server cold start vinext 3.35 s 3.40 s ⚫ +1.5%
Production build time vinext 3.80 s 3.77 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 132.8 KB 133.1 KB ⚫ +0.2%
Server bundle size (gzip) vinext 231.2 KB 232.3 KB ⚫ +0.5%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

* server component can mark the render dynamic any more.
*/
export function startCandidateSearchParamsGate(): CandidateSearchParamsGate {
const controller = createSearchParamsGate({ onOpen: markDynamicUsage });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the cache veto in the parent request scope

When isRenderDynamicLatched() is already true because a layout probe called a dynamic API inside runWithIsolatedDynamicUsage, this callback runs inside handleSsr’s nested runWithNavigationContext scope. markDynamicUsage() therefore sets only that shallow-cloned child’s boolean; the parent flag consumed by finalizeAppPageHtmlCacheResponse remains false. The gate is nevertheless opened, so useSearchParams() can render the real query and, if the final render does not repeat the dynamic read (for example because the probed work is memoized), that HTML is stored under the query-free key. Propagate the gate-open cache veto to the owning request scope, or make finalization consult shared gate/latch state.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fb51d5a. A candidate HTML render now also treats the request's shared dynamic latch as dynamic usage, so usage in a child scope (layout probe or SSR) vetoes both core's write and adapter admission, and sets the response policy when it happens before headers.

(options?.isStaticGeneration === true ? options.isForceStatic !== true : undefined);
return buildHeadInjectionHtml(
ssrNavigationContext,
searchParamsGate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the effective query for gates that open as dynamic

This always serializes an empty query with searchParamsFromBrowser: true for every candidate, including gates decided as real. A rewrite can add query parameters that are intentionally visible through useSearchParams() (the middleware fixture documents this contract), while window.location.search still contains only the public URL. In a render that becomes dynamic via headers()/cookies(), SSR now emits the rewrite query but hydration restores the browser query, causing a mismatch and then returning different values client-side. Once the gate opens, serialize ssrNavigationContext.searchParams instead of forcing the browser-sourced payload (including the case where the decision occurs after the first head insertion).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fb51d5a for gates that open before the head is written: the payload then serializes the effective query without the browser flag. That covers dynamic APIs in the shell, such as the rewrite fixture's headers() page. It's pinned in the Response Store e2e.

Gates that open after the head was flushed are left as they are. By then the browser-sourced payload is already on the wire, and the client reads it once, before hydration starts. Fixing that needs client runtime work beyond this PR. It only matters when a rewrite changes the query, the dynamic API runs after the shell, and a client component reads useSearchParams(). React's hydration then recovers with the browser query.

`${error.reason} should be wrapped in a suspense boundary at page "${options.routePattern}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout`,
);
options.clearRequestContext();
return Promise.resolve(internalServerErrorResponse());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve middleware response headers on this 500

This new early response bypasses buildAppPageHtmlResponse, so middleware-provided response headers are dropped only for the missing-Suspense failure path. That loses Set-Cookie, CSP/security headers, and other custom headers that all normal and recovered App-page responses merge from options.middlewareContext.headers. Build/merge the error response through the same middleware-header path while keeping status 500 and a non-cacheable policy.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fb51d5a. The 500 now merges middleware headers and is sent with Next.js's never-cache Cache-Control plus the CDN headers, matching the recovered shell-error path.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Submitted 3 actionable inline findings. Focused tests and checks passed.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head fb51d5ab79a6ab4d8c855835090fe41e39f91937 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

if (settled) return;
settled = true;
unsubscribe();
controller.settle();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Open the gate for fetches that make the output fresh

settle() only checks the dynamic-API latch indirectly through the listener, but several fetch paths deliberately call recordDynamicFetchObservation() without markDynamicUsage() (notably the default uncached fetch, auth-keyed safety bypass, and cache-key-generation fallback). Those observations make the page output ineligible for shared reuse, yet this gate settles to "bailout", so a Suspense-wrapped useSearchParams() renders its fallback instead of the real query on an otherwise dynamic/fresh response. Include the completed render/fetch observations in the settle decision, or signal the gate when one of these cache-vetoing fetches is observed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one, because it would diverge from Next.js. These fetch paths are Next.js's auto no cache cases: the default fetch with no cache config, and the auth or cookie header bypass. Next.js deliberately doesn't let autoNoCache make a render dynamic: // we don't consider autoNoCache to switch to dynamic for ISR in packages/next/src/server/lib/patch-fetch.ts. Only revalidate: 0 and no-store fetches call markCurrentScopeAsDynamic. vinext's markUncachedFetchForPageOutput mirrors that.

So in Next.js a static page with one of these fetches is still prerendered. Its Suspense-wrapped useSearchParams() bails out to the fallback, and the page is stored with revalidate = false. dynamicFetches doesn't veto the ISR write in vinext either; it only feeds client reuse metadata and layout classification. Opening the gate here would call markDynamicUsage and stop the page being stored, which is less than Next.js stores. The key-generation fallback is the same: Next.js would cache that fetch, so the page stays static there too.

const headers = new Headers();
mergeMiddlewareResponseHeaders(headers, options.middlewareContext.headers);
headers.set("Cache-Control", NEVER_CACHE_CONTROL);
const response = internalServerErrorResponse(undefined, { headers });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the edge-runtime marker on the bailout response

This direct response bypasses buildAppPageHtmlResponse, so an otherwise cache-candidate page configured with runtime = "edge" loses the x-edge-runtime: 1 marker only on this missing-Suspense 500. Other App-page response paths consistently call applyEdgeRuntimeHeader; apply it here as well using options.isEdgeRuntime.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9ec96e. The bail-out 500 now applies x-edge-runtime from options.isEdgeRuntime, like the other App-page response paths. The unit test asserts it.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Submitted 2 actionable inline findings. Targeted tests passed; no files modified.

github run

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from fb51d5a to f9ec96e Compare September 25, 2026 01:38
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head f9ec96ee2273a76d882e9950ad934b4ad86db273 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-export preview production
web preview production

// A render that may be stored, and so must not let the request's query reach
// its output unless it turns out dynamic. PPR fallback shells follow
// cacheComponents' model instead.
const isCacheCandidate = shouldReadCache && options.pprRuntime === undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Gate normal cacheComponents renders too

pprRuntime is supplied on every request whenever cacheComponents is enabled; only pprRuntime.getState()/pprFallbackShell identifies an active fallback-shell render. This condition therefore disables candidate mode for ordinary HTML misses in every cacheComponents app. Those renders can still be cacheable (for example revalidate = 60 or a generateStaticParams route with the Infinity default), so a client component's useSearchParams() reads the real request query during SSR and the resulting HTML is written under the query-free page key, preserving the poisoning bug this PR fixes. Exempt only an active fallback-shell render rather than every build that has a PPR runtime.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Submitted 1 actionable inline finding. Targeted tests and checks passed; no files modified.

github run

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from f9ec96e to 3fadbd1 Compare September 25, 2026 01:59
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 3fadbd1b5cc2070c9fcd6f5906f1fe0fc5b6b03d without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from 3fadbd1 to edf4f2b Compare September 25, 2026 02:06
try {
result = await reader.read();
} catch (error) {
settle();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not turn Flight failures into a static bailout

This error path calls settle(), which fulfills every suspended useSearchParams() with the BailoutToCSRError before propagating the actual Flight error. If the root model was decoded before a later stream failure, Fizz can retry the waiting client component and the new bailout can replace the real render failure with the missing-Suspense 500 (or a Suspense fallback). Cancellation at line 60 similarly is not the normal EOF that proves a static render. Abnormal termination should unsubscribe and reject/open the gate while preserving the original failure, rather than deciding "bailout".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 307bbf1. Only a normal end of the Flight stream settles the gate as a bailout now. A failed or cancelled stream opens it instead: that marks the render dynamic, so it's never stored, and waiting useSearchParams() calls read real values rather than throwing a bailout that could replace the real failure. The Flight error still reaches SSR unchanged. Tests: cancellation opens the gate and marks the render dynamic, and a Flight failure rejects the read with the original error and opens the gate.

handlerStart: options.handlerStart,
hasLoadingBoundary: hasActiveLoadingBoundary,
omitPendingDynamicCacheState: hasRequestSearchParams,
omitPendingDynamicCacheState: hasRequestSearchParams && !isCacheCandidate,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep query-bearing RSC cache state provisional

isCacheCandidate is true for RSC cache misses too, but only HTML installs the new search-params gate (isCacheCandidateHtmlRender explicitly excludes RSC). A loading-boundary RSC render can therefore read searchParams after headers are built, skip the cache write, and still expose X-Vinext-Cache: MISS; before this change query-bearing RSC responses deliberately omitted that uncorrectable provisional state. Restrict this exception to HTML candidates (or keep omitPendingDynamicCacheState true for isRscRequest); the existing loading-boundary test only asserts not HIT, so it misses this regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a8897d. The provisional-state exception now covers only candidate HTML renders (hasRequestSearchParams && !(isCacheCandidate && !isRscRequest)), since only HTML installs the gate. Query-bearing RSC responses again send no X-Vinext-Cache. The loading-boundary RSC test now asserts the header is absent. It got MISS before the fix.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Submitted 2 actionable inline findings. Targeted tests passed (177 tests); no files modified.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 0a8897d3b7afa48c6e46331be9278b079d54ceb8 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from 0a8897d to 2495c69 Compare September 25, 2026 02:32
@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

No additional findings beyond the existing unresolved inline finding. Targeted tests passed (178); no files modified.

github run

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from 3ff3648 to d9efbaa Compare September 25, 2026 13:18
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head d9efbaabf7fd283df93bb523e8c53ec5ebdea4b8 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor
  • P1 app-page-dispatch.ts:985: cacheComponents still disables candidate gating for normal cacheable renders, allowing query-poisoned HTML caching. Already covered by the unresolved inline finding.

No new findings. Exact head verified; 489 targeted tests passed. Worktree unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from d9efbaa to a6fc690 Compare September 25, 2026 13:54
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head a6fc690911a607c758815cb24fad329e6f145a0c without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head a6fc690911a607c758815cb24fad329e6f145a0c without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor
  • P1 app-page-dispatch.ts:995: cacheComponents still disables candidate gating for normal cacheable renders, allowing query-poisoned HTML caching. Already covered by the unresolved inline finding.

No new findings. Exact head verified; 490 targeted tests passed. Worktree unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from a6fc690 to d69107d Compare September 25, 2026 14:49
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head d69107d88da033c83aaae53a625bea548b9c0d0b without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor
  • P1 app-page-dispatch.ts:995: cacheComponents still disables candidate gating for normal cacheable renders, allowing query-poisoned HTML caching. Already covered by the unresolved inline finding.

No new findings. Exact head verified; 492 targeted tests passed. Worktree unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch 2 times, most recently from 841b2cc to f0c09f9 Compare September 25, 2026 18:07
@james-elicx
james-elicx removed this pull request from stack #3471 September 25, 2026 18:50
@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from f0c09f9 to a5388ea Compare September 25, 2026 18:50
@james-elicx
james-elicx added this pull request to stack #3474 September 25, 2026 18:51
@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from a5388ea to a4d2a5a Compare September 25, 2026 19:18
A production render that may be stored no longer lets the request's query
reach its HTML unless the render turns dynamic:

- SSR useSearchParams() waits on a per-request gate. It opens with real
  values once the render uses a dynamic API (the sticky latch), and
  otherwise bails out to client rendering once the RSC stream has ended
  and SSR has read the whole Flight response.
- An unwrapped call fails the render with a 500 and Next.js's
  missing-suspense message instead of rendering error.tsx.
- The navigation payload leaves the query for the browser to read.
- Candidate renders report x-vinext-cache: MISS with or without a query.
- Treat a candidate HTML render as dynamic once its request latches,
  since usage in child scopes (the layout probe, SSR) never reaches the
  render's own flag but still opens the gate with the real query.
- Keep the effective query in the navigation payload when the gate has
  opened before the head is written, so rewrite queries hydrate.
- Merge middleware headers into the missing-Suspense 500 and mark it
  never-cacheable.
…bleError

A cache-candidate render that settles static bails useSearchParams() out to
its Suspense boundary, which React reports on hydration as a recoverable
error carrying the BAILOUT_TO_CLIENT_SIDE_RENDERING digest. Like Next.js's
onRecoverableError, skip it before reporting it globally or failing the
initial visited-response cache publication.
@james-elicx
james-elicx force-pushed the isr-query/05-candidate-ssr-gates branch from a4d2a5a to fe36ba1 Compare September 25, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant