Skip to content

fix(app-router): bail out static search params rendering - #2882

Merged
james-elicx merged 3 commits into
mainfrom
codex/fix-app-static-searchparams-bailout-31439707085
Aug 11, 2026
Merged

fix(app-router): bail out static search params rendering#2882
james-elicx merged 3 commits into
mainfrom
codex/fix-app-static-searchparams-bailout-31439707085

Conversation

@james-elicx

@james-elicx james-elicx commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Fix the non-cache App Router parity failure from Actions run 31439707085, job 93624401572:

  • test/e2e/app-dir/app-static/app-static.test.ts
  • useSearchParams server response should bailout to client rendering with suspense boundary

During legacy static generation, vinext previously supplied an empty server navigation search-params value to client components. That rendered N/A into static HTML instead of throwing the canonical CSR bailout that lets React emit the nearest Suspense fallback.

This change threads static-generation and force-static state through the shared App Router SSR boundary, throws BailoutToCSRError("useSearchParams()") only for non-force-static legacy static artifacts, and applies the same state during runtime cache regeneration. Ordinary dev/runtime SSR remains server-rendered.

Active PPR fallback-shell generation is deliberately excluded from the legacy bailout so useSearchParams() continues to mark hasDynamicBoundary and preserve the existing PPR abort/cache-task coordination.

Existing james-elicx PR #2243 was not adopted because it conflicts with current main.

Next.js reference

Verified against Next.js v16.2.6 at ee6e79b1792a4d401ddf2480f40a83549fe8e722, including its useDynamicSearchParams behavior and the exact upstream app-static fixture/assertions.

Validation

  • Exact targeted Next.js command, run twice on the production implementation:
    REPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/app-static/app-static.test.ts
    • target server-response assertion: passed twice
    • target client/browser hydration assertion: passed twice
    • each aggregate: 86 passed, 7 skipped, 1 failed
    • sole failure: updateTag/revalidateTag should successfully update tag when called from server action; this is cache-function behavior explicitly outside this backlog item and unrelated to the changed static navigation/PPR paths
  • Full affected helper files (shims, app-page stream/cache-render/render/dispatch): 1,440/1,440 passed
  • Static PPR navigation regression plus related hook gates: 4/4 passed
  • Build-time prerender and ordinary runtime SSR regressions: 2/2 passed
  • Full App Router dev integration file after hardening its streaming assertion: 183/183 passed
  • Cache-regeneration option-forwarding gate: 1/1 passed
  • Scoped format/lint/type checks: passed
  • vp run vinext#build: passed
  • examples/app-router-cloudflare production build: all 5/5 RSC/client/SSR environments passed
  • Independent parity re-review: no findings
  • Big Bonk review at final head cdffbc54b6866d47770f8c66b8e411ede7263bdb: no blocking issues
  • Final CI/check matrix: 66/66 completed clean

The only post-implementation follow-up narrows the dev regression to assert the resolved runtime query value. A cold streaming render may legitimately include a transient Suspense shell before the resolved value in the same HTML response; presence of the resolved value is what distinguishes runtime SSR from the static CSR bailout.

This PR is intentionally limited to the non-cache useSearchParams Suspense failure. It does not change use cache, Cache Components, or cache-function semantics.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: cdffbc5

@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 Aug 11, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared cdffbc5 against base d558d62 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 135.2 KB 135.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.7 KB 122.7 KB ⚫ +0.0%
Dev server cold start vinext 2.95 s 2.92 s ⚫ -0.9%
Production build time vinext 3.06 s 3.07 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 114.2 KB 114.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 193.0 KB 193.2 KB ⚫ +0.1%

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): bail out static search params rendering

I traced the full data flow and ran the affected tests. No blocking issues found. The change is correct, well-scoped, and matches Next.js useDynamicSearchParams semantics.

What I verified

1. Cross-environment PPR state sharing is sound. The bailout guard is:

