Skip to content

fix(app-router): default static and SSG routes to revalidate = false - #3452

Draft
james-elicx wants to merge 10 commits into
isr-query/01-static-eligibilityfrom
isr-query/02-revalidate-false-default
Draft

james-elicx wants to merge 10 commits into
isr-query/01-static-eligibilityfrom
isr-query/02-revalidate-false-default

Conversation

@james-elicx

Copy link
Copy Markdown
Member

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

Problem

Next.js defaults every static or SSG App page to revalidate = false. It serves such a page with s-maxage=31536000 until revalidatePath, revalidateTag or the next deploy.

vinext only applied that default to generateStaticParams routes. On a route without dynamic segments (or a force-static route) with no revalidate export, fetch revalidate or cacheLife, revalidateSeconds stayed null. The finalizer then found no cache policy, so core, the Response Store and Workers Cache never stored the page.

Two related gaps on routes that are already Infinity (generateStaticParams routes and explicit revalidate = false):

  • RSC capture was skipped (mayResolveCacheLifeAfterHeaders was false for Infinity). So an RSC-only miss stored nothing, and a cacheLife resolved after headers couldn't lower the rsc: lifetime.
  • The RSC response policy sent STATIC for any Infinity render. That made the finalizer keep s-maxage=31536000, even on a miss that reaches a dynamic API late, for example cookies() under Suspense. The HTML policy already sends MISS for the same case.

