Skip to content

fix(app-router): isolate query-dependent ISR HTML#2584

Open
james-elicx wants to merge 13 commits into
codex/security-cdn-late-dynamicfrom
codex/fix-app-isr-searchparams-poisoning
Open

fix(app-router): isolate query-dependent ISR HTML#2584
james-elicx wants to merge 13 commits into
codex/security-cdn-late-dynamicfrom
codex/fix-app-isr-searchparams-poisoning

Conversation

@james-elicx

@james-elicx james-elicx commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • include client-side query access in App Router cacheability decisions
  • keep request-dependent ISR HTML private while preserving static edge caching
  • restore each visitor’s current query state during hydration
  • preserve force-static and dynamic-error behavior across Suspense boundaries

Validation

  • origin and configured edge-cache production regressions
  • built-production Chromium hydration coverage
  • concurrent request and cancellation checks
  • focused server, cache, and compatibility suites
  • vinext and Cloudflare adapter builds

Dependency

Stacked on #2582 for bounded edge-cache eligibility verification.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: dc12d30

@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
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared dc12d30 against base 41821b6 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.6 KB 126.8 KB ⚫ +0.1%
Client entry size (gzip) vinext 120.6 KB 120.7 KB ⚫ +0.1%
Dev server cold start vinext 2.75 s 2.80 s 🔴 +2.0%
Production build time vinext 3.04 s 3.03 s ⚫ -0.5%
RSC entry closure size (gzip) vinext 99.6 KB 99.8 KB ⚫ +0.2%
Server bundle size (gzip) vinext 166.3 KB 166.7 KB ⚫ +0.3%

View detailed results and traces

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

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): isolate query-dependent ISR HTML

Reviewed the diff against the base branch codex/security-cdn-late-dynamic. This is a well-constructed security fix for ISR query-string poisoning. The design is sound, the dev/prod/edge parity is covered, and the targeted test suites pass locally (app-page-render, app-page-cache, app-router-production-server, check). A few observations below — none are blocking.

What the fix does (verified correct)

  • Cross-environment access reporting. SSR runs in a separate Vite module graph, so the fix threads a request-local onSearchParamsAccess() callback through NavigationContext and reports the result back to the RSC-owned cache lifecycle via a searchParamsAccessed promise. The promise resolves in cleanup, which is wired to deferUntilStreamConsumed — so it only settles after the HTML stream drains. Both consumers (app-page-cache-finalizer.ts and the CDN-verify path in app-page-render.ts) read the stream to completion before awaiting the promise, so there is no hang and the access flag is accurate (all useSearchParams() calls have run by flush time). Good.
  • No hang on the CDN non-complete path. htmlRender.searchParamsAccessed is only awaited inside if (verification.complete) (app-page-render.ts:1122-1128). When the verifier bails on deadline/maxBytes it hands off the stream and never awaits the promise, so a partially-drained stream can't deadlock. Confirmed against verifyCdnCacheCandidateStream (complete: true only on reader done).
  • force-static / dynamic-error modes. onSearchParamsAccess returns early for force-static (keeps HTML cacheable, since pageSearchParams is already emptied at app-page-dispatch.ts:578-581) and throws the static-generation error for dynamic="error". Correct given vinext's existing model.
  • Hydration restore. restoreHydrationNavigationContext(rsc.nav.pathname, window.location.search, params) uses the URL bar as authoritative for this visitor's query while keeping the server-rendered pathname. new URLSearchParams(window.location.search) handles the leading ? correctly, and this mirrors the pre-existing call at app-browser-entry.ts:1413. Emptying navPayload.searchParams to [] in the injected bootstrap is consistent since applyRuntimeRscBootstrap no longer reads it.

Non-blocking observations