ctx?.isStaticGeneration === true && ctx.isForceStatic !== true && getPprFallbackShellState() === null

The concern was that useSearchParams() runs in a "use client" component during the SSR environment (a separate Vite module graph from RSC), so a module-local getPprFallbackShellState() could return null during PPR fallback generation and incorrectly bail out. This is not a problem: ppr-fallback-shell.ts obtains its ALS via getOrCreateAls(...), which is a globalThis[Symbol.for(...)] cross-module-instance singleton (shims/internal/als-registry.ts). Combined with dispatchAppPage wrapping the entire dispatch — including the awaited handleSsr — in pprRuntime.run() (app-page-dispatch.ts:618), the SSR-env call correctly sees the non-null shell state and suppresses the bailout. The PPR exclusion described in the PR body holds.

2. State threading is complete for both static paths.

  • Lifecycle render: app-page-render.ts:1065 sets isStaticGeneration: options.isPrerender === true — no bailout for ordinary runtime SSR (confirmed by the new dev-server test).
  • Cache regeneration: app-page-cache-render.ts:98 sets isStaticGeneration: true unconditionally with isForceStatic forwarded from revalidationDynamicConfig === "force-static" (app-page-dispatch.ts:824), consistent with the sibling observe*SearchParamsAccess guards.
  • The single renderAppPageHtmlStream call site (via renderAppPageHtmlStreamWithRecovery) carries the flags through app-page-stream.tshandleSsr.

3. No client-payload leak. buildHeadInjectionHtml (app-ssr-entry.ts:323) serializes only pathname/searchParams/params into the bootstrap script; isStaticGeneration/isForceStatic are not serialized, matching the navigation-context-state.ts doc comment ("Client navigation payloads omit it").

4. handleSsr shallow-copy is safe. The new spread at app-ssr-entry.ts:407 creates a fresh nav context; both setNavigationContext and buildHeadInjectionHtml (line 703) read the copy, so no identity/mutation regression.

5. force-static path. ctx.isForceStatic !== true short-circuits the bailout and falls through to the pre-existing empty-search-params snapshot behavior (verified by the new shims test).

Tests run locally (all pass)

  • tests/shims.test.ts — new bailout / fallback-shell / force-static cases
  • tests/prerender.test.ts — Suspense fallback emitted into static HTML
  • tests/app-router-dev-server.test.ts — runtime SSR stays server-rendered
  • tests/app-page-cache-render.test.ts — regeneration marks static state
  • vp check on all 5 changed source files — format/lint/types clean

Minor observations (non-blocking)

  • The shims.test.ts unit tests exercise getPprFallbackShellState() synchronously (same call stack), which does not cover the RSC→SSR cross-environment ALS propagation. The prerender.test.ts integration test does cover the real path, so coverage is adequate — just noting the unit test alone would not catch an ALS-scoping regression.
  • Consider a future integration test asserting a PPR route with useSearchParams does not bail out (the current PPR unit test only checks hasDynamicBoundary, not the end-to-end HTML), to lock in the exclusion at the integration level.

LGTM.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2882. Summary of my findings:

No blocking issues found

I traced the full data flow and ran the affected tests. The change correctly threads static-generation state through the App Router SSR boundary and throws the canonical BailoutToCSRError("useSearchParams()") for non-force-static legacy static artifacts.

Key correctness checks that passed:

  1. Cross-environment PPR state sharing — The trickiest part. useSearchParams() runs in the SSR Vite environment (separate module graph from RSC), so I verified the getPprFallbackShellState() === null guard works across environments. It does: the PPR ALS is a globalThis[Symbol.for(...)] singleton shared across all module instances, and dispatchAppPage wraps the awaited handleSsr inside pprRuntime.run(). So PPR fallback shells correctly suppress the bailout.

  2. Complete state threading — Both static paths (lifecycle prerender via isPrerender, and cache regeneration unconditionally) set the flags; ordinary runtime SSR does not (isPrerender === false).

  3. No client-payload leakbuildHeadInjectionHtml serializes only pathname/searchParams/params; the new fields are not sent to the client, matching the doc comment.

  4. Safe shallow copy in handleSsr; force-static correctly short-circuits.

