Skip to content

fix(app-router): support runtime instant prefetch shells - #2850

Draft
james-elicx wants to merge 12 commits into
mainfrom
codex/fix-prefetch-true-instant-current
Draft

fix(app-router): support runtime instant prefetch shells#2850
james-elicx wants to merge 12 commits into
mainfrom
codex/fix-prefetch-true-instant-current

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • detect runtime unstable_instant on pages, layouts, slots, aliases, and local re-export chains
  • downgrade explicit/full prefetches to non-reusable partial shells that preserve completed cache and request-data branches while deferring connection() content
  • keep instant shells out of navigation/CDN caches, learn exact-href optimistic templates, and invalidate them with their source prefetch
  • refresh route policy in dev when route or followed config modules change

Validation

  • vp check
  • independent review: no findings after two fix/review cycles
  • 1,528 focused cache/render/analyzer tests plus the external-config HMR regression
  • targeted Next.js E2E: test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts — 2/2 passed

Failure source: Actions run 31290819291.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 317c423

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

Reviewed the 28 changed files, focusing on the new shell state machine, the route-graph runtime-instant detection, prefetch-policy/link/navigation wiring, the RSC finalizer, and the dev HMR change. Ran the targeted suites (app-page-render, app-route-graph, build-report, app-optimistic-routing, app-rsc-response-finalizer, link, link-navigation, prefetch-cache) plus vp check on the core changed files — all green. The core mechanism (partial-shell abort after cache tasks settle + connection() suspension, never caching aborted shells, learning exact-href optimistic templates with source-scoped invalidation) is sound. A few observations below; none are blocking, but the first two are worth a decision before merge.

1. runtimeInstantConfigDependencies is a module-scoped set that is never cleared (routing/app-route-graph.ts:255)

Entries are only ever added (:313), never removed. Across dev route-graph rebuilds this set grows monotonically and retains stale paths. If a local unstable_instant re-export chain is later removed or repointed, the old dependency path lingers, so isRuntimeInstantConfigDependency (used by the HMR change handler in index.ts:4652) keeps triggering route-graph invalidation for a file that is no longer a config dependency. Over-invalidation is safe for correctness, but it's an unbounded-growth leak on a long-lived dev server. Consider clearing/repopulating the set per buildAppRouteGraph call (mirroring the per-scan findFileProbeCache clone pattern already used a few lines below).

2. Unresolved external re-exports are conservatively classified as runtime-instant (routing/app-route-graph.ts:306-312)

