Skip to content

fix(app-router): support instant route prefetch shells#2374

Open
james-elicx wants to merge 8 commits into
codex/fix-suspense-derived-prefetchfrom
codex/fix-prefetch-true-instant-stacked
Open

fix(app-router): support instant route prefetch shells#2374
james-elicx wants to merge 8 commits into
codex/fix-suspense-derived-prefetchfrom
codex/fix-prefetch-true-instant-stacked

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • detect truthy unstable_instant exports on pages and ancestor layouts
  • downgrade explicit prefetch={true} to an instant runtime shell for those routes
  • preserve cached content while suspending connection() content until the navigation response arrives
  • retain instant shells for optimistic navigation without treating them as authoritative full-prefetch responses

Next.js parity

Ported against:

  • test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts

Validation

  • vp check on all 19 touched source/test files
  • 1,550 focused unit/integration assertions pass
  • exact Next.js v16.2.6 deploy target: 2 passed, 0 failed
  • package builds: vinext, cloudflare

Stack

Depends on #2364 (codex/fix-suspense-derived-prefetch) for user-authored Suspense shell prefetching.

@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@2374
npm i https://pkg.pr.new/vinext@2374

commit: f5ebe12

@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

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from fea3789 to 76b8990 Compare June 25, 2026 20:18
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from 76b8990 to 0d87d61 Compare June 25, 2026 20:20

@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: instant route prefetch shells

I focused on Next.js parity (per AGENTS.md the Next.js test suite is the authoritative reference) and on the runtime correctness of the new sanitize path. Two issues look blocking, plus a smaller race concern.


1. Wrong export name — does not match Next.js (unstable_instant vs instant + prefetch) — blocking

This PR keys the whole feature off a unstable_instant page/layout export. Next.js does not use that export. The exact test this PR claims to port (test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts) opts a route into instant prefetch with:

export const instant = {
  unstable_samples: [{ cookies: [{ name: 'test', value: null }] }],
}
export const prefetch = 'allow-runtime'

(see app/target-page/page.tsx and app/layout-instant/layout.tsx in that fixture). There is no unstable_instant export anywhere in the Next.js source or test suite (gh api search/code?q=unstable_instant+repo:vercel/next.js → 0 results).

The consequence is a silent behavioral divergence: a real Next.js app that uses export const prefetch = 'allow-runtime' / export const instant = {...} gets none of this behavior on vinext, and a vinext app written against this PR uses an export that Next.js ignores. The PR's own e2e fixture hard-codes the invented name:

// tests/e2e/app-router/nextjs-compat/suspense-prefetch.browser.spec.ts:119
`export const unstable_instant = { prefetch: "runtime", samples: [] };\n${instantPage}`

so the test passes against the wrong contract. Please align detection with the actual Next.js surface (prefetch === 'allow-runtime', optionally instant) in routeModuleHasInstant (packages/vinext/src/routing/app-route-graph.ts) and update the fixtures/tests to match.


2. sanitizeInstantShellValue throws on pending/halted RSC chunks — blocking

packages/vinext/src/server/app-optimistic-routing.ts:289-307:

if (isUnknownRecord(payload) && payload.status === "rejected") {
  return createElement(OptimisticRouteSegment);
}
if (typeof initialize === "function") {
  return sanitizeInstantShellValue(Reflect.apply(initialize, undefined, [payload]));
}

