Skip to content

fix(app-router): preserve page result render ordering - #2760

Merged
james-elicx merged 10 commits into
mainfrom
codex/fix-page-render-regressions
Jul 30, 2026
Merged

fix(app-router): preserve page result render ordering#2760
james-elicx merged 10 commits into
mainfrom
codex/fix-page-render-regressions

Conversation

@james-elicx

@james-elicx james-elicx commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

  • split App Router page initialization from page-result serialization so layouts and templates can observe state established by the page without blocking ancestor Suspense streaming
  • preserve parent-before-child serialization across plain, memo, lazy, and forward-ref page/layout components
  • restore segment-cache stale-window behavior by republishing consumed prefetches with their original absolute deadline
  • keep runtime-prefetch cacheLife expiry independent from visited/BFCache staleTimes.dynamic expiry
  • retain request-scoped cacheLife metadata until RSC stream completion, even after the request AsyncLocalStorage scope has exited
  • add focused unit, browser, and upstream Next.js regression coverage

Root causes

Page render ordering

PR #2751 used one dependency for both page initialization and full async page completion. Layouts and parallel slots therefore waited too long, blocking ancestor Suspense streaming, while the returned page subtree bypassed the existing layout/template result barriers.

The page dependency now has two phases: an initialization barrier released after the page's first async continuation, and result dependencies applied to the returned subtree after layouts/templates have produced their elements.

Per-page stale window

Navigation consumed and removed the prefetched Flight entry. Returning to the source page exposed no reusable prefetch, so a visible Link fetched a replacement and restarted the stale window. Committed navigations now republish the consumed snapshot while retaining its original absolute expiry.

Runtime-prefetch expiry

Runtime prefetches and visited/BFCache entries were sharing one expiry calculation. Next.js uses the completed cacheLife claim for runtime-prefetch freshness, while visited reuse remains bounded by staleTimes.dynamic; the implementation now preserves those as independent snapshots and deadlines.

The full deploy-suite run also exposed a timing-dependent Linux failure: RSC completion metadata re-resolved request cache state after the request AsyncLocalStorage scope had exited, occasionally losing the completed cacheLife claim and falling back to 30 seconds. Dispatch now captures request-bound cache-life accessors before creating the stream, so footer finalization reads the correct request state.

Validation

Final head: 81d08c16bdf0ad763ff6d72261f9c52a67b59e75

  • vp check over all changed files — format, lint, and types passed
  • 7 focused Vitest files — 535/535 passed
  • full-prefetch.browser.spec.ts2/2 passed
  • Next.js segment-cache-per-page-dynamic-stale-time.test.tsreuses dynamic data within the per-page stale time window passed
  • Next.js segment-cache-stale-time.test.tsexpires runtime prefetches when their stale time has elapsed passed
  • other targeted Next.js checks: metadata-streaming-static-generation.test.ts 7/7, app-basepath/index.test.ts 13/13, and optimistic-routing.test.ts 9/9; known unrelated baseline failures remain in broader files
  • PR CI66/66 checks passed
  • BigBonk exact-head reviewno correctness blockers; safe to merge
  • full Next.js deploy suite, Next.js v16.2.6, suite-filter=all — 2,615 passed / 189 known parity failures / 631 skipped; both regression assertions above passed on their first invocation

All local Next.js checks used scripts/run-targeted-nextjs-e2e.sh against Next.js v16.2.6 with concurrency 1.

@pkg-pr-new

pkg-pr-new Bot commented Jul 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2760
npm i https://pkg.pr.new/create-vinext-app@2760
npm i https://pkg.pr.new/@vinext/types@2760
npm i https://pkg.pr.new/vinext@2760

commit: 81d08c1

@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
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 81d08c1 against base ad991a7 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.3 KB 134.4 KB ⚫ +0.1%
Client entry size (gzip) vinext 121.9 KB 122.1 KB ⚫ +0.1%
Dev server cold start vinext 2.37 s 2.33 s 🟢 -1.5%
Production build time vinext 2.48 s 2.48 s ⚫ +0.1%
RSC entry closure size (gzip) vinext 111.6 KB 111.9 KB ⚫ +0.3%
Server bundle size (gzip) vinext 189.3 KB 189.6 KB ⚫ +0.2%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review — PR #2760: preserve page result render ordering