Any route that does export { unstable_instant } from "@myorg/config" (Vite alias / package export the sync scanner can't resolve) is treated as hasRuntimeInstant, forcing every prefetch for that route into a non-reusable partial shell — even when the actual value is { prefetch: "static" }. This is a documented, deliberate safety default, but it can be a broad prefetch-cacheability regression for apps that centralize route config in a shared package. Worth confirming this matches the intended tradeoff, and ideally noting it in user-facing docs since the failure mode (prefetches silently stop being reused) is hard to diagnose.

3. HMR change handler invalidates on any script under app/ (index.ts:4648-4660)

fileMatcher.isPageFile(filePath) || SCRIPT_IMPORT_RE.test(filePath) matches every .ts/.tsx/.js/... file under app/, so editing an arbitrary helper or leaf component now re-runs invalidateAppRoutingModules() + invalidateAppBrowserEntry() (full route re-scan) on every save. Given AGENTS.md's emphasis on keeping the dev graph lightweight, this is a noticeable dev-loop cost. It's understandable as the safety net for re-export chains not yet recorded in runtimeInstantConfigDependencies, but it's a wide net. If perf matters, consider gating on "file is imported by a known route module" rather than "any script under app/".

4. Property-value alias not resolved in hasNamedExportObjectStringProperty (build/report.ts)

resolveLocalConstExpression follows the top-level export const alias, but the object property value is only unwrapStaticExpression'd, not alias-resolved. So const RUNTIME = "runtime"; export const unstable_instant = { prefetch: RUNTIME } won't be detected as runtime-instant. This is a conservative miss (falls back to non-instant), not a correctness bug, but it's a small parity gap if you want to advertise full static analysis of the config.

5. Pre-existing gap, not introduced here: app-only add/unlink doesn't refresh the browser entry

invalidateHybridClientEntries() early-returns when !hasPagesDir (index.ts:4417), so in an app-only project adding/removing a runtime-instant route on the add/unlink watcher paths never calls invalidateAppBrowserEntry() — the new change handler only covers change events. The prefetch route manifest (now carrying hasRuntimeInstant) can go stale until the next full reload. This predates the PR, but the PR makes the staleness more meaningful. A follow-up to invalidate the app browser entry on add/unlink in app-only mode would close it.

Nice work on the abort scheduling (two-macrotask defer + cache-task refcount + cancelPendingAbort) and on keeping instant shells out of both navigation reuse (consumeMatchedPrefetchResponse bails on cacheForNavigation === false) and the CDN cache (lockPartialShellCacheHeaders). The exact-href template learning with entry-identity-checked invalidation (attachPrefetchInvalidationCallback returning false when the entry was superseded) is the right call.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2850. Summary of what I did and found:

Review complete

I reviewed all 28 changed files, focusing on the shell state machine, route-graph runtime-instant detection, prefetch/link/navigation wiring, the RSC finalizer, and the dev HMR change. Ran the targeted test suites and vp check on core files — all green.

Findings (none blocking, first two worth a pre-merge decision):

  1. Unbounded module-scoped setruntimeInstantConfigDependencies (app-route-graph.ts:255) is only ever added to, never cleared. It grows monotonically across dev rebuilds and retains stale re-export dependency paths, causing lingering over-invalidation. Suggested clearing it per buildAppRouteGraph call.

  2. Conservative external re-export classification — routes that re-export unstable_instant from an unresolvable package are always treated as runtime-instant, forcing non-reusable partial shells even when prefetch: "static". Deliberate but a potential broad prefetch-cacheability regression; worth confirming and documenting.

  3. Broad dev HMR invalidation — the new change handler re-scans routes on any script save under app/, a noticeable dev-loop cost given the repo's lightweight-graph guidance.

  4. Property-value alias not resolved in hasNamedExportObjectStringProperty — a small static-analysis parity gap (conservative miss, not a bug).

  5. Pre-existing gap — app-only add/unlink watcher paths don't refresh the app browser entry, so the hasRuntimeInstant prefetch manifest can go stale until full reload.

I also confirmed the good parts: instant shells are correctly kept out of navigation reuse (cacheForNavigation === false bail-outs) and the CDN cache (lockPartialShellCacheHeaders), and the exact-href template learning uses entry-identity-checked invalidation.

github run

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 317c423 against base d558d62 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 135.2 KB 136.1 KB ⚫ +0.7%
Client entry size (gzip) vinext 122.7 KB 123.5 KB ⚫ +0.7%
Dev server cold start vinext 3.03 s 2.99 s ⚫ -1.4%
Production build time vinext 3.03 s 3.07 s ⚫ +1.4%
RSC entry closure size (gzip) vinext 114.2 KB 115.9 KB ⚫ +1.5%
Server bundle size (gzip) vinext 193.0 KB 194.8 KB ⚫ +1.0%

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 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review: runtime instant prefetch shells (re-review)

Re-reviewed the 40 changed files with focus on the six commits stacked on this branch, cross-checking against the earlier ask-bonk review. The core mechanism is sound and, importantly, all five findings from the prior review cycle have been addressed:

  1. instantConfigDependencies unbounded growth — fixed. The set is no longer module-scoped-and-append-only. buildAppRouteGraph now allocates a fresh Set per call (app-route-graph.ts:1270) and returns it; index.ts:1453 reassigns the per-plugin-instance instantConfigDependencies wholesale on every rebuild via captureInstantRouteMetadata. No stale-path retention across dev rebuilds.

  2. HMR change handler over-invalidation — fixed. The handler no longer fires on any script under app/. It gates on isTrackedInstantConfigDependency(filePath) || routeModuleNowExportsInstantConfig(filePath) (index.ts:4725-4728), where the latter is narrowly scoped to app-router page/layout files that actually export unstable_instant. refreshInstantMetadataAfterChange then only invalidates routing modules when the derived metadata signature actually changed (index.ts:4663-4670), preserving fine-grained HMR for ordinary edits.

  3. Property-value alias resolution — fixed. Both hasNamedExportObjectStringProperty and analyzeNamedExportObjectStringProperty now resolve the object property value through resolveLocalConstExpression (build/report.ts), so const RUNTIME = "runtime"; export const unstable_instant = { prefetch: RUNTIME } is detected.

  4. App-only add/unlink browser-entry staleness — addressed. In the add/unlink watchers, both shouldInvalidateAppRouteFile and the instantConfigChanged branch call invalidateAppRoutingModules(), which invokes invalidateAppBrowserEntry() (index.ts:4477). The prefetch manifest carrying hasRuntimeInstant/hasInstant is refreshed for tracked config dependencies without relying on invalidateHybridClientEntries (which still early-returns in app-only mode).

  5. Unresolved external re-export → conservative runtime. Still a deliberate documented default (app-route-graph.ts routeModuleExportInstantPrefetchMode returns "runtime" for unresolved named re-exports). Confirmed intentional — worth a user-facing docs note since the failure mode (prefetches silently stop being reused) is hard to diagnose, but not blocking.

Correctness spot-checks (all pass)

  • Abort refcount + nested cache-task tracking. The fetch-cache write is wrapped in a second trackInstantPrefetchShellCacheTask (fetch-cache.ts) that increments before the outer fn resolves, so the shell abort stays deferred until the cold cache entry is durable — no premature abort, no leaked refcount. scheduleAfterTask's secondTimer TDZ concern is a non-issue (the first timer callback runs after the sync let executes).
  • Instant shells kept out of reuse caches. promotePrefetchEntriesForNavigation skips instantShell/optimisticRouteShell/partialSuspenseShell entries (link.tsx:847); lockPartialShellCacheHeaders forces no-store and strips CDN-Cache-Control/Cache-Tag (app-rsc-response-finalizer.ts); wasInstantPrefetchShellAborted only reports true when a dynamic boundary was actually hit, so fully-static instant routes still produce complete cacheable payloads.
  • Cross-environment ALS access. instant-prefetch-shell.ts reads the use-cache/unstable-cache stores via globalThis[Symbol.for(...)], matching getOrCreateAls's registration slot — correct across RSC/SSR/client module instances.
  • pageProps = {} when no page component. Both pageProps consumers are guarded by EffectivePageComponent ? … : …, so the empty branch is never read — no dropped-params regression.
  • resolveFullAppRoutePrefetch(href) signature change. All three call sites pass an href.

Verification

  • vp check on instant-prefetch-shell.ts, app-route-graph.ts, build/report.ts, app-optimistic-routing.ts — clean.
  • Targeted suites green: app-route-graph (70), build-report (103), app-optimistic-routing (13), app-page-render (67), app-rsc-response-finalizer (19), thenable-params (22), link (129), link-navigation (76), prefetch-cache (72), entry-templates (57), app-page-element-builder (65), app-page-route-wiring (80). 773 passing, 0 failing.

No blocking issues. Nice work closing the review loop — the abort scheduling (two-macrotask defer + cache-task refcount) and the entry-identity-checked optimistic-template invalidation (attachPrefetchInvalidationCallback returning false on supersession) are the right calls.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2850.

Summary

I re-reviewed all 40 changed files across the 6 commits, cross-checking against the earlier review cycle. All five prior-review findings have been resolved, and I found no new blocking issues.

Prior findings, now fixed:

  1. instantConfigDependencies unbounded growth → now a fresh Set per buildAppRouteGraph call, reassigned wholesale per rebuild (app-route-graph.ts:1270, index.ts:1453).
  2. HMR change over-invalidation → now gated on tracked config deps or app-router page/layout files that actually export unstable_instant, with signature-diff before invalidating (index.ts:4725-4670).
  3. Property-value alias resolution → both analyzer functions resolve via resolveLocalConstExpression.
  4. App-only add/unlink browser-entry staleness → invalidateAppRoutingModules() refreshes the app browser entry for tracked deps.
  5. Unresolved external re-export → conservative runtime remains a deliberate documented default.

Correctness spot-checks that passed:

  • Nested cache-task refcount keeps the shell abort deferred until cold cache writes are durable.
  • Instant shells correctly excluded from navigation reuse (promotePrefetchEntriesForNavigation) and CDN cache (lockPartialShellCacheHeaders); fully-static instant routes still cache complete payloads.
  • Cross-environment ALS access via globalThis[Symbol.for(...)] is correct.
  • pageProps = {} fallback is safe (both consumers guard on EffectivePageComponent).

Verification: vp check clean on core files; 773 targeted tests passing, 0 failing.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 05:30
@james-elicx

Copy link
Copy Markdown
Member Author

Mapping note for the cache-components backlog from Actions run 31290819291: I think this PR fixes both test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts failures. The targeted Next.js wrapper passes 2/2.

@james-elicx
james-elicx marked this pull request as draft August 10, 2026 10:07
@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: runtime instant prefetch shells (independent pass)

Reviewed all 40 changed files with focus on the new shell state machine (instant-prefetch-shell.ts), route-graph runtime-instant detection (app-route-graph.ts), the static-analysis in build/report.ts, the three cache-task refcount wrappers (fetch-cache.ts, cache.ts, cache-runtime.ts), thenable-params suspension, the RSC finalizer/render-mode plumbing, optimistic-template learning, and the dev HMR change. Ran the targeted suites and vp check on the core files — all green.

The core design is correct and I found no blocking issues. The prior review cycle's findings (unbounded config-dep set, broad HMR invalidation, property-value alias resolution, app-only add/unlink browser-entry staleness) are all resolved in the tree I reviewed.

Correctness spot-checks (all pass)

  • ALS key parity. instant-prefetch-shell.ts reads the use-cache/unstable-cache stores via globalThis[Symbol.for("vinext.cacheRuntime.contextAls")] / Symbol.for("vinext.unstableCache.als") — exactly the keys cache-runtime.ts:156 and cache.ts:474 register through getOrCreateAls. The variant !== "private" gate matches CacheContext.variant (cache-runtime.ts:135).
  • Abort scheduling + cache-task refcount. The two-macrotask defer in scheduleAfterTask has no TDZ issue (the firstTimer callback is a macrotask, so let secondTimer has executed). cancelPendingAbort at the top of trackInstantPrefetchShellCacheTask correctly prevents an abort from firing between a settled task and a newly-started one, and the re-check inside the deferred callback guards pendingCacheTasks === 0. The nested void trackInstantPrefetchShellCacheTask(() => cacheWrite, "fetch") in fetch-cache.ts increments the refcount before the outer fn resolves, keeping the shell open until the cold entry is durable.
  • dynamicAbortController never aborted — intentional. The connection()/params/private-cache hanging promises use dynamicAbortController.signal purely as a hole marker; the render is cut by reactAbortController.abort(), matching Next.js's separate dynamic-tracking vs render-abort signals.
  • Instant shells excluded from reuse. promotePrefetchEntriesForNavigation skips instantShell/optimisticRouteShell/partialSuspenseShell (link.tsx:847); lockPartialShellCacheHeaders forces no-store and strips CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag (app-rsc-response-finalizer.ts); wasInstantPrefetchShellAborted only reports true when a dynamic boundary was actually hit, so fully-static instant routes still produce complete, cacheable payloads (and get promoted to a reusable navigation entry via the VINEXT_RSC_PARTIAL_SHELL_HEADER check in prefetchRscResponse).
  • Source-scoped template invalidation. attachPrefetchInvalidationCallback now takes an expectedEntry and returns false on identity mismatch/supersession; discardLearningOnlyPrefetchCacheEntry handles the new instant-shell kind, so a learned optimistic template is invalidated with its source prefetch.
  • pageProps = {} fallback. Both consumers guard on EffectivePageComponent, so the empty-object branch is never read — no dropped-params regression.
  • Validation parity. toLinkPrefetchRoutes throws on unstable_instant in a Client Component module and on use without cacheComponents, matching Next.js validation; both are gated on the metadata flags so non-instant apps are unaffected.

Non-blocking observations

  1. Unresolved external re-export → conservative runtime (app-route-graph.ts routeModuleExportInstantPrefetchMode). A route that does export { unstable_instant } from "@myorg/config" (Vite alias / package export the sync scanner can't resolve) is always classified runtime-instant, forcing non-reusable partial shells even when the real value is { prefetch: "static" }. This is a documented, deliberate safety default carried over from the prior cycle. It remains worth a user-facing docs note, because the failure mode — prefetches silently stop being reused for that route — is hard to diagnose. Not blocking.

  2. Dead-in-production helper exports. hasNamedExportObjectStringProperty and findNamedExternalReexport in build/report.ts are exported but only referenced by tests/build-report.test.ts; the route graph uses analyzeNamedExportObjectStringProperty exclusively. Harmless, but they read as public API. Consider marking them @internal or dropping them if the test coverage can move onto analyzeNamedExportObjectStringProperty.

  3. prefetched.has(cacheKey) freshness gate is now uniform (link.tsx:543, navigation.ts:2815). The old code let a navigation-reusable entry fall through and re-prefetch even when already prefetched; the new hasFreshPrefetchCacheEntry guard skips any fresh entry regardless of cacheForNavigation. This is a deliberate de-dup improvement and the link/prefetch suites cover it, but it is a behavior change in the reusable path worth a second look if any caller relied on the re-prefetch-while-fresh semantics.

Verification

  • vp check clean on instant-prefetch-shell.ts, app-route-graph.ts, build/report.ts, fetch-cache.ts, cache-runtime.ts.
  • Targeted suites green: app-route-graph (70), build-report (103), shims (1285), fetch-cache (145), app-page-render (67), app-rsc-response-finalizer (19), app-optimistic-routing (13), link (129), link-navigation (77), prefetch-cache (74). 1782 passing, 0 failing.

Nice work closing the review loop. The nested cache-task refcount (deferring the shell abort until cold cache writes are durable) and the entry-identity-checked optimistic-template invalidation are the right calls.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2850.

Summary

I did an independent review of all 40 changed files, focusing on the shell state machine, route-graph runtime-instant detection, static analysis, the three cache-task refcount wrappers, thenable-params suspension, the RSC finalizer/render-mode plumbing, and the dev HMR change. Ran targeted suites and vp check — all green (1782 tests passing, 0 failing).

No blocking issues. The core mechanism is correct and all prior-cycle findings are resolved in the tree I reviewed.

Correctness spot-checks that passed:

  • ALS key parity between instant-prefetch-shell.ts and the cache runtimes (vinext.cacheRuntime.contextAls / vinext.unstableCache.als), plus the variant !== "private" gate matching CacheContext.variant.
  • Abort scheduling: no TDZ in the two-macrotask defer; cancelPendingAbort + re-check prevent premature/leaked aborts; the nested trackInstantPrefetchShellCacheTask on the cold cache write keeps the shell open until durable.
  • dynamicAbortController intentionally never aborted (hole marker; render cut by reactAbortController).
  • Instant shells correctly excluded from navigation reuse and CDN cache; fully-static instant routes still promote to reusable complete payloads.
  • Source-scoped, identity-checked optimistic-template invalidation.
  • pageProps = {} fallback is safe.

Three non-blocking observations flagged: (1) unresolved external re-exports conservatively classified as runtime — worth a docs note; (2) hasNamedExportObjectStringProperty/findNamedExternalReexport are exported but only test-referenced (route graph uses analyzeNamedExportObjectStringProperty); (3) the uniform hasFreshPrefetchCacheEntry gate is a deliberate re-prefetch de-dup behavior change worth a second look.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@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: runtime instant prefetch shells (independent pass)

Reviewed all 40 changed files with a focus on the new shell state machine (instant-prefetch-shell.ts), the route-graph runtime-instant detection (app-route-graph.ts), the static analysis in build/report.ts, the three cache-task refcount wrappers (fetch-cache.ts, cache.ts, cache-runtime.ts), thenable-params suspension, the render-mode plumbing, the RSC finalizer, optimistic-template learning, and the dev HMR change. Ran the targeted suites and vp check on the core files — all green.

No blocking issues. The core mechanism is correct, and all five prior-cycle findings are resolved in the tree I reviewed. Confirmed:

  1. instantConfigDependencies fresh per rebuildbuildAppRouteGraph allocates a fresh Set per call (app-route-graph.ts:1270) and captureInstantRouteMetadata reassigns it wholesale (index.ts:1453). No monotonic growth.
  2. HMR change handler narrowly gated — fires only on tracked config deps or app-router page/layout files that actually export unstable_instant (index.ts:4725-4728), and refreshInstantMetadataAfterChange only invalidates routing modules when the metadata signature actually changed (index.ts:4666-4670). Fine-grained HMR for ordinary edits is preserved.
  3. Property-value alias resolution — both hasNamedExportObjectStringProperty and analyzeNamedExportObjectStringProperty resolve the property value via resolveLocalConstExpression (build/report.ts:180,245).
  4. App-only add/unlink browser-entry refreshinvalidateAppRoutingModules()invalidateAppBrowserEntry() runs for tracked deps on add/unlink.

Correctness spot-checks (all pass)

  • Abort scheduling + cache-task refcount. scheduleAfterTask has no TDZ (the firstTimer callback is a macrotask, so let secondTimer has executed). cancelPendingAbort clears a scheduled abort when a new cache task starts, and the re-check inside the deferred callback guards pendingCacheTasks === 0 / !aborted. The nested void trackInstantPrefetchShellCacheTask(() => cacheWrite, "fetch") (fetch-cache.ts:1365) increments the refcount before the outer task's return response resolves, so there is no zero-refcount window that could fire a premature abort before the cold cache write is durable.
  • Private-cache staging. suspendStaticInstantPrefetchPrivateCache() is called before trackCacheTaskForShells in cache-runtime.ts:497, so a static shell suspends before both lookup and execution and never registers a cache task that would deadlock against its own abort.
  • dynamicAbortController never aborted — intentional hole marker; the render is cut by reactAbortController.
  • Instant shells excluded from reuse. promotePrefetchEntriesForNavigation skips instantShell/optimisticRouteShell/partialSuspenseShell (link.tsx:852-859); lockPartialShellCacheHeaders forces no-store and strips CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag, and runs after config headers so a matching header rule can't leave a stray cache tag (app-rsc-response-finalizer.ts:125,134). A complete (non-aborted) instant render is promoted to a reusable navigation entry via the VINEXT_RSC_PARTIAL_SHELL_HEADER check (navigation.ts:1394-1404).
  • Identity-checked template invalidation. attachPrefetchInvalidationCallback returns false on entry supersession (navigation.ts:1060); the browser entry bails when the attach fails (server/app-browser-entry.ts).
  • Validation parity. toLinkPrefetchRoutes throws on unstable_instant in a Client Component module and on use without cacheComponents; both are gated so non-instant apps are unaffected. Runtime configs correctly require samples (app-route-graph.ts:388, covered by tests).

Non-blocking observations

  1. Unresolved external re-export → conservative runtime (app-route-graph.ts:475-481). A route that does export { unstable_instant } from "@myorg/config" (Vite alias / package export the sync scanner can't resolve) is always classified runtime-instant, forcing non-reusable partial shells even when the real value is { prefetch: "static" }. Deliberate and documented; still worth a user-facing docs note because the failure mode (prefetches silently stop being reused) is hard to diagnose. Carried over from prior cycles.

  2. isClientReferenceAppComponent only checks the top-level $$typeof (app-render-dependency.tsx:31-33). A client reference wrapped in React.memo/forwardRef at a page/layout/slot boundary would not be detected, so its params/searchParams would be given suspendStaticInstantShell: true and suspend during a static instant shell even though they are transport data for a Client Component. This is a conservative miss (over-suspends → the shell just becomes a hole for that segment), not a correctness bug, and mirrors the earlier property-value-alias parity gap. Non-blocking; note it if full parity of the static-shell client-reference fast path matters.

  3. export const unstable_instant = false requires cacheComponents. hasInstantConfig is true for the explicit-disable value too (app-route-graph.test.ts:192), so toLinkPrefetchRoutes throws "cannot use unstable_instant without enabling cacheComponents" even when the author is disabling instant. Confirm this matches the target Next.js canary's validation; if Next.js allows the disable form without the flag, this is a small parity divergence. Tested and appears deliberate, so flagging only for confirmation.

Verification

  • vp check clean on instant-prefetch-shell.ts, app-page-render.ts.
  • Targeted suites green: app-route-graph (70), app-page-render (67), app-rsc-response-finalizer (19), thenable-params (22), app-optimistic-routing (13), entry-templates (57), build-report (103), prefetch-cache (74). 425 passing, 0 failing.

Nice work closing the review loop. The two-macrotask defer + nested cache-task refcount and the entry-identity-checked optimistic-template invalidation are the right calls.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2850.

Summary

I did an independent review of all 40 changed files, focusing on the shell state machine (instant-prefetch-shell.ts), route-graph runtime-instant detection, the static analysis in build/report.ts, the three cache-task refcount wrappers, thenable-params suspension, the RSC finalizer/render-mode plumbing, optimistic-template learning, and the dev HMR change. Ran the targeted suites and vp check — all green (425 targeted tests passing, 0 failing).

No blocking issues. The core mechanism is correct and all five prior-cycle findings are confirmed resolved in the current tree:

  • instantConfigDependencies is now a fresh Set per rebuild
  • HMR change handler is narrowly gated (tracked deps or app-router pages that actually export unstable_instant), with a signature-diff before invalidating
  • property-value alias resolution via resolveLocalConstExpression
  • app-only add/unlink browser-entry refresh

Key correctness spot-checks that passed:

  • The nested trackInstantPrefetchShellCacheTask on the cold fetch-cache write increments the refcount before the outer task resolves, so there's no zero-refcount window that could fire a premature abort before the write is durable
  • Private cache suspends before both lookup and execution, so it never deadlocks against its own abort
  • Instant shells are correctly excluded from navigation reuse and CDN cache; complete (non-aborted) instant renders promote to reusable navigation entries
  • lockPartialShellCacheHeaders runs after config headers, so a matching header rule can't leave a stray Cache-Tag

Three non-blocking observations flagged for confirmation:

  1. Unresolved external re-exports conservatively classified as runtime (carried over; worth a docs note)
  2. isClientReferenceAppComponent only checks the top-level $$typeof, so a memo/forwardRef-wrapped client reference over-suspends in a static shell (conservative miss, not a bug)
  3. export const unstable_instant = false still requires cacheComponents — worth confirming against the target Next.js canary's validation

github run

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 11:26
…ue-instant-current

# Conflicts:
#	packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
#	packages/vinext/src/shims/link.tsx
#	packages/vinext/src/shims/navigation.ts
#	tests/link.test.ts
@james-elicx
james-elicx marked this pull request as draft August 10, 2026 21:03
…ue-instant-current

# Conflicts:
#	packages/vinext/src/shims/cache-runtime.ts
@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: runtime instant prefetch shells (independent pass)

Reviewed all 40 changed files with focus on the shell state machine (instant-prefetch-shell.ts), route-graph runtime-instant detection (app-route-graph.ts), the static analysis in build/report.ts, the cache-task refcount wrappers (fetch-cache.ts, cache-runtime.ts, cache.ts), thenable-params suspension, the RSC finalizer / render-mode plumbing, client-side optimistic-template learning, and the dev HMR change. Ran targeted suites and vp check on the core files — all green.

No blocking issues. The core design is correct and the prior-cycle findings are resolved in the tree I reviewed. The hasFreshPrefetchCacheEntry gate referenced in earlier review cycles no longer exists — the current freshness gate in link.tsx:627-653 keys off hasNavigationCacheEntry, so that earlier observation is moot.

Correctness spot-checks (all pass)

  • Abort scheduling + refcount. scheduleAfterTask's two-macrotask defer has no TDZ (the firstTimer callback runs as a macrotask, after the sync let secondTimer). cancelPendingAbort at the top of trackInstantPrefetchShellCacheTask clears a pending abort when a new cache task starts, and the re-check inside the deferred callback re-guards pendingCacheTasks === 0 / !aborted. The nested void trackInstantPrefetchShellCacheTask(() => cacheWrite, "fetch") (fetch-cache.ts:1365) increments the refcount before the outer task's return response resolves, so there is no zero-refcount window that could fire a premature abort before the cold cache write is durable.
  • Cache-scope deadlock avoidance. Request-data reads inside a public cache scope return null from suspendStaticInstantPrefetchRequestData (isInsideStaticRequestDataCacheScope gate), and the key-generation params reads happen inside cacheContextStorage.run, so the scope is active when the refcount is held. Private caches suspend before trackCacheTaskForShells (cache-runtime.ts:586), so a static shell never registers a cache task that would wait on its own abort.
  • Runtime vs static staging. headers()/cookies()/draftMode() suspend only in the static stage; runtime shells intentionally admit request data and stop at connection(). Matches the module docstring and Next's staging.
  • Complete-render promotion. A non-aborted instant render omits VINEXT_RSC_PARTIAL_SHELL_HEADER, so prefetchRscResponse (navigation.ts:1408-1417) promotes it to a reusable navigation entry; aborted shells force no-store + strip CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag via lockPartialShellCacheHeaders, which runs after config headers so a matching header rule can't leave a stray cache tag.
  • Instant shells excluded from reuse. promotePrefetchEntriesForNavigation skips instantShell/optimisticRouteShell/partialSuspenseShell (link.tsx:852-858).
  • Identity-checked template invalidation. attachPrefetchInvalidationCallback returns false on entry supersession; discardLearningOnlyPrefetchCacheEntry now also handles instant-shell entries.
  • HMR narrowly gated. The change handler fires only on tracked config deps or app-router page/layout files that actually export unstable_instant, and refreshInstantMetadataAfterChange invalidates routing modules only when the derived metadata signature changed (index.ts:4655-4670). instantConfigDependencies is a fresh Set per rebuild, reassigned wholesale by captureInstantRouteMetadata.
  • Client-side lazy sanitization. sanitizeInstantShellValue's _init call is idempotent for resolved React lazies and short-circuits suspended ones via the status check, so no shared-module mutation.

Non-blocking observations

  1. Unresolved external re-export → conservative runtime (app-route-graph.ts:475-481). export { unstable_instant } from "@myorg/config" (Vite alias / package export the sync scanner can't resolve) is always classified runtime-instant, forcing non-reusable partial shells even when the real value is { prefetch: "static" }. Deliberate and documented; still worth a user-facing docs note since the failure mode (prefetches silently stop being reused) is hard to diagnose. Carried over from prior cycles.

  2. isClientReferenceAppComponent checks only the top-level $$typeof (app-render-dependency.tsx:31-33). A client reference wrapped in React.memo/forwardRef at a page/layout boundary is not detected, so makeComponentParams gives it suspendStaticInstantShell: true and its transport params over-suspend into a hole during a static shell. Conservative miss (over-suspend, not incorrect output), and it mirrors the createPageElement isReactOwnedAppComponent gate. Non-blocking.

  3. extractStaticJsonValue sparse-array behavior change (build/report.ts). Array holes now push undefined instead of returning UNSUPPORTED_STATIC_VALUE. For instant samples a hole would yield an undefined sample that fails isInstantSample and throws a clear validation error — arguably better than silently classifying the whole config as non-static, but it is a behavior change worth a confirming test. The new cycle-detection visiting set with try/finally cleanup is a nice defensive addition.

  4. export const unstable_instant = false still requires cacheComponents. hasInstantConfig is true for the explicit-disable value, so toLinkPrefetchRoutes throws even when the author is disabling instant. Confirm against the target Next.js canary's validation; if Next allows the disable form without the flag this is a small parity divergence. Tested and appears deliberate.

Verification

  • vp check clean on instant-prefetch-shell.ts, app-optimistic-routing.ts, thenable-params.ts.
  • Targeted suites green: app-route-graph (70), build-report (103), thenable-params (22), app-page-render (67), app-optimistic-routing (13), app-rsc-response-finalizer (19), link (130), link-navigation (78), prefetch-cache (76). 578 passing, 0 failing.

Nice work closing the review loop — the two-macrotask defer + nested cache-task refcount and the entry-identity-checked optimistic-template invalidation are the right calls.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2850.

Summary

I did an independent review of all 40 changed files, focusing on the shell state machine, route-graph runtime-instant detection, the static analysis in build/report.ts, the cache-task refcount wrappers, thenable-params suspension, the RSC finalizer/render-mode plumbing, client-side optimistic-template learning, and the dev HMR change. Ran targeted suites and vp check — all green (578 targeted tests passing, 0 failing).

No blocking issues. The core mechanism is correct and prior-cycle findings are resolved in the current tree.

Key correctness spot-checks that passed:

  • The nested trackInstantPrefetchShellCacheTask on the cold fetch-cache write increments the refcount before the outer task resolves, so no premature abort fires before the write is durable.
  • Public cache scopes correctly suppress request-data suspension (avoiding the abort/task deadlock), and private caches suspend before registering a task.
  • lockPartialShellCacheHeaders runs after config headers and strips CDN cache tags; complete (non-aborted) instant renders promote to reusable navigation entries.
  • HMR change handler is narrowly gated and instantConfigDependencies is a fresh Set per rebuild.

Four non-blocking observations flagged for confirmation:

  1. Unresolved external re-exports conservatively classified as runtime (carried over; worth a docs note).
  2. isClientReferenceAppComponent only checks top-level $$typeof, so memo/forwardRef-wrapped client references over-suspend in a static shell (conservative miss).
  3. extractStaticJsonValue now pushes undefined for array holes instead of bailing — a behavior change worth a confirming test.
  4. export const unstable_instant = false still requires cacheComponents — worth confirming against the target Next.js canary.

I also noted that the hasFreshPrefetchCacheEntry gate flagged in some earlier review cycles no longer exists in the tree, so that prior observation is moot.

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: runtime instant prefetch shells (independent pass)

Reviewed all 40 changed files with focus on the new shell state machine (instant-prefetch-shell.ts), the route-graph runtime-instant detection (app-route-graph.ts), the static analysis in build/report.ts, the three cache-task refcount wrappers (fetch-cache.ts, cache-runtime.ts, cache.ts), thenable-params suspension, the render-mode/finalizer plumbing, client-side optimistic-template learning + promotion, and the dev HMR change. Ran targeted suites and vp check on the core files — all green.

No blocking issues. The design is correct and the prior-cycle findings (unbounded config-dep set, broad HMR invalidation, property-value alias resolution, app-only add/unlink browser-entry refresh) are all resolved in the tree I reviewed.

Correctness spot-checks (all pass)

  • Abort scheduling + refcount. scheduleAfterTask's two-macrotask defer has no TDZ (the firstTimer callback is a macrotask, so let secondTimer at line 54 has already executed). cancelPendingAbort at the top of trackInstantPrefetchShellCacheTask clears a scheduled abort when a new task starts, and the deferred callback re-guards pendingCacheTasks === 0 / !aborted. The nested void trackInstantPrefetchShellCacheTask(() => cacheWrite, "fetch") (fetch-cache.ts) increments the refcount before the outer task's return response resolves, so there is no zero-refcount window that could fire a premature abort before the cold cache write is durable.
  • Private-cache staging moved out of the tracked task. In cache-runtime.ts, suspendStaticInstantPrefetchPrivateCache() (and the private-in-public error) now runs before trackCacheTaskForShells, so a static shell suspends before registering a cache task and never deadlocks against its own abort. Moving the parent-context check to the top also makes it fire even for non-serializable args — strictly more correct.
  • Complete-render promotion. A non-aborted instant render omits VINEXT_RSC_PARTIAL_SHELL_HEADER, so prefetchRscResponse promotes it to a reusable navigation entry (cacheForNavigation = true, prefetchKind = "navigation"); aborted shells force no-store + strip CDN-Cache-Control/Cloudflare-CDN-Cache-Control/Cache-Tag via lockPartialShellCacheHeaders, which runs after config headers so a matching header rule can't leave a stray cache tag.
  • Instant shells excluded from reuse. promotePrefetchEntriesForNavigation skips instantShell/optimisticRouteShell/partialSuspenseShell; discardLearningOnlyPrefetchCacheEntry now also handles instant-shell.
  • Identity-checked template invalidation. attachPrefetchInvalidationCallback returns false on entry supersession; the source-key set (optimisticRouteTemplateSources) is added inside learnOptimisticRouteTemplateFromPrefetch using the same getOptimisticPrefetchSourceKey derivation as the outer learning loop's optimisticRouteTemplateLearning.delete, so keys match and no learning slot leaks.
  • HMR narrowly gated. The change handler fires only on tracked config deps or app-router page/layout files that actually export unstable_instant, and refreshInstantMetadataAfterChange invalidates routing modules only when the derived metadata signature changed. instantConfigDependencies is a fresh Set per buildAppRouteGraph, reassigned wholesale by captureInstantRouteMetadata. The unconditional invalidateAppRouteCache() before the re-scan only clears a memo cache, so skipping invalidateAppRoutingModules() on an unchanged signature is safe.

Non-blocking observations

  1. Promoted instant entries lack preparedElements (perf, not correctness). When a runtime instant shell is promoted to a navigation entry inside prefetchRscResponse.then (navigation.ts:1411-1418), the fixed behavior object still has prepareSnapshot === undefined (instant prefetches set it only when cacheForNavigation was truthy at call time). So the promoted entry gets entry.snapshot but never entry.preparedElements, and navigation must decode the buffered RSC payload through the normal path instead of reusing pre-decoded elements. The prepareSnapshot comment already calls preparation "an acceleration only," so output is correct — a promoted instant navigation is just slightly slower to commit than a natively-reusable full prefetch. Worth a follow-up if instant→navigation commit latency matters.

  2. Unresolved external re-export → conservative runtime (app-route-graph.ts routeModuleExportInstantPrefetchMode). export { unstable_instant } from "@myorg/config" (Vite alias / package export the sync scanner can't resolve) is always classified runtime-instant, forcing non-reusable partial shells even when the real value is { prefetch: "static" }. Deliberate and documented; still worth a user-facing docs note since the failure mode (prefetches silently stop being reused) is hard to diagnose. Carried over from prior cycles.

  3. extractStaticJsonValue sparse-array / undefined behavior change is untested (build/report.ts). Array holes now push undefined (and an undefined identifier resolves to undefined) instead of returning UNSUPPORTED_STATIC_VALUE. Downstream this is benign — a hole in samples yields an undefined element that fails isInstantSample and throws a clear assertValidInstantConfig error, and export const unstable_instant = undefined similarly throws — but tests/build-report.test.ts has no case covering it. A confirming test would lock in the intended "throws a clear error" outcome. The new cycle-detection visiting set with try/finally cleanup is a good defensive addition.

  4. isClientReferenceAppComponent checks only the top-level $$typeof (app-render-dependency.tsx:31-33). A client reference wrapped in React.memo/forwardRef at a page/layout boundary is not detected, so makeComponentParams gives it suspendStaticInstantShell: true and its transport params over-suspend into a hole during a static shell. Conservative miss (over-suspend, not wrong output), mirroring the isReactOwnedAppComponent gate. Non-blocking.

  5. export const unstable_instant = false still requires cacheComponents. hasInstantConfig is true for the explicit-disable value (app-route-graph.test.ts:192), so toLinkPrefetchRoutes throws "cannot use unstable_instant … without enabling cacheComponents" even when the author is disabling instant. Tested and appears deliberate; worth confirming against the target Next.js canary's validation in case Next allows the disable form without the flag.

Verification

  • vp check clean on instant-prefetch-shell.ts, app-route-graph.ts, build/report.ts.
  • Targeted suites green: build-report (103), app-page-render (67), link-navigation (78), prefetch-cache (76). No failures.

Nice work closing the review loop — the two-macrotask defer + nested cache-task refcount and the entry-identity-checked optimistic-template invalidation are the right calls.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2850.

Summary

I did an independent review of all 40 changed files, focusing on the shell state machine (instant-prefetch-shell.ts), route-graph runtime-instant detection, the static analysis in build/report.ts, the three cache-task refcount wrappers, thenable-params suspension, the render-mode/finalizer plumbing, client-side optimistic-template learning + promotion, and the dev HMR change. Ran targeted suites and vp check on core files — all green (324 targeted tests passing, 0 failing).

No blocking issues. The core mechanism is correct and all prior-cycle findings are resolved in the current tree.

Key correctness spot-checks that passed:

  • Nested trackInstantPrefetchShellCacheTask on the cold fetch-cache write increments the refcount before the outer task resolves — no premature abort before the write is durable.
  • Private-cache staging now runs before the tracked task, avoiding the abort/task deadlock.
  • Complete instant renders promote to reusable navigation entries; aborted shells force no-store and strip CDN cache tags (finalizer runs after config headers).
  • Optimistic-template source-key derivation matches the learning loop, so no learning slot leaks.
  • HMR change handler is narrowly gated; instantConfigDependencies is a fresh Set per rebuild.

One new observation worth a follow-up (non-blocking): promoted instant→navigation entries never get preparedElements (the fixed behavior.prepareSnapshot stays undefined for instant prefetches), so they decode the buffered RSC payload on click instead of reusing pre-decoded elements — a commit-latency perf gap, not a correctness bug.

Four carried-over non-blocking notes: unresolved external re-exports conservatively classified runtime (needs docs), the untested extractStaticJsonValue sparse-array behavior change, isClientReferenceAppComponent missing memo/forwardRef wrappers (conservative over-suspend), and unstable_instant = false still requiring cacheComponents (confirm against Next.js canary).

github run

# Conflicts:
#	packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
#	packages/vinext/src/shims/navigation.ts
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