Skip to content

feat(app-router): admit rendered App pages only with a negative searchParams proof - #3461

Draft
james-elicx wants to merge 8 commits into
isr-query/09-rsc-hit-headersfrom
isr-query/11-admission-safety-net
Draft

james-elicx wants to merge 8 commits into
isr-query/09-rsc-hit-headersfrom
isr-query/11-admission-safety-net

Conversation

@james-elicx

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

Copy link
Copy Markdown
Member

Stacked on #3460. Second half of plan PR 4 (transport contract): the admission safety net.

Problem

Response-stage caches (Response Store, Workers Cache) are going to key App pages without the query. Completed-response admission trusted the renderer's cache policy without checking that the render left searchParams unread. The finalizer did have that observation, but it threw it away.

Change

  • The App page finalizer now carries the proof into the cacheability outcome: searchParamsUnread: true, set only when the render's observation shows no searchParams read.
  • Admission refuses the renderer's policy for an App page without that proof. The response is served with no-store, not replaced.
  • It runs only on outcomes that are otherwise cacheable, after the static-to-dynamic 500 branch, so a server await searchParams on a static-candidate path still gets the 500.
  • A response admitted through a later public policy, i.e. next.config headers, is exempt. That policy replaces the renderer's and is cached per URL, as in Next.js.

Tests

  • tests/cacheability-admission.test.ts: finalizer to admission, end to end. A render that left searchParams unread is admitted. One that read it, or has no observation, is kept private (these fail without the gate). A final config policy is still admitted without the proof.
  • Existing admission and response-stage tests now supply the proof where they expect admission.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 42faaa134b601fc40ef939b899941aa29cddeeb6 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@3461

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: 6f63f80

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 6f63f80 against base 3da2dd7 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 143.1 KB 143.4 KB ⚫ +0.2%
Client entry size (gzip) vinext 131.0 KB 131.5 KB ⚫ +0.4%
Dev server cold start vinext 3.45 s 3.42 s ⚫ -0.9%
Production build time vinext 3.89 s 3.90 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 130.7 KB 130.7 KB ⚫ +0.0%
Server bundle size (gzip) vinext 232.1 KB 232.5 KB ⚫ +0.2%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from 42faaa1 to 421ed11 Compare September 25, 2026 03:28
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 421ed11f9a10cc3032ec90fea604715394c59acf 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

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from 421ed11 to d122a5c Compare September 25, 2026 04:12
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head d122a5c08fbde414031f8bebebd59832c0484794 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

LGTM!

github run

@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from d122a5c to 14e43e7 Compare September 25, 2026 04:44
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

if (completed) return;
completed = true;

const observationState = options.consumeRenderObservationState?.();

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.

The observation consumer is now security-significant, but it runs from deferUntilStreamConsumed's stream pull/flush context. renderAppPageLifecycleImpl explicitly binds the analogous dynamic-usage reader because that context is no longer the render's unified request scope; this raw callback can therefore consume the fallback/current state and return an empty requestApis, incorrectly producing searchParamsUnread: true. Bind consumeRenderObservationState to the originating request context before passing it into deferred finalization (and add an integration test using the real request context rather than a closure returning a literal observation).

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 7a3f1a9. The observation consumer is now bound to the render's request context, like the dynamic-usage reader. The new test uses the real request context and cancels the probe's body outside it; it fails without the fix.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Focused tests pass: 85/85.

github run

@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from 14e43e7 to 7a3f1a9 Compare September 25, 2026 05:16
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