I reviewed all 9 changed files, traced the render-ordering rework end-to-end, and ran the targeted suites plus the broader app-router/features suites. All green (tests/app-render-dependency.test.ts, tests/app-page-element-builder.test.ts, tests/app-page-route-wiring.test.ts, tests/app-browser-entry.test.ts, tests/app-router.test.ts, tests/features.test.ts) and vp check is clean over the changed source.

Overall

The two-phase page dependency (initialization barrier + resultDependencies) is a clean way to express the intended ordering:

  • Layouts/templates gate on the page initialization barrier via renderAfterAppDependencies(..., [pageRenderDependency, ...]) (route-wiring L1130-1133, L1198-1199), released after the page's first async continuation (void Promise.resolve().then(() => renderDependency.release()), element-builder L544).
  • The returned page subtree gates on layout/template dependencies via renderAfterAppDependencies(result, renderDependency.resultDependencies) (element-builder L546-561).

Because the flat Flight record serializes page and layouts as siblings reconnected through <Slot>/<Children> (not nested in a single render pass), and each barrier releases when the owning component produces its element (before its children render), the parent-before-child contract holds without introducing a page↔layout deadlock. Consolidating the previous with/without-loading-boundary branches into a single init-release plus result-gating is a genuine simplification, and it correctly stops turning an ancestor Suspense boundary into a blocking render.

Things I verified (no change requested)

  • resultDependencies reference safetysetResultDependencies(pageDependencies) is called at route-wiring L1059 after the dependency-collection loop finishes (last mutation at L1056), and it's read lazily via a getter at render time. Safe.
  • isAppRenderSuspension parity — byte-for-byte equivalent to the removed isReactSuspension, so suspension-vs-error release semantics are unchanged.
  • memo/lazy/forwardRef unwrapping — recursion handles memo(clientRef) correctly (client ref path does createElement); lazy's thrown thenable lands in the suspension branch and re-invokes on retry. forwardRef undefined-ref argument matches the Flight contract established in 13c3a68 and is asserted by the new subprocess test.
  • Prefetch snapshot normalizationprepareConsumedPrefetchResponseForPublication strips expiresAt/preparedElements so snapshot matches createCachedRscResponseSnapshot output shape, and the consumed-snapshot reuse path in app-browser-entry.ts still reads completedDynamicStaleTimeSeconds and re-applies navResponseExpiresAt consistently. Covered by the new unit test.

Minor / non-blocking observations

  1. renderAppComponentWithDependencyBarrier now invokes the component directly rather than rendering a pre-built <LayoutComponent> element. For forwardRef/memo/lazy wrappers this bypasses React's own reconciliation of those wrappers (e.g. memo no longer memoizes, forwardRef receives undefined ref, defaultProps aren't resolved). For App Router layouts/templates these are all deprecated/irrelevant paths, and the subprocess Flight test locks in the ordering, so this is acceptable — but it's worth a one-line note that this deliberately trades wrapper fidelity for ordering control, in case someone later wraps a layout in memo expecting memoization.

  2. isReactOwnedAppComponent(null) returns true via typeof null !== "function". Callers guard with a truthiness check first, so it's never reached with null today; just flagging that the | null in the cast is effectively dead.

No correctness blockers found. The change is well-scoped, well-tested (including a real react-server subprocess Flight regression), and matches the documented Next.js ordering intent.

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2760.

Summary

I reviewed all 9 changed files, traced the render-ordering rework, and ran the targeted suites plus broader app-router/features suites — all green, and vp check clean.

