fix(app-router): prefetch user Suspense shells#2364
Conversation
|
/bigbonk review for issues |
commit: |
|
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Reviewed the 13 changed files against main. Targeted unit tests pass (228), vp check is clean on the changed source files, and the render-mode/cache-busting/consumer wiring is internally consistent. The core mechanism (request-local ALS shell state + connection() suspending + double-setTimeout abort matching Next.js scheduleInSequentialTasks, + a partial-shell response header that the consumer demotes to a non-navigation entry) is sound.
One correctness issue worth fixing before merge, plus a couple of smaller notes.
Blocking concern: partial Suspense shells can be re-promoted to full navigation entries
In shims/link.tsx the existing re-entry de-dupe block flips a cached entry back to cacheForNavigation: true:
if (prefetched.has(cacheKey)) {
if (!autoPrefetch.cacheForNavigation) {
return;
}
const existing = getPrefetchCache().get(cacheKey);
if (existing?.cacheForNavigation === false) {
existing.cacheForNavigation = true; // <-- re-promotes a partial shell
}
}Before this PR, shell prefetches were always issued with cacheForNavigation: false (loading-shell path), so they hit the early return above and never reached the flip. The new suspense-shell path issues the request with cacheForNavigation: true and only demotes it after the response arrives (navigation.ts flips cacheForNavigation = false + optimisticRouteShell = true when X-Vinext-Rsc-Partial-Shell: 1 is present).
That means on a second prefetch of the same suspense-shell URL (re-entering the viewport via scroll, or hover after viewport), prefetched.has(cacheKey) is true, autoPrefetch.cacheForNavigation is true, and the stored partial shell's cacheForNavigation gets flipped back to true. Both consumption gates (findPrefetchCacheEntryForNavigation at line ~416/427 and consumePrefetchResponse at line ~790/807) only check cacheForNavigation, not optimisticRouteShell, so the demoted partial shell becomes consumable as a complete navigation payload again — defeating the central guarantee of this PR.
Suggested fix: don't re-promote entries that are partial shells. Either gate the flip on existing.optimisticRouteShell !== true, or have consumePrefetchResponse/findPrefetchCacheEntryForNavigation also treat optimisticRouteShell === true as non-navigable (defense in depth). A regression test that prefetches the same dynamic-no-loading route twice and asserts the second prefetch does not make the entry navigable would lock this down.
Notes (non-blocking)
-
shims/server.tsreads the render mode via the hardcoded literal"x-vinext-rsc-render-mode"and compares against the literal"prefetch-suspense-shell". It works (Headers.getis case-insensitive and the value matches the constant), but every other call site usesVINEXT_RSC_RENDER_MODE_HEADER+APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL. Using the constants here avoids drift if either value ever changes. -
The
schedulechange (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches run their scheduling synchronously rather than during idle, not just suspense-shell ones. The actualfetchstays async inside the IIFE, so the cost is the per-LinkresolveAutoAppRoutePrefetch+ URL/cache-key computation now running eagerly on the main thread. This matches the stated intent ("start viewport prefetches immediately"), and there's a focused test, but it's a broad behavioral change bundled with the suspense feature — calling it out for awareness. -
dev/prod parity: this lives in sharedapp-page-render.tsand the consumer shims, so it covers both dev and prod App Router. The Pages Router prod path is unaffected (App-only feature). No parity gap spotted.
|
Review posted to PR #2364. SummaryReviewed all 13 files in PR #2364 ("prefetch user Suspense shells"). Targeted unit tests pass (228/228) and I posted one blocking concern and a few non-blocking notes: Blocking — partial shells can be re-promoted to navigable entries. The pre-existing re-entry de-dupe block in Non-blocking:
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Verified all 13 changed files against main. Targeted unit tests pass (228/228 across link.test.ts, link-navigation.test.ts, prefetch-cache.test.ts, app-page-response.test.ts). The core mechanism is sound: request-local ALS shell state, connection() suspending on a hanging promise, the double-setTimeout abort matching Next.js scheduleInSequentialTasks, an X-Vinext-Rsc-Partial-Shell response header, and a consumer that demotes partial shells to non-navigation entries.
One blocking correctness issue, plus a few non-blocking notes.
Blocking: partial Suspense shells can be re-promoted to navigable entries
shims/link.tsx:505-514 — the re-entry de-dupe block flips a cached entry back to cacheForNavigation: true:
if (prefetched.has(cacheKey)) {
if (!autoPrefetch.cacheForNavigation) {
return;
}
const existing = getPrefetchCache().get(cacheKey);
if (existing?.cacheForNavigation === false) {
existing.cacheForNavigation = true; // re-promotes a demoted partial shell
}
}For a dynamic route with no loading boundary, resolveMatchedAutoAppRoutePrefetch returns cacheForNavigation: true and prefetchSuspenseShell: true (link.tsx:347-352). So the suspense-shell request is issued with cacheForNavigation: true, and the demotion happens only after the response arrives — navigation.ts:743-745 sets cacheForNavigation = false + optimisticRouteShell = true when X-Vinext-Rsc-Partial-Shell: 1 is present.
On a second prefetch of the same URL (viewport re-entry via scroll, or hover after viewport), the flow is:
autoPrefetch.cacheForNavigation === true→ does not hit the earlyreturnat line 506-508existing.cacheForNavigation === false(demoted by the first response) → flipped back totrueat line 512
Both consumption gates only check cacheForNavigation, never optimisticRouteShell:
findPrefetchCacheEntryForNavigation—navigation.ts:416and:427consumePrefetchResponse—navigation.ts:790and:807
So the re-promoted partial shell becomes consumable as a complete navigation payload, defeating the central guarantee of this PR.
This is the same class of invariant that promotePrefetchEntriesForNavigation already enforces — it explicitly continues past optimisticRouteShell === true entries (link.tsx:631). The re-entry block at 510-512 is inconsistent with that established pattern.
Suggested fix: gate the flip on existing.optimisticRouteShell !== true, and/or treat optimisticRouteShell === true as non-navigable in findPrefetchCacheEntryForNavigation/consumePrefetchResponse for defense in depth. Add a regression test that prefetches the same dynamic-no-loading route twice and asserts the second prefetch does not make the entry navigable. The existing prefetch-cache.test.ts "downgrades server-declared partial shells" test only covers the first-fetch demotion; the re-entry path is untested.
Non-blocking
-
shims/server.ts:1063reads the render mode via the hardcoded literal"x-vinext-rsc-render-mode"and compares against"prefetch-suspense-shell". It works (Headers.getis case-insensitive and the value matches the constant), but every other call site usesVINEXT_RSC_RENDER_MODE_HEADER+APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL. Using the constants here avoids silent drift if either value ever changes. -
shims/link.tsx:454— theschedulechange (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously instead of during idle, not just suspense-shell ones. Thefetchitself stays async inside the IIFE, so the cost is per-LinkresolveAutoAppRoutePrefetch+ URL/cache-key computation now running eagerly on the main thread. Matches the stated intent ("start viewport prefetches immediately") and has a focused test, but it's a broad behavioral change bundled with the suspense feature — flagging for awareness. -
Dev/prod parity: the server logic lives in shared
app-page-render.ts/app-page-response.tsand the consumer shims, covering both dev and prod App Router. Pages Router prod path is unaffected (App-only feature). No parity gap.
|
Review posted to PR #2364. SummaryI independently verified all 13 changed files against Blocking issue (confirmed by code tracing): Partial Suspense shells can be re-promoted to navigable entries. A dynamic-no-loading route is issued with Non-blocking: hardcoded header literals in |
|
/bigbonk review for issues |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Independently verified all 13 changed files against main. Targeted unit tests pass (228/228 across link.test.ts, link-navigation.test.ts, prefetch-cache.test.ts, app-page-response.test.ts). The core mechanism is sound: a request-local ALS shell state (prefetch-suspense-shell.ts), connection() suspending on a makeHangingPromise (faithful Next.js port), the double-setTimeout abort matching Next.js scheduleInSequentialTasks, the X-Vinext-Rsc-Partial-Shell response header, partial-shell ISR-write suppression, and a consumer that demotes partial shells to non-navigation entries.
One blocking correctness issue (reproduced at the unit level), plus a few non-blocking notes.
Blocking: partial Suspense shells can be re-promoted to navigable entries
shims/link.tsx:505-514 — the re-entry de-dupe block flips a cached entry back to cacheForNavigation: true:
if (prefetched.has(cacheKey)) {
if (!autoPrefetch.cacheForNavigation) {
return;
}
const existing = getPrefetchCache().get(cacheKey);
if (existing?.cacheForNavigation === false) {
existing.cacheForNavigation = true; // re-promotes a demoted partial shell
}
}For a dynamic route with no loading boundary, resolveMatchedAutoAppRoutePrefetch returns cacheForNavigation: true and prefetchSuspenseShell: true (link.tsx:349-350). So the suspense-shell request is issued with cacheForNavigation: true, and the demotion happens only after the response arrives — navigation.ts:743-745 sets cacheForNavigation = false + optimisticRouteShell = true when X-Vinext-Rsc-Partial-Shell: 1 is present.
On a second prefetch of the same URL (viewport re-entry via scroll, or hover after viewport):
autoPrefetch.cacheForNavigation === true→ does not hit the earlyreturnatlink.tsx:506-508existing.cacheForNavigation === false(demoted by the first response) → flipped back totrueatlink.tsx:512- The subsequent
hasPrefetchCacheEntryForNavigationgate (link.tsx:518-523) now finds the re-promoted, non-expired entry and early-returns — so the entry is left permanently navigable.
Both consumption gates only check cacheForNavigation, never optimisticRouteShell:
findPrefetchCacheEntryForNavigation—navigation.ts:416and:427consumePrefetchResponse—navigation.ts:790and:807
So the re-promoted partial shell becomes consumable as a complete navigation payload, defeating the central guarantee of this PR.
Reproduced at the unit level. Seeding a partial-shell entry (demoted to cacheForNavigation: false + optimisticRouteShell: true), then flipping cacheForNavigation back to true (exactly what link.tsx:512 does), makes consumePrefetchResponse(rscUrl) return the partial shell's snapshot instead of null.
This is the same invariant that promotePrefetchEntriesForNavigation already enforces — it explicitly continues past optimisticRouteShell === true entries (link.tsx:631). The re-entry block at 510-512 is inconsistent with that established pattern.
Suggested fix: gate the flip on existing.optimisticRouteShell !== true, and/or treat optimisticRouteShell === true as non-navigable in findPrefetchCacheEntryForNavigation/consumePrefetchResponse for defense in depth. Add a regression test that prefetches the same dynamic-no-loading route twice (second fetch returning the partial-shell header on the first) and asserts the entry never becomes navigable. The existing prefetch-cache.test.ts:327 "downgrades server-declared partial shells" test only covers first-fetch demotion; the re-entry path is untested.
Non-blocking
-
shims/server.ts:1063reads the render mode via the hardcoded literal"x-vinext-rsc-render-mode"and compares against"prefetch-suspense-shell". It works (Headers.getis case-insensitive, the header constant is"X-Vinext-Rsc-Render-Mode", and the value matchesAPP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL), but every other call site usesVINEXT_RSC_RENDER_MODE_HEADER+ the render-mode constant. Using the constants here avoids silent drift if either value ever changes. -
shims/link.tsx:453-458— theschedulechange (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously instead of during idle, not just suspense-shell ones. Thefetchitself stays async inside the IIFE, so the cost is per-LinkresolveAutoAppRoutePrefetch+ URL/cache-key computation now running eagerly on the main thread. Matches the stated intent ("start viewport prefetches immediately") and has a focused test (link-navigation.test.ts:1424), but it's a broad behavioral change bundled with the suspense feature — flagging for awareness. -
Dev/prod parity: the server logic lives in shared
app-page-render.ts/app-page-response.tsand the consumer shims, covering both dev and prod App Router. The partial-shell entries are also correctly kept out of the RSC cache / ISR (app-page-render.tsskips the cache write and metadata capture whenprefetchSuspenseShellWasAborted). Pages Router prod path is unaffected (App-only feature). No parity gap spotted.
|
Review posted to PR #2364. SummaryIndependently verified all 13 changed files against Blocking — partial Suspense shells can be re-promoted to navigable entries (reproduced). A dynamic-no-loading route is issued with Non-blocking: hardcoded header literals in |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Re-reviewed at the current HEAD (324d243, "lock partial shell cache policy"). The new commit since the last round only hardens CDN cache headers for partial shells (lockPartialShellCacheHeaders in app-rsc-response-finalizer.ts strips CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag and forces no-store). It does not touch the client-side re-promotion path, so the previously-flagged blocking issue is still live. Targeted unit tests pass (29 in prefetch-cache.test.ts, 49 in link-navigation.test.ts) and vp check is clean on the changed shims. The core mechanism remains sound: request-local ALS shell state, connection() suspending on makeHangingPromise, the double-setTimeout abort matching Next.js scheduleInSequentialTasks, the X-Vinext-Rsc-Partial-Shell header, ISR/cache-write suppression, and a consumer that demotes partial shells.
Blocking (still present, reproduced this round): partial Suspense shells can be re-promoted to navigable entries
shims/link.tsx:505-514 — the re-entry de-dupe block flips a cached entry back to cacheForNavigation: true without checking whether it is a partial shell:
if (prefetched.has(cacheKey)) {
if (!autoPrefetch.cacheForNavigation) {
return;
}
const existing = getPrefetchCache().get(cacheKey);
if (existing?.cacheForNavigation === false) {
existing.cacheForNavigation = true; // re-promotes a demoted partial shell
}
}For a dynamic route with no loading boundary, resolveMatchedAutoAppRoutePrefetch returns cacheForNavigation: true and prefetchSuspenseShell: true (link.tsx:349-350). The suspense-shell request is therefore issued with cacheForNavigation: true, and demotion happens only after the response — navigation.ts:743-745 sets cacheForNavigation = false + optimisticRouteShell = true when X-Vinext-Rsc-Partial-Shell: 1 is present.
On a second prefetch of the same URL (viewport re-entry via scroll, or hover after viewport):
autoPrefetch.cacheForNavigation === true→ does not hit the earlyreturnatlink.tsx:506-508existing.cacheForNavigation === false(demoted by the first response) → flipped back totrueatlink.tsx:512- The
hasPrefetchCacheEntryForNavigationgate (link.tsx:518-523) then finds the re-promoted, non-expired entry and early-returns — leaving it permanently navigable.
Both consumption gates only check cacheForNavigation, never optimisticRouteShell:
findPrefetchCacheEntryForNavigation—navigation.ts:416and:427consumePrefetchResponse—navigation.ts:790and:807
So the re-promoted partial shell becomes consumable as a complete navigation payload — defeating the central guarantee of this PR. Note the CDN-header lock added in 324d243 does not help here: the bug is entirely in the in-memory client prefetch cache, which never round-trips through HTTP cache headers.
Reproduced at the unit level this round. Seeding a partial-shell entry (demoted to cacheForNavigation: false + optimisticRouteShell: true via the partial-shell header), then flipping cacheForNavigation back to true exactly as link.tsx:512 does, makes consumePrefetchResponse(rscUrl) return the partial shell's snapshot instead of null.
This is the same invariant the codebase already enforces at link.tsx:631, where promotePrefetchEntriesForNavigation explicitly continues past optimisticRouteShell === true entries. The re-entry block at 510-512 is inconsistent with that established pattern.
Suggested fix: gate the flip on existing.optimisticRouteShell !== true, and/or treat optimisticRouteShell === true as non-navigable in findPrefetchCacheEntryForNavigation/consumePrefetchResponse for defense in depth. Add a regression test that prefetches the same dynamic-no-loading route twice and asserts the entry never becomes navigable. The existing prefetch-cache.test.ts:327 "downgrades server-declared partial shells" test only covers first-fetch demotion; the re-entry path remains untested.
Non-blocking
-
shims/server.ts:1063reads the render mode via the hardcoded literal"x-vinext-rsc-render-mode"and compares against"prefetch-suspense-shell". It works (Headers.getis case-insensitive; the header constant is"X-Vinext-Rsc-Render-Mode"and the value matchesAPP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL), but every other call site usesVINEXT_RSC_RENDER_MODE_HEADER+ the render-mode constant. Using the constants here avoids silent drift if either value ever changes. -
shims/link.tsx:453-458— theschedulechange (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously instead of during idle, not just suspense-shell ones. Thefetchitself stays async inside the IIFE, so the cost is per-LinkresolveAutoAppRoutePrefetch+ URL/cache-key computation now running eagerly on the main thread. Matches the stated intent ("start viewport prefetches immediately") and has a focused test, but it's a broad behavioral change bundled with the suspense feature — flagging for awareness. -
Dev/prod parity: server logic lives in shared
app-page-render.ts/app-page-response.tsand the consumer shims, covering both dev and prod App Router. Partial-shell entries are correctly excluded from the RSC cache / ISR (app-page-render.tsskips the cache write and metadata capture whenprefetchSuspenseShellWasAborted) and now also from CDN caches (324d243). Pages Router prod path is unaffected (App-only feature). No parity gap spotted.
|
/bigbonk review for issues |
|
Review posted to PR #2364. SummaryRe-reviewed at the current HEAD ( Blocking (still present, reproduced this round): Partial Suspense shells can be re-promoted to navigable entries. A dynamic-no-loading route is issued with Suggested fix: gate the flip on Non-blocking: hardcoded header literals in |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Re-reviewed at HEAD (d723513, "preserve partial prefetch classification"). The blocking issue flagged across all four prior review rounds is now fixed. Targeted unit tests pass (97/97 across prefetch-cache.test.ts, link-navigation.test.ts, app-rsc-response-finalizer.test.ts) and vp check is clean on the six changed source files.
Resolved: partial Suspense shells can no longer be re-promoted to navigable entries
The previously-flagged re-promotion bug is fixed in three places:
- Primary fix —
link.tsx:511now gates the re-entry flip onexisting.optimisticRouteShell !== true, so a second prefetch of a demoted partial shell no longer flipscacheForNavigationback totrue. This mirrors the established invariant atlink.tsx:631(promotePrefetchEntriesForNavigationskipsoptimisticRouteShell === true). - Defense in depth (find) —
findPrefetchCacheEntryForNavigationnow rejectsoptimisticRouteShell === trueon both the exact path (navigation.ts:417) and the loop path (navigation.ts:428). - Defense in depth (consume) —
consumePrefetchResponsenow rejectsoptimisticRouteShell === trueon both the exact path (navigation.ts:792) and the final gate (navigation.ts:809).
I verified this is a real fix, not a vacuous one: I temporarily removed the consumer guards and confirmed the new prefetch-cache.test.ts regression test ("rejects partial shells even if their navigation flag is promoted") fails (consumePrefetchResponse returns the partial shell snapshot instead of null), then restored the guards and confirmed it passes. The re-entry path is now covered by both that test and the new link-navigation.test.ts test ("does not promote a server-declared partial shell on repeat prefetch").
The demotion remains sticky and correct: a X-Vinext-Rsc-Partial-Shell: 1 response sets cacheForNavigation = false + optimisticRouteShell = true (navigation.ts:744-746), and optimisticRouteShell is never cleared once set.
The server side is also sound: partial shells get no-store, are stripped of CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag, and are excluded from both the RSC cache write and cache-metadata capture (app-page-render.ts). The new commit also correctly deletes a config-injected X-Vinext-Rsc-Partial-Shell header from full responses (lockPartialShellCacheHeaders), closing a spoofing vector where config headers could mark a complete payload as partial.
Remaining non-blocking note
shims/server.ts:1063still reads the render mode via the hardcoded literal"x-vinext-rsc-render-mode"and compares against"prefetch-suspense-shell". It works (Headers.getis case-insensitive; the header constant isVINEXT_RSC_RENDER_MODE_HEADER = "X-Vinext-Rsc-Render-Mode"and the value matchesAPP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL), but every other call site uses the shared constants. Using them here too avoids silent drift if either value ever changes. Minor; can be a follow-up.
Other observations (unchanged from prior rounds, all fine)
- The
schedulechange (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously rather than during idle. Matches the stated intent ("start viewport prefetches immediately") and has a focused test. Broad but deliberate. - Dev/prod parity holds: server logic lives in shared
app-page-render.ts/app-page-response.tsand consumer shims, covering both dev and prod App Router. Pages Router prod path is unaffected (App-only feature). - The
schedulePrefetchSuspenseShellAbortdouble-setTimeoutfaithfully mirrors Next.jsscheduleInSequentialTasks, andsuspendPrefetchSuspenseShellusesmakeHangingPromise— a faithful port.
LGTM once you decide whether to swap the server.ts:1063 literals for the constants (non-blocking).
|
Review posted to PR #2364. SummaryRe-reviewed at the current HEAD ( Blocking issue — now RESOLVED. Partial Suspense shells can no longer be re-promoted to navigable entries. The fix lands in three places:
I confirmed the fix is real, not vacuous: I temporarily removed the consumer guards and verified the new regression test ( Verification: 97/97 targeted unit tests pass, Non-blocking (one remaining): Verdict: LGTM — the central guarantee of the PR is now enforced and tested. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Independently re-reviewed at HEAD (d5b4051, "reject forged partial shell markers"), which is one new commit since the last (round-5) review. I verified all 15 changed files against main, ran the targeted unit suites (128/128 pass across prefetch-cache.test.ts, link-navigation.test.ts, app-rsc-response-finalizer.test.ts, app-page-response.test.ts), and vp check is clean on all 8 changed source files.
The core mechanism is sound: a request-local ALS shell state (prefetch-suspense-shell.ts), connection() suspending on a makeHangingPromise, the double-setTimeout abort matching Next.js scheduleInSequentialTasks, the X-Vinext-Rsc-Partial-Shell response header, RSC-cache/ISR/CDN write suppression for partial shells, and a consumer that demotes partial shells to non-navigation entries.
Previously-blocking re-promotion bug: confirmed fixed
The bug flagged across the first four review rounds (partial shells re-promoted to navigable entries on a repeat prefetch) is fixed in three places, and I confirmed each guard is live in the current tree:
- Primary fix —
link.tsx:511now gates the re-entry de-dupe flip onexisting.optimisticRouteShell !== true, mirroring the established invariant atlink.tsx:631. - Defense in depth (find) —
findPrefetchCacheEntryForNavigationrejectsoptimisticRouteShell === trueon both the exact path (navigation.ts:417) and the loop path (navigation.ts:428). - Defense in depth (consume) —
consumePrefetchResponserejectsoptimisticRouteShell === trueon both the exact-mismatch delete guard (navigation.ts:792) and the final gate (navigation.ts:809).
The re-entry path is now covered by link-navigation.test.ts ("does not promote a server-declared partial shell on repeat prefetch") and prefetch-cache.test.ts ("rejects partial shells even if their navigation flag is promoted"). The latter explicitly flips cacheForNavigation back to true and asserts both hasPrefetchCacheEntryForNavigation and consumePrefetchResponse still reject it — so the guard is non-vacuous.
Spoofing protection: now hardened on both axes
The partial-shell marker is an in-band header, so a forged marker could otherwise let a complete payload be demoted (or a partial shell be laundered). Both directions are closed:
- Middleware spoofing —
buildAppPageRscResponse(app-page-response.ts:282-285) nowdeletesX-Vinext-Rsc-Partial-Shellon full responses after merging middleware headers (new ind5b4051), with a regression test ("does not allow middleware to mark a full RSC response as a partial shell"). - Config-header spoofing —
finalizeAppRscResponsecapturesisPartialShellbefore applying config headers (app-rsc-response-finalizer.ts:64), thenlockPartialShellCacheHeadersstrips a config-injected marker from full responses and forcesno-store+ stripsCDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tagon genuine partial shells.
Server-side correctness
- The suspense-shell render path (
app-page-render.ts:702-721) reuses the existing PPR-fallback prerender pattern and is gated onprerenderToReadableStream, which is always wired into the App RSC entry (entries/app-rsc-entry.ts:889) — so the non-abortingrenderToReadableStreamfall-through is not reachable for App Router, and aconnection()-induced hang cannot occur. - Routes that complete without a dynamic boundary leave
prefetchSuspenseShellWasAborted === false, so they keep full prefetch reuse and normal caching — matching the PR's stated guarantee. - The
connection()shim (server.ts:1061-1067) gates on the render-mode header before the dynamicimport(), so only suspense-shell prefetches pay the import cost — consistent with the repo's hot-path guidance.
Dev/prod parity
Server logic lives in shared app-page-render.ts/app-page-response.ts/app-rsc-response-finalizer.ts plus the consumer shims, covering both dev and prod App Router. Partial shells are excluded from the RSC cache write, cache-metadata capture, ISR, and CDN caches. Pages Router prod path is unaffected (App-only feature). No parity gap.
Remaining non-blocking note
shims/server.ts:1063still reads the render mode via the hardcoded literals"x-vinext-rsc-render-mode"/"prefetch-suspense-shell"instead ofVINEXT_RSC_RENDER_MODE_HEADER/APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL. It works (Headers.getis case-insensitive and the values match), andserver.tsalready imports from../server/headers.jsand the render-mode module is a tiny constants-only file — so swapping to the constants is a low-risk improvement that avoids silent drift. Minor; fine as a follow-up.
Other observations (all fine)
- The
schedulechange (link.tsx:453-458,priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously rather than during idle. Thefetchitself stays async inside the IIFE. This is a broad but deliberate change matching the stated intent ("start viewport prefetches immediately") and is covered by a focused test.
LGTM — the central guarantee is enforced, the spoofing vectors are closed on both the middleware and config-header axes, and the changes are well-tested. The only open item is the cosmetic literal-vs-constant note in server.ts.
|
Review posted to PR #2364. SummaryIndependently re-reviewed at HEAD ( Verdict: LGTM — no blocking issues. Key findings:
One remaining non-blocking note: |
|
/bigbonk review for issues |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Re-reviewed at HEAD (e35952a, "suspend request APIs in shell prefetch"), which is one new commit since the round-6 review. Independently verified the changed files against main. Targeted unit suites pass (shims.test.ts, prefetch-cache.test.ts 29/29, link-navigation.test.ts 50/50, link.test.ts 121/121, app-page-response.test.ts 31/31, app-rsc-response-finalizer.test.ts 18/18) and vp check is clean on all 10 changed source files.
New commit (e35952a): correct and consistent
The commit extends suspense-shell suspension from connection() to headers() and cookies() (shims/headers.ts:879-882, :918-921). This is the right call — Next.js suspends all request-data APIs (connection, headers, cookies, draftMode-adjacent) when a prerender is cut short, not just connection(). Verified:
- Safe outside prefetch shells.
suspendPrefetchSuspenseShellreturnsnullwhen theprefetchSuspenseShellAlsstore is absent (prefetch-suspense-shell.ts:52-53), so normal renders, PPR fallbacks, and Pages Router are unaffected. The ALS store is established only in theAPP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELLrender branch (app-page-render.ts:702-712). - Correct priority over PPR fallback. In
headers()/cookies()the prefetch-shell check runs beforecreatePprFallbackShellSuspensePromise. The two render paths are mutually exclusive at dispatch (app-page-render.ts:702-721returns before the PPR-fallback branch at:723), so only one ALS state is ever live; ordering is correct. - No hang risk. The hanging promise rejects on
dynamicAbortController.signal, which is always aborted by the double-setTimeoutinschedulePrefetchSuspenseShellAbort(scheduled unconditionally in the shell branch). If the render completes first,cancelAbort()clears the timers andwasPrefetchSuspenseShellAbortedstaysfalse, preserving full prefetch reuse. - Tested.
shims.test.ts("headers() and cookies() suspend during Suspense shell prefetch") drives both APIs through the ALS, asserts they stay pending across a microtask, then aborts and asserts rejection. Non-vacuous.
Carried-over verifications (all still hold)
- Re-promotion bug (blocking in rounds 1–4): fixed.
link.tsx:511gates the re-entry flip onexisting.optimisticRouteShell !== true;findPrefetchCacheEntryForNavigation(navigation.ts:417/428) andconsumePrefetchResponse(navigation.ts:792/809) both rejectoptimisticRouteShell === true. Covered by the re-entry regression tests. - Spoofing closed on both axes. Middleware:
buildAppPageRscResponsesets/deletesX-Vinext-Rsc-Partial-Shellfrom the trusted server value after merging middleware headers (app-page-response.ts:282-286). Config headers:finalizeAppRscResponsecapturesisPartialShellbefore applying config headers (app-rsc-response-finalizer.ts:67), thenlockPartialShellCacheHeadersre-asserts on genuine shells and strips a config-injected marker from full responses (:91/128). Both have regression tests. - Cache exclusion. Partial shells get
no-store, are stripped ofCDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag, and skip the RSC cache write + cache-metadata capture. - Dev/prod parity. Server logic lives in shared
app-page-render.ts/app-page-response.ts/app-rsc-response-finalizer.ts+ consumer shims, covering both dev and prod App Router. Pages Router prod path is unaffected (App-only feature).
Remaining non-blocking note (unchanged)
shims/server.ts:1063still reads the render mode via the hardcoded literals"x-vinext-rsc-render-mode"/"prefetch-suspense-shell"instead ofVINEXT_RSC_RENDER_MODE_HEADER/APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL(both exported fromserver/headers.tsandserver/app-rsc-render-mode.ts). It works (Headers.getis case-insensitive and the values match), but it's the only call site not using the shared constants — a low-risk drift risk. Note the new commit'sheaders()/cookies()call sites don't need this header guard at all (the ALS store is the gate), so the inconsistency is now slightly more visible:connection()guards on the header before the dynamicimport()whileheaders()/cookies()callsuspendPrefetchSuspenseShelldirectly. Both are correct; just worth a follow-up to align on the constants.
LGTM — the new commit is a sound, well-tested extension; the central guarantee remains enforced and the spoofing vectors stay closed. Only open item is the cosmetic literal-vs-constant note in server.ts.
|
Review posted to PR #2364. SummaryRe-reviewed at HEAD ( New commit verdict: correct and well-tested. It extends suspense-shell suspension from
Carried-over verifications all hold: the round-1–4 re-promotion bug is still fixed (the three One non-blocking note (unchanged): Verdict: LGTM. |
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: prefetch user Suspense shells
Re-reviewed at HEAD (2a4681d), which adds two new commits since the round-7 review (ba3f538 "distinguish user suspense prefetch shells" and 2a4681d "scope suspense prefetch scheduling"). Independently verified the changed files against main, ran the targeted unit suites (link-navigation.test.ts, prefetch-cache.test.ts, shims.test.ts, link.test.ts — 1346/1346 pass), and vp check is clean on the four changed source files (link.tsx, server.ts, navigation.ts, prefetch-suspense-shell.ts).
ba3f538 (distinguish partial suspense shells): correct improvement
Previously the response-side demotion of a partial suspense shell reused optimisticRouteShell = true, conflating two distinct concepts (loading-shell prefetches vs. server-declared partial suspense shells). This commit introduces a dedicated partialSuspenseShell flag (navigation.ts:754, next-shims.d.ts:250) and threads it through every gate that previously only checked optimisticRouteShell:
- Re-entry de-dupe —
link.tsx:514-518now refuses to re-promote whenpartialSuspenseShell === true(in addition tooptimisticRouteShell), preserving the round-1–4 fix for the new flag. - Navigation promote —
link.tsx:638skipspartialSuspenseShell === trueentries. - Find (defense in depth) —
findPrefetchCacheEntryForNavigationrejectspartialSuspenseShell === trueon both the exact path (navigation.ts:419) and the loop path (navigation.ts:433). - Consume (defense in depth) —
consumePrefetchResponserejectspartialSuspenseShell === trueon both the exact-mismatch delete guard (navigation.ts:801) and the final gate (navigation.ts:821).
I confirmed the demotion sets only partialSuspenseShell = true (not optimisticRouteShell), so the two states are now cleanly separated. The regression tests were updated to assert optimisticRouteShell: false + partialSuspenseShell: true after a X-Vinext-Rsc-Partial-Shell: 1 response (prefetch-cache.test.ts:344, link-navigation.test.ts:1511), keeping the re-promotion guarantee non-vacuous. The central guarantee of the PR remains enforced.
2a4681d (scope scheduling): resolves the round-1–7 non-blocking note
This commit narrows the immediate-scheduling change that all prior rounds flagged as a broad behavioral bundling. Scheduling is now synchronous only for priority === "high" or when appAutoPrefetch?.prefetchSuspenseShell === true (link.tsx:456-457); ordinary App Router viewport prefetches go back on the idle queue. New test link-navigation.test.ts:1447 ("keeps ordinary App Router viewport prefetches on the idle queue") asserts requestIdleCallback is used and fetch is not called synchronously. resolveAutoAppRoutePrefetch is a pure function (reads window globals + a trie cache, no mutations), so the eager call at link.tsx:453-454 is safe and is reused (not recomputed) at link.tsx:479 via appAutoPrefetch ?? ….
The same commit also removes the x-vinext-rsc-render-mode header read from connection() (server.ts:1063-1067), which resolves the only remaining non-blocking note carried since round 1. connection() now gates purely on the ALS store via suspendPrefetchSuspenseShell — consistent with headers()/cookies(). This is safe: suspendPrefetchSuspenseShell returns null when the ALS store is absent (prefetch-suspense-shell.ts:52-53), and the store is established only inside the APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL render branch (app-page-render.ts:702-712). New test shims.test.ts:4653 ("connection() suspends from shell state even when request headers omit the render mode") confirms this is more robust than the header guard it replaces.
Carried-over verifications (all still hold)
- Re-promotion bug (blocking rounds 1–4): fixed, and now also covered for the new
partialSuspenseShellflag. - Spoofing closed on both axes — middleware (
app-page-response.ts) and config headers (app-rsc-response-finalizer.tscapturingisPartialShellbefore config headers +lockPartialShellCacheHeaders). - No hang risk — the
dynamicAbortController/reactAbortControllerare always aborted by the double-setTimeout(schedulePrefetchSuspenseShellAbort), which faithfully mirrors Next.jsscheduleInSequentialTasks;cancelAbort()clears timers if the render completes first. - Cache exclusion — partial shells get
no-store, are stripped ofCDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag, and skip the RSC cache write + cache-metadata capture. - Dev/prod parity — server logic lives in shared
app-page-render.ts/app-page-response.ts/app-rsc-response-finalizer.ts+ consumer shims; Pages Router prod path is unaffected (App-only feature).
No remaining open items
The literal-vs-constant note flagged in every prior round is now moot — connection() no longer reads the header at all, and the headers()/cookies() paths never did. No new issues found.
Verdict: LGTM. Both new commits are sound and well-tested: ba3f538 cleanly separates the partial-suspense-shell state from optimistic route shells while preserving all re-promotion/consumer guards, and 2a4681d scopes the immediate-scheduling change to only suspense-shell prefetches and removes the last header/constant inconsistency. The central guarantee is enforced, spoofing vectors stay closed, and all targeted tests pass.
|
Review posted to PR #2364. SummaryRe-reviewed at HEAD (
Carried-over verifications all hold: re-promotion bug fixed (now also for the new flag), spoofing closed on both axes, no hang risk, cache exclusion intact, dev/prod parity holds. Verdict: LGTM — no blocking issues and no remaining open items. |
Summary
<Suspense>fallbacks whenconnection()suspendsNext.js parity
Targets the first-stage failure in Next.js v16.2.6:
test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.tsBefore this change the exact suite timed out waiting for any request after revealing the link. After this change the request starts immediately and the focused non-cache fixture receives
Loading a...from the user Suspense boundary.The upstream fixture itself enables
cacheComponents: true; its remaining response-body mismatch is cacheComponents-specific and intentionally excluded from this PR. Route-identity recovery after the shell is independently covered by #2358.Overlap was checked against:
loading.tsxancestor loading boundariesThis PR does not duplicate those behaviors.
Validation
vp checkon all changed filesvp run vinext#buildTimed out waiting for a request to be initiatedto response-body inspection; remaining mismatch is undercacheComponents: trueExcludes PPR, resume, fallback-shell persistence, and cacheComponents behavior.