1. dynamic="error" + client useSearchParams → 500 is a deliberate divergence from Next.js. In Next.js, useSearchParams() in a Client Component during prerender is not a server dynamic API — it triggers a client-side-render bailout to the nearest <Suspense> boundary (see the useSearchParams docs), and does not throw under dynamic="error". This PR makes it throw a 500 (tests/app-router-production-server.test.ts asserts status 500). That's a reasonable defense-in-depth choice for this security fix, but per AGENTS.md ("Fixing Bugs") a divergence from Next.js should be explicitly documented. Consider a code comment noting that vinext intentionally treats client useSearchParams access under dynamic="error" as a hard error rather than a CSR bailout, so a future reader doesn't "fix" it back to Next.js parity and reopen the poisoning hole.

2. New fixtures rely on vinext not enforcing the Suspense requirement. The isr-client-search-force-static and isr-client-search-poison fixtures call useSearchParams() in a client component with no <Suspense> boundary. Real Next.js fails the build here ("Missing Suspense boundary with useSearchParams"), and the CSR-bailout mechanism it documents works by serving the Suspense fallback in static HTML — so there is never a text mismatch. vinext instead renders "none" server-side and swaps to the URL value on the client, which is a React #418 hydration text mismatch. The deleted rsc-context-lazy-stream.test.ts cases documented exactly this #418 concern for the old bootstrap-embed approach. This is a pre-existing vinext characteristic (it doesn't enforce the Suspense rule), not a regression from this PR, and the security behavior is correct either way. But the E2E tests only assert the post-hydration text — they don't guard against console hydration errors. If you want to lock in "no hard hydration error," consider wrapping the fixture client components in <Suspense> (matching how Next.js docs recommend authoring these) so the tests exercise the same shape real apps use.

3. Minor: dead searchParams: [] in the bootstrap payload. buildHeadInjectionHtml now always emits searchParams: [] as [string, string][] and nothing reads it anymore (the browser entry restores from window.location). Harmless, and keeping the field avoids a wider type change, but it's now pure dead data — a short comment or eventual removal would reduce confusion.

Tests

Ran locally on the PR head (92bc746):

  • vp test run tests/app-page-render.test.ts tests/app-page-cache.test.ts → 93 passed
  • vp test run tests/app-router-production-server.test.ts → 71 passed
  • vp check on all 8 changed source files → clean (format, lint, types)

Overall this is a solid, well-tested fix. The observations above are about documenting the intentional Next.js divergence and hardening the hydration-error surface, not correctness of the security fix itself.

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2584.

Summary

I reviewed the diff of #2584 against its base branch codex/security-cdn-late-dynamic, traced the runtime flow across the RSC/SSR environment boundary, verified the stream/promise ordering, and ran the targeted test suites.

The fix is sound and well-tested. It correctly:

  • Threads a request-local onSearchParamsAccess() callback through NavigationContext to report Client Component useSearchParams() usage during SSR back to the RSC-owned cache lifecycle.
  • Resolves the searchParamsAccessed promise only after the stream drains (via deferUntilStreamConsumedcleanup), and both consumers read the stream to completion before awaiting it — no hang, accurate flag.
  • Avoids a deadlock on the CDN non-complete path (promise only awaited when the verifier fully drains).
  • Restores each visitor's live query from window.location.search during hydration while keeping the pathname from the server payload.

Non-blocking observations I flagged:

  1. dynamic="error" + client useSearchParams → 500 is a deliberate divergence from Next.js (which does a CSR bailout, not a throw). Reasonable for a security fix, but AGENTS.md wants such divergences documented in a code comment.
  2. New fixtures call useSearchParams() with no <Suspense> boundary, so SSR renders "none" then swaps on the client — a React docs/runtime: warn on external rewrites, sanitize Content-Disposition #418 hydration mismatch that the E2E tests don't guard against. Pre-existing vinext behavior, not a regression, but wrapping in <Suspense> would match real-world app shape.
  3. navPayload.searchParams is now always [] and unread — harmless dead data.

Tests run locally on head 92bc746: 93 + 71 passed, vp check clean on all 8 changed source files.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): isolate query-dependent ISR HTML

I reviewed the diff against the base branch codex/security-cdn-late-dynamic, traced the RSC/SSR boundary flow, and ran the targeted suites. The core poisoning fix (private query-dependent HTML + hydration restore from window.location.search) is sound. But there is a blocking test failure that contradicts one of the PR's stated goals.

