Skip to content

fix(app-router): keep the previous entry when an App page regeneration fails - #3459

Draft
james-elicx wants to merge 14 commits into
isr-query/06-core-write-safety-netfrom
isr-query/08-regen-failure-backoff
Draft

james-elicx wants to merge 14 commits into
isr-query/06-core-write-safety-netfrom
isr-query/08-regen-failure-backoff

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Stacked on #3456. First part of plan PR 3 (core parity fixes): regeneration failures.

Problem

When a stale App page regeneration threw or turned out to use a dynamic API, core logged the error and kept the old entry exactly as it was. The next stale hit retried at once, with no backoff. A regeneration that read a dynamic API wasn't reported at all.

Next.js

server/response-cache/index.ts catches every regeneration error. If the previous entry has cache control, it re-stores that entry and rethrows:

  • revalidate = Math.min(Math.max(prev.revalidate || 3, 3), 30);
  • expire = Math.max(revalidate + 3, prev.expire).

A static page that uses a dynamic API during a revalidation throws "Page changed from static to dynamic at runtime", which onRequestError reports.

Change

  • renderAppPageCacheRender now returns usedDynamicApi instead of discarding the signal.
  • A stale regeneration that used a dynamic API throws Next.js's static-to-dynamic error (Page changed from static to dynamic at runtime <path>, plus the docs link).
  • On any regeneration error, the entry that triggered the regeneration is re-stored under its own key only, with the clamped revalidate, the raised expire, and its own tags. An entry stored as revalidate = false retries after 3 s, as in Next.js. Then the error is rethrown to the existing background handler.
  • An entry with no stored cache policy or no render observation is left alone. Its tags can't be recovered, so re-storing it with path tags only would drop the fetch tags it was written with.
  • A failing re-store is logged, and the regeneration's own error is still the one reported.
  • Force-static regenerations that read headers() or searchParams are still stored, as before.

Tests

  • tests/app-page-cache.test.ts:
    • the clamp table;
    • per-key re-store for HTML- and RSC-triggered regenerations;
    • entries without a policy or an observation are left alone;
    • a store failure keeps the original error;
    • with a real cache handler, a throwing regeneration re-serves HIT within the window and STALE after it (finite revalidate and revalidate = false).
  • tests/app-page-dispatch.test.ts:
    • a stale regeneration that reads searchParams rejects and re-stores the previous entry;
    • a force-static regeneration that reads headers() and searchParams still stores.
  • tests/app-page-cache-render.test.ts: usedDynamicApi is reported.

Not matched

  • The error omits Next.js's , reason: … suffix. vinext doesn't track a bailout description.
  • Entries written without a render observation are not re-stored on failure. That covers entries from before this stack, and prerender seeds until the seed-observation PR lands.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 2c2b205494ec458c73abf74f56b549e5a54de56f 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@3459

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: 75475a4

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 75475a4 against base 0d1e348 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.15 s 3.16 s ⚫ +0.3%
Production build time vinext 3.48 s 3.52 s ⚫ +1.2%
RSC entry closure size (gzip) vinext 130.6 KB 130.7 KB ⚫ +0.0%
Server bundle size (gzip) vinext 231.4 KB 232.2 KB ⚫ +0.4%

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
  • Medium: packages/vinext/src/server/app-page-cache.ts:515-574 only retains the previous entry when rendering fails. Failures from cache-control resolution, key creation, or either fresh isrSet bypass retention, leaving the entry stale and retried on every request. Wrap the entire regeneration and persistence flow in the retention handler, matching Next.js.

Focused tests and checks passed. Worktree remained unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from 2c2b205 to e7b8d3e Compare September 25, 2026 03:28
@james-elicx

Copy link
Copy Markdown
Member Author

Re the medium finding in #3459 (comment): fixed in e7b8d3e. The whole regeneration, including cache-control resolution, key creation and both fresh isrSet calls, now runs inside the retention handler, as in Next.js. Any failure re-stores the previous entry with the backoff lifetime and rethrows. The new test "keeps the previous entry when storing the regenerated page fails" covers a store failure.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head e7b8d3e27f073ce12a48f5cb53efc97d47f7c0c0 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
  • Medium: packages/vinext/src/server/app-page-cache.ts:550 uses Promise.all, which rejects before sibling writes settle. A delayed HTML write can overwrite the restored stale entry after an RSC write fails, defeating the backoff. Await all writes settling before restoration.

Focused tests passed: 174 tests.

github run

@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from e7b8d3e to df42196 Compare September 25, 2026 04:12
@james-elicx

Copy link
Copy Markdown
Member Author