Fix

  • Revalidate default: dispatch defaults every static-eligible route (from fix(app-router): only full-page cache routes Next.js classifies as static or SSG #3451) to revalidate = false. Non-eligible and PPR routes keep today's value, and fetch caching is unchanged because the fetch shim treats null and Infinity the same. Literal routes now take the shouldWriteToCache path that generateStaticParams routes already use, instead of the speculative path.
  • RSC capture: mayResolveCacheLifeAfterHeaders is now true for Infinity on static-eligible routes. RSC is captured and stored from the same render, and a cacheLife resolved later still lowers the stored lifetime (min(route revalidate, cacheLife)).
  • RSC MISS: in production, an Infinity RSC render is sent as MISS, so the finalizer applies the pending-dynamic headers, as it does for HTML.
  • STATIC for force-static: only force-static and dynamic = "error" routes whose revalidate is absent or false keep STATIC, since they can't turn dynamic while streaming. The HTML policy's force-static branch now accepts the defaulted Infinity too.

Behaviour change

Static pages with no revalidate source are now cached until they're revalidated, as on Next.js. A page that reads data without a dynamic API, for example a direct database query, now stays cached until revalidatePath/revalidateTag or the next deploy.

Tests

  • tests/app-page-render.test.ts:

    • HTML and RSC are stored with revalidate: Infinity;
    • a cacheLife resolved after headers lowers both entries to 60 s;
    • an RSC-only miss stores rsc: with the cacheLife lifetime;
    • an Infinity RSC miss doesn't send static headers;
    • force-static keeps STATIC.

    Three of these fail without this change.

  • tests/app-page-dispatch.test.ts: a literal route with no revalidate source writes html: and rsc: with Infinity, and adapter admission reports it cacheable. All three fail without this change.

  • tests/app-page-response.test.ts: the RSC policy matrix.

  • Response Store e2e: a new /static-default demo page goes MISS → HIT with the same render, and its RSC request hits. 22/22 pass locally.

  • Deployed e2e (cache-prewarm.spec.ts): /static-default is served from one render on the response-store, workers-cache and KV deployments.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 1bf33db041b6f93b99762cc39b33303cc0b84a9c 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 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3452

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: 5cb2228

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5cb2228 against base b2b9419 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 129.7 KB ⚫ -0.0%
Dev server cold start vinext 3.39 s 3.36 s ⚫ -0.9%
Production build time vinext 3.92 s 3.88 s ⚫ -0.9%
RSC entry closure size (gzip) vinext 129.6 KB 129.7 KB ⚫ +0.1%
Server bundle size (gzip) vinext 227.9 KB 228.0 KB ⚫ +0.1%

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

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head bf6a826278f7235e84718b197d4b6c3bffff23f5 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/02-revalidate-false-default branch 2 times, most recently from c0dad91 to 125c186 Compare September 25, 2026 01:34
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 125c1863afffd4239275753750664ac30426ab5f 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/02-revalidate-false-default branch from 125c186 to 7c37905 Compare September 25, 2026 01:58
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

revalidate: effectiveRevalidate,
// JSON can't hold Infinity, so store `revalidate = false` as false,
// like Next.js does. Reads turn it back into Infinity.
revalidate: Number.isFinite(effectiveRevalidate) ? effectiveRevalidate : 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.

[P2] Avoid giving revalidate = false entries the finite KV TTL. effectiveRevalidate is still Infinity here, so the revalidateAt calculation above produces Infinity and the later revalidateAt !== null check passes ttlSeconds (30 days) to kv.put. JSON turns the stored timestamp into null, but Cloudflare KV will still delete the key after 30 days, so these pages do not remain cached until invalidation/deploy as intended. Treat non-finite revalidate values as having no revalidateAt/expirationTtl, and assert the put options in the round-trip test.

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 c974d11. revalidateAt is now only set for a finite positive revalidate, so revalidate = false entries get no revalidateAt and no KV expirationTtl. They stay until they're invalidated, like entries stored with no revalidate. The round-trip test now asserts the put options (expirationTtl: undefined).

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests and checks passed.

github run

@james-elicx
james-elicx force-pushed the isr-query/02-revalidate-false-default branch from 7c37905 to c974d11 Compare September 25, 2026 02:05
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

typeof effectiveRevalidate === "number" && effectiveRevalidate > 0
typeof effectiveRevalidate === "number" &&
effectiveRevalidate > 0 &&
Number.isFinite(effectiveRevalidate)

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] Keep invalidation markers as long as indefinite entries. This change gives revalidate = false entries no KV TTL, but revalidateTag() markers still expire after 30 days below. If such an entry is invalidated and is not read during that window, the marker disappears while the entry remains; the next read accepts the old entry again and serves invalidated content. Persist these markers without an expiration (newer entries are already protected by the lastModified comparison), or otherwise ensure an invalidated entry cannot outlive its marker, and cover the >30-day case.

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 01d284ccc. revalidateTag() markers are now written with no expirationTtl, so an invalidated revalidate = false entry can't outlive its marker. Newer entries still pass the marker by lastModified. The new test stores a revalidate = false entry, invalidates its tag, asserts the marker put has no options, and checks that a fresh handler 31 days later still treats the entry as invalidated.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted checks passed: 319 tests and focused format/lint/type checks.

github run

@james-elicx
james-elicx force-pushed the isr-query/02-revalidate-false-default branch from c974d11 to 3d66f3b Compare September 25, 2026 02:25
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 3d66f3bc0096e070c08cdc534feb7d34bab9cc3e 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/02-revalidate-false-default branch from 3d66f3b to 4b874c3 Compare September 25, 2026 02:37
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 4b874c33a85ba0eaf3274e058444042f6a226e61 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/02-revalidate-false-default branch from 4b874c3 to 3827fbd Compare September 25, 2026 02:57
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 97b0d84009379f15436a0634014c2be51004d787 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/02-revalidate-false-default branch from 97b0d84 to 6cda46e Compare September 25, 2026 13:54
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