Tests I ran locally — all pass: the new shims, prerender, dev-server, and cache-render cases, plus vp check (format/lint/types) on all 5 changed source files.

I noted two non-blocking suggestions: the unit tests don't cover the RSC→SSR cross-environment ALS path (the integration prerender test does), and a future integration test asserting a PPR route does not bail out would lock in the exclusion end-to-end.

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): bail out static search params rendering

I independently traced the full data flow and ran the affected tests. No blocking issues found. The change is correct, well-scoped, and matches Next.js useDynamicSearchParams semantics.

What I verified

1. State threading is complete and asymmetric in the right way.

  • Lifecycle render sets isStaticGeneration: options.isPrerender === true (app-page-render.ts:1065), so ordinary runtime SSR never bails (confirmed by the new app-router-dev-server.test.ts case, which asserts ?value=runtime still server-renders runtime).
  • Cache regeneration sets isStaticGeneration: true unconditionally with isForceStatic forwarded from revalidationDynamicConfig === "force-static" (app-page-dispatch.ts:824).
  • isForceStatic is a real required field on the lifecycle options type (app-page-render.ts:139), not a silent undefined typo — plumbing is genuine.

2. Cache-regeneration path can't wrongly bail dynamic routes. My main correctness concern was that renderAppPageCacheArtifacts sets isStaticGeneration: true unconditionally. But regeneration is only entered for cacheable artifacts: force-dynamic / revalidateSeconds === 0 are excluded from caching (app-page-method.ts:23, app-page-cache.ts:461), and the regenerated element is built with an empty new URLSearchParams() (app-page-dispatch.ts:789). So a dynamic route never reaches this path, and search params are correctly meaningless in the static artifact.

3. Cross-environment PPR exclusion holds. useSearchParams() runs in the SSR Vite environment (separate module graph from RSC). The getPprFallbackShellState() === null guard works across environments because the PPR ALS is a globalThis[Symbol.for(...)] singleton (shims/internal/als-registry.ts) and dispatchAppPage wraps the awaited handleSsr in pprRuntime.run(). When the shell state is non-null the code skips the throw and falls through to markPprFallbackShellDynamicBoundary(), preserving hasDynamicBoundary (verified by the fallback-shell shims test). In the non-PPR static path there is no shell, so throwing before markPprFallbackShellDynamicBoundary() (a no-op there) is harmless.

4. Bailout is a recognized control-flow signal. BailoutToCSRError's BAILOUT_TO_CLIENT_SIDE_RENDERING digest is matched by getDigestForWellKnownError (app-rsc-errors.ts:62), so it is not reported as a real server error — React renders the nearest Suspense fallback instead of crashing the render.

5. No client-payload leak. buildHeadInjectionHtml serializes only pathname/searchParams/params (app-ssr-entry.ts:323); the new isStaticGeneration/isForceStatic fields are never sent to the client, matching the navigation-context-state.ts doc comment. The handleSsr shallow spread (app-ssr-entry.ts:407) creates a fresh nav context read by both setNavigationContext and buildHeadInjectionHtml, so no identity/mutation regression.

6. Symmetry with sibling guards. isForceStatic is applied consistently with the existing observeMetadataSearchParamsAccess/observePageSearchParamsAccess !== "force-static" guards on the RSC side. Those observe server-component searchParams props; the new bailout covers SSR of client-component useSearchParams() — distinct mechanisms, kept consistent.