🔴 Blocking: the dynamic = "error" test fails deterministically

tests/app-router-production-server.test.ts:1659 ("rejects client useSearchParams under dynamic = 'error' without poisoning later requests") fails on the PR head (87fc4d8):

× rejects client useSearchParams under dynamic = 'error' without poisoning later requests
AssertionError: expected 200 to be 500
- 500
+ 200
  1664| expect(errorRes.status).toBe(500);

Reproduced multiple ways:

  • Full file: vp test run tests/app-router-production-server.test.ts1 failed | 70 passed
  • Isolated: vp test run tests/app-router-production-server.test.ts -t "dynamic = 'error'" → fails on every run

The PR body claims it "preserve[s] force-static and dynamic-error behavior" and the prior review reported "71 passed" for this file — neither is accurate. CI (Vitest) will fail.

Root cause. The dynamic="error" path throws from onSearchParamsAccess (app-ssr-entry.ts:405):

if (incomingNavigationContext.searchParamsAccessMode === "error") {
  throw new Error(getAppPageStaticGenerationErrorMessage());
}

But onSearchParamsAccess is invoked from the SSR-environment client component (useSearchParams() in navigation.ts:1657), and the fixture — correctly, per the Next.js Suspense requirement — wraps that client component in <Suspense> (tests/fixtures/app-basic/app/isr-client-search-dynamic-error/[slug]/page.tsx). During streaming SSR, React catches the thrown error at the enclosing Suspense boundary, streams the fallback, and the request completes with 200, not 500. The synchronous throw never propagates to handleSsr's catch (app-ssr-entry.ts:776) or to renderAppPageHtmlStreamWithRecovery.

In Next.js, dynamic="error" is enforced during the RSC prerender (server dynamic-API access throws and propagates as a render/build error), not from a client component's SSR pass inside a Suspense boundary. Throwing from the SSR client render can't reliably reject — it just gets absorbed by Suspense.

What to do (pick one):

  1. Detect the "error" access mode where it can actually reject the render (RSC/prerender side, or by surfacing the flag to the lifecycle and returning a 500 non-cacheable response) rather than throw-ing inside the SSR client render behind Suspense.
  2. If a hard 500 for this case isn't feasible, treat "error" mode the same as "dynamic" for cacheability (set didAccessSearchParams = true, keep the HTML private, return 200) and update the test + PR description to reflect that — the security property (no shared HTML) is what matters, and this matches Next.js's CSR-bailout-not-throw behavior more closely.

Either way, the failing test must be green. Do not merge with a red required check.

⚠️ Order-dependent / shared-server test smell

