fix(app-router): track searchParams access for static bailout#1788
Conversation
App Router pages that awaited or used the searchParams prop during prerender could still be classified as static when the prerender request had an empty query string. That seeded no-query output into the deploy cache and served it for requests like ?search=hello. The dynamic decision was based on query-string content instead of page prop access. Observe the searchParams thenable itself, including React use() status reads, and mark searchParams dynamic only when the prop is consumed.
commit: |
2e62a79 to
5f96477
Compare
5f96477 to
06ae8a2
Compare
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: searchParams static-bailout via access observation
I reviewed the full diff and ran the affected test files locally — all pass:
tests/app-page-dispatch.test.ts(16) +tests/app-page-probe.test.ts(21)tests/shims.test.ts(1027) +tests/app-page-element-builder.test.ts+tests/app-page-cache.test.ts+tests/app-page-render.test.ts(1126 total)tests/app-router-production-server.test.ts+tests/app-router-dev-server.test.ts(202)tests/prerender.test.ts(79)vp checkon the new/changed source files: clean.
What's solid
The overall direction is correct and matches Next.js semantics well:
- The cache-proof guard is positioned correctly.
readAppPageCacheResponsecheckshasRequestSearchParams && !hasQueryInvariantAppPageProofbefore both the HIT (app-page-cache.ts:373) and STALE (app-page-cache.ts:410) serving paths, so a query-bearing request can never be served an unproven entry from either path. It also applies to both RSC and HTML reads since it runs beforebuildAppPageCachedResponsebranches onisRscRequest. - The loading.tsx false-proof defense is real. When the observed thenable is awaited during render,
markDynamicUsage()fires, andfinalizeAppPageHtmlCacheResponseskips the write entirely (app-page-cache.ts:561-567). So no query-invariant entry is written for a page that actually readssearchParamsbehind a loading boundary. The dispatch test correctly allows either "nothing written" or "written-with-searchParams-observed proof." - The
observeReactPromiseStatussplit is a nice detail. Probe path opts in (app-page-probe.ts:259) so a React.statusread counts as access; the real render-tree thenable does not (so React'suse()caching.statusreads don't spuriously bail out). The element-builder tests pin this distinction. force-staticoverride correctly disables the proof requirement (app-page-dispatch.ts:530), matching Next.js whereforce-staticyields an emptysearchParams.- Defense-in-depth on cache keys: both the transform metadata (
appPageDefaultExport) and the non-enumerablemarkAppPagePropsForUseCachemarker are honored inunwrapThenableObjects, andthrowIfInsideCacheScopenow recordsinvalidDynamicUsageErroron the cache context so a caught throw still prevents storing under the omitted-searchParamskey.
Concerns (non-blocking)
-
Slot/intercept probe may over-bail (false dynamic). The probe walks all
route.slots, not just the slots mounted/active for this request. At render time, a slot with bothpage.tsxanddefault.tsxmay renderdefault(seeapp-page-route-wiring.tsx:741-750), but the probe unconditionally invokes__slot?.page?.default. A route whose inactive@modal/page.tsxawaitssearchParamswould mark the whole request dynamic on a request where that page never renders, defeating static caching for an otherwise-static page. This fails safe (toward dynamic), so it's a caching-efficiency regression rather than a correctness/security issue, but it diverges from the PR description's "active parallel slot pages" wording. -
Prerender HTML is now fully buffered in memory.
renderAppPageLifecyclebuffers the entire HTML viareadStreamAsTextthen re-emits (app-page-render.ts:902-903). This is build-time-only and #1795 already tracks replacing it, but worth calling out: large prerendered pages now hold the full HTML in memory before responding. -
Generated-entry thickness. Per AGENTS.md,
entries/app-rsc-entry.tsshould stay thin. TheprobePage()body now hand-rolls a slot/intercept probe loop inline. Consider extracting this into aserver/*helper (e.g.buildAppPageProbes(...)) so it's unit-testable directly and the entry stays codegen glue. The probe helper itself (probeAppPage) is already extracted; the fan-out over slots/intercepts is the part that grew inline.
None of these block merge. (1) is the one I'd most want a follow-up on, since it can silently disable caching for routes with parallel slots.
|
Review posted successfully. Here's a summary of my review of PR #1788. SummaryI reviewed the full diff for Verdict: Sound, well-tested, non-blocking concerns onlyWhat's correct:
Three non-blocking concerns flagged:
|
…tatic-bailout-standalone # Conflicts: # packages/vinext/src/server/app-page-probe.ts
Move the per-request page probe fan-out (matched page + parallel slot pages + interception page) out of the generated RSC entry and into a unit-testable buildAppPageProbes() helper in server/app-page-probe.ts, keeping entries/app-rsc-entry.ts thin (AGENTS.md guidance). Addresses bonk review concern #3 on cloudflare#1788. Document the known slot over-bail (probing inactive parallel-route slot pages can mark an otherwise-static page dynamic; fails safe toward dynamic) and track it as a caching-efficiency follow-up in cloudflare#1798 (bonk review concern #1).
|
Thanks for the thorough review. Addressed the actionable concerns: Concern #3 (generated-entry thickness) — fixed. Extracted the inline slot/intercept probe fan-out out of Concern #1 (slot probe over-bail) — tracked as follow-up. Filed #1798. As you noted this fails safe (toward dynamic — it never serves a stale query-bearing response), so it's a caching-efficiency regression rather than a correctness issue. A correct narrowing needs to mirror the render-time active-slot resolution (slot overrides from interception + mounted-slot state) currently computed in Concern #2 (prerender HTML buffering) — already tracked. Build-time-only and covered by #1795, as you noted. Also merged latest |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Re-review: searchParams static-bailout via access observation
Verified the full diff, read every changed source module (observation helper, buildAppPageProbes, dispatch, cache-proof guard, cache-runtime, headers shim, element-builder, render lifecycle, entry wiring), and ran the affected suites locally:
tests/app-page-probe.test.ts+tests/app-page-dispatch.test.ts— 41 passedtests/app-page-element-builder.test.ts+tests/app-page-cache.test.ts+tests/app-page-render.test.ts+tests/shims.test.ts— 1126 passedtests/app-router-production-server.test.ts+tests/app-router-dev-server.test.ts+tests/prerender.test.ts+tests/features.test.ts— 592 passedvp checkon the changed source files — clean (format + lint + types)
Verdict: correct and well-tested; no blocking issues
Confirmed correct:
- The query-proof guard (
readAppPageCacheResponse,app-page-cache.ts:358) runs before both the HIT (:373) and STALE (:410) serving paths and before the RSC/HTML branch inbuildAppPageCachedResponse, so a query-bearing request can never be served an unproven entry from either path. markAppPageSearchParamsAccess()callsthrowIfInsideCacheScope("searchParams")beforemarkDynamicUsage(), mirroringheaders()/cookies(). Inside a public"use cache"the throw recordsinvalidDynamicUsageErroron the cache context (headers.ts:274), andrunCachedFunctionWithContextrethrows it (cache-runtime.ts:745) so a caught throw still blocks the shared write. The twoshims.test.tscases pin this.- Cache-key omission is gated on explicit transform metadata (
appPageDefaultExport) or the non-enumerableAPP_PAGE_PROPS_CACHE_KEY_MARKER, not on object shape or cache-id strings — the "does not infer page props from the cache id alone" and "keeps arbitrary params/searchParams objects distinct" tests confirm userland functions are unaffected. force-staticcorrectly bypasses the proof requirement:hasRequestSearchParams: !isForceStatic && hasSearchParams(...)(app-page-dispatch.ts:530).- The
observeReactPromiseStatussplit (probe opts in viaapp-page-probe.ts:250, render-tree thenable does not) keeps React's internal.statusreads from spuriously bailing out, while the loading-boundary render-tree thenable still observes a realawait. Element-builder tests pin both directions. findInterceptis a pure route-graph lookup, so the extra call insideprobePage()is redundant work only on the probe path, not a correctness/side-effect concern. The new fan-out also strictly improves on the oldprobeAppPagecall, which never probed slots or intercepts.
Follow-ups (already tracked, non-blocking):
- Slot/intercept over-bail:
buildAppPageProbesprobes every declaredslot.page.defaultplus the intercept page rather than only the render-time active slot, which can over-bail an otherwise-static page. This fails safe (toward dynamic, never serving a stale query response) and is documented in thebuildAppPageProbesdoc comment with a link to #1798. - Prerender HTML is fully buffered in memory (
app-page-render.ts:902); build-time only, tracked by #1795.
Concern #3 from the prior review (generated-entry thickness) is resolved — the fan-out is now an extracted, unit-tested buildAppPageProbes() helper and the entry's probePage() is a single delegation. Looks good to merge once CI is green.
|
Review posted successfully. Here's a summary of my re-review of PR #1788. SummaryI reviewed the full diff for Verdict: correct and well-tested, no blocking issuesWhat I verified as correct:
Follow-ups (already tracked, non-blocking):
The prior review's actionable concern (generated-entry thickness) is resolved via the extracted, unit-tested |
Narrow the searchParams probe fan-out so it only probes page components that actually render for the request. When an interception matches, it replaces the page of the slot named by intercept.slotKey (the element builder sets overrides[slotKey].pageModule to the interception page, which wins over slot.page in app-page-route-wiring.tsx). We now probe the interception page in place of that slot's own page instead of probing both — probing the overridden slot page marked an otherwise static request dynamic for a component that never renders. Fixes the over-bail flagged in review of cloudflare#1788.
|
Update: I addressed concern #1 directly on the PR instead of punting it to a follow-up (closed #1798). Concern #1 (slot probe over-bail) — fixed in 08a86dc. The probe now only invokes page components that actually render for the request. The over-bail was specifically the interception-override case: when an interception matches, the element builder replaces the page of the slot named by Concern #3 (generated-entry thickness) — fixed (prior commit). Fan-out extracted into the unit-testable Concern #2 (prerender HTML buffering) — build-time-only, tracked by #1795 as you noted.
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: searchParams static bailout via access observation
I read the full diff and the key source modules (the new observation helper, buildAppPageProbes, the dispatch query-proof guard, the cache-runtime page-prop key handling, throwIfInsideCacheScope, the element-builder render-tree wiring, and the prerender buffering in renderAppPageLifecycle). I also ran tests/app-page-probe.test.ts + tests/app-page-dispatch.test.ts locally — 42 passed.
Verdict: correct and well-tested; no blocking issues
The approach (observe actual searchParams access instead of source-token classification) is sound and matches Next.js semantics. The pieces I verified as correct:
- Query-proof guard placement —
readAppPageCacheResponsecheckshasRequestSearchParams && !hasQueryInvariantAppPageProof(cachedValue)(app-page-cache.ts:358-371) before both the HIT (:373) and STALE (:410) serving paths, and beforebuildAppPageCachedResponsebranches on RSC vs HTML, so a query-bearing request can never be served an unproven entry from either path. - Cache-scope rejection ordering —
markAppPageSearchParamsAccess()callsthrowIfInsideCacheScope("searchParams")beforemarkDynamicUsage()(app-page-search-params-observation.ts:13-17), mirroringheaders()/cookies(). The recordedinvalidDynamicUsageErroris rethrown byrunCachedFunctionWithContext(cache-runtime.ts:745-747) so a caught throw still blocks the shared write. - Cache-key omission gating — keyed on explicit
appPageDefaultExporttransform metadata or the non-enumerableAPP_PAGE_PROPS_CACHE_KEY_MARKER(cache-runtime.ts:937-951), never on object shape or cache-id strings; the userland-distinct and cache-id-alone tests confirm arbitrary cached functions are unaffected. force-staticoverride —hasRequestSearchParams: !isForceStatic && hasSearchParams(...)(app-page-dispatch.ts:530) correctly disables the proof requirement.observeReactPromiseStatussplit — probe opts in (app-page-probe.ts:250-252) so a.statusread counts as access; the render-tree observed thenable does not, so React's internaluse()status reads don't spuriously bail. Element-builder tests pin both directions.- App page module detection —
isAppPageModule(index.ts) is correctly scoped viaisInsideDirectory(appDir, ...)so Pages Router files and non-pagebasenames are excluded.
Two non-blocking observations below.
| searchParams, | ||
| }), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Non-blocking: the comment block above (and the PR thread) state the probe "only invokes page components that actually render for this request." That's true for the interception-override case you fixed, but this loop still probes slot.page.default for every non-overridden slot, even when that slot will render its default.tsx instead of its page.tsx at render time.
At render time, buildAppPageElements picks overrideOrPageComponent ?? defaultComponent and additionally skips a slot entirely on soft-nav when only default.tsx exists and the slot is already mounted (app-page-route-wiring.tsx:741-750). So a mounted slot rendering default on an RSC soft-nav can still have its page.tsx probed here. This fails safe (toward dynamic — never serves a stale query response), so it's a caching-efficiency edge rather than a correctness bug, but the doc comment slightly overstates the precision. Worth either tightening the wording or tracking the soft-nav/mounted-default case as a follow-up.
There was a problem hiding this comment.
Good nudge on the wording — tightened the doc comment in bb51b70.
On the soft-nav/mounted-default case specifically: it can't actually over-bail. The continue at app-page-route-wiring.tsx:741 is gated on !overrideOrPageComponent, i.e. the slot has no page.tsx (and no override). In that case slot.page?.default is undefined, so probeAppPage short-circuits to null and probes nothing — a no-op, not a dynamic bailout. Conversely, when a slot does have a page.tsx, overrideOrPageComponent is truthy so that page renders (overrideOrPageComponent ?? defaultComponent), meaning probing slot.page.default is exactly correct.
So after the interception-override fix the fan-out is precise: page-bearing slots render their page (probed), default-only slots have nothing to probe. The doc comment now spells this out, plus notes that a default.tsx awaiting searchParams is backstopped by the real render's observation (so it can't under-bail either).
| // Eagerly read values that must be captured before the stream is consumed. | ||
| if (options.isPrerender === true) { | ||
| const bufferedHtml = await readStreamAsText(htmlStream); | ||
| htmlStream = createBufferedHtmlStream(bufferedHtml); |
There was a problem hiding this comment.
Non-blocking (already tracked by #1795): the prerender path now fully buffers the rendered HTML into a string via readStreamAsText and re-emits it through createBufferedHtmlStream. This is build-time-only, but for very large prerendered pages it holds the entire HTML in memory before responding. Fine to defer to the #1795 SSR metadata-readiness contract; flagging so it isn't lost.
There was a problem hiding this comment.
Acknowledged — this is build-time-only (prerender path) and intentionally deferred to the SSR metadata-readiness contract tracked in #1795, which is the right place to remove the full-HTML buffer. Keeping it as-is in this PR so the searchParams change stays focused; flagging here that it's tracked and not lost.
|
Review posted successfully. Here's a summary of my review of PR #1788. SummaryI reviewed the full diff for Verdict: correct and well-tested; no blocking issuesVerified correct:
Two non-blocking comments posted inline:
The PR's earlier-flagged actionable concerns (generated-entry thickness, interception over-bail) were both addressed in prior commits. Recommended to merge once CI is green. |
Address review feedback: the doc comment overstated precision without explaining why non-overridden slots are exact. Spell out that a slot with a page.tsx always renders that page (so probing it is correct), default-only slots have no slot.page.default so probing is a no-op (not an over-bail), and a default.tsx awaiting searchParams is backstopped by the real render's observation (so no under-bail).
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: searchParams static bailout via access observation
I read the full diff and the key source modules (the new app-page-search-params-observation helper, buildAppPageProbes, the dispatch query-proof guard, the cache-runtime page-prop key handling + invalidDynamicUsageError rethrow, throwIfInsideCacheScope, the element-builder/route-wiring searchParams forwarding, and the prerender buffering in renderAppPageLifecycle). Ran the affected suites locally — all green:
tests/app-page-probe.test.ts+tests/app-page-dispatch.test.ts+tests/app-page-cache.test.ts(72)tests/app-page-element-builder.test.ts+tests/app-page-render.test.ts(69)tests/shims.test.ts -t "searchParams|page default|cache id|params/searchParams"(7)
Verdict: correct and well-tested; no blocking issues
The shift from source-token classification to access observation is the right call and matches Next.js semantics. Confirmed correct:
- Query-proof guard placement —
readAppPageCacheResponse(app-page-cache.ts:358-371) checkshasRequestSearchParams && !hasQueryInvariantAppPageProofbefore both the HIT (:373) and STALE (:410) serving paths and beforebuildAppPageCachedResponsebranches on RSC vs HTML, so a query-bearing request can never be served an unproven entry from either artifact. - Caught-throw still blocks the shared write —
runCachedFunctionWithContextrethrowsctx.invalidDynamicUsageError(cache-runtime.ts:745-747) immediately afterfnreturns and before any cache-write logic, so catching thesearchParamsthrow inside a page-default"use cache"cannot store request-specific output under the omitted-searchParamskey. The twoshims.test.tscases pin this. - Cache-key omission gating — keyed on explicit
appPageDefaultExporttransform metadata or the non-enumerableAPP_PAGE_PROPS_CACHE_KEY_MARKER(cache-runtime.ts), never on object shape or cache-id strings.isAppPageModule(index.ts) is correctly scoped viaisInsideDirectory(appDir, ...)+path.parse(name).name === "page"+extensionRegex, so Pages Router files androute.ts/layout.tsxare excluded. Userland-distinct + cache-id-alone tests confirm arbitrary cached functions are unaffected. force-staticoverride —hasRequestSearchParams: !isForceStatic && hasSearchParams(...)(app-page-dispatch.ts:530) correctly disables the proof requirement.observeReactPromiseStatussplit — probe opts in (app-page-probe.ts) so a.statusread counts as access; the render-tree thenable does not, so React's internaluse()status caching doesn't spuriously bail. The loading-boundary render thenable still observes a realawait. Element-builder tests pin both directions.
One non-blocking observation inline. The previously-flagged concerns (generated-entry thickness, interception override over-bail) were addressed in prior commits, and prerender HTML buffering is tracked by #1795.
| asyncRouteParams: unknown; | ||
| searchParams: URLSearchParams | null | undefined; | ||
| intercept?: AppPageProbeIntercept; | ||
| /** Fallback raw params used when an interception match omits its own. */ |
There was a problem hiding this comment.
Non-blocking (fails safe toward dynamic): buildAppPageProbes fans out over the matched route's route.slots, but interception can re-target a different route. resolveAppPageInterceptState (app-page-request.ts:333-342) returns a source-route kind whose rendered tree is sourceRoute, not options.currentRoute — so the slots that actually render in that case are sourceRoute.slots, while the probe still walks the matched route's slots.
The interception page itself is still probed (so the modal that renders is covered), and a searchParams-reading slot under the source route is backstopped by the real render's observation skipping the cache write, so this can't under-bail or serve a stale query response. But it can probe a matched-route slot that doesn't render (over-bail) or miss a source-route slot at probe time (handled by the render backstop). Worth a line in the buildAppPageProbes doc comment noting the source-route interception case, or folding it into the #1798-style follow-up — the current comment only describes the current-route override case.
Also note interception only fires for isRscRequest (app-page-request.ts:324), but findIntercept in probePage() isn't gated on that, so HTML requests probe the interception page unconditionally. Same fail-safe direction.
There was a problem hiding this comment.
Fixed both points in df0fd12.
isRscRequest gating — buildAppPageProbes now ignores the interception match when !isRscRequest, matching resolveAppPageInterceptState (returns kind: "none" for non-RSC, app-page-request.ts:324). So HTML requests probe every slot's own page and skip the interception probe — no more skipping a slot that actually renders or probing an interception page that doesn't. Added a unit test pinning this.
Source-route interception — verified this case never reaches the probe: in dispatchAppPageInner, resolveAppPageIntercept returns interceptResult.response for the source-route kind and dispatch returns early (app-page-dispatch.ts:763), before probePage is invoked (:866). So by the time buildAppPageProbes runs, any matched interception is the current-route override case (sourceRoute === currentRoute), where intercept.slotKey is a slot in the matched route and probing the matched route's slots is correct. The over-bail/miss you described would require probePage to run in the source-route case, which it can't. Documented this in the buildAppPageProbes doc comment so the invariant is explicit.
vp check clean; probe/dispatch/entry-templates (68) and prod/dev server (202) pass locally.
|
Review posted successfully. Here's a summary of my review of PR #1788. SummaryI reviewed the full diff for Verdict: correct and well-tested; no blocking issuesConfirmed correct:
One non-blocking inline comment: I flagged the source-route interception case in The PR's previously-flagged concerns (generated-entry thickness, interception override over-bail) were addressed in prior commits, and prerender HTML buffering is tracked by #1795. Recommended to merge once CI is green. |
Interception only fires for RSC navigations (resolveAppPageInterceptState returns kind:none when !isRscRequest, app-page-request.ts:324). The probe fan-out called findIntercept unconditionally, so on HTML requests it would skip the matched route's slot page (which DOES render normally) and probe the interception page (which never renders). buildAppPageProbes now ignores the interception match when !isRscRequest, so HTML requests probe every slot's own page and skip the interception probe. The source-route interception case (a different route renders) never reaches this probe: dispatchAppPage returns the intercepted response before calling probePage, so any interception seen here is the current-route override case. Documented both in the buildAppPageProbes doc comment. Addresses review feedback on cloudflare#1788.
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: searchParams static bailout via access observation
Reviewed the full diff independently, read the core source modules (app-page-search-params-observation, thenable-params, buildAppPageProbes, the dispatch query-proof guard, cache-runtime key handling + invalidDynamicUsageError rethrow, throwIfInsideCacheScope, cache-proof, the element-builder/route-wiring forwarding, and the prerender buffering), and ran the affected suites locally — all green (probe+dispatch 43, shims cache-key cases 5).
Verdict: correct and well-tested; no blocking issues
The shift from source-token classification to access observation is the right approach and matches Next.js semantics. Independently confirmed:
- Query-proof guard placement —
readAppPageCacheResponsecheckshasRequestSearchParams && !hasQueryInvariantAppPageProofbefore both the HIT and STALE serving paths and beforebuildAppPageCachedResponsebranches on RSC vs HTML, so a query-bearing request can never be served an unproven entry. The proof requirescompleteness: "complete"+searchParams: notObservedviahasCompleteNegativeRequestApiProof. - Cache-scope rejection ordering —
markAppPageSearchParamsAccess()callsthrowIfInsideCacheScope("searchParams")first, mirroringheaders()/cookies(). The recordedinvalidDynamicUsageErroris rethrown byrunCachedFunctionWithContextimmediately afterfnreturns and before any cache-write logic, so a caught throw inside a page-default"use cache"still blocks the shared write. For the"use cache"+ query case the cache read is independently protected by the query-proof guard (the unproven entry is a miss regardless of dynamic marking). - Cache-key omission gating — keyed on explicit
appPageDefaultExporttransform metadata or the non-enumerableAPP_PAGE_PROPS_CACHE_KEY_MARKER, never on object shape or cache-id strings.isAppPageModuleinindex.tsis correctly scoped viaisInsideDirectory(appDir, ...)+path.parse(name).name === "page"+extensionRegex, so Pages Router files,route.ts/layout.tsx, and userland functions are unaffected. force-staticoverride —hasRequestSearchParams: !isForceStatic && hasSearchParams(...)correctly disables the proof requirement.observeReactPromiseStatussplit — the onlythenable-paramschange is the opt-instatusobservation; thehas/getOwnPropertyDescriptor/ownKeysobservation already existed pre-PR, so no key-derivation regression. The probe opts in; the render-tree thenable does not.- Source-route interception — verified
dispatchAppPageInnerreturnsinterceptResult.response(line 763) beforebuildAppPageElement/probePageruns, sobuildAppPageProbesonly ever sees the current-route override case where probing the matched route's slots is correct.markAppPagePropsForUseCachecreates a fresh props object per probe call, so theconfigurable: falsemarker can't double-define.
Follow-ups (already tracked, non-blocking)
- Prerender HTML is fully buffered in memory (
app-page-render.ts) — build-time only, tracked by #1795. - The generated-entry thickness and interception over-bail concerns from earlier rounds were both addressed in prior commits (
buildAppPageProbesextracted + unit-tested; interception probed in place of the overridden slot).
Recommend merging once CI is green.
|
Review posted successfully. SummaryI independently reviewed PR #1788 ( Verdict: correct and well-tested; no blocking issues. Key things I verified independently rather than trusting the PR description:
The earlier-round actionable concerns (generated-entry thickness, interception over-bail) were addressed in prior commits, and the remaining item (prerender HTML buffering) is build-time-only and tracked by #1795. |
Scope
Fixes 3 failing assertions from the upstream Next.js deploy-suite candidate in
test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts. With this patch, the full upstream file passes: 5/5 tests under the Vinext deploy harness.Refs #1524. This intentionally does not close #1524 because that issue also tracks the separate
app-static/ PagesuseSearchParams()failure, which is outside this standalone PR.CI report triage
The old #1788 blobs were not flaky. They showed branch regressions in static/ISR classification:
tests/app-router-dev-server.test.tsrevalidatepages emittedno-storeinstead of static/ISR cache headers.tests/features.test.tsno-store.tests/app-router-production-server.test.tspage ISR + searchParamscache behavior regressed.tests/prerender.test.tsA clean
origin/mainbaseline passed the prerender cases, so the report pointed at this PR’s dynamic-marking/cache-key behavior rather than CI noise.What changed
searchParamsis actually accessed/awaited/used.Page(props) { return <Child {...props} /> }is observed whenChildconsumessearchParams.probePage()or user page/server-component code before serving cached HTML/RSC. Query-bearing requests only serve cached app-page artifacts that carry a complete negativesearchParamsrender observation; older/unproofed artifacts fall through to a fresh render.searchParamsthenables observe actual access, soloading.tsxroutes that skip the early page probe still cannot write a false query-invariant cache proof. Passive RSC serialization of the prop remains inert.dynamic = "force-static"force-staticremains an explicit override and still allows static page cache reads even if the request has a query."use cache"page keysregisterCachedFunction(), so page-default cache keys omit only the rootsearchParamsprop. This is consumed at the cached-function call boundary and does not depend on React preserving a non-enumerable marker throughcreateElement().searchParamsinside public cache scopessearchParamsaccess now uses the same cache-scope guard asheaders()/cookies()/connection(). Access inside public"use cache"rejects and records invalid dynamic usage on the active cache context; if user code catches that throw, the cached wrapper still rethrows before storing a shared data-cache entry.{ params, searchParams }still key bysearchParams; page cache semantics are not inferred from object shape or cache id strings.This remains unstacked from the harness fix. The deploy-suite validation below was run with the #1786 harness packaging fix applied temporarily in a separate verification worktree; this PR does not include that harness change.
Follow-up
renderAppPageLifecycle()with an explicit SSR metadata-readiness contract. That work is intentionally not included here: this PR keeps the correctness fix local tosearchParamsstatic bailout, while the follow-up can change the SSR/RSC lifecycle boundary deliberately.Regression coverage
searchParamsproof.loading.tsx + revalidate + await searchParamsdoes not write a query-invariant cache entry and does not serve one query’s HTML for another query.searchParamsmention stays static and can read cached production HTML.force-staticoverrides observedsearchParamsaccess for cache reads.searchParams.buildPageElements()with two queries while reusing the same"use cache"entry and not observingsearchParams."use cache"rejects when it reads observedsearchParams, including the dangerous two-query case wheresearchParamsis omitted from the page-default cache key.searchParamsaccess inside page default"use cache"is still treated as invalid dynamic usage and is not stored under the omitted-searchParamskey.{ params, searchParams }keep distinct cache keys bysearchParams.Validation
vp checkResult: passed.
vp run knipResult: passed.
vp test run tests/shims.test.ts -t "app page searchParams access inside page default|caught searchParams access|page default export searchParams|cache id alone|params/searchParams objects"Result: 5 passed.
vp test run tests/app-page-element-builder.test.ts tests/shims.test.ts tests/app-page-probe.test.ts tests/app-page-dispatch.test.ts tests/app-page-render.test.tsResult: 1133 passed.
vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.tsResult: 5 passed, 0 failed. Run in a separate verification worktree with fix(test): package workspace deps in deploy harness #1786 applied temporarily for the deploy-harness packaging fix.
References
await searchParamsmust bail out from static output.use(searchParams)in a client page must bail out.searchParamsprop must preserve the same behavior.use().searchParamsaccess during prerender.searchParamsdocs