Skip to content

fix(app-router): prefetch user Suspense shells#2364

Open
james-elicx wants to merge 11 commits into
mainfrom
codex/fix-suspense-derived-prefetch
Open

fix(app-router): prefetch user Suspense shells#2364
james-elicx wants to merge 11 commits into
mainfrom
codex/fix-suspense-derived-prefetch

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • start App Router viewport prefetches immediately instead of waiting for browser idle
  • add a request-local prefetch mode that captures user-authored <Suspense> fallbacks when connection() suspends
  • mark partial shell responses so they cannot be consumed as complete navigation payloads
  • preserve full prefetch reuse for dynamic routes whose render completes without a dynamic boundary

Next.js parity

Targets the first-stage failure in Next.js v16.2.6:

  • test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts

Before 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:

This PR does not duplicate those behaviors.

Validation

  • 261 focused unit assertions across Link scheduling, prefetch cache, RSC request/response, and render mode tests
  • focused vp check on all changed files
  • vp run vinext#build
  • isolated production browser fixture, one worker: 1/1 passed
  • exact upstream suite, one worker: advanced from Timed out waiting for a request to be initiated to response-body inspection; remaining mismatch is under cacheComponents: true

Excludes PPR, resume, fallback-shell persistence, and cacheComponents behavior.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 2a4681d

@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