The three new integration tests share the single production server (per the file's existing ISR caching notes). The dynamic-error test's result is independent of ordering here (it fails everywhere), but note that -t "ISR" and -t "search" name filters silently exclude it, which is how "71 passed" was mis-reported. When validating, run the file with no -t filter.

✅ What's correct (verified)

  • Cross-environment reporting & no hang. searchParamsAccessed resolves in cleanup via deferUntilStreamConsumed's flush. Both consumers drain the stream before awaiting: the finalizer reads streamForCache to completion (app-page-cache-finalizer.ts:172) before awaiting at :173; the CDN path only awaits inside if (verification.complete) (app-page-render.ts:1122-1125), and complete: true is set only when the verifier reads to done (app-page-cdn-verification.ts:192-193), which drains the deferred stream and fires cleanup. No deadlock on the CDN bail path.
  • Poison + force-static. Both integration tests pass. Force-static correctly returns early in onSearchParamsAccess and stays cacheable; poison correctly keeps per-query HTML private and un-shared.
  • Hydration restore. restoreHydrationNavigationContext(rsc.nav.pathname, window.location.search, params) (app-browser-entry.ts:1424) uses the URL bar as authoritative for the visitor's query while keeping the server pathname. Emptying navPayload.searchParams to [] (app-ssr-entry.ts:319) is consistent since the browser entry no longer reads it.

Minor

  • Fixtures already use <Suspense>. Contrary to the earlier review's observation #2, all new fixtures (force-static, poison, dynamic-error, query-isr) wrap useSearchParams() in <Suspense>, matching the Next.js-required shape. That's good — but it's also precisely why the dynamic="error" throw gets swallowed (see blocking issue).
  • Dead searchParams: [] as [string, string][] in buildHeadInjectionHtml (app-ssr-entry.ts:319) is now unread. Harmless; a short comment or removal would reduce confusion.

Test commands run

  • vp test run tests/app-router-production-server.test.ts1 failed | 70 passed
  • vp test run tests/app-cdn-cache-production.test.ts → 8 passed
  • vp test run tests/app-page-render.test.ts -t "CDN-managed HTML private" → 1 passed (note: this unit test stubs searchParamsAccessed: Promise.resolve(true), so it does not exercise the real throw-behind-Suspense path that the integration test exposes)

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2584 as Changes Requested.

Summary

I found a blocking issue that the previous review missed: the PR's own test for a stated goal fails deterministically.

🔴 Blocking — dynamic = "error" test fails (200 instead of 500):

  • tests/app-router-production-server.test.ts:1659 fails on the PR head, both in the full-file run (1 failed | 70 passed) and in isolation.
  • The PR body claims it "preserve[s]... dynamic-error behavior" and the earlier ask-bonk review reported "71 passed" — both inaccurate. CI (Vitest, a required check) will fail.
  • Root cause: onSearchParamsAccess throws for "error" mode, but it's invoked from an SSR-environment client component (useSearchParams()) that the fixture correctly wraps in <Suspense>. React's streaming SSR catches the throw at the Suspense boundary, streams the fallback, and returns 200 — the throw never reaches handleSsr's catch. Next.js enforces dynamic="error" during the RSC prerender, not from a client SSR render behind Suspense, so throwing there can't reliably reject.

Verified correct: the cross-environment searchParamsAccessed promise resolution (no hang/deadlock on either the finalizer or CDN-verify path), the poison + force-static protections (tests pass), and the hydration restore from window.location.search.

Corrections to the prior review: its claim that fixtures lack <Suspense> is wrong (they all have it), and its "71 passed" was a filtered run that silently excluded the failing test.

I also flagged the order-dependent shared-server test smell and the now-dead searchParams: [] payload field as minor items.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): isolate query-dependent ISR HTML

Reviewed at head 01acea3 (the PR was substantially reworked since the two prior ask-bonk reviews, which were against 92bc746/87fc4d8). I re-traced the RSC/SSR boundary flow, the client hydration path, and the stream/promise ordering, and ran the targeted suites. The previous blocking issue is resolved and the design is now more correct than the version that throw a 500 for dynamic="error". Approving.

The previous blocker is fixed — and the approach changed for the better

The earlier reviews correctly flagged that throwing a static-generation error from an SSR client render behind <Suspense> gets swallowed by React (200, not 500), so the old dynamic="error" test failed deterministically. This PR abandons that approach entirely. There is no longer an "error" access mode. The access-mode enum is now "dynamic" | "bailout" | "force-static" (navigation-context-state.ts:26), and dynamic="error" cacheable routes now resolve to "bailout" (app-page-dispatch.ts:578-585).

  • useSearchParams() in bailout mode throws BailoutToCSRError (navigation.ts:1661-1665), which React catches at the enclosing Suspense boundary and streams the fallback — matching Next.js's documented CSR-bailout behavior rather than diverging from it. The prior AGENTS.md "document the divergence" concern is moot because there is no longer a divergence.
  • prodOnRecoverableError now suppresses the expected bailout so it isn't reported as a hydration failure (app-browser-error.ts:85-91), mirroring Next.js.
  • Server-side dynamic="error" is still enforced where it can reliably reject: on the RSC prerender when the page-level searchParams prop is read (verified by tests/app-page-dispatch.test.ts:1459, which now asserts dispatchAppPage(...) rejects directly).

I ran the previously-failing file: tests/app-router-production-server.test.ts73 passed (0 failed). The dynamic="error" test now correctly asserts a cached 200 with the Suspense fallback, not a 500.