options.isProduction && options.pprRuntime === undefined && isNextStaticEligible;
const currentRevalidateSeconds =
options.revalidateSeconds ??
(hasStaticRevalidateDefault || options.hasAnyGenerateStaticParams ? Infinity : 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] Let dynamic-fetch observations veto this new cache path. A literal static route now gets Infinity here, so an auth-keyed fetch taking the safety bypass in fetch-cache.ts records dynamicFetchUrls but deliberately leaves consumeDynamicUsage() false. Both HTML and RSC finalizers only test the latter before writing, while readAppPageCacheResponse() does not reject an observation whose downgrade target is freshRender; the first user's auth-derived payload is therefore stored indefinitely and served as a HIT to later users. This also makes response-store admission report the render cacheable because that path consumes but never checks the observation. Please reject page writes/admission when the completed render has dynamic fetch observations (or otherwise feed that signal into dynamic usage), and cover a default-static route whose auth-keyed fetch varies between requests.

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.

Declining this for Next.js parity. Adding the veto would make vinext store less than Next.js 16.2.7, which stores this render:

  • In a runtime (ISR/on-demand) render, patch-fetch.ts sets autoNoCache for any fetch with no explicit cache config (L533–547). That gives finalRevalidate = 0 with cacheReason 'auto no cache' (L657), so the fetch itself isn't data-cached, but L675–676 skip both markCurrentScopeAsDynamic and the prerender store's revalidate lowering for it: // we don't consider autoNoCache to switch to dynamic for ISR. An auth header only matters when the store is already at revalidate === 0.
  • The page's collectedRevalidate (app-render.tsx) therefore stays false, so Next.js stores the page and sends s-maxage=31536000.
  • vinext's auth-header bypass leaves dynamic usage unset on purpose, to match that autoNoCache path (fix(cache): honor fetch opt-outs and force-dynamic revalidate parity #1907: "does not mark pages dynamic"). Vetoing on dynamicFetchUrls would undo that. It would also veto every default-static page that does a plain fetch(url), since the configless branch records the same observation.
  • A per-user payload needs cookies()/headers() or another request API, and those already mark dynamic usage, so neither Next.js nor vinext stores it. An auth-keyed fetch with no request API behind it (e.g. a server secret) is static in Next.js.
  • This PR doesn't add the path; it widens it. On the parent, routes with generateStaticParams or an explicit revalidate already stored such renders through the same finalizers and admission. The comments in fetch-cache.ts that suggest the observation downgrades full-page output are misleading and predate this PR; they're noted for a separate follow-up.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Focused validation passed: 389 tests across 4 files. Worktree remained unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/02-revalidate-false-default branch from 6cda46e to c65e6d6 Compare September 25, 2026 14:49
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head c65e6d64788ddca7eed3b8e574bcea64c13033c6 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/02-revalidate-false-default branch 2 times, most recently from f7f5fa4 to dffd1ef Compare September 25, 2026 18:07
Next.js serves a static or SSG route with no revalidate source until it is
revalidated. vinext only defaulted generateStaticParams routes, so a static
page without a revalidate export, fetch revalidate or cacheLife was never
stored on any backend.

Default every static-eligible route to revalidate = false, keep capturing
its RSC payload so a cacheLife resolved after headers still lowers the
stored lifetime, and send RSC misses that can still turn dynamic as MISS
instead of STATIC. force-static and dynamic = "error" keep STATIC.
…ntext

The RSC completion marker consumes dynamic usage from the response stream's
pull, outside the render's request scope. It memoized false there, so the RSC
cache write stored renders that had read a dynamic API.
JSON turned revalidate: Infinity into null, which failed entry validation, so
KV never served a static page. Store it as false, like Next.js, and read it
back as Infinity.
… page entries

Next.js pairs expireTime only with a finite revalidate, so a revalidate =
false entry stays until it is invalidated. The App page writers stored the
route expireTime with it, and the memory and KV handlers then hard-expired
the entry after a year. A cacheLife expire still bounds it.
@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/02-revalidate-false-default branch from dffd1ef to 5cb2228 Compare September 25, 2026 18:50
@james-elicx
james-elicx added this pull request to stack #3474 September 25, 2026 18:51
@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