// in Next.js.
if (
state.route.kind === "app-page" &&
outcome === rendererOutcome &&

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.

Require an actual cache-policy replacement before skipping the proof. recordResponseStageCachePolicy sets explicitConfigCachePolicy for any nonempty transported policy, while resolveResponseStageCachePolicy can return only Vary. In that case inferFinalAppPageCacheability creates a fresh outcome with the renderer's unchanged cache-control, so outcome !== rendererOutcome and an otherwise cacheable App render lacking searchParamsUnread is admitted (notably with a verbatim-Vary adapter). This defeats the fail-closed missing-observation case. Please base the exemption on CDN cache-policy provenance/value rather than object identity, or only mark the config policy explicit when it contains a CDN response-policy header.

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 e97ed44. The exemption now needs the outcome's CDN cache-control to differ from the renderer's snapshot. A Vary-only config policy therefore still needs the renderer's searchParamsUnread proof. I kept explicitConfigCachePolicy as it is: it comes from main and also drives Route Handler admission.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Focused tests pass: 180/180.

github run

@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from 7a3f1a9 to e97ed44 Compare September 25, 2026 06:05
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

// rule) replaces nothing.
const replacesRendererPolicy =
outcome !== rendererOutcome &&
readCdnResponseCacheControl(response.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] Track config cache-policy provenance instead of comparing values. If the renderer snapshots s-maxage=60 and an explicit matching next.config rule also sets Cache-Control: s-maxage=60, inferFinalAppPageCacheability correctly creates the config-owned outcome, but this equality makes replacesRendererPolicy false and rejects a render without searchParamsUnread. That response is safe to admit because the config policy is cached per request URL, and the PR explicitly exempts such policies; this currently turns a valid configured cache hit into no-store. Please distinguish policy headers from Vary-only transport when recording config provenance, rather than requiring the final value to differ.

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 26c0a7d: the exemption now comes from provenance. A config rule that transports a CDN cache-policy header is recorded as owning the policy, even when the value matches the renderer's. A Vary-only transport still requires the proof. There are tests for a matching s-maxage=60 rule and for Vary-only.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Focused tests pass: 185/185.

github run

@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch 2 times, most recently from 85d2804 to 606a4a8 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/11-admission-safety-net branch from 606a4a8 to de1cee4 Compare September 25, 2026 18:51
@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/11-admission-safety-net branch from de1cee4 to c202a8c Compare September 25, 2026 19:18
@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch 2 times, most recently from 3b7bc7c to 2f6de9e Compare September 25, 2026 20:46
@james-elicx
james-elicx removed this pull request from stack #3474 September 25, 2026 21:30
@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from 2f6de9e to 12f343f Compare September 25, 2026 21:31
@james-elicx
james-elicx added this pull request to stack #3481 September 25, 2026 21:32
…hParams proof

Response-stage caches will key App pages without the query, so completed-
response admission now refuses the renderer's policy unless the render's
observation proves searchParams went unread. The finalizer used to discard
that observation; the outcome now carries the proof. The check runs after
the static-to-dynamic 500, and a later next.config policy, which replaces
the renderer's and is cached per URL, doesn't need it.
…text

A disconnecting client's cancel finishes the cacheability probe outside the
render's request scope, where the observation consumer read no request APIs
and proved searchParams unread.
…renderer's policy

A response-stage config policy marks the config policy explicit even when it
holds only Vary, so admission built a fresh outcome from the renderer's own
cache-control and skipped the searchParams proof by object identity. The
exemption now requires the final CDN cache-control to differ from the
framework's, and otherwise checks the renderer's proof.
…roof by its provenance

Admission compared the final cache-control with the renderer's to tell a replacing next.config policy from a Vary-only one, so a config rule matching the renderer's value was rejected without a searchParams proof. The config recording now notes whether the policy set a cache policy header, and that exempts the render.
…stage path

Single-stage App renders apply next.config headers through applyConfigHeadersToResponse, which marked only explicitConfigCachePolicy. A config rule matching the renderer's cache-control was then rejected without a searchParams proof. The config marker now also records that a cache policy header was set, as the response-stage path does; a Vary-only rule still requires the proof.
…precedence

Admission exempted a render from the searchParams proof whenever next.config set any cache policy header. With the Cloudflare adapter, a renderer's Cloudflare-CDN-Cache-Control outranks a config Cache-Control or CDN-Cache-Control, so the renderer's policy was admitted for every query. Config now records the policy headers it set by name and value, and the exemption applies only when the header the adapter reads carries config's value, including when it matches the renderer's.
…pter's own header name

Admission found the header an adapter reads by probing it with synthetic max-age values, but an adapter that interprets opaque values falls back past them, so a config Cache-Control could be credited with a renderer-owned edge policy and skip the searchParams proof. CdnResponsePolicy gains an optional readCacheControlHeaderName that the Cloudflare adapter implements; an adapter without it leaves the policy unattributed and the proof required. Config policy values are also recorded as Headers.set applies them, so a whitespace-padded value matching the response still counts as config-owned.
…config policy

A changed effective policy value is not provenance: an adapter without
readCacheControlHeaderName can map a renderer-owned header to a new value,
which the value-difference fallback treated as a config replacement. Grant
the exemption only when the adapter-reported (or default cache-control)
effective header carries the value next.config recorded for it.
@james-elicx
james-elicx force-pushed the isr-query/11-admission-safety-net branch from 12f343f to 6f63f80 Compare September 25, 2026 21:56
@james-elicx
james-elicx removed this pull request from stack #3481 September 25, 2026 22:13
@james-elicx
james-elicx added this pull request to stack #3482 September 25, 2026 22:14
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