Verified correct

  • Cross-environment reporting & no deadlock. searchParamsAccessed resolves in cleanup via deferUntilStreamConsumed (app-ssr-entry.ts:439, 763-776). The finalizer reads streamForCache to completion at app-page-cache-finalizer.ts:172 (which, via the tee() at :154, drains the deferred SSR stream and fires cleanup) before awaiting the promise at :173. The CDN path only awaits inside if (verification.complete) (app-page-render.ts:1127), and complete is set only when the verifier reads to done. No hang on either the finalizer or the CDN bail path.
  • Hydration parity (the subtle part). This is the crux of avoiding React #418. applyHydrationNavigationSnapshot (app-browser-entry.ts:1387-1405) defers to window.location.search only for "dynamic" mode, but uses the neutral server-rendered params for "bailout"/"force-static". Because useSyncExternalStore's server snapshot (getServerSearchParamsSnapshot) reads the hydration-restored context, the client's initial render matches the SSR output (none/fallback), then getSearchParamsSnapshot switches to the live URL after commit (navigation.ts:1339-1360). This turns a would-be hydration mismatch into a post-hydration update. The E2E suite locks this in — every new case asserts hydrationErrors).toEqual([]) (client-cache.spec.ts, search-params-static.spec.ts).
  • Rewrite query semantics. clientSearchParams (the pre-rewrite browser-visible query) is now threaded from app-rsc-handler.ts:613 through dispatch to the SSR context. Server components' searchParams prop sees the rewrite destination query while client useSearchParams() sees the visible URL — matching Next.js, and covered by the rewrite-query-visible fixture + E2E (server sees hidden=secret, client sees only shown=yes, no console errors).
  • getInsertedHTML guard. useServerInsertedHTML callbacks run outside the component request scope, so reading useSearchParams() there is invalid. The temporary "bailout" mode + finally restore (app-ssr-entry.ts:716-727), plus the isBailoutToCSRError re-throw in renderInsertedHtml (app-ssr-entry.ts:241-243), correctly surface this as a 500 (verified by the inserted-error fixtures in both the unit and E2E suites).
  • Suspense requirement honored. All new fixtures wrap useSearchParams() in <Suspense> (e.g. isr-client-search-poison/[slug]/page.tsx), matching the Next.js authoring rule — which is precisely what makes the bailout cacheable and mismatch-free.

Tests run locally (head 01acea3)

  • tests/app-router-production-server.test.ts73 passed
  • tests/app-page-render.test.ts tests/app-page-cache.test.ts tests/app-page-response.test.ts → 125 passed
  • tests/app-cdn-cache-production.test.ts tests/app-browser-entry.test.ts → 236 passed
  • tests/app-page-dispatch.test.ts → 63 passed
  • tests/nextjs-compat/rsc-context-lazy-stream.test.ts tests/nextjs-compat/use-client-page-pathname.test.ts → 29 passed
  • vp check on all 13 changed source files → clean (format, lint, types)