Verdict: no correctness blockers. The two-phase page dependency (initialization barrier + resultDependencies) correctly implements the intended ordering:

  • Layouts/templates gate on page initialization (released after the page's first async continuation)
  • The returned page subtree gates on layout/template dependencies

Because the flat Flight record serializes page and layouts as siblings (reconnected via <Slot>/<Children>) and each barrier releases when the owning component produces its element before its children render, the parent-before-child contract holds with no page↔layout deadlock.

Verified safe:

  • setResultDependencies is called after the dependency-collection loop and read lazily via a getter — no stale/partial array
  • isAppRenderSuspension is byte-for-byte equivalent to the removed isReactSuspension
  • memo/lazy/forwardRef unwrapping recursion is correct; forwardRef undefined ref matches the Flight contract
  • Prefetch snapshot normalization strips expiresAt/preparedElements to match createCachedRscResponseSnapshot shape

Minor non-blocking notes I raised:

  1. renderAppComponentWithDependencyBarrier now invokes components directly, bypassing React reconciliation of memo/forwardRef/lazy wrappers — fine for layouts (deprecated paths) but worth a comment about the deliberate tradeoff.
  2. isReactOwnedAppComponent(null) returns true via dead | null branch; callers guard, so harmless.

github run

@james-elicx
james-elicx marked this pull request as ready for review July 30, 2026 19:09
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Re-review — PR #2760: preserve page result render ordering

Re-reviewed on top of the earlier /bigbonk pass, this time drilling into the three areas the first review only summarized: the prefetch peek-vs-consume ownership change, the runtime-prefetch expiry semantics change in navigation.ts, and the seedPrefetchCache flip in app-browser-entry.ts. Ran tests/app-render-dependency.test.ts, tests/app-page-element-builder.test.ts, tests/app-page-route-wiring.test.ts, tests/prefetch-cache.test.ts, tests/app-page-render.test.ts, tests/app-browser-entry.test.ts507 passed.

Verdict: no correctness blockers. Confirming the change is safe to merge.

Additional things I verified (no change requested)

  1. Peek → consume URL matching (app-browser-entry.ts L1799, navigation.ts L1547/L577). The settled prefetch is now peeked by targetPathAndSearch and only consumed later via consumePrefetchResponseForNavigation(rscUrl, ...), where rscUrl is derived from settledPrefetchedResponse.url. findPrefetchCacheEntryForNavigation does exact-key lookup first, then normalized/alias matching (L598-615), so the deferred consume resolves the same entry the peek found. The ownership transfer correctly moves to the reuse planner without risking a "peek hit, consume miss" navigation break.

  2. Runtime-prefetch expiry semantics (navigation.ts L486-510). The switch from min(dynamic, server) to "prefer completed cacheLife, fall back to dynamicStaleTimeSeconds only when no cacheLife claim" is the intended parity fix. Walked the cases: resolved server=240/dynamic=30 → 240s (was 30s — the regression being fixed); pending-server floor and no-cacheLife cases stay identical to before because serverStaleTimeSeconds({kind:"pending"}) returns the 30s floor and undefined server falls through to dynamicStaleTimeSeconds. resolveCachedRscResponseTtlMs (visited/BFCache path) still uses the old min-combine, keeping the two lattices independent as the comment claims.

  3. seedPrefetchCache now always true (app-browser-entry.ts L2341). In the non-cacheRestorable branch this now seeds the prefetch cache even for completed-resolved-dynamic responses. Combined with the consumedPrefetchSnapshot reuse (L2274-2276) that avoids waiting on the cache tee, this closes the back-nav window where a just-committed destination was absent from both caches. completedDynamicStaleTimeSeconds survives prepareConsumedPrefetchResponseForPublication (only expiresAt/preparedElements are stripped), so the cacheRestorable decision at L2279-2280 is unchanged for consumed prefetches.

  4. app-page-render.ts completion footer (L903-926). Dropping !shouldEmitDynamicStaleTime from the guard and adding the inner "emit footer only for a distinct completed cacheLife claim" check (L917-919) means a known-dynamic response now carries BFCache bound in the header and a completion footer when — and only when — cacheLife resolved. tests/app-page-render.test.ts locks in both header + footer together.

Concurring with prior notes

  • Two-phase page dependency (init barrier + resultDependencies) holds parent-before-child with no page↔layout deadlock: layouts gate on page init (released at element-builder L544), page result gates on layout/template deps that release once each layout produces its element.
  • renderAppComponentWithDependencyBarrier bypasses React reconciliation of memo/forwardRef/lazy wrappers — acceptable for deprecated layout-wrapper paths; the react-server subprocess Flight test locks in ordering + forwardRef undefined-ref contract.
  • isReactOwnedAppComponent(null)'s | null cast branch is dead (callers guard); harmless.

Well-scoped, well-tested, matches documented Next.js ordering + segment-cache intent.

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2760.

Summary

I re-reviewed the PR (a /bigbonk pass was already done), drilling into the three areas the first review only summarized. Ran 6 targeted test files — 507 passed.

Verdict: no correctness blockers.

Areas I independently verified this round:

  1. Peek → consume URL matching — the settled prefetch is now peeked by target path but consumed by resolved rscUrl. Confirmed findPrefetchCacheEntryForNavigation normalizes/alias-matches, so there's no "peek hit, consume miss" navigation break. Ownership transfer correctly moves to the reuse planner.

  2. Runtime-prefetch expiry semantics (navigation.ts) — the min(dynamic, server) → "prefer cacheLife, fall back to dynamic only when no cacheLife claim" switch is the intended fix. Walked the cases: the resolved server=240/dynamic=30 case now yields 240s (the regression being fixed); pending-floor and no-cacheLife cases stay byte-identical. The visited/BFCache path still uses the old min-combine, keeping the two lattices independent.

  3. seedPrefetchCache now always true — closes the back-nav window where a just-committed destination was absent from both caches. Verified completedDynamicStaleTimeSeconds survives snapshot normalization, so the cacheRestorable decision is unchanged.

  4. Completion footer guard (app-page-render.ts) — a known-dynamic response now emits header + footer only when cacheLife resolved; test locks both in.

I concurred with the prior review's two non-blocking notes (wrapper-reconciliation tradeoff, dead | null branch) and confirmed the two-phase page dependency has no page↔layout deadlock. The change is well-scoped and well-tested.

github run

@james-elicx
james-elicx marked this pull request as draft July 30, 2026 19:46
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Re-review — PR #2760: preserve page result render ordering

Third /bigbonk pass. Rather than re-summarize the two prior reviews, I independently traced the render-ordering rework, the prefetch peek→consume ownership change, the navigation.ts expiry switch, and the seedPrefetchCache flip — then ran the touched suites and vp check.

Ran: tests/app-render-dependency.test.ts, tests/app-page-element-builder.test.ts, tests/app-page-route-wiring.test.ts (146 passed) + tests/prefetch-cache.test.ts, tests/app-browser-entry.test.ts, tests/app-page-render.test.ts (362 passed). vp check clean over all 7 changed source files.

Verdict: no correctness blockers. Safe to merge.

Independently verified this round

  1. Two-phase page dependency has no deadlock across all page kinds. Layouts/templates gate on [pageRenderDependency, ...before] (route-wiring L1130-1133, L1198-1201); the returned page subtree gates on renderDependency.resultDependencies (element-builder L546-561). The init barrier releases from a microtask before the full page result (element-builder L544), so the page-init→layout→page-result chain is acyclic. Crucially I checked the pageRenderDependency === null path (client-ref/class page, element-builder L506-509): layouts then do not wait on the page, and the page gates on the full pageDependencies set (route-wiring L1094-1096) — the pre-PR ordering, still acyclic. Both branches preserve parent-before-child.

  2. setResultDependencies array is fully populated. pageDependencies is mutated in place through L1056 and the setter is called at L1059 after the last push; the getter reads the live array lazily at render time. No stale/partial snapshot. The page result (via getter) and the null-dependency page (L1096) read the same fully-populated array.

  3. Prefetch peek→consume ownership is race-free. The settled prefetch is peeked by targetPathAndSearch (L1802) and consumed by the same targetPathAndSearch key (L1979-1983) — not by rscUrl, so there is no key drift between peek and consume. The reuse planner picks either visited-response reuse (which returns/continues at L1962-1965 before ever reaching the consume) or consumePrefetch; no unconditional await sits between peek and consume on the consume path. And a peek-hit/consume-miss degrades gracefully: navResponse stays undefined and fallbackReuseDecision recomputes to a fresh fetch (L2011-2024). The peek deliberately leaves the entry available so a visited-response hit can still serve visible Links after back-nav.

  4. seedPrefetchCache now always true (L2350) is safe for dynamic responses. A known-dynamic response seeded into the prefetch cache still gets its expiry from resolveCachedRscResponseExpiresAtmin(dynamic, server) (navigation.ts L475-483, seed at L1063), so staleTimes.dynamic still bounds its reuse — it is not over-reused as a stale prefetch. Combined with the consumedPrefetchSnapshot reuse (L2284-2286) this closes the back-nav window where a just-committed destination was in neither cache. preserveCommittedPrefetchExpiry correctly keeps the absolute prefetch deadline only for consumed prefetches (where navResponseExpiresAt is set) and no-ops otherwise (prefetch-response.ts L96-101).

  5. Runtime-prefetch expiry switch (navigation.ts L495-503): prefer completed cacheLife (serverStaleTimeSeconds), fall back to dynamicStaleTimeSeconds only when there is no cacheLife claim. resolveCachedRscResponseTtlMs/resolveCachedRscResponseExpiresAt (visited/BFCache) keep the old min-combine, so the two staleness lattices stay independent as the comment claims.

  6. Completion-footer guard (app-page-render.ts L917-919): a known-dynamic response emits header + footer only when a distinct completed cacheLife claim resolved; otherwise the footer is dropped and the header alone carries the BFCache bound. tests/app-page-render.test.ts locks this in.

Concurring with the prior notes

  • renderAppComponentWithDependencyBarrier invokes memo/lazy/forwardRef wrappers directly, bypassing React's reconciliation of those wrappers — acceptable for the deprecated layout-wrapper paths, and the react-server subprocess Flight test locks in both the release ordering across plain/memo/lazy/forwardRef and the forwardRef undefined-ref contract.
  • isReactOwnedAppComponent(null)'s | null cast branch is dead (callers guard); harmless.

Well-scoped, well-tested (including a real react-server subprocess Flight regression), matches the documented Next.js execution-order + segment-cache intent.

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2760.

Summary

Third /bigbonk pass. Instead of re-summarizing the two prior reviews, I independently traced the four substantive changes, ran the touched suites (146 + 362 = 508 passed), and confirmed vp check is clean over all 7 changed source files.

Verdict: no correctness blockers. Safe to merge.

Areas I verified myself this round:

  1. Two-phase page dependency is deadlock-free across both page kinds — I specifically checked the pageRenderDependency === null path (client-ref/class pages), which the prior reviews didn't call out. In that case layouts don't wait on the page and the page gates on the full dependency set — the pre-PR ordering, still acyclic.

  2. setResultDependencies array is fully populated — setter runs after the last .push, getter reads the live array lazily.

  3. Peek→consume is race-free — both peek and consume use the same targetPathAndSearch key (the prior review incorrectly said consume uses rscUrl); the visited-reuse path returns before ever reaching the consume; and a peek-hit/consume-miss degrades to a fresh fetch via fallbackReuseDecision.

  4. seedPrefetchCache always-true is safe for dynamic responses — the seeded entry's expiry still comes from min(dynamic, server), so staleTimes.dynamic bounds reuse.

  5. Runtime-prefetch expiry switch and completion-footer guard both match documented Next.js semantics and are locked in by tests.

I concurred with the two prior non-blocking notes (wrapper-reconciliation tradeoff, dead | null branch).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Re-review — PR #2760: preserve page result render ordering

Fourth /bigbonk pass. Rather than restate the three prior reviews, I independently traced each substantive change from the source and drilled into two areas the earlier passes only asserted at a high level: the captured cache-life accessor lifetime and the completion-footer stream framing side effect. Ran the 7 touched suites (535 passed) and vp check clean over all 9 changed source files.

Verdict: no correctness blockers. Safe to merge.

Independently verified this round

  1. _captureRequestScopedCacheLifeAccessors captures the live request-context object, not fallbackState. The accessor is captured at app-page-dispatch.ts:1086, before renderAppPageLifecycle, which runs inside the unified request scope the RSC entry establishes — so getCacheState() returns getRequestContext() (the real context), and the closure sees requestScopedCacheLife mutations made during body render. The whole point is that the consume/peek callbacks fire later from stream finalization, outside the ALS scope, where the old _consumeRequestScopedCacheLife() would have resolved fallbackState and dropped the claim. The new tests/unified-request-context.test.ts case ("retains captured cache-life access after the request scope exits") locks exactly this. peek() returns a defensive copy; consume() nulls the slot. Correct.

  2. Completion-footer guard rewrite is a strict superset with a gated addition. Previously a known-dynamic response (shouldEmitDynamicStaleTime === true) bypassed appendRscCompletionMetadata entirely (served raw). Now every dynamicStaleTimeSeconds !== undefined response is wrapped, but the inner guard (app-page-render.ts:917-919) returns undefined for the known-dynamic/no-cacheLife case — so no footer is emitted there, matching old behavior. A footer is now added only for the previously-missing "known-dynamic + distinct completed cacheLife" case. I checked the stream-framing consequence: appendRscCompletionMetadata escapes every chunk (escapeFlightChunk) even when the footer is undefined, and now sets VINEXT_RSC_COMPLETION_METADATA_HEADER on known-dynamic responses too. This is self-consistent — the header signals the client to run stripRscCompletionMetadata, which handles both the has-footer and escaped-only-no-footer cases. tests/app-page-render.test.ts locks header + footer together.

  3. Peek→consume key parity confirmed at the source. Both peekSettledPrefetchResponseForNavigation (prefetch-response.ts:17) and the settled-path consume (consumePrefetchResponse(targetPathAndSearch, ...), browser-entry.ts) key on the same targetPathAndSearch — resolving the earlier reviews' disagreement in favor of "same key, no drift." A peek-hit/consume-miss degrades to a fresh fetch via fallbackReuseDecision; a visited-response hit returns before the consume, leaving the peeked entry available.

  4. storeVisitedResponseSnapshot's new prefetchSnapshot param is inert when unused. It defaults to snapshot and is only read under seedPrefetchCache: true (seed + delete). The one false-seed call site (initial hydration, browser-entry.ts:1564) never touches it; the two true-seed sites pass preserveCommittedPrefetchExpiry(snapshot, navResponseExpiresAt) explicitly. The visited/BFCache snapshot never inherits the prefetch's absolute navResponseExpiresAt; only the seeded prefetchSnapshot does, and only for consumed prefetches (where navResponseExpiresAt is set). Matches the stated intent.

  5. Two-phase page dependency is acyclic on both page kinds. page-result gates on resultDependencies (= the fully-populated pageDependencies, set at route-wiring.tsx:1059 after the last push); each layout gates on [pageRenderDependency(init), ...before] and releases its own dep when it produces its element (barrier); the init barrier releases from a microtask after the page's first continuation (element-builder.tsx:544). The pageRenderDependency === null path (client-ref/class page) drops layouts' wait on the page and gates the page on the full set — the pre-PR ordering, still acyclic. Flat Flight entries are serialized independently and reconnected via <Slot>/<Children>, so no synchronous render nesting can deadlock.

Concurring with prior notes (both still non-blocking)

  • renderAppComponentWithDependencyBarrier invokes memo/lazy/forwardRef wrappers directly, bypassing React's reconciliation of those wrappers — acceptable for the deprecated layout-wrapper paths; the react-server subprocess Flight test locks in ordering + the forwardRef undefined-ref contract. Still worth a one-line note in-code that this deliberately trades wrapper fidelity for ordering control.
  • isReactOwnedAppComponent(null)'s | null cast branch is dead (callers guard); harmless.

Well-scoped, well-tested, matches documented Next.js execution-order + segment-cache intent.

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2760.

Summary

Fourth /bigbonk pass. Instead of restating the three prior reviews, I traced each substantive change from source and drilled into two areas the earlier passes only asserted at a high level. Ran the 7 touched suites (535 passed) and vp check clean over all 9 changed source files.

Verdict: no correctness blockers. Safe to merge.

New verification this round:

  1. _captureRequestScopedCacheLifeAccessors captures the live request-context object at dispatch time (inside the request scope), so the consume/peek callbacks that fire later from stream finalization — outside the ALS scope — no longer resolve fallbackState and drop the cacheLife claim. This is the actual fix behind the "retain cache life through stream completion" commit, and the new unified-request-context test locks it in.

  2. Completion-footer guard rewrite is a strict superset: known-dynamic responses now flow through appendRscCompletionMetadata (chunks escaped, header set), but the inner guard returns undefined for the known-dynamic/no-cacheLife case (old behavior preserved). A footer is added only for the previously-missing known-dynamic + completed-cacheLife case. Verified the stream-framing consequence is self-consistent with client-side stripRscCompletionMetadata.

  3. Peek→consume key parity — both peek and consume use the same targetPathAndSearch, resolving the earlier reviews' internal disagreement.

  4. storeVisitedResponseSnapshot's new prefetchSnapshot param is inert unless seedPrefetchCache is true; visited/BFCache snapshots never inherit the prefetch's absolute expiry.

  5. Two-phase page dependency is acyclic on both page kinds (including the pageRenderDependency === null client-ref/class path).

Concurred with the two prior non-blocking notes (direct-invoke wrapper-reconciliation tradeoff; dead | null branch in isReactOwnedAppComponent).

github run

@james-elicx
james-elicx marked this pull request as ready for review July 30, 2026 22:48
@james-elicx
james-elicx merged commit af7bb92 into main Jul 30, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-page-render-regressions branch July 30, 2026 22:57
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