Tests run locally (all pass)

  • tests/shims.test.ts -t useSearchParams — 7 passed (bailout, fallback-shell preservation, force-static empty snapshot)
  • tests/prerender.test.ts — the integration case asserts the Suspense fallback IS emitted into static HTML and id="search-params-value" is NOT, exercising the real RSC→SSR cross-environment path end-to-end
  • tests/app-page-cache-render.test.ts, tests/app-page-stream.test.ts, tests/app-router-dev-server.test.ts — all pass
  • vp check on all 7 changed source files — format/lint/types clean

Minor observations (non-blocking)

  • The shims.test.ts unit tests exercise getPprFallbackShellState() on the same synchronous call stack, so they don't cover the RSC→SSR cross-environment ALS propagation on their own. The prerender.test.ts integration test does cover the real path, so coverage is adequate.
  • A future integration test asserting a PPR route with useSearchParams does not bail out (current PPR unit coverage only checks hasDynamicBoundary) would lock the exclusion in at the integration level.

LGTM.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2882.

Summary

I independently reviewed PR #2882 (fix(app-router): bail out static search params rendering), tracing the full data flow across all 5 changed source files and running the affected tests. No blocking issues found.

Key correctness checks that passed

  1. State threading is asymmetric in the right way — Lifecycle render only marks isStaticGeneration when isPrerender === true, so ordinary runtime SSR never bails. Cache regeneration marks it unconditionally with isForceStatic forwarded correctly.

  2. Cache-regeneration can't wrongly bail dynamic routes — My main concern was the unconditional isStaticGeneration: true in renderAppPageCacheArtifacts, but regeneration is only entered for cacheable artifacts (force-dynamic/revalidate=0 are excluded from caching), and the element is built with empty search params. Sound.

  3. Cross-environment PPR exclusion holds — The getPprFallbackShellState() === null guard works across the RSC/SSR module graph split because the PPR ALS is a globalThis[Symbol.for(...)] singleton and dispatch wraps the awaited handleSsr in pprRuntime.run().

  4. Bailout is a recognized control-flow signal — the digest is matched by getDigestForWellKnownError, so React renders the Suspense fallback rather than crashing.

  5. No client-payload leak — the new fields are never serialized into the bootstrap script.

  6. isForceStatic is a real required field, not a silent-undefined typo, and is applied consistently with the sibling observe*SearchParamsAccess guards.

The prerender.test.ts integration test is the strongest check: it asserts the Suspense fallback IS in the static HTML and the value is NOT, exercising the real RSC→SSR path end-to-end. All targeted tests and vp check pass. LGTM.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 02:14
@james-elicx
james-elicx marked this pull request as draft August 11, 2026 02:14
@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 02:16
@james-elicx

Copy link
Copy Markdown
Member Author

Independent exact-head review of cdffbc54b6866d47770f8c66b8e411ede7263bdb: NO FINDINGS.

I reviewed the full fourteen-file diff independently against Next.js's current useDynamicSearchParams work-unit behavior. The static-generation state is carried across the RSC/SSR environment boundary, the canonical CSR bailout is limited to legacy static artifacts, force-static remains empty without bailing, ordinary runtime/dev SSR remains server-rendered, and active PPR fallback shells preserve dynamic-boundary tracking through the shared ALS.

The cache-render touch is route-artifact regeneration plumbing, not use cache, cache-function, or Cache Components semantics; non-cacheable dynamic routes cannot enter that producer. The existing required-wrapper proof exercises the target app-static server and hydration assertions twice, with its sole aggregate failure explicitly confined to the excluded cache-function behavior.

Fresh exact-head validation:

  • focused shim, stream, route-cache-artifact, prerender, and dev integration tests: 1,594/1,594 passed;
  • scoped format, lint, and types: clean;
  • git diff --check: clean;
  • remote and local head SHAs matched.

I found no actionable bailout, PPR, force-static, ordinary-SSR, or scope-isolation issue.

@james-elicx
james-elicx merged commit f48aeb5 into main Aug 11, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-app-static-searchparams-bailout-31439707085 branch August 11, 2026 13:19
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