Minor / non-blocking

  1. !options.scriptNonce disables bailout (app-page-dispatch.ts:584), so a CSP-nonce request on an otherwise-cacheable route falls back to "dynamic" (private, uncached) instead of the CSR-bailout-and-cache path. This is a safe conservative choice, but it's a silent cacheability difference for nonce'd deployments that isn't called out in a comment — a one-line note on why nonce forces the private path would help a future reader.
  2. Dead searchParams: [] payload — now carries an explanatory comment (app-ssr-entry.ts:318-321) addressing the earlier review; fine to leave as-is.
  3. Order-dependent shared-server integration tests remain (as noted in the file's own header). Not introduced by this PR, but worth running the file with no -t filter when validating, since name filters can silently exclude the query cases.

Overall: a solid, well-tested security fix that now aligns with Next.js semantics instead of diverging from them. No blocking issues.

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2584 as Approve.

Summary

I reviewed at the current head 01acea3the PR was substantially reworked since the two prior reviews (which tested 92bc746/87fc4d8). The previous blocking issue is now resolved, and the fix is more correct than before.

The blocker is fixed by changing the approach, not patching the test. The old design threw a static-generation error from an SSR client render behind <Suspense>, which React swallowed (200, not 500) → deterministic test failure. This PR removes the "error" access mode entirely. Cacheable dynamic="error" routes now resolve to "bailout", where useSearchParams() throws BailoutToCSRError → React CSR bailout → cached Suspense fallback. This now matches Next.js instead of diverging from it, so the prior AGENTS.md "document the divergence" concern is moot. Server-side dynamic="error" is still enforced on the RSC prerender where it can reliably reject.

Verified correct:

  • The previously-failing tests/app-router-production-server.test.ts73 passed (0 failed).
  • Cross-environment searchParamsAccessed promise: no deadlock on either the finalizer or CDN-verify path (both drain the stream before awaiting).
  • Hydration parity — the subtle part: bailout/force-static modes hydrate against neutral server params (matching SSR) then switch to the live URL post-commit, turning a would-be React docs/runtime: warn on external rewrites, sanitize Content-Disposition #418 mismatch into a post-hydration update. Every new E2E case asserts hydrationErrors === [].
  • Rewrite semantics: server prop sees destination query, client hook sees visible URL.

Non-blocking: !scriptNonce silently disables bailout for CSP-nonce'd routes (safe but undocumented); dead searchParams: [] now has a comment; order-dependent shared-server integration tests should be run without -t filters.

Also ran: page render/cache/response (125), CDN + browser entry (236), dispatch (63), nextjs-compat hydration (29), and vp check on all 13 changed source files (clean).

github run

@james-elicx james-elicx marked this pull request as ready for review July 10, 2026 17:45
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): isolate query-dependent ISR HTML

Reviewed at head dc12d30. The PR gained one new commit (dc12d30 "preserve static search shell during ISR") since the prior ask-bonk approval, which was against 01acea3. I focused this pass on that delta, re-traced the ISR regeneration path, and re-ran the targeted suites. The new commit fixes a real gap in the previously-approved version and is correct. Continuing to approve.

What the new commit fixes (verified correct)

Before dc12d30, the ISR background-regeneration render (runAppPageRevalidationContext) set the navigation context without a searchParamsAccessMode (app-page-dispatch.ts:519-523, old). With the mode unset, a Client Component useSearchParams() during regeneration skipped the bailout branch (navigation.ts:1661) and instead fired onSearchParamsAccess() (navigation.ts:1673) — a different code path from the initial render, which uses "bailout". The regenerated artifact could therefore diverge in shape from the initial static shell.

The commit sets searchParamsAccessMode: dynamicConfig === "force-static" ? "force-static" : "bailout" (app-page-dispatch.ts:523) on the regeneration context. Now regeneration throws BailoutToCSRError → React streams the Suspense fallback → the regenerated ISR artifact is the same clean static shell as the initial render. This is exactly what the strengthened E2E test now asserts: the full MISS → STALE → HIT lifecycle all serves the loading shell and never leaks FIRST/SECOND/THIRD_QUERY into shared HTML, while each visitor's client still shows their own live query (client-cache.spec.ts:520-554, dynamic-preload.spec.ts:79-113).

  • "bailout" default is safe here. renderFreshPageForCache only runs for cacheable routes (ISR / prerender); force-dynamic routes are never cached and never reach this path. Regeneration always uses empty searchParams (app-page-dispatch.ts:521, 733), so "bailout" produces a clean shell — consistent with the main dispatch path, which also resolves ISR routes to "bailout" (app-page-dispatch.ts:999-1003). No divergence between first render and regeneration.
  • revalidate: 60 → 1 on the dynamic-error fixtures is a test-timing change so the STALE→regen→HIT cycle is observable within the E2E window. Sound.

Worker fixture nonce scoping (verified no regression)

The commit narrows the fixture worker's CSP nonce from every request to only /dynamic-preload (fixture/worker/index.mjs:9-18). This is necessary and correct: scriptNonce intentionally forces the private/uncached path (app-page-dispatch.ts:585, app-page-render.ts:1179), so a global nonce would have made the new query-dynamic-error route uncacheable and defeated the HIT assertions.