This only special-cases status === "rejected". For any other status it calls _init(payload) (react-server-dom's readChunk). For instant shells the dynamic connection() subtree is intentionally suspended, so its serialized chunk arrives pending / blocked / halted, not fulfilled or rejected. readChunk throws the chunk itself in those states:

case "pending":
case "blocked":
case "halted":
  throw chunk;

(verified against the bundled react-server-dom-webpack-client.node.development.js and reproduced standalone). That throw propagates out of sanitizeInstantShellElementscreateOptimisticRouteTemplatelearnOptimisticRouteTemplateFromPrefetch. It's swallowed by Promise.allSettled in learnOptimisticRouteTemplatesFromPrefetchCache (app-browser-entry.ts:508), so there's no crash — but the optimistic instant template is silently never learned for exactly the routes this feature targets (cached content + suspended dynamic content), defeating the stated goal of "preserve cached content while suspending connection() content."

The unit test masks this because it mocks the dynamic child as _payload: { status: "rejected" } and the cached child as "resolved_model" (tests/app-optimistic-routing.test.ts:307-343). Neither reflects the real pending/halted dynamic chunk. Please:

  • treat pending/blocked/halted chunks as suspended segments (return OptimisticRouteSegment) rather than calling _init, and
  • add a unit test that exercises a status: "pending" (and "halted") lazy child whose _init throws the chunk, asserting it is replaced by the suspense trigger rather than propagating.

3. Instant-mode abort scheduling depends on connection() being reached — confirm

In app-page-render.ts:719-722, instant mode does not call schedulePrefetchSuspenseShellAbort up front; scheduling only happens lazily inside suspendPrefetchSuspenseShell when connection() is encountered (prefetch-suspense-shell.ts:69). If an instant route renders without ever hitting connection() (or a dynamic API), the abort is never scheduled, wasPrefetchSuspenseShellAborted stays false, and partialShell (app-page-render.ts:841) is not set. Is that intended (a fully-cacheable instant route is a complete, non-partial response)? If so a comment would help; if not, this is a correctness gap. Either way please confirm against Next.js' behavior for an instant route with no dynamic APIs.


Process note

Per AGENTS.md ("Always verify Next.js behavior first" / "Searching the Next.js Test Suite" is a required step), issue #1 should have been caught by reading the ported fixture's exports. Recommend re-deriving the detection contract directly from prefetch-true-instant before re-requesting review.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2374 requesting changes. Summary of findings:

Blocking issues

1. Wrong export name — Next.js parity violation
The feature keys off a unstable_instant export that does not exist in Next.js. The ported test (prefetch-true-instant) uses export const instant = {...} + export const prefetch = 'allow-runtime'. unstable_instant returns 0 results in the entire Next.js repo. The PR's own e2e fixture hard-codes the invented name, so the test validates the wrong contract. Real Next.js apps get none of this behavior on vinext.

2. sanitizeInstantShellValue throws on pending/halted RSC chunks
packages/vinext/src/server/app-optimistic-routing.ts:289-307 only special-cases status === "rejected", then calls _init for everything else. For instant shells the suspended connection() subtree arrives as a pending/blocked/halted chunk, and react-server-dom's readChunk throws the chunk in those states (verified against the bundled runtime and reproduced standalone). The throw is swallowed by Promise.allSettled, so the optimistic instant template is silently never learned for the exact routes this feature targets. The unit test masks it by mocking the dynamic child as "rejected" / "resolved_model" instead of "pending".

Non-blocking

3. Instant-mode abort scheduling is lazy and only fires if connection() is reached; a route with no dynamic API never sets partialShell. Asked the author to confirm intent against Next.js.

Both blocking issues stem from skipping the required "verify Next.js behavior first" step — the export contract was discoverable directly in the ported fixture.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch 2 times, most recently from f9032d8 to 3c8accc Compare June 25, 2026 20:26
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from 3c8accc to 8a5c51f Compare June 25, 2026 20:28
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared f5ebe12 against base 2a4681d 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.3 KB 117.9 KB ⚫ +0.5%
Client entry size (gzip) vinext 112.6 KB 113.3 KB ⚫ +0.6%
Dev server cold start vinext 2.54 s 2.54 s ⚫ +0.2%
Production build time vinext 3.06 s 3.01 s ⚫ -1.5%
RSC entry closure size (gzip) vinext 93.4 KB 93.6 KB ⚫ +0.1%
Server bundle size (gzip) vinext 157.3 KB 157.5 KB ⚫ +0.1%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from 8a5c51f to 65b1ff8 Compare June 26, 2026 08:11
@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: instant route prefetch shells

I focused on Next.js parity (per AGENTS.md the Next.js test suite is the authoritative reference) and re-checked the two issues raised in the prior /bigbonk round. Issue #2 (sanitize throwing on suspended chunks) is now fixed. Issue #1 (wrong export contract) is still unaddressed and remains blocking — and digging into the real Next.js surface, the detection is wrong on two axes, not just the name.


1. Detection uses an invented export and the wrong semantics — blocking

routeModuleHasInstant (packages/vinext/src/routing/app-route-graph.ts:199-206) keys the entire feature off a truthy unstable_instant export:

function routeModuleHasInstant(filePath: string | null): boolean {
  if (filePath === null) return false;
  try {
    return hasTruthyNamedExport(fs.readFileSync(filePath, "utf8"), "unstable_instant");
  } catch {
    return false;
  }
}

This does not match Next.js, and there are two distinct problems:

(a) unstable_instant is not a real Next.js export. gh api search/code?q=unstable_instant+repo:vercel/next.js → 0 results. The actual segment config exports are prefetch and instant (packages/next/src/build/segment-config/app/app-segment-config.ts:137-153, :261-281). Runtime prefetch is gated specifically on the prefetch config:

// packages/next/src/server/app-render/instant-validation/instant-config.tsx
const prefetchConfig = layoutOrPageMod ? (layoutOrPageMod as AppSegmentConfig).prefetch : undefined
if (prefetchConfig === 'allow-runtime') {
  return true
}

The ported test this PR claims to mirror opts a route in with export const prefetch = 'allow-runtime' + export const instant = {...}, never unstable_instant. Result: a real Next.js app using prefetch = 'allow-runtime' gets none of this behavior on vinext, and a vinext app written against this PR uses an export Next.js ignores.

(b) Even with the right name, a truthiness check is wrong. prefetch is an enum string — 'auto' | 'partial' | 'unstable_eager' | 'force-disabled' | 'allow-runtime' (app-segment-config.ts:44, schema error at :207). Only 'allow-runtime' enables instant runtime prefetch. hasTruthyNamedExport(code, "prefetch") would treat prefetch = 'auto', prefetch = 'partial', and even prefetch = 'force-disabled' as instant routes, since all are truthy strings. force-disabled is the exact opposite of the intended behavior. Detection needs an exact value match (prefetch === 'allow-runtime'), not truthiness.

Please re-derive detection directly from the prefetch-true-instant fixture: match prefetch === 'allow-runtime' (with instant as the related config), and update hasTruthyNamedExport-based detection to a value-aware check. Then fix the fixtures/tests, which currently hard-code the invented name:

  • packages/vinext/src/routing/app-route-graph.ts:202
  • tests/e2e/app-router/nextjs-compat/suspense-prefetch.browser.spec.ts:119,123,266
  • tests/app-route-graph.test.ts:104-119,254-283
  • tests/build-report.test.ts:98-112

Because every test validates the invented contract, the suite stays green while the feature is invisible to real apps — exactly the silent-divergence failure mode AGENTS.md's "verify Next.js behavior first" step exists to prevent.


2. sanitizeInstantShellValue on suspended chunks — resolved (was blocking)

This is now correct. app-optimistic-routing.ts:307-326 special-cases pending/blocked/halted/rejected via SUSPENDED_LAZY_STATUSES and returns OptimisticRouteSegment before calling _init, so the suspended connection() subtree no longer throws out of sanitizeInstantShellElements. The new tests/app-optimistic-routing.test.ts:396-451 cases exercise pending/blocked/halted lazy children whose _init throws and assert they're replaced by the suspense trigger across page/layout/slot elements. Good — no further action here.


3. Instant-mode abort scheduling depends on connection() being reached — confirm

app-page-render.ts instant mode does not call schedulePrefetchSuspenseShellAbort up front; scheduling happens lazily inside suspendPrefetchSuspenseShell only when connection() (or a dynamic API) is hit (prefetch-suspense-shell.ts). For an instant route that renders with no dynamic API, the abort never schedules, wasPrefetchSuspenseShellAborted stays false, and partialShell is not set. If that's intentional (a fully-cacheable instant route is a complete, non-partial response), add a comment; otherwise this is a correctness gap. Please confirm against Next.js' behavior for an instant route with no dynamic APIs.


Summary

  • #1 blocking — wrong export name and wrong detection semantics; fix the contract and tests against prefetch-true-instant.
  • #2 resolved.
  • #3 non-blocking — please confirm intent / add a comment.

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Posted a CHANGES_REQUESTED review to PR #2374. Findings:

Blocking

1. Detection uses an invented export and wrong semantics
routeModuleHasInstant (app-route-graph.ts:199-206) keys the feature off a truthy unstable_instant export. Verified against Next.js:

  • unstable_instant0 results in the Next.js repo. The real exports are prefetch and instant. Runtime prefetch is gated on prefetch === 'allow-runtime' (instant-config.tsx, app-segment-config.ts).
  • Even with the right name, a truthiness check is wrong: prefetch is an enum ('auto' | 'partial' | 'unstable_eager' | 'force-disabled' | 'allow-runtime'). Only 'allow-runtime' should opt in; hasTruthyNamedExport would wrongly treat 'auto', 'partial', and even 'force-disabled' as instant routes. Needs an exact value match.

All tests hard-code the invented name, so the suite stays green while the feature is invisible to real Next.js apps — the silent-divergence failure mode AGENTS.md's "verify Next.js behavior first" step is meant to catch.

Resolved since last round

2. sanitizeInstantShellValue throwing on suspended chunks — now fixed. The code special-cases pending/blocked/halted/rejected before calling _init (app-optimistic-routing.ts:307-326), with new unit tests at tests/app-optimistic-routing.test.ts:396-451 exercising those statuses.

Non-blocking

3. Instant-mode abort scheduling is lazy and only fires if connection() is reached — asked the author to confirm intent for an instant route with no dynamic APIs.

github run

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from 65b1ff8 to 9faed78 Compare June 26, 2026 08:14
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from 9faed78 to bb7cf30 Compare June 26, 2026 08:18
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

UnknownError: The operation timed out.

github run

@james-elicx james-elicx force-pushed the codex/fix-prefetch-true-instant-stacked branch from bb7cf30 to ac4232c Compare June 26, 2026 08:25
@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: instant route prefetch shells

Re-reviewed against the Next.js source/test suite (per AGENTS.md, that's the authoritative reference). Issue #2 stays resolved. Issue #1 — the wrong export contract — is still unaddressed after three rounds and remains blocking. Verified the real Next.js surface directly this round.


1. Detection uses an invented export and the wrong semantics — blocking

routeModuleHasInstant (packages/vinext/src/routing/app-route-graph.ts:199-206):

function routeModuleHasInstant(filePath: string | null): boolean {
  if (filePath === null) return false;
  try {
    return hasTruthyNamedExport(fs.readFileSync(filePath, "utf8"), "unstable_instant");
  } catch {
    return false;
  }
}

Two distinct problems, both confirmed against the real Next.js source:

(a) unstable_instant is not a real Next.js export. gh api 'search/code?q=unstable_instant+repo:vercel/next.js'0 results. The actual segment-config exports are prefetch and instant (packages/next/src/build/segment-config/app/app-segment-config.ts). The prefetch-true-instant fixture this PR claims to mirror opts a route in with:

// app/target-page/page.tsx and app/layout-instant/layout.tsx
export const instant = {
  unstable_samples: [{ cookies: [{ name: 'test', value: null }] }],
}
export const prefetch = 'allow-runtime'

Note: prefetch is a top-level export whose value is 'allow-runtime' — not a prefetch: "runtime" property on an unstable_instant object (the shape this PR's tests fabricate). A real Next.js app using prefetch = 'allow-runtime' gets none of this behavior on vinext, and a vinext app written against this PR uses an export Next.js ignores.

(b) Even with the right name, a truthiness check is wrong. Runtime prefetch in Next.js is gated on an exact value match (packages/next/src/server/app-render/instant-validation/instant-config.tsx):

const prefetchConfig = layoutOrPageMod
  ? (layoutOrPageMod as AppSegmentConfig).prefetch
  : undefined
if (prefetchConfig === 'allow-runtime') {
  return true
}

prefetch is an enum string — "auto" | "partial" | "unstable_eager" | "force-disabled" | "allow-runtime" (app-segment-config.ts:207). Only 'allow-runtime' opts in. hasTruthyNamedExport(code, "prefetch") would treat prefetch = 'auto', 'partial', and even prefetch = 'force-disabled' as instant routes, since all are truthy string literals (hasTruthyNamedExport returns Boolean(value) for string literals, report.ts:156-157). Treating force-disabled as instant is the exact opposite of the intended behavior. Detection needs an exact value match, not truthiness.

Suggested fix. extractExportConstString already lives in the same module (packages/vinext/src/build/report.ts:244) and gives you the value. Match prefetch === 'allow-runtime' (with instant as the related config) and update the fixtures/tests, which currently hard-code the invented name and shape:

  • packages/vinext/src/routing/app-route-graph.ts:195,202
  • tests/e2e/app-router/nextjs-compat/suspense-prefetch.browser.spec.ts:119,123,266
  • tests/app-route-graph.test.ts:104-119,254-283
  • tests/build-report.test.ts:94-166

Because every test validates the invented unstable_instant contract, the suite stays green while the feature is invisible to real Next.js apps — exactly the silent-divergence failure mode AGENTS.md's "verify Next.js behavior first" step is meant to prevent.


2. sanitizeInstantShellValue on suspended chunks — resolved (was blocking)

Confirmed fixed. app-optimistic-routing.ts:307-326 special-cases pending/blocked/halted/rejected via SUSPENDED_LAZY_STATUSES and returns OptimisticRouteSegment before calling _init, so the suspended connection() subtree no longer throws out of sanitizeInstantShellElements. New tests at tests/app-optimistic-routing.test.ts:396-451 exercise the pending/blocked/halted paths. No further action.


3. Instant abort scheduling only fires on connection() — confirm intent

Two related observations in instant mode:

  • Scheduling is lazy: app-page-render.ts:719-722 calls cancelPrefetchSuspenseShellAbort (not schedulePrefetchSuspenseShellAbort) for instant shells, so the abort is only armed inside suspendPrefetchSuspenseShell when reached (prefetch-suspense-shell.ts:69). An instant route that renders with no dynamic API never sets partialShell.
  • Narrow trigger: prefetch-suspense-shell.ts:68 only suspends instant mode on expression === "connection()", ignoring cookies(), headers(), searchParams, etc. The fixture intentionally preserves cookies()-derived "cached" content, so suspending only on connection() may be deliberate — but please confirm this matches Next.js' instant-shell behavior for routes whose only dynamic dependency is something other than connection(), and add a comment documenting the intent either way.

Summary

  • #1 blocking — wrong export name and wrong detection semantics; re-derive the contract from prefetch-true-instant (prefetch === 'allow-runtime', related instant) and fix the tests.
  • #2 resolved.
  • #3 non-blocking — confirm intent / add a comment.

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2374 as CHANGES_REQUESTED.

Summary

I verified all three prior findings against the actual Next.js source/test suite.

Blocking

1. Detection uses an invented export and wrong semantics — still unaddressed after three rounds.

  • routeModuleHasInstant (app-route-graph.ts:202) keys off hasTruthyNamedExport(..., "unstable_instant"). unstable_instant returns 0 results in the Next.js repo.
  • The real prefetch-true-instant fixture opts in with a top-level export const prefetch = 'allow-runtime' + export const instant = {...} — not a prefetch: "runtime" property on an unstable_instant object (the shape the PR's tests fabricate).
  • Even with the right name, Next.js gates on an exact match prefetchConfig === 'allow-runtime' (instant-config.tsx). A truthiness check would wrongly opt in 'auto', 'partial', and even 'force-disabled' (the opposite intent), since prefetch is an enum string.
  • A value-aware helper (extractExportConstString) already exists in the same build/report.ts module, so the correct fix is readily available.
  • All tests hard-code the invented contract, so the suite stays green while the feature is invisible to real Next.js apps.

Resolved

2. sanitizeInstantShellValue throwing on suspended chunks — confirmed fixed via SUSPENDED_LAZY_STATUSES (app-optimistic-routing.ts:307-326) with new tests.

Non-blocking

3. Instant abort scheduling is lazy and the instant suspend trigger only fires on connection() (ignoring cookies()/headers()/searchParams). Asked the author to confirm intent against Next.js and document it.

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: instant route prefetch shells

Re-reviewed against the Next.js source/test suite (per AGENTS.md, that's the authoritative reference), including the new commit 5aef816 ("close instant prefetch invalidation races"). Issue #2 stays resolved. The new race fix is sound. Issue #1 — the wrong export contract — is still unaddressed after four rounds and remains blocking. I verified the real Next.js fixture and gating logic directly this round.


1. Detection uses an invented export and the wrong semantics — blocking (unchanged)

routeModuleHasInstant (packages/vinext/src/routing/app-route-graph.ts:199-206) still keys the entire feature off a truthy unstable_instant export, and the new commit doesn't touch it:

function routeModuleHasInstant(filePath: string | null): boolean {
  if (filePath === null) return false;
  try {
    return hasTruthyNamedExport(fs.readFileSync(filePath, "utf8"), "unstable_instant");
  } catch {
    return false;
  }
}

Two distinct problems, both confirmed against the real Next.js source this round:

(a) unstable_instant is not a real Next.js export. gh api 'search/code?q=unstable_instant+repo:vercel/next.js'0 results. The prefetch-true-instant fixture this PR claims to mirror opts a route in with two top-level exports — verified by reading the fixture directly:

// test/e2e/app-dir/prefetch-true-instant/app/target-page/page.tsx
// (identical exports in app/layout-instant/layout.tsx)
export const instant = {
  unstable_samples: [{ cookies: [{ name: 'test', value: null }] }],
}
export const prefetch = 'allow-runtime'

There is no unstable_instant export, and prefetch is a top-level string export valued 'allow-runtime' — not a prefetch: "runtime" property on an unstable_instant object (the shape this PR fabricates). A real Next.js app using prefetch = 'allow-runtime' gets none of this behavior on vinext; a vinext app written against this PR uses an export Next.js ignores.

(b) Even with the right name, a truthiness check is wrong. Runtime prefetch in Next.js is gated on an exact value match (verified in packages/next/src/server/app-render/instant-validation/instant-config.tsx):

const prefetchConfig = layoutOrPageMod
  ? (layoutOrPageMod as AppSegmentConfig).prefetch
  : undefined
if (prefetchConfig === 'allow-runtime') {
  return true
}

prefetch is an enum string — 'auto' | 'partial' | 'unstable_eager' | 'force-disabled' | 'allow-runtime' (app-segment-config.ts:42-54). Only 'allow-runtime' opts in. hasTruthyNamedExport(code, "prefetch") would treat prefetch = 'auto', 'partial', and even prefetch = 'force-disabled' as instant routes (all truthy string literals — hasTruthyNamedExport returns Boolean(value) for string literals, report.ts:156-157). Treating force-disabled as instant is the exact opposite of the intended behavior. Detection needs an exact value match, not truthiness.

Suggested fix. extractExportConstString already lives in the same module (packages/vinext/src/build/report.ts:244) and returns the string value. Match prefetch === 'allow-runtime' (with instant as the related config) and update the fixtures/tests, which currently hard-code the invented name and shape:

  • packages/vinext/src/routing/app-route-graph.ts:195,202
  • tests/e2e/app-router/nextjs-compat/suspense-prefetch.browser.spec.ts:51,55 (and the unstable_instant strings)
  • tests/app-route-graph.test.ts
  • tests/build-report.test.ts:94-166 (the entire hasTruthyNamedExport instant suite asserts the invented unstable_instant / prefetch: 'runtime' shape)

Because every test validates the invented unstable_instant contract, the suite stays green while the feature is invisible to real Next.js apps — exactly the silent-divergence failure mode AGENTS.md's "verify Next.js behavior first" step is meant to prevent. The contract was discoverable by reading the ported fixture's exports.


2. sanitizeInstantShellValue on suspended chunks — resolved (was blocking)

Confirmed still fixed. app-optimistic-routing.ts:307-326 special-cases pending/blocked/halted/rejected via SUSPENDED_LAZY_STATUSES and returns OptimisticRouteSegment before calling _init, so the suspended connection() subtree no longer throws out of sanitizeInstantShellElements. Unit tests at tests/app-optimistic-routing.test.ts exercise the suspended paths. No further action.


New commit 5aef816 (invalidation race fix) — looks correct

Reviewed the fresh commit independently:

  • learnOptimisticRouteTemplateFromPrefetch now adds optimisticRouteTemplateSources synchronously (rather than in the post-await .then), and gates the instantShell case on attachPrefetchInvalidationCallback(..., options.entry) returning true. The new expectedEntry identity check (navigation.ts:587) correctly bails (return false) when the cache entry was replaced between prefetch resolution and callback attachment, and the caller returns false without registering a stale template.
  • Ordering is safe: schedulePrefetchInvalidation uses setTimeout (delay ≥ 0), so the invalidation callback can't fire synchronously during attachPrefetchInvalidationCallback — the template is always in optimisticRouteTemplates before any invalidation runs. The optimisticRouteTemplates.get(templateKey) === template identity guard further protects against deleting a newer template.
  • New regression test tests/prefetch-cache.test.ts ("does not attach invalidation ownership to a replaced prefetch entry") covers the race. Verified passing locally (vp test run tests/prefetch-cache.test.ts → 32 passed).
  • The dev-only server.watcher.on("change") handler (index.ts:3583) and the invalidateHybridClientEntries gating change (hasAppDir-only, app-browser entry invalidation) are in scope and consistent. No duplicate change handler.

No issues here.


3. Instant abort scheduling only fires on connection() — non-blocking (aligns with fixture)

prefetch-suspense-shell.ts:68 suspends instant mode only on expression === "connection()", ignoring cookies()/headers()/searchParams, and scheduling is lazy (app-page-render.ts calls cancelPrefetchSuspenseShellAbort, not schedulePrefetchSuspenseShellAbort, up front). Reading the real fixture, this actually matches Next.js intent: Cached uses cookies() and is preserved as cached content, while Dynamic uses connection() and is suspended. So suspending only on connection() is consistent with the fixture. Please add a short comment documenting that cookies()-derived content is intentionally treated as cacheable (not suspended) so this isn't mistaken for a gap later.


Summary

  • #1 blocking — wrong export name and wrong detection semantics; re-derive the contract from prefetch-true-instant (prefetch === 'allow-runtime', related instant) using extractExportConstString, and fix the fixtures/tests.
  • #2 resolved.
  • New race fix — correct, no issues.
  • #3 non-blocking — aligns with the fixture; add a clarifying comment.

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Posted a CHANGES_REQUESTED review to PR #2374.

Summary

Reviewed the latest state including the new commit 5aef816, verifying everything independently against the actual Next.js source/fixtures.

Blocking

#1 — Detection uses an invented export and wrong semantics (still unaddressed after 4 rounds)

  • routeModuleHasInstant (app-route-graph.ts:202) keys off hasTruthyNamedExport(..., "unstable_instant"). Verified unstable_instant0 results in the Next.js repo.
  • Read the real prefetch-true-instant fixture directly: it opts in with two top-level exports — export const instant = {...} + export const prefetch = 'allow-runtime' — not a prefetch: "runtime" property on an unstable_instant object (the shape this PR fabricates).
  • Even with the right name, Next.js gates on an exact prefetchConfig === 'allow-runtime' (instant-config.tsx). A truthiness check would wrongly opt in 'auto', 'partial', and even 'force-disabled'. Needs extractExportConstString (already in report.ts:244).
  • All tests hard-code the invented contract, so the suite stays green while the feature is invisible to real apps.

Resolved / sound

  • Published npm package ships sourcemaps pointing to missing source files #2 (sanitize throwing on suspended chunks) — confirmed still fixed via SUSPENDED_LAZY_STATUSES.
  • New race-fix commit 5aef816 — verified correct: synchronous source registration, expectedEntry identity guard, safe ordering (setTimeout-based invalidation can't fire synchronously), regression test passes locally. No issues.

Non-blocking

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Bonk clarification: this PR is intentionally verified against the deploy-suite pinned Next.js v16.2.6 checkout at ee6e79b1792a4d401ddf2480f40a83549fe8e722 (packages/next/dist/.build-commit matches). In that exact source and fixture, runtime prefetch uses unstable_instant = { prefetch: "runtime" }:

  • test/e2e/app-dir/prefetch-true-instant/app/target-page/page.tsx
  • test/e2e/app-dir/prefetch-true-instant/app/layout-instant/layout.tsx
  • packages/next/src/server/app-render/instant-validation/instant-config.tsx
  • packages/next/src/build/segment-config/app/app-segment-config.ts

The exact pinned Next.js test passes 2/2 on head 0f09c0ac. Please review against that pinned source rather than a different branch/version.

/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: instant route prefetch shells (verified against pinned Next.js v16.2.6 ee6e79b1)

Per the bonk clarification, I re-verified this PR against the deploy-suite pinned Next.js checkout ee6e79b1792a4d401ddf2480f40a83549fe8e722 (v16.2.6), not canary. I fetched that exact commit and read the fixture and gating source directly. The detection contract this PR implements is correct for the pinned target. The prior rounds' blocking finding (#1) does not apply to this version.

#1 — Export contract: correct for v16.2.6 (NOT a divergence)

The earlier reviews asserted unstable_instant is "0 results / invented" and that the real contract is export const instant = {...} + export const prefetch = 'allow-runtime'. That is the canary surface. In the pinned ee6e79b1 source the contract is exactly what this PR implements:

// test/e2e/app-dir/prefetch-true-instant/app/target-page/page.tsx (and layout-instant/layout.tsx)
export const unstable_instant = {
  prefetch: 'runtime',
  samples: [{ cookies: [{ name: 'test', value: null }] }],
}

And the gating logic (packages/next/src/server/app-render/instant-validation/instant-config.tsx, anySegmentHasRuntimePrefetchEnabled):

const instantConfig = (layoutOrPageMod as AppSegmentConfig).unstable_instant
const hasRuntimePrefetch =
  instantConfig && typeof instantConfig === 'object'
    ? instantConfig.prefetch === 'runtime'
    : false

app-segment-config.ts in this commit declares unstable_instant: InstantConfigSchema.optional() with a discriminated union on prefetch: 'static' | 'runtime'. There is no top-level prefetch enum and no 'allow-runtime' value in this version. So:

  • unstable_instant is the correct export name here.
  • Gating on unstable_instant.prefetch === 'runtime' is correct.

The PR's routeModuleHasInstant (app-route-graph.ts:199-211) now matches this exactly via the new hasNamedExportObjectStringProperty(code, "unstable_instant", "prefetch", "runtime") helper added in 0f09c0a.

#1b — Truthiness concern: addressed in commit 0f09c0a

The one legitimate kernel of the prior reviews — that a bare truthiness check would over-match — has been fixed. hasNamedExportObjectStringProperty (report.ts:160-190) does an exact prefetch === 'runtime' literal match against the parsed object expression, handles aliased re-exports, unwraps TS wrappers, requires a non-computed key, and refuses to guess external re-exports. The new test at tests/app-route-graph.test.ts confirms unstable_instant = { prefetch: 'static' } correctly yields hasInstant === false — matching Next.js, where 'static' is not runtime prefetch. Verified tests/app-route-graph.test.ts + tests/build-report.test.ts pass (168/168).

#2sanitizeInstantShellValue on suspended chunks: resolved

SUSPENDED_LAZY_STATUSES special-cases pending/blocked/halted/rejected and returns OptimisticRouteSegment before calling _init (app-optimistic-routing.ts:307-326), with covering tests. Confirmed passing.

#3connection()-only suspend / lazy abort: correct, matches the fixture

suspendPrefetchSuspenseShell suspends instant mode only on connection() and preserves cookies()-derived content as cached (prefetch-suspense-shell.ts:68). This is exactly what the pinned e2e test asserts: Cached (cookies()) is included in the downgraded prefetch; Dynamic (connection()) is excluded and only resolves on the (blocked) navigation. The layout-level test confirms SubtreeHasInstant propagation, which routeHasInstant (app-route-graph.ts:213-227) implements by scanning page + ancestor layouts + parallel slots. Non-blocking nit: a one-line comment noting cookies() content is intentionally treated as cacheable would help future readers, but behavior is correct.

Verification summary

  • Fetched pinned ee6e79b1 (v16.2.6); read fixture pages/layouts and instant-config.tsx / app-segment-config.ts directly.
  • Detection, exact-value matching, suspended-chunk handling, and connection()-only suspend all match the pinned source/fixture.
  • tests/app-route-graph.test.ts, tests/build-report.test.ts, tests/app-optimistic-routing.test.ts, tests/prefetch-cache.test.ts, tests/link.test.ts pass locally (340 assertions across these files).

Approving. The prior CHANGES_REQUESTED blocker was based on a different Next.js version; against the pinned deploy target this PR is correct, and the truthiness refinement landed in 0f09c0a. Recommend addressing the #3 comment nit at the author's discretion (non-blocking).

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2374 as APPROVED.

Summary

I verified this PR against the deploy-suite pinned Next.js checkout ee6e79b1792a4d401ddf2480f40a83549fe8e722 (v16.2.6) as the bonk clarification requested — fetching that exact commit and reading the fixture and gating source directly. My findings overturn the prior rounds' blocking concern:

#1 (the standing blocker) does not apply to the pinned target. Prior reviews verified against canary, where the API is instant + prefetch: 'allow-runtime'. In the pinned v16.2.6 source, the contract is exactly what the PR implements:

  • The fixture pages/layouts use export const unstable_instant = { prefetch: 'runtime', ... }.
  • instant-config.tsx gates runtime prefetch on unstable_instant.prefetch === 'runtime'.
  • app-segment-config.ts declares unstable_instant with a prefetch: 'static' | 'runtime' union — there is no 'allow-runtime' value or top-level prefetch enum in this version.

#1b (the one legitimate kernel — truthiness over-matching) is fixed in commit 0f09c0a: detection now uses hasNamedExportObjectStringProperty for an exact prefetch === 'runtime' literal match, correctly excluding prefetch: 'static' (verified by the new test).

#2 (suspended-chunk sanitize) resolved; #3 (connection()-only suspend, cookies() preserved) matches the pinned e2e fixture's asserted behavior, including layout-level SubtreeHasInstant propagation.

All relevant test files pass locally. Left a non-blocking nit suggesting a clarifying comment for the cookies()-cacheable behavior.

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: instant route prefetch shells (verified against installed Next.js v16.2.7 9bd3c26a)

I reviewed against the authoritative installed Next.js source in this repo's node_modules (next@16.2.7, build commit 9bd3c26a733901956549889f28c0047553bba622) — read instant-config.js, app-segment-config.js, and the e2e fixture directly. The detection contract this PR implements is correct for the deploy target. The prior rounds' standing blocker (#1) was verified against canary, which has a newer/different API; it does not apply here.

#1 — Export contract & semantics: correct (not a divergence)

Installed dist/esm/server/app-render/instant-validation/instant-config.js:

const instantConfig = layoutOrPageMod ? layoutOrPageMod.unstable_instant : undefined;
const hasRuntimePrefetch =
  instantConfig && typeof instantConfig === 'object' ? instantConfig.prefetch === 'runtime' : false;

And dist/esm/build/segment-config/app/app-segment-config.js declares unstable_instant: InstantConfigSchema.optional() with a discriminated union on prefetch: 'static' | 'runtime'. There is no top-level prefetch enum and no 'allow-runtime' value in this version.

So in the pinned/installed target:

  • unstable_instant is the correct export name (a real export).
  • Gating on unstable_instant.prefetch === 'runtime' is correct.

routeModuleHasInstant (app-route-graph.ts:199-211) matches this exactly via hasNamedExportObjectStringProperty(code, "unstable_instant", "prefetch", "runtime"). The e2e fixture (suspense-prefetch.browser.spec.ts:110-123) uses export const unstable_instant = { prefetch: "runtime", samples: [] } with cookies() (cached) + connection() (dynamic), matching the installed fixture shape.

#1b — Truthiness over-matching: fixed

The one legitimate kernel of the prior reviews. hasNamedExportObjectStringProperty (report.ts:160-190) does an exact prefetch === 'runtime' literal match against the parsed object expression — handles aliased local re-exports, unwraps TS wrappers, requires a non-computed key, and conservatively refuses to guess external re-exports. Tests confirm prefetch: 'static'false and prefetch: 'runtime'true (build-report.test.ts:169-213). static is correctly excluded.

#2sanitizeInstantShellValue on suspended chunks: resolved

SUSPENDED_LAZY_STATUSES special-cases pending/blocked/halted/rejected and returns OptimisticRouteSegment before calling _init (app-optimistic-routing.ts:307-333). It also wraps _init in a try/catch that converts a thrown chunk/thenable into the suspense trigger — defense in depth. Covering tests pass.

#3connection()-only suspend / lazy abort: correct, matches the fixture

suspendPrefetchSuspenseShell suspends instant mode only on connection() and preserves cookies()-derived content as cached (prefetch-suspense-shell.ts:68-71). Abort scheduling is lazy: app-page-render.ts:719-723 uses cancelPrefetchSuspenseShellAbort up front, and the abort is only armed inside suspendPrefetchSuspenseShell when connection() is reached. For an instant route with no dynamic API, partialShell stays false → a complete instant shell. This matches the installed fixture (Cached via cookies() is included; Dynamic via connection() is suspended). Non-blocking nit: a one-line comment noting cookies()-derived content is intentionally treated as cacheable would help future readers.

New commit 7033498 (preserve instant shell HMR semantics) — looks correct

  • sanitizeInstantShellValue now recurses into all element props, not just children (app-optimistic-routing.ts:335-342). This is needed because suspended instant content can be passed via a named prop (e.g. content), as the new test at app-optimistic-routing.test.ts:444-466 exercises. Without it, a suspended chunk nested under a non-children prop would have escaped sanitization.
  • The dev change handler (index.ts:3602-3610) now invalidates the app route cache only when a file's instant status flips, tracked via instantConfigByFile. This is a deliberate, correct optimization: hasInstant is baked into the route graph at scan time, so the graph only needs rebuilding when the config flips; content edits are handled by normal Vite HMR, and structural file add/remove is handled by the add/unlink handlers. The instantConfigByFile map is seeded asynchronously, but the ?? false default makes the first post-seed flip detection safe (worst case: one extra invalidation on a genuine flip that raced the seed — harmless in dev).

Race fix 5aef816 — confirmed correct

learnOptimisticRouteTemplateFromPrefetch registers optimisticRouteTemplateSources synchronously and gates the instantShell case on attachPrefetchInvalidationCallback(..., options.entry) returning true; the expectedEntry identity guard (navigation.ts:582-590) bails when the cache entry was replaced. Instant templates are keyed by concreteHrefKey (app-optimistic-routing.ts:396-398), so they are URL-specific and won't leak across param values; lookup tries the concrete key first, then the route-level loading-shell fallback (:436-452). No issues.

Verification summary

  • Read installed next@16.2.7 (9bd3c26a) instant-config.js / app-segment-config.js and the e2e fixture directly.
  • Detection, exact-value matching (runtime in, static out), suspended-chunk handling, all-prop sanitization, connection()-only suspend, and the HMR/race fixes all match the installed source/fixture.
  • tests/app-route-graph.test.ts (60), tests/build-report.test.ts (108), tests/app-optimistic-routing.test.ts (22), tests/link.test.ts (121), tests/prefetch-cache.test.ts (32) all pass locally.

No blocking issues. Recommend addressing the #3 comment nit at the author's discretion (non-blocking).

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2374.

Summary

I reviewed PR #2374 against the authoritative installed Next.js source (next@16.2.7, commit 9bd3c26a) in the repo's node_modules — not canary, which the prior CHANGES_REQUESTED rounds had used. I also reviewed the newest commit 7033498 that previous bonk rounds hadn't seen.

No blocking issues found. Key findings:

  • Virtual module imports break esbuild dependency optimization when vinext is installed from npm #1 export contract — The prior standing blocker does not apply. The installed instant-config.js confirms the contract is exactly unstable_instant.prefetch === 'runtime', which is what the PR implements. The earlier reviews verified against canary's newer API (prefetch === 'allow-runtime').

  • #1b truthiness over-matching — Fixed. hasNamedExportObjectStringProperty does an exact literal match; tests confirm prefetch: 'static' is correctly excluded.

  • Published npm package ships sourcemaps pointing to missing source files #2 suspended chunk sanitize — Resolved via SUSPENDED_LAZY_STATUSES plus a try/catch fallback.

  • New commit 7033498 — Two correct fixes: sanitize now recurses into all element props (not just children), needed for suspended content passed via named props; and the dev HMR change handler now invalidates only when instant status flips, a sound optimization.

  • Race fix 5aef816 — Verified correct (synchronous registration, expectedEntry identity guard, concrete-href keying prevents param leakage).

  • fix: exclude vinext from optimizeDeps to prevent virtual module resolution errors #3 — Non-blocking; connection()-only suspend matches the installed fixture. Suggested a clarifying comment.

All relevant test files (343 assertions) pass locally.

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: instant route prefetch shells

Reviewed against the installed Next.js source in node_modules (next@16.2.7, build commit 9bd3c26a) — the deploy target — and including the newest commit 1e89c91 ("align programmatic instant prefetch") that prior bonk rounds had not seen.

Prior findings — status

#1 export contract — correct, not a divergence. I read dist/esm/server/app-render/instant-validation/instant-config.js directly. In the installed target the gate is:

const instantConfig = layoutOrPageMod ? layoutOrPageMod.unstable_instant : undefined;
const hasRuntimePrefetch =
  instantConfig && typeof instantConfig === 'object' ? instantConfig.prefetch === 'runtime' : false;

unstable_instant.prefetch === 'runtime' is exactly what routeModuleHasInstant (app-route-graph.ts:199-211) checks via hasNamedExportObjectStringProperty. The earlier CHANGES_REQUESTED rounds verified against canary (prefetch === 'allow-runtime'), which is a newer API that does not exist in the pinned/installed version. Not a blocker.

#1b truthiness over-matching — fixed. Exact literal match; prefetch: 'static' is correctly excluded (build-report.test.ts).

#2 sanitize on suspended chunks — resolved. SUSPENDED_LAZY_STATUSES returns OptimisticRouteSegment before _init, plus a try/catch fallback (app-optimistic-routing.ts:307-333).

#3 connection()-only suspend — non-blocking, matches fixture. cookies() content is intentionally treated as cacheable. A one-line comment is still worth adding.

New in commit 1e89c91 — incomplete Link/router.prefetch alignment (non-blocking)

The commit extracts resolveAppRoutePrefetchPolicy into client/app-route-prefetch-policy.ts and routes both <Link> and router.prefetch() through it. For instant routes the two paths now agree (prefetchInstantShell: trueINSTANT_SHELL in both), and the added test covers that — good.

But the alignment is incomplete for non-instant dynamic routes without a loading shell, where the policy returns { cacheForNavigation: false, prefetchSuspenseShell: true, optimisticRouteShell(implicit): false }. The two entry points then diverge:

  • <Link> (link.tsx:441-451, :532-536): treats !cacheForNavigation as an optimistic-route-shell prefetch — render mode is overridden to APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, the entry is marked optimisticRouteShell: true, and it early-returns when interceptionContext !== null (link.tsx:442).
  • router.prefetch() (navigation.ts:1937-1969): sends APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL, never sets optimisticRouteShell, and has no interception-context guard.

Consequences for that route class:

  1. The two paths request different server render modes for the identical URL, producing different RSC payloads / cache entries.
  2. learnOptimisticRouteTemplateFromPrefetch gates allowLoadingShell on optimisticRouteShell === true || instantShell === true (app-browser-entry.ts:450-451). The Link-warmed entry contributes a loading-shell optimistic template; the router.prefetch-warmed entry does not.
  3. Missing the interceptionContext !== null guard means router.prefetch can warm an interception-keyed entry that the Link path deliberately skips.

This is newly introduced: before this commit router.prefetch() always did a plain full prefetch (no render mode, cacheForNavigation: true). The commit's stated goal — aligning instant prefetch — is achieved, but the shared policy now also drives the non-instant suspense/optimistic-shell branch on the router.prefetch path without replicating the Link path's optimistic-shell handling. Only the instant case is tested.

Recommend either (a) replicating the Link path's optimistic-route-shell handling (loading-shell render mode, optimisticRouteShell: true, the interceptionContext early-return) in router.prefetch, or (b) deliberately scoping router.prefetch to instant + plain-cacheable prefetch and documenting why the optimistic-shell branch is Link-only. Add a router.prefetch test for a non-instant dynamic route either way.

Verification

  • Read installed next@16.2.7 instant-config.js directly to confirm the export contract.
  • tests/prefetch-cache.test.ts (33) and tests/link.test.ts (121) pass locally.

Non-blocking overall: the headline instant-prefetch feature is correct against the deploy target. The flagged item is the unfinished non-instant alignment in the latest commit.

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Posted a review to PR #2374. Summary of what I found:

Verified against the deploy target (next@16.2.7, installed source)

No blocking issues.

New finding (commit 1e89c91, unseen by prior rounds)

The commit extracts a shared resolveAppRoutePrefetchPolicy and routes both <Link> and router.prefetch() through it. Instant routes are correctly aligned and tested. But the alignment is incomplete for non-instant dynamic routes without a loading shell: the <Link> path treats those as optimistic-route-shell prefetches (loading-shell render mode, optimisticRouteShell: true, plus an interceptionContext !== null early-return), while router.prefetch() sends a suspense-shell mode, never sets optimisticRouteShell, and lacks the interception guard. This makes the two entry points fetch different payloads and learn optimistic templates differently for the same URL. It's a newly introduced gap (previously router.prefetch did a plain full prefetch) and is untested for the non-instant case.

I flagged it as non-blocking with a recommendation to either replicate the Link path's optimistic-shell handling in router.prefetch or deliberately scope+document the difference, plus add a non-instant router.prefetch test.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head f5ebe12, including the final fix that preserves full non-instant router.prefetch caching.

@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: instant route prefetch shells (head f5ebe12)

Reviewed against the installed Next.js source (next@16.2.7, the deploy target) in node_modules, with focus on the final fix f5ebe12 ("preserve full programmatic prefetches"). No blocking issues.

Final fix f5ebe12 — correct; resolves the prior round's non-instant router.prefetch concern

The previous round flagged that commit 1e89c91 routed router.prefetch() through the shared policy and sent APP_RSC_RENDER_MODE_PREFETCH_SUSPENSE_SHELL with cacheForNavigation driven by the policy — diverging from the <Link> path and from the pre-PR behavior. f5ebe12 resolves this by scoping router.prefetch() to:

  • instant routesAPP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, instantShell: true, cacheForNavigation: true (the server's partial-shell header self-downgrades the entry via prefetchRscResponse navigation.ts:762-765, so this is self-correcting);
  • everything else → no render mode (plain full prefetch), cacheForNavigation: true.

I confirmed this exactly restores the pre-PR baseline (2a4681d:navigation.ts): before this PR, router.prefetch() had no render-mode header and called prefetchRscResponse with no behavior object (defaulting to cacheForNavigation: true). So the suspense-shell branch introduced in 1e89c91 is removed, and the non-instant path is unchanged from main. This is option (b) from the prior review (deliberately scope router.prefetch to instant + plain-cacheable) and is the right call. The new test tests/prefetch-cache.test.ts:579 ("keeps ordinary router.prefetch responses available for navigation") asserts cacheForNavigation: true, instantShell: false. Passes locally.

Prior findings — status

  • #1 export contract — correct, not a divergence. Read installed dist/esm/server/app-render/instant-validation/instant-config.js directly: the gate is instantConfig.prefetch === 'runtime' on layoutOrPageMod.unstable_instant. That is exactly what routeModuleHasInstant (app-route-graph.ts:199-211) checks via hasNamedExportObjectStringProperty(code, "unstable_instant", "prefetch", "runtime"). The earlier CHANGES_REQUESTED rounds verified against canary (prefetch === 'allow-runtime'), a newer API absent from the pinned/installed 16.2.7. Not a blocker.
  • #1b truthiness over-matching — fixed. hasNamedExportObjectStringProperty (report.ts:160-190) does an exact literal match (value.value === expectedValue), unwraps TS wrappers, requires a non-computed key, and handles aliased re-exports. prefetch: 'static'false (tested).
  • #2 sanitize on suspended chunks — resolved. SUSPENDED_LAZY_STATUSES returns OptimisticRouteSegment before calling _init, plus a try/catch fallback that converts a thrown chunk/thenable into the suspense trigger (app-optimistic-routing.ts:307-332). Recurses into all element props, not just children (:335-342).
  • #3 connection()-only suspend — non-blocking, matches fixture. cookies() content is intentionally treated as cacheable; abort scheduling is lazy. A one-line comment documenting the cookies()-cacheable intent would still help future readers.

Non-blocking observations

  1. Pre-existing parity note (not introduced by this PR): installed Next.js router.prefetch() defaults to PrefetchKind.AUTOFetchStrategy.PPR, which for a non-PPR route falls back to LoadingBoundary (renders only up to the first loading boundary) — i.e. a partial prefetch, not Full (app-router-instance.js:273, segment-cache/scheduler.js:444, types.js:28-37; note also the upstream comment "We don't currently offer a way to issue a runtime prefetch via router.prefetch()"). vinext's router.prefetch() does a full prefetch instead. This divergence predates this PR (it was the main baseline) and f5ebe12 deliberately preserves it rather than introducing it, so it is out of scope here — but it's worth a tracked follow-up if Segment-Cache-style partial programmatic prefetch is ever in scope.

  2. Test coverage of the dynamic-without-shell case: the new router.prefetch test uses isDynamic: false, canPrefetchLoadingShell: true. The fix sets cacheForNavigation: true unconditionally, so behavior is identical for a dynamic-without-loading-shell route, but a test with isDynamic: true (the route class the prior round specifically called out) would lock in the intended "full prefetch" behavior more directly. Minor.

Verification

  • Read installed next@16.2.7 instant-config.js and app-router-instance.js / segment-cache types.js+scheduler.js to confirm the export contract and router.prefetch semantics.
  • Confirmed f5ebe12 restores the 2a4681d (pre-PR) router.prefetch full-prefetch behavior for non-instant routes.
  • vp check clean on navigation.ts + app-route-prefetch-policy.ts.
  • Tests pass locally: prefetch-cache (34), link (121), app-route-graph (61), build-report (108), app-optimistic-routing (22).

No blocking issues. The final fix correctly preserves full non-instant router.prefetch caching while keeping the instant-shell path intact. Recommend the #3 comment nit and the two non-blocking items at the author's discretion.

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Posted a review to PR #2374 at head f5ebe12. Summary:

No blocking issues.

Final fix f5ebe12 is correct. It resolves the prior round's concern about router.prefetch() diverging from <Link> for non-instant routes. I verified it exactly restores the pre-PR baseline (2a4681d): router.prefetch() now does a plain full prefetch (cacheForNavigation: true, no render mode) for non-instant routes, and only uses the instant-shell render mode for instant routes. The intermediate suspense-shell branch from 1e89c91 is removed. The partial-shell server header self-downgrades instant entries (prefetchRscResponse navigation.ts:762-765), so passing cacheForNavigation: true with instantShell: true is self-correcting. New regression test passes.

Prior findings verified resolved:

Two non-blocking notes: (1) a pre-existing parity gap — Next.js router.prefetch() defaults to partial (AUTO/PPR→LoadingBoundary), not Full, but this predates the PR and the fix deliberately preserves the existing full-prefetch behavior; (2) the new test could use a isDynamic: true route to more directly lock in the dynamic-without-shell case.

github run

@james-elicx james-elicx marked this pull request as ready for review June 26, 2026 09:07
@james-elicx

Copy link
Copy Markdown
Member Author

Deploy-suite run 28478866791 / job 84413308650 triage: I think this PR already covers the instant route full-prefetch failures: test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts (2 failures around dynamic content exclusion when unstable_instant is enabled). I am skipping a duplicate backlog fix for that item and leaving this note so the overlap is visible.

@james-elicx

Copy link
Copy Markdown
Member Author

Deploy-suite run 28478866791 / job 84413308650 backlog check: this PR appears to cover the test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts failure, specifically runtime prefetching > can completely prefetch a page that is fully static. The upstream case is a fully static route that still exports unstable_instant = { prefetch: "runtime" }; this PR adds the instant/runtime prefetch route detection and client/server handling for that path. Skipping a duplicate implementation pass for this item.

@james-elicx

Copy link
Copy Markdown
Member Author

Backlog check for deploy-suite run 28478866791 / report job 84413308650: this PR likely covers the remaining non-cacheComponents prefetch-true-instant failures. The failed assertions in the report are:

  • prefetch={true} with instant route > does not include dynamic content in the prefetch when the target route has instant
  • prefetch={true} with instant route > also disables full prefetch when instant is on a layout, not the page

Those line up with this PR's instant-route shell handling and its exact upstream target test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts, so I am not opening a duplicate fix branch.

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