@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: 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.ts reads the render mode via the hardcoded literal "x-vinext-rsc-render-mode" and compares against the literal "prefetch-suspense-shell". It works (Headers.get is case-insensitive and the value matches the constant), but every other call site uses VINEXT_RSC_RENDER_MODE_HEADER + APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL. Using the constants here avoids drift if either value ever changes.

  • The schedule change (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches run their scheduling synchronously rather than during idle, not just suspense-shell ones. The actual fetch stays async inside the IIFE, so the cost is the per-Link resolveAutoAppRoutePrefetch + 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 shared app-page-render.ts and 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.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Reviewed all 13 files in PR #2364 ("prefetch user Suspense shells"). Targeted unit tests pass (228/228) and vp check is clean on the changed source. The core mechanism is sound: a request-local ALS shell state, connection() suspending via a hanging promise, a double-setTimeout abort that matches Next.js's scheduleInSequentialTasks, an X-Vinext-Rsc-Partial-Shell response header, and a consumer that demotes partial shells to non-navigation entries.

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 shims/link.tsx:511-512 flips cacheForNavigation back to true. The old loading-shell path never reached it (it issued requests with cacheForNavigation: false), but the new suspense-shell path issues with cacheForNavigation: true and only demotes after the response arrives. So a second prefetch of the same suspense-shell URL (re-entering viewport, or hover-after-viewport) re-promotes the stored partial shell, and both consumption gates (findPrefetchCacheEntryForNavigation, consumePrefetchResponse) only check cacheForNavigation — not optimisticRouteShell — so the partial shell becomes consumable as a complete navigation payload, defeating the PR's central guarantee. Suggested fix: gate the flip on optimisticRouteShell !== true (and/or treat optimisticRouteShell as non-navigable in the consumer for defense in depth), plus a regression test.

Non-blocking:

  • shims/server.ts uses hardcoded header/value literals instead of VINEXT_RSC_RENDER_MODE_HEADER / APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL constants (works, but inconsistent and drift-prone).
  • The schedule change affects all App Router prefetches (immediate scheduling), not just suspense-shell ones — a broad behavioral change bundled in; flagged for awareness.
  • Dev/prod parity is fine (App Router shared path; Pages Router unaffected).

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.

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:

  1. autoPrefetch.cacheForNavigation === true → does not hit the early return at line 506-508
  2. existing.cacheForNavigation === false (demoted by the first response) → flipped back to true at line 512

Both consumption gates only check cacheForNavigation, never optimisticRouteShell:

  • findPrefetchCacheEntryForNavigationnavigation.ts:416 and :427
  • consumePrefetchResponsenavigation.ts:790 and :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:1063 reads the render mode via the hardcoded literal "x-vinext-rsc-render-mode" and compares against "prefetch-suspense-shell". It works (Headers.get is case-insensitive and the value matches the constant), but every other call site uses VINEXT_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 — the schedule change (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously instead of during idle, not just suspense-shell ones. The fetch itself stays async inside the IIFE, so the cost is per-Link resolveAutoAppRoutePrefetch + 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.ts and the consumer shims, covering both dev and prod App Router. Pages Router prod path is unaffected (App-only feature). No parity gap.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

I independently verified all 13 changed files against main. Targeted unit tests pass (228/228) and the core mechanism is sound — request-local ALS shell state, connection() suspending on a hanging promise, the double-setTimeout abort matching Next.js scheduleInSequentialTasks, the X-Vinext-Rsc-Partial-Shell header, and a consumer that demotes partial shells.

Blocking issue (confirmed by code tracing): Partial Suspense shells can be re-promoted to navigable entries. A dynamic-no-loading route is issued with cacheForNavigation: true + prefetchSuspenseShell: true, demoted to cacheForNavigation: false + optimisticRouteShell: true only after the response. On a second prefetch of the same URL, the re-entry de-dupe block at link.tsx:510-512 flips cacheForNavigation back to true. Both consumption gates (navigation.ts:416/427 and :790/807) only check cacheForNavigation, never optimisticRouteShell, so the partial shell becomes consumable as a complete navigation payload — defeating the PR's central guarantee. The codebase already enforces this invariant elsewhere (promotePrefetchEntriesForNavigation skips optimisticRouteShell === true at link.tsx:631), so the fix should mirror it. The existing test only covers first-fetch demotion; the re-entry path is untested.

Non-blocking: hardcoded header literals in server.ts:1063 instead of the shared constants; the schedule change broadly affects all App Router prefetches; dev/prod parity is fine.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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: 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):

  1. autoPrefetch.cacheForNavigation === true → does not hit the early return at link.tsx:506-508
  2. existing.cacheForNavigation === false (demoted by the first response) → flipped back to true at link.tsx:512
  3. The subsequent hasPrefetchCacheEntryForNavigation gate (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:

  • findPrefetchCacheEntryForNavigationnavigation.ts:416 and :427
  • consumePrefetchResponsenavigation.ts:790 and :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:1063 reads the render mode via the hardcoded literal "x-vinext-rsc-render-mode" and compares against "prefetch-suspense-shell". It works (Headers.get is case-insensitive, the header constant is "X-Vinext-Rsc-Render-Mode", and the value matches APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL), but every other call site uses VINEXT_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 — the schedule change (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously instead of during idle, not just suspense-shell ones. The fetch itself stays async inside the IIFE, so the cost is per-Link resolveAutoAppRoutePrefetch + 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.ts and 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.ts skips the cache write and metadata capture when prefetchSuspenseShellWasAborted). Pages Router prod path is unaffected (App-only feature). No parity gap spotted.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Independently verified all 13 changed files against main and reproduced the blocking issue at the unit level. Targeted unit tests pass (228/228). The core mechanism is sound (request-local ALS shell state, connection() suspending on a hanging promise, the double-setTimeout abort matching Next.js scheduleInSequentialTasks, the X-Vinext-Rsc-Partial-Shell header with ISR-write suppression, and a consumer that demotes partial shells).

Blocking — partial Suspense shells can be re-promoted to navigable entries (reproduced). A dynamic-no-loading route is issued with cacheForNavigation: true + prefetchSuspenseShell: true, then demoted to cacheForNavigation: false + optimisticRouteShell: true only after the response. On a second prefetch of the same URL, the re-entry de-dupe block at link.tsx:510-512 flips cacheForNavigation back to true, and the hasPrefetchCacheEntryForNavigation gate then leaves it permanently navigable. Both consumption gates (navigation.ts:416/427 and :790/807) only check cacheForNavigation, never optimisticRouteShell. I wrote a throwaway unit test that seeds a demoted partial shell, flips the flag back (mirroring link.tsx:512), and confirmed consumePrefetchResponse returns the partial shell's snapshot instead of null — defeating the PR's central guarantee. The codebase already enforces this exact invariant at link.tsx:631 (promotePrefetchEntriesForNavigation skips optimisticRouteShell === true), so the fix should mirror it. Re-entry path is untested.

Non-blocking: hardcoded header literals in server.ts:1063 instead of the shared constants; the schedule change broadly affects all App Router prefetches (immediate scheduling); dev/prod parity is fine and partial shells are correctly excluded from ISR.

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.

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):

  1. autoPrefetch.cacheForNavigation === true → does not hit the early return at link.tsx:506-508
  2. existing.cacheForNavigation === false (demoted by the first response) → flipped back to true at link.tsx:512
  3. The hasPrefetchCacheEntryForNavigation gate (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:

  • findPrefetchCacheEntryForNavigationnavigation.ts:416 and :427
  • consumePrefetchResponsenavigation.ts:790 and :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:1063 reads the render mode via the hardcoded literal "x-vinext-rsc-render-mode" and compares against "prefetch-suspense-shell". It works (Headers.get is case-insensitive; the header constant is "X-Vinext-Rsc-Render-Mode" and the value matches APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL), but every other call site uses VINEXT_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 — the schedule change (priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously instead of during idle, not just suspense-shell ones. The fetch itself stays async inside the IIFE, so the cost is per-Link resolveAutoAppRoutePrefetch + 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.ts and the consumer shims, covering both dev and prod App Router. Partial-shell entries are correctly excluded from the RSC cache / ISR (app-page-render.ts skips the cache write and metadata capture when prefetchSuspenseShellWasAborted) and now also from CDN caches (324d243). Pages Router prod path is unaffected (App-only feature). No parity gap spotted.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Re-reviewed at the current HEAD (324d243), which added a new commit ("lock partial shell cache policy") since the previous review rounds. That commit only hardens CDN cache headers for partial shells — it does not address the re-promotion bug flagged in all three prior reviews. Targeted unit tests pass (29 + 49) and vp check is clean on the changed shims.

Blocking (still present, reproduced this round): Partial Suspense shells can be re-promoted to navigable entries. A dynamic-no-loading route is issued with cacheForNavigation: true + prefetchSuspenseShell: true, demoted to cacheForNavigation: false + optimisticRouteShell: true only after the response. On a second prefetch of the same URL, the re-entry de-dupe block at link.tsx:510-512 flips cacheForNavigation back to true without checking optimisticRouteShell. Both consumption gates (navigation.ts:416/427 and :790/807) only check cacheForNavigation, so the partial shell becomes consumable as a complete navigation payload — defeating the PR's central guarantee. The CDN-header lock from the new commit doesn't help: this is entirely in the in-memory client cache. The codebase already enforces this exact invariant at link.tsx:631. I wrote a throwaway unit test (now reverted) that confirmed re-promotion makes consumePrefetchResponse return the partial shell's snapshot instead of null.

Suggested fix: gate the flip on existing.optimisticRouteShell !== true (and/or treat optimisticRouteShell === true as non-navigable in the consumer for defense in depth), plus a re-entry regression test.

Non-blocking: hardcoded header literals in server.ts:1063 instead of the shared constants; the schedule change broadly affects all App Router prefetches; dev/prod parity is fine and partial shells are now also excluded from CDN caches.

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.

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:

  1. Primary fixlink.tsx:511 now gates the re-entry flip on existing.optimisticRouteShell !== true, so a second prefetch of a demoted partial shell no longer flips cacheForNavigation back to true. This mirrors the established invariant at link.tsx:631 (promotePrefetchEntriesForNavigation skips optimisticRouteShell === true).
  2. Defense in depth (find)findPrefetchCacheEntryForNavigation now rejects optimisticRouteShell === true on both the exact path (navigation.ts:417) and the loop path (navigation.ts:428).
  3. Defense in depth (consume)consumePrefetchResponse now rejects optimisticRouteShell === true on 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:1063 still reads the render mode via the hardcoded literal "x-vinext-rsc-render-mode" and compares against "prefetch-suspense-shell". It works (Headers.get is case-insensitive; the header constant is VINEXT_RSC_RENDER_MODE_HEADER = "X-Vinext-Rsc-Render-Mode" and the value matches APP_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 schedule change (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.ts and consumer shims, covering both dev and prod App Router. Pages Router prod path is unaffected (App-only feature).
  • The schedulePrefetchSuspenseShellAbort double-setTimeout faithfully mirrors Next.js scheduleInSequentialTasks, and suspendPrefetchSuspenseShell uses makeHangingPromise — a faithful port.

LGTM once you decide whether to swap the server.ts:1063 literals for the constants (non-blocking).

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Re-reviewed at the current HEAD (d723513), which is a new commit since the prior four review rounds. That commit fixes the blocking re-promotion bug that all previous reviews flagged.

Blocking issue — now RESOLVED. Partial Suspense shells can no longer be re-promoted to navigable entries. The fix lands in three places:

  • link.tsx:511 — the re-entry de-dupe flip is now gated on existing.optimisticRouteShell !== true (the primary fix, mirroring the existing invariant at link.tsx:631).
  • navigation.ts:417/428findPrefetchCacheEntryForNavigation now rejects optimisticRouteShell === true (defense in depth).
  • navigation.ts:792/809consumePrefetchResponse now rejects optimisticRouteShell === true (defense in depth).

I confirmed the fix is real, not vacuous: I temporarily removed the consumer guards and verified the new regression test (prefetch-cache.test.ts "rejects partial shells even if their navigation flag is promoted") fails, then restored them and confirmed it passes. The re-entry path is now covered by two new tests.

Verification: 97/97 targeted unit tests pass, vp check clean on all six changed source files, working tree clean.

Non-blocking (one remaining): server.ts:1063 still uses hardcoded header/value literals instead of VINEXT_RSC_RENDER_MODE_HEADER / APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL. Works correctly; suggested as a follow-up. The broad schedule change and dev/prod parity are both fine.

Verdict: LGTM — the central guarantee of the PR is now enforced and tested.

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.

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 fixlink.tsx:511 now gates the re-entry de-dupe flip on existing.optimisticRouteShell !== true, mirroring the established invariant at link.tsx:631.
  • Defense in depth (find)findPrefetchCacheEntryForNavigation rejects optimisticRouteShell === true on both the exact path (navigation.ts:417) and the loop path (navigation.ts:428).
  • Defense in depth (consume)consumePrefetchResponse rejects optimisticRouteShell === true on 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 spoofingbuildAppPageRscResponse (app-page-response.ts:282-285) now deletes X-Vinext-Rsc-Partial-Shell on full responses after merging middleware headers (new in d5b4051), with a regression test ("does not allow middleware to mark a full RSC response as a partial shell").
  • Config-header spoofingfinalizeAppRscResponse captures isPartialShell before applying config headers (app-rsc-response-finalizer.ts:64), then lockPartialShellCacheHeaders strips a config-injected marker from full responses and forces no-store + strips CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag on 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 on prerenderToReadableStream, which is always wired into the App RSC entry (entries/app-rsc-entry.ts:889) — so the non-aborting renderToReadableStream fall-through is not reachable for App Router, and a connection()-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 dynamic import(), 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:1063 still reads the render mode via the hardcoded literals "x-vinext-rsc-render-mode" / "prefetch-suspense-shell" instead of VINEXT_RSC_RENDER_MODE_HEADER / APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL. It works (Headers.get is case-insensitive and the values match), and server.ts already imports from ../server/headers.js and 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 schedule change (link.tsx:453-458, priority === "high" || hasAppNavigationRuntime()) makes all App Router prefetches schedule synchronously rather than during idle. The fetch itself 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.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Independently re-reviewed at HEAD (d5b4051), which is one new commit since the prior round-5 review. Verified all 15 changed files against main, ran targeted unit tests (128/128 pass), and vp check is clean on all 8 changed source files.

Verdict: LGTM — no blocking issues.

Key findings:

  • Re-promotion bug (blocking in rounds 1–4) is fixed and tested. Confirmed all three guards are live: link.tsx:511 gates the re-entry flip on optimisticRouteShell !== true, and both findPrefetchCacheEntryForNavigation and consumePrefetchResponse reject optimisticRouteShell === true. The regression test that flips the flag back and still asserts rejection makes the fix non-vacuous.
  • Spoofing closed on both axes. The new commit deletes the partial-shell header from full responses after middleware merge (middleware-spoofing), and finalizeAppRscResponse captures isPartialShell before config headers run, then strips a config-injected marker (config-spoofing). Both have regression tests.
  • Server correctness verified. prerenderToReadableStream is always wired into the App RSC entry, so the non-aborting fall-through (and a potential connection() hang) is unreachable. Routes without a dynamic boundary keep full prefetch reuse.
  • Dev/prod parity holds; partial shells are kept out of RSC cache, ISR, and CDN caches.

One remaining non-blocking note: server.ts:1063 still uses hardcoded header/value literals instead of the shared constants — low-risk follow-up.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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: 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. suspendPrefetchSuspenseShell returns null when the prefetchSuspenseShellAls store 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 the APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL render branch (app-page-render.ts:702-712).
  • Correct priority over PPR fallback. In headers()/cookies() the prefetch-shell check runs before createPprFallbackShellSuspensePromise. The two render paths are mutually exclusive at dispatch (app-page-render.ts:702-721 returns 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-setTimeout in schedulePrefetchSuspenseShellAbort (scheduled unconditionally in the shell branch). If the render completes first, cancelAbort() clears the timers and wasPrefetchSuspenseShellAborted stays false, 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:511 gates the re-entry flip on existing.optimisticRouteShell !== true; findPrefetchCacheEntryForNavigation (navigation.ts:417/428) and consumePrefetchResponse (navigation.ts:792/809) both reject optimisticRouteShell === true. Covered by the re-entry regression tests.
  • Spoofing closed on both axes. Middleware: buildAppPageRscResponse sets/deletes X-Vinext-Rsc-Partial-Shell from the trusted server value after merging middleware headers (app-page-response.ts:282-286). Config headers: finalizeAppRscResponse captures isPartialShell before applying config headers (app-rsc-response-finalizer.ts:67), then lockPartialShellCacheHeaders re-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 of CDN-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:1063 still reads the render mode via the hardcoded literals "x-vinext-rsc-render-mode" / "prefetch-suspense-shell" instead of VINEXT_RSC_RENDER_MODE_HEADER / APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL (both exported from server/headers.ts and server/app-rsc-render-mode.ts). It works (Headers.get is 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's headers()/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 dynamic import() while headers()/cookies() call suspendPrefetchSuspenseShell directly. 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.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Re-reviewed at HEAD (e35952a, "suspend request APIs in shell prefetch") — one new commit since the round-6 review. Independently verified all changed files against main, ran the targeted unit suites (all passing), and vp check is clean on all 10 changed source files.

New commit verdict: correct and well-tested. It extends suspense-shell suspension from connection() to headers()/cookies() — matching Next.js, which suspends all request-data APIs when a prerender is cut short. I verified:

  • Safe outside prefetch shells (suspendPrefetchSuspenseShell returns null when the ALS store is absent; the store is only set in the suspense-shell render branch).
  • Correct priority over the PPR fallback path (mutually exclusive at dispatch).
  • No hang risk (the dynamicAbortController is always aborted by the scheduled double-setTimeout).
  • Covered by a non-vacuous test in shims.test.ts.

Carried-over verifications all hold: the round-1–4 re-promotion bug is still fixed (the three optimisticRouteShell guards are live), both spoofing axes (middleware + config headers) remain closed with regression tests, partial shells stay out of RSC/ISR/CDN caches, and dev/prod parity holds.

One non-blocking note (unchanged): shims/server.ts:1063 still hardcodes "x-vinext-rsc-render-mode" / "prefetch-suspense-shell" instead of the shared constants. Low-risk follow-up.

Verdict: LGTM.

github run

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 2a4681d against base de014d4 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 117.1 KB 117.3 KB ⚫ +0.2%
Client entry size (gzip) vinext 112.5 KB 112.6 KB ⚫ +0.1%
Dev server cold start vinext 2.47 s 2.45 s ⚫ -0.8%
Production build time vinext 2.87 s 2.85 s ⚫ -0.4%
RSC entry closure size (gzip) vinext 93.0 KB 93.4 KB ⚫ +0.4%
Server bundle size (gzip) vinext 156.9 KB 157.3 KB ⚫ +0.3%

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: 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.ts1346/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-dupelink.tsx:514-518 now refuses to re-promote when partialSuspenseShell === true (in addition to optimisticRouteShell), preserving the round-1–4 fix for the new flag.
  • Navigation promotelink.tsx:638 skips partialSuspenseShell === true entries.
  • Find (defense in depth)findPrefetchCacheEntryForNavigation rejects partialSuspenseShell === true on both the exact path (navigation.ts:419) and the loop path (navigation.ts:433).
  • Consume (defense in depth)consumePrefetchResponse rejects partialSuspenseShell === true on 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 partialSuspenseShell flag.
  • Spoofing closed on both axes — middleware (app-page-response.ts) and config headers (app-rsc-response-finalizer.ts capturing isPartialShell before config headers + lockPartialShellCacheHeaders).
  • No hang risk — the dynamicAbortController/reactAbortController are always aborted by the double-setTimeout (schedulePrefetchSuspenseShellAbort), which faithfully mirrors Next.js scheduleInSequentialTasks; cancelAbort() clears timers if the render completes first.
  • Cache exclusion — partial shells get no-store, are stripped of CDN-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.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2364.

Summary

Re-reviewed at HEAD (2a4681d), which adds two new commits since the round-7 review. Verified all changed files against main, ran targeted unit suites (1346/1346 pass), and vp check is clean on the four changed source files.

ba3f538 (distinguish partial suspense shells): correct improvement. Introduces a dedicated partialSuspenseShell flag instead of overloading optimisticRouteShell for the server-declared post-response demotion. I confirmed the new flag is threaded through all five gates (re-entry de-dupe, navigation promote, find ×2, consume ×2), the demotion sets only partialSuspenseShell (cleanly separating the two states), and the regression tests were updated to keep the re-promotion guarantee non-vacuous.

2a4681d (scope scheduling): resolves the long-standing non-blocking note. Narrows immediate scheduling to only priority === "high" or prefetchSuspenseShell === true — ordinary App Router viewport prefetches go back on the idle queue (with a new test). The eager resolveAutoAppRoutePrefetch call is safe (pure function, reused not recomputed). The same commit removes the x-vinext-rsc-render-mode header read from connection(), making it ALS-driven like headers()/cookies() — which resolves the only remaining note carried since round 1.

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.

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 19:28
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