I checked for collateral damage:

  • The existing nonce test still targets /dynamic-preload exactly (dynamic-preload.spec.ts:40-69), which is the one path usesNonce still covers. ✅
  • The other CSP-nonce E2E (hydration.spec.ts:65) runs against a different server (port 4176 = examples/app-router-cloudflare, nonce gated by ?csp-nonce=1 in its own middleware.ts), not this fixture (port 4192). Unaffected. ✅

Re-verified from the prior approval (still holds)

  • Cross-environment searchParamsAccessed promise: no deadlock on either the finalizer or CDN-verify path (both drain the stream before awaiting).
  • dynamic="error" server-side enforcement still happens on the RSC prerender where it can reliably reject.
  • Hydration parity: bailout/force-static hydrate against neutral server params, then switch to the live URL post-commit — a post-hydration update, not a #418 mismatch. Every new E2E case asserts hydrationErrors === [].

Tests run locally (head dc12d30)

  • tests/app-router-production-server.test.ts73 passed
  • tests/app-page-dispatch.test.ts tests/app-page-cache.test.ts → 100 passed
  • tests/app-page-render.test.ts tests/app-page-response.test.ts tests/app-browser-entry.test.ts → 313 passed
  • tests/app-cdn-cache-production.test.ts → 11 passed
  • vp check packages/vinext/src/server/app-page-dispatch.ts → clean (format, lint, types)

Minor / non-blocking (carried from prior reviews, still applicable)

  1. !options.scriptNonce silently disables the cache/bailout path (app-page-dispatch.ts:585, app-page-render.ts:1179). This is now visibly load-bearing — the fixture worker had to stop sending a global nonce for the new tests to cache. Safe and conservative, but a one-line comment near the !options.scriptNonce condition explaining "nonce'd requests take the private/uncached path" would help a future reader connect it to the fixture's nonce scoping.
  2. Order-dependent shared-server integration tests remain; run the file with no -t filter when validating (name filters have silently excluded query cases before).
  3. Dead searchParams: [] payload — already carries an explanatory comment; fine to leave.

Overall: the new commit closes a genuine regeneration-shape gap in the previously-approved fix, is well-covered by a strengthened full-lifecycle E2E, and introduces no fixture regressions. No blocking issues.

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2584 as Approve.

Summary

The PR gained a new commit (dc12d30 "preserve static search shell during ISR") since the last ask-bonk approval (which was against 01acea3), so I focused this pass on that delta.

The new commit fixes a real gap and is correct. Before it, the ISR background-regeneration render (runAppPageRevalidationContext) set the navigation context without a searchParamsAccessMode. With the mode unset, a client useSearchParams() during regeneration took a different code path (onSearchParamsAccess()) than the initial render ("bailout"), so the regenerated artifact could diverge in shape from the initial static shell. The commit sets searchParamsAccessMode to "force-static"/"bailout" on the regeneration context, so the regenerated ISR artifact is the same clean Suspense-fallback shell — verified by the now-strengthened full MISS → STALE → HIT E2E lifecycle that asserts no query ever leaks into shared HTML while each visitor keeps their own live query.

No fixture regression from the nonce scoping change. Narrowing the fixture worker's CSP nonce to only /dynamic-preload was necessary (nonce forces the private/uncached path). I confirmed the existing nonce test still targets /dynamic-preload, and the other CSP-nonce E2E runs against a different server (port 4176, examples/app-router-cloudflare), so it's unaffected.

Verified "bailout" default is saferenderFreshPageForCache only runs for cacheable routes; force-dynamic is never cached and never reaches it.

Tests run locally on head dc12d30: production-server 73, dispatch+cache 100, render/response/browser-entry 313, cdn-cache-production 11 — all passing; vp check clean on the changed source file.

Non-blocking notes carried forward: the now load-bearing !scriptNonce cache-disable deserves a one-line comment; order-dependent shared-server tests should be run without -t filters.

github run

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