Re the medium finding in the last review (Promise.all rejecting before sibling writes settle): fixed in df42196. The regeneration now waits for every write to settle before rethrowing the first failure. So a slower sibling write can't land over the previous entry that the failure handler re-stores. Test: "re-stores the previous entry only after a slower sibling write settles".

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head df4219655a770fca17bd3fbf45d6736601b67538 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
  • Medium: packages/vinext/src/server/app-page-cache.ts:485 throws for dynamic API usage even for cacheComponents/PPR routes, where dynamic APIs are expected. Next.js explicitly gates this error with !isRoutePPREnabled. A stale PPR page using headers() or cookies() will therefore never refresh. Pass the PPR state through and only throw for non-PPR regeneration.

Targeted tests passed: 181 tests.

github run

@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from df42196 to 01f73c5 Compare September 25, 2026 04:44
@james-elicx

Copy link
Copy Markdown
Member Author

Re the medium finding in the last review (dynamic API use failing PPR regenerations): fixed in 01f73c5. As in Next.js (!isRoutePPREnabled), dynamic API use only fails a regeneration for a route without PPR. A PPR shell expects dynamic holes, so its regeneration stores as before. The new dispatch test "doesn't fail a PPR regeneration that reads headers()" fails without the fix.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

const previousTags = cachedValue.renderObservation?.cacheTags;
if (previousCacheControl && previousTags) {
try {
await options.isrSet(isrKey, cachedValue, {

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.

Medium: This unconditional restore can overwrite a newer value from a concurrent regeneration. HTML and RSC requests are deduped under different keys, but an HTML regeneration also writes the RSC key. If that write succeeds while an RSC-triggered regeneration later fails, this closure writes its captured stale RSC value back over the fresh one and marks it fresh for the backoff window. Guard the restore with the original entry version/lastModified (or otherwise coordinate the cross-key writes) so it only replaces the entry that actually triggered this regeneration.

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 d8f4538. The failure handler re-reads the key and skips the restore if another write replaced the entry it found. A missing entry is still restored, as Next.js restores unconditionally.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding.

Focused checks passed: 184 tests plus formatting, lint, and types. Worktree unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from 01f73c5 to d8f4538 Compare September 25, 2026 05:16
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

// one is restored, as Next.js restores unconditionally.
const current = await options.isrGet(isrKey);
if (!current || current.value.lastModified === cached.value.lastModified) {
await options.isrSet(isrKey, cachedValue, {

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.

Medium: The version check is still racy because isrGet() and isrSet() are separate operations. An HTML regeneration can write a fresh RSC value after line 574 returns the captured stale version but before this restore runs, and this isrSet() then overwrites that fresh value with the stale payload. The new test only places the newer write before the read, so it cannot exercise this window. Serialize all writers/restores for the shared RSC key, or use an atomic compare-and-set in the cache backend.

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 one. The memory and KV backends have no compare-and-set, and a per-process lock would be isolate-local state, which vinext avoids for cache decisions (it wouldn't serialize across isolates anyway). Next.js restores unconditionally here, so it has a wider version of this window. The re-read narrows the window to one get→set round trip. If a fresh write does land inside it, the key holds the previous payload for at most the 3–30 s failure backoff, and then the next request regenerates it. The payload was already being served as stale, so nothing is lost for longer than that.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding.

Focused tests passed: 188 tests. Worktree unchanged.

github run

@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from d8f4538 to 41fe04f Compare September 25, 2026 06:05
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

// writes the RSC key), so don't replace a newer entry. A missing
// one is restored, as Next.js restores unconditionally.
const current = await options.isrGet(isrKey);
if (!current || current.value.lastModified === cached.value.lastModified) {

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.

Medium: This check also mistakes this regeneration’s own successful HTML write for an unrelated newer value. On an HTML-triggered regeneration, the RSC and HTML writes run concurrently; if the RSC write rejects but the HTML write succeeds, Promise.allSettled enters this handler and isrGet(isrKey) returns that just-written HTML with a different lastModified. The restore is therefore skipped, leaving half of a failed regeneration published and the HTML key without failure backoff. Either avoid committing the triggering-key write until its sibling succeeds, or track that this regeneration wrote the current value and restore it while retaining the external-writer guard.

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 3b7bbe5: the regeneration now writes the RSC key first, and writes the triggering HTML key only once that succeeds. A failed RSC write leaves nothing half-published, and the restore guard only sees another regeneration's writes. There's a test for an RSC write that rejects.

@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from 821668c to 726daa1 Compare September 25, 2026 19:18
@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from 726daa1 to 07f8a14 Compare September 25, 2026 20:04
@james-elicx
james-elicx force-pushed the isr-query/08-regen-failure-backoff branch from 07f8a14 to ca5de55 Compare September 25, 2026 20:46
…n fails

Next.js re-stores the previous entry when a regeneration throws or turns
dynamic, with revalidate clamped to 3-30 s and expire raised to at least
revalidate + 3 s, so a failing page keeps serving stale content without
retrying on every request (server/response-cache/index.ts).

- The regeneration render now reports dynamic API use instead of
  discarding it. A dynamic regeneration fails with Next.js's "Page changed
  from static to dynamic at runtime" error, which is logged and reported
  like any other regeneration failure.
- The previous entry is re-stored under its own key only, with the tags
  from its render observation, so an RSC-triggered failure never writes
  under the HTML key.
- revalidate = false retries after 3 s, like Next.js's revalidate || 3.
- An entry without a render observation isn't re-stored: its tags can't be
  recovered, and re-storing it with path tags only would drop the fetch
  tags it was written with.
- A failing re-store is logged, and the regeneration's own error is still
  the one reported.
- Test a throwing regeneration against a real cache handler, and pin that a
  force-static regeneration reading headers() and searchParams still stores.
…pp page fails

Next.js wraps the whole regeneration, the new entry's store included, in the
handler that keeps the previous entry. A failing cache write now gets the
same backoff as a failing render.
…he previous entry

A failed write rejected the regeneration while a slower sibling write was
still in flight, so that write could land over the re-stored previous entry.
Next.js throws the static-to-dynamic error only when the route has no PPR
(`!isRoutePPREnabled`); a PPR shell expects dynamic holes, so its
regeneration stores as before.
A client page reads searchParams in SSR's child scope, which reaches the
render's dynamic latch but not its own flag, so the regeneration was stored.
…n's HTML write fails

Next.js keeps a page's HTML and RSC as one entry, so a failed regeneration
never leaves half of it published. An HTML-triggered regeneration writes two
keys; it now holds the RSC key's previous entry and puts it back if the
HTML write fails, unless another writer has replaced it since. Its own write
is recognized by its payload bytes, since a store may hand back a copy.
…regeneration

Next.js's IncrementalCache.set only warns when its cache handler fails, so a
store failure never reaches handleRevalidate's backoff and the previous page
entry stays as it was. A regeneration's store failure now warns the same way
without re-storing anything under a backoff policy: a failed RSC write skips
the HTML write, and a failed HTML write puts the RSC key's previous entry back
with its own policy. A failed render still keeps the previous entry with the
backoff policy.
… fails

A regeneration's store failure only warns, as Next.js's IncrementalCache.set
does, so it no longer holds a backoff window over the page. Fresh RSC beside
stale HTML is then exactly the state an RSC-triggered regeneration leaves in
the two-key design, and the stale HTML regenerates on its next request. The
RSC key's previous entry is no longer read and put back after a failed HTML
write: that put-back could skip a missing entry, roll back a concurrent
regeneration's byte-identical write, and reset the restored entry's
lastModified so the old RSC counted as fresh again.

The RSC key is still written first, and a failed RSC write still skips the
HTML write. A failed render still keeps the previous entry with a backoff
policy.
…te is 0

Next.js throws "Page changed from static to dynamic at runtime" for any
non-PPR regeneration whose render's cacheControl.revalidate is 0, not only
one that used a dynamic API (build/templates/app-page.ts, from
applyMetadataFromPrerenderResult's collectedRevalidate). A stale intercepted
entry whose tree turned revalidate = 0 or force-dynamic, rendering no request
API, was stored at revalidate 0, or at its cacheLife's.

Carry the regenerated tree's route-level revalidate as null when it has none,
so a real revalidate = 0 is no longer read as the cacheLife discovery seed,
and fail the regeneration when the revalidate it would store is 0. The PPR
exemption moves into the cache module with the check, so both follow
Next.js's `!isRoutePPREnabled`.
@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/08-regen-failure-backoff branch from ca5de55 to 3e43e43 Compare September 25, 2026 21:31
@james-elicx
james-elicx added this pull request to stack #3481 September 25, 2026 21:32
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 3e43e43e96fa831983fdcec2b2361b1d1327d9d5 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
  • Medium: packages/vinext/src/server/app-page-cache.ts:524 uses the current route’s cache-read seed instead of the regeneration target’s route policy. An intercepted target changing to revalidate = 0 or force-dynamic can still be persisted as static. This regresses the previously resolved review finding; thread the target’s effective revalidate through renderFreshPageForCache.

Focused tests passed: 187 tests. Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Fixed the Medium finding (app-page-cache.ts:524) in 75475a4: renderFreshPageForCache returns the regenerated route's own route-level revalidate (resolveRouteRevalidateSeconds(revalidationTarget.route), falling back to the revalidate = false default for a static route). A stale intercepted entry therefore stores its source route's revalidate, and fails like Next.js when that route turned revalidate = 0 / force-dynamic. Covered by a new dispatch test.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 75475a429bc632672a678e61ebf5aaa0971f74b1 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 #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