Skip to content

fix(app-router): send Next.js's never-cache header for known-dynamic App pages - #3453

Draft
james-elicx wants to merge 11 commits into
isr-query/02-revalidate-false-defaultfrom
isr-query/03-never-cache-control
Draft

james-elicx wants to merge 11 commits into
isr-query/02-revalidate-false-defaultfrom
isr-query/03-never-cache-control

Conversation

@james-elicx

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

Copy link
Copy Markdown
Member

Stacked on #3452. Third PR in the stack that brings ISR query-key handling in line with Next.js.

Problem

Next.js sends Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate for every App page response it knows is dynamic (getCacheControlHeader with revalidate: 0), and for draft mode (build/templates/app-page.ts). vinext sent no-store, must-revalidate for these responses.

Fix

The HTML and RSC response policies now send NEVER_CACHE_CONTROL for:

A direct intercepted RSC response gets the same header from its source route's config. Deriving intercepted renders' config from the intercepting tree isn't part of this stack. That's a parity gap already on main; the parked draft #3473 records one approach.

Some responses keep no-store, must-revalidate:

  • streamed misses whose store decision isn't known when headers go out, which keep the origin header (plan row 25);
  • skip-transport and mounted-slot variants;
  • script-nonce and progressive-action renders.

Tests

  • tests/app-page-response.test.ts: the policy matrix now expects the Next.js header for draft mode, force-dynamic, revalidate = 0 and dynamic renders. Script-nonce and progressive-action renders still expect no-store, must-revalidate.
  • tests/app-page-dispatch.test.ts and tests/app-page-render.test.ts: the two dynamic-before-headers assertions are updated.
  • Response Store e2e: /use-cache (connection() at the top) gets the Next.js header. 23/23 pass locally.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 807deb0f80c8c43ea1ffa2c0fe8ebaa5b27e52f9 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@3453

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: efd7761

): AppPageResponsePolicy {
if (options.isDraftMode) {
return { cacheControl: NO_STORE_CACHE_CONTROL };
return { cacheControl: NEVER_CACHE_CONTROL };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve the development cache policy here. All of the changed branches now return NEVER_CACHE_CONTROL regardless of options.isProduction, so draft-mode, force-dynamic, revalidate = 0, and early-dynamic dev responses receive the production header. Next.js sets the never-cache value for draft/dynamic production responses but then explicitly overwrites App-page responses with Cache-Control: no-store when routeModule.isDev; vinext has no later dev override on this path. Keep the new value production-only (at minimum retaining the existing no-store policy outside production) and add a dev assertion.

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 4521b0d (rebased). The never-cache value is now production-only. Dev keeps its existing no-store policy for draft mode, force-dynamic, revalidate = 0 and early-dynamic responses, and dev assertions cover it.

Comment thread tests/app-page-dispatch.test.ts Outdated
expect(probePage).not.toHaveBeenCalled();
expect(response.headers.get("x-vinext-cache")).toBeNull();
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
expect(response.headers.get("cache-control")).toBe("private, no-cache, no-store, max-age=0, must-revalidate");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Format the two changed long assertions before merging. vp check packages/vinext/src/server/app-page-response.ts tests/app-page-response.test.ts tests/app-page-render.test.ts tests/app-page-dispatch.test.ts currently fails formatting on this file and the analogous changed assertion in tests/app-page-render.test.ts, so the required Check job cannot pass.

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: the long assertions are formatted, and vp check passes on those files.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings. Targeted tests passed; vp check exposed the formatting failure.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

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

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared efd7761 against base 5cb2228 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.3 KB 142.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 129.7 KB 130.4 KB ⚫ +0.6%
Dev server cold start vinext 3.39 s 3.35 s ⚫ -1.3%
Production build time vinext 3.85 s 3.81 s ⚫ -1.2%
RSC entry closure size (gzip) vinext 129.7 KB 129.8 KB ⚫ +0.1%
Server bundle size (gzip) vinext 228.0 KB 228.4 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/03-never-cache-control branch from 522adb2 to 66e49eb Compare September 25, 2026 00:23
@james-elicx
james-elicx force-pushed the isr-query/03-never-cache-control branch from 66e49eb to e1a2459 Compare September 25, 2026 00:35
@james-elicx
james-elicx force-pushed the isr-query/03-never-cache-control branch from e1a2459 to 4521b0d Compare September 25, 2026 01:38
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 4521b0d4eec1f7b163ec9971caf24f294881f718 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/03-never-cache-control branch from 4521b0d to edaf06b Compare September 25, 2026 01:58
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head edaf06b98600ec42e72b09e5241aef5ae01f0f3d 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/03-never-cache-control branch from edaf06b to f3d4efc Compare September 25, 2026 02:05
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head f3d4efc93a1b312da8a902ef5d585bbc49e14dc7 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/03-never-cache-control branch from f3d4efc to 3c62f67 Compare September 25, 2026 02:25
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

copyLinkHeaderProvenance(response.headers, stamped.headers);
applyCdnResponseHeaders(stamped.headers, { cacheControl: NEVER_CACHE_CONTROL });
applyCdnResponseHeaders(stamped.headers, {
cacheControl: resolveUncacheableCacheControl(options.isProduction),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Apply this policy to all known-dynamic early responses

applyIneligibleRouteCachePolicy returns immediately when isDraftMode is true or isStaticEligible is true. Consequently draft-mode early responses and cacheComponents force-dynamic/revalidate = 0 early responses (for example a prepareElement redirect, probe special error, or SSR recovery response) never reach this changed header; the outer finalizer even skips redirects. These can leave without Next.js’s never-cache value. Make this helper recognize the same known-dynamic conditions as the normal response policies and add early-response coverage for them.

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 39c83b4. The early-response stamp now uses the same known-dynamic conditions as the normal response policies: a route that can't be static, draft mode, force-dynamic, or revalidate = 0. So draft-mode early responses, and cacheComponents force-dynamic / revalidate = 0 early responses, get Next.js's never-cache value (no-store in dev). The early-response test now covers a prepared redirect under each of the three, on a route that can otherwise be static.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests and checks passed; worktree remains clean at 3c62f67.

github run

@james-elicx
james-elicx force-pushed the isr-query/03-never-cache-control branch from 1ec5417 to 7ebbccd Compare September 25, 2026 18:07
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

const dynamicParamsResponse = await validateAppPageDynamicParams({
enforceStaticParamsOnly: options.dynamicParamsConfig === false,
generateStaticParams: options.generateStaticParams,
enforceStaticParamsOnly: dynamicParamsConfig === false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Treat intercept-only dynamic segments as a dynamic route

isDynamicRoute below still comes from the promoted source route. A static /feed can own @modal/(.)photo/[id] with dynamicParams = false; for an intercepted /photo/unknown request the current route is /feed, so route.isDynamic is false and validateAppPageDynamicParams returns before consulting the intercepted tree config/generator. The unknown target then renders 200. Derive this flag from the effective intercepting route/tree (or otherwise force validation when that tree introduces params), and cover a static source with a dynamic intercept target.

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.

The interception work moved out of this PR into #3473, which now fixes this in 214f417: an attached intercept's own dynamic segments make the params gate treat the intercepting tree as dynamic, so a static /feed rendering @modal/(.)photo/[id] under dynamicParams = false 404s an id its generators never returned. Covered in the dispatch tests.

generateStaticParams: options.generateStaticParams,
enforceStaticParamsOnly: dynamicParamsConfig === false,
generateStaticParams: currentRouteIntercept
? options.resolveRouteGenerateStaticParams?.(route, currentRouteIntercept)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Pass the complete intercepted-route parameter schema

Selecting the intercepting tree generators is incomplete without its required/optional parameter names. This call supplies neither, so areStaticParamsAllowed checks only keys present in the request and allows missing values. For @modal/(.)[[...photo]] with dynamicParams = false and a generator returning only { photo: ["known"] }, an interception where photo is omitted is accepted even though Next.js does not generate that path (it requires an explicit empty optional-catch-all value). Resolve the effective tree param names/types and pass requiredParamNames plus optionalCatchAllParamNames, with omitted optional-catch-all coverage.

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.

The interception work moved out of this PR into #3473, which now fixes this in 4a073a3. The gate now also compares the intercepting tree's optional catch-alls that the request omits, for both independent and chained generators. So @modal/(.)[[...photo]] 404s when the generators return only { photo: ["known"] }, and renders for { photo: [] }. I didn't pass requiredParamNames: in the validator that means metadata-route semantics, which reject every chained-only generator set and generated sets that lack a key a parent layout supplies.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings. All 227 targeted tests passed; worktree remains clean at 7ebbccd.

github run

…App pages

Next.js sends private, no-cache, no-store, max-age=0, must-revalidate for
every App page render it knows is dynamic (revalidate 0) and for draft mode.
vinext sent no-store, must-revalidate. Use Next.js's header for draft mode,
force-dynamic, revalidate = 0, and renders that are dynamic before headers
are sent. Streamed misses whose store decision isn't known when headers go
out keep the origin no-store header.
…esponses

An otherwise static page that calls connection() and then redirect() during
the probe, or reads a dynamic API before SSR recovers, now gets the
never-cache header on its early response too. As in the HTML response
policy, force-static and dynamic = "error" without a revalidate period stay
static.
…responses

The HTML response policy keeps nonce-bearing and progressive action renders
no-store, after draft mode, non-static routes and force-dynamic but before
revalidate = 0 and dynamic usage. Early responses now follow the same
order. Generated-param misses also pass every known-dynamic input, so
they get the same header as the other early responses.
…rce-dynamic and revalidate = 0 routes

In a cacheComponents build these routes stay static-eligible, so the
intercepted RSC response left without the header their own render sends.
…or its RSC cache policy

The direct intercepted RSC response renders the source route, but its
force-dynamic and revalidate = 0 checks read the matched target's config. A
cacheComponents build keeps every route static-eligible, so a force-dynamic
source left without the never-cache header, and a force-dynamic target
stamped it on a static source's payload. The response now uses the source
config activated for the render.
…boundary route

/boundary-ssr-only reads cookies(), so it is dynamic. Its assertion only held
because the build prerendered it: the static pass rethrows the SSR shell
error, renders the local error.tsx on the server, and that response went out
without the never-cache header, so the page was stored as a static 200.

Early responses now count the dynamic API read, so the route is skipped as
dynamic, as Next.js does. The runtime pass serves the __next_error__ shell and
the browser re-render succeeds, the same result an uncached request already
got before. The spec now asserts that, which also fails if the dynamic route
is prerendered again.
…rly-response policy

It passed only the static-eligibility inputs, so production read as dev and
sent dev's no-store header instead of Next.js's never-cache header.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

const isKnownDynamic =
options.revalidateSeconds === 0 ||
(!ignoresDynamicUsage && (options.peekDynamicUsage?.() ?? peekDynamicUsage()));
if (!isKnownDynamic) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Apply no-store before returning for nonce/action renders

For an otherwise static-eligible HTML render with no dynamic API read and a nonzero/null revalidate value, isKnownDynamic is false and this return runs before the nonce/progressive-action branch. An early response from prepareElement, a probe, or SSR recovery therefore gets no Cache-Control at all, whereas resolveAppPageHtmlResponsePolicy makes these render modes unconditionally no-store; that can expose request-specific nonce/action output to caching. Move the HTML nonce/progressive-action check before this return (after the draft/non-static/force-dynamic precedence) and add cases with no other dynamic signal.

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 efd7761: early responses now give nonce-bearing and progressive action HTML renders no-store right after the draft mode, non-static and force-dynamic checks, as the HTML policy does, with or without another dynamic signal. Added cases with no other dynamic signal.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. All 209 targeted tests and checks passed; worktree remains clean at 9c02b87.

github run

…-store

An early response of a static-eligible nonce-bearing or progressive action
HTML render without another dynamic signal left without a Cache-Control
header. The HTML policy makes those renders no-store right after draft mode,
non-static routes and force-dynamic, whatever else they do, and early
responses now follow the same order.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head efd77614ce31c94409d51c49af0acf7512873639 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 removed this pull request from stack #3474 September 25, 2026 21:30
@james-elicx
james-elicx added this pull request to stack #3481 September 25, 2026 21:32
@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