Skip to content

fix(app-router): full prefetch static loading routes#2446

Open
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-async-metadata-prefetch-fallback
Open

fix(app-router): full prefetch static loading routes#2446
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-async-metadata-prefetch-fallback

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • full-prefetch static App Router routes even when they have loading boundaries
  • keep dynamic loading-boundary routes shell-only so loading UI can still commit on navigation
  • update Link prefetch tests for static loading-boundary cache reuse

Next.js parity

Targets the App Router navigation behavior covered by:

  • test/e2e/app-dir/navigation/navigation.test.ts

This fixes the case where a visible static link with a loading boundary has already completed prefetch: navigation should use the prefetched full RSC payload rather than showing the fallback again.

Validation

  • vp test run tests/link.test.ts -t "automatic full RSC prefetch|full-prefetches static routes|distinguishes automatic prefetch"
  • vp test run tests/link-navigation.test.ts -t "full-prefetches visible static links with loading boundaries"
  • vp check tests/link.test.ts tests/link-navigation.test.ts packages/vinext/src/shims/link.tsx
  • REPO="/Users/jamesanderson/.codex/worktrees/async-metadata-prefetch-fallback/vinext" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/navigation/navigation.test.ts (50/50 passed)

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 2464ce6

@github-actions

github-actions Bot commented Jul 1, 2026

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 — LGTM

Focused, correct fix. Verified locally: vp check clean on the 3 changed files, and vp test run tests/link.test.ts tests/link-navigation.test.ts → 176 passed.

What the change does

resolveMatchedAutoAppRoutePrefetch now returns cacheForNavigation: !hasLoadingShell || !route.isDynamic. The only behavioral delta is static routes with a loading boundary (e.g. /settings): they flip from shell-only (cacheForNavigation: false) to full prefetch (true). The truth table is unchanged for every other combination:

Route isDynamic hasLoadingShell cacheForNavigation prefetchShellFirst
/about false false true true
/settings false true true (was false) true
/blog/:slug true true false false
/products/:id true false true false

This matches Next.js: a fully-static route is fully prefetchable even with a loading.js boundary, while dynamic loading-boundary routes stay shell-only so loading.js can still commit on navigation.

Things I checked

  • Downstream flow (auto mode): with cacheForNavigation: true, isOptimisticRouteShellPrefetch is false, so renderMode is undefined (full RSC), and neither the render-mode header nor NEXT_ROUTER_PREFETCH_HEADER is set. The shell-first fetchPromise branch (link.tsx:540–544) is gated on mode === "full" | "full-after-shell" | __prefetchInlining, so auto mode goes straight to a full fetch. Matches the new test's assertions (optimisticRouteShell: false, no prefetch/render-mode headers).
  • Single source of truth: the loading-shell decision lives only in resolveMatchedAutoAppRoutePrefetch; no duplicated prefetch logic in server entries, so no dev/prod parity gap to sync.
  • prefetchShellFirst still true for static routes is harmless in auto mode (branch not taken). Under __VINEXT_PREFETCH_INLINING=true a static loading-shell route in auto mode will now take the shell-first path (previously short-circuited by cacheForNavigation: false); this is consistent with the Segment Cache prefetchInlining contract rather than a regression.

Minor notes (non-blocking)

  • The new linkPrefetchRoutes entry (metadata-await-promise/nested) is a synthetic manifest entry for the isolated unit test — no real fixture/loading.js is required since fetch is stubbed. That's fine for this unit test.
  • Tests are well-labeled and the Next.js parity references are included. Nice.

No correctness or parity issues found. Approving.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2446.

Summary

Reviewed PR #2446 (fix(app-router): full prefetch static loading routes). No issues found — LGTM.

The change is a one-line logic fix in resolveMatchedAutoAppRoutePrefetch (link.tsx:325): cacheForNavigation: !hasLoadingShell || !route.isDynamic. The only behavioral delta is that static routes with a loading.js boundary now do a full RSC prefetch instead of shell-only, matching Next.js.

What I verified:

  • vp check clean on all 3 changed files; vp test run tests/link.test.ts tests/link-navigation.test.ts → 176 passed.
  • Traced the downstream auto-mode flow: full prefetch, correct headers (optimisticRouteShell: false, no render-mode/prefetch headers) — matches the new test.
  • Confirmed single source of truth (no dev/prod parity duplication in server entries).
  • Confirmed the __prefetchInlining interaction is intentional, not a regression.
  • The truth table for all other route combinations is unchanged; test coverage is comprehensive and correctly labeled with Next.js parity references.

The new synthetic metadata-await-promise/nested manifest entry is fine for the isolated unit test (fetch is stubbed, no real fixture needed).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

This looks like it covers the run 28478866791 / job 84413308650 failure for test/e2e/app-dir/navigation/navigation.test.ts (navigating to a page with async metadata > shows a fallback when prefetch completed). I am skipping a duplicate backlog fix for that failure.

@james-elicx

Copy link
Copy Markdown
Member Author

Backlog mapping for GitHub Actions run 28478866791 / job 84413308650: this PR appears to own the App Router static loading-boundary/full-prefetch failures in test/e2e/app-dir/navigation/navigation.test.ts and related static loading prefetch rows.

Per instruction I am skipping a duplicate implementation because this PR is by james-elicx. Remaining work looks like final proof/readying the draft or closing it as superseded if another PR fully covers the rows.

@james-elicx

james-elicx commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Run 29871622126 / job 88775791401 still fails test/e2e/app-dir/navigation/navigation.test.ts at navigating to a page with async metadata shows a fallback when prefetch completed. I think this PR owns that exact async-metadata fallback failure. I am keeping it mapped here for current-main revival and targeted v16.2.6 proof.

@james-elicx
james-elicx force-pushed the codex/fix-async-metadata-prefetch-fallback branch from 38c752d to af91241 Compare July 21, 2026 23:59
@james-elicx

Copy link
Copy Markdown
Member Author

Refreshed onto current main 3d30a3ca3 and pushed af9124146.

Current-main exact proof reproduced the owned failure: navigating to a page with async metadata > shows a fallback when prefetch completed failed (suite 46/50). On this head that exact assertion passes; the suite improves to 47/50. The remaining three failures are the separately mapped #2104 hash/query navigation rows.

Focused validation: tests/link.test.ts + tests/link-navigation.test.ts 196/196; scoped format/lint/types clean. The rebase also preserves main’s interception-source shell behavior while full-prefetching non-intercepted static loading routes.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 2464ce6 against base 3d30a3c using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 130.6 KB 131.2 KB ⚫ +0.5%
Client entry size (gzip) vinext 118.1 KB 118.4 KB ⚫ +0.3%
Dev server cold start vinext 2.83 s 2.90 s 🔴 +2.4%
Production build time vinext 3.15 s 3.15 s ⚫ -0.0%
RSC entry closure size (gzip) vinext 101.8 KB 101.8 KB ⚫ -0.0%
Server bundle size (gzip) vinext 169.1 KB 169.1 KB ⚫ -0.0%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

Updated the independent-review blocker at 364592ce5a826ab4b9b96c0437d1594a2c81b41e.

The full-prefetch decision no longer treats a fixed filesystem pathname as proof of a static render. Automatic loading-boundary prefetches now use the RSC response policy as the authority: no-store responses are not reusable for navigation, and generated dynamic-param candidates require positive shared-cache proof. Server-rejected payloads also cannot be promoted or learned as optimistic templates.

Regression coverage includes a fixed force-dynamic/cookie-style loading route whose completed viewport prefetch must not survive request-state mutation, plus generated-param static/fallback admission.

Validation:

  • vp test run tests/link.test.ts tests/link-navigation.test.ts tests/prefetch-cache.test.ts tests/entry-templates.test.ts — 288/288
  • scoped vp check on all changed files — clean
  • exact test/e2e/app-dir/navigation/navigation.test.ts — 47/50; both owned async-metadata rows pass. The three remaining failures are the separately-owned fix(app-router): align navigation prefetch commits #2104 hash/query rows.

Ready for a fresh independent review of this exact head; no Bonk/ready transition requested yet.

@james-elicx

Copy link
Copy Markdown
Member Author

Updated the branch after the independent review findings.

The cache-policy concern was resolved against the Next.js oracle rather than by adding vinext-only public-cache proof headers. Next's Segment Cache explicitly treats a Full prefetch as a navigation issued ahead of time and intentionally stores/reuses dynamic Full responses; it does not reject them based on Cache-Control: no-store:

  • packages/next/src/client/components/segment-cache/cache.ts (fetchSegmentPrefetchesUsingDynamicRequest)
  • packages/next/src/client/components/segment-cache/scheduler.ts (FetchStrategy.Full)

The revised behavior is:

  • a loading-boundary Full prefetch starts non-consumable;
  • pointer intent cannot promote it while its body is still open;
  • a separate loading-shell prefetch remains available during that interval;
  • only after the Full response has been completely snapshotted does it become reusable, including dynamic/no-store responses, matching Next.js.

Validation at 880b1c36c7ae93cffa59729f8226b96335a841ef:

  • focused vinext suite: 385/385 passed
  • scoped format/lint/types: passed
  • exact Next.js test/e2e/app-dir/navigation/navigation.test.ts: 47/50
  • both owned async-metadata rows passed:
    • shows a fallback when prefetch was pending
    • shows a fallback when prefetch completed
  • the remaining three failures are the separately-owned fix(app-router): align navigation prefetch commits #2104 hash/query failures.

@james-elicx

Copy link
Copy Markdown
Member Author

Fresh-review P1 fixes are now pushed at d6113730cdcf8769aa9d5285c9445ce13dc6f3ef.

What changed:

  • Automatic Full prefetches behind a loading boundary no longer trust filesystem shape or the presence of generateStaticParams as staticness proof.
  • Vinext first fetches the loading shell and treats its response as a concrete capability probe. A Full request starts only with positive shared/static artifact evidence (s-maxage > 0 or X-Vinext-Cache: HIT|STALE|STATIC).
  • Fixed dynamic/no-store routes and non-generated fallback params therefore stay shell-only. This is selection logic, not broad response rejection: explicit prefetch={true} and intent-upgraded Full prefetches remain unconditional and reusable once complete, matching Next's Full-prefetch semantics.
  • Ineligible automatic shells are not learned as optimistic templates, so the fresh live RSC stream remains authoritative.
  • full-after-shell intent now detects a normalized pending provisional Full entry and does not replace it or start a duplicate Full request.

New regression coverage verifies:

  • the current cache entry object is unchanged across hover intent;
  • request count remains exactly two (one shell + one Full);
  • click consumption remains a miss while the Full body is open;
  • the same response becomes reusable after EOF;
  • fixed no-store and GSP fallback/absent-proof automatic routes issue no Full request.

Validation:

  • focused vinext tests: 614/614 passed
  • scoped format/lint/types: passed
  • clean serialized exact Next.js test/e2e/app-dir/navigation/navigation.test.ts: 47/50
  • owned rows passed:
    • pending prefetch: 5632ms
    • completed prefetch: 10945ms
  • remaining failures are the same three separately-owned fix(app-router): align navigation prefetch commits #2104 hash/query failures.

@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 — LGTM (no issues found)

Re-reviewed the current head 2464ce6, which has grown well past the original one-line fix. Verified locally: vp check clean on all 5 changed files, and the focused suites pass — tests/link.test.ts (127), tests/link-navigation.test.ts (74), tests/prefetch-cache.test.ts (43), tests/entry-templates.test.ts (48), plus tests/app-router.test.ts + tests/features.test.ts (349) with no regressions. 292/292 across the four directly-touched files.

What the change now does

The policy no longer trusts filesystem shape (isDynamic, fixed pathname, or generateStaticParams) as proof of staticness. Instead:

  • AppRoutePrefetchPolicy splits the old cacheForNavigation boolean into three concepts: fetchFullPayload (issue a Full request), cacheForNavigationOnComplete (become reusable only after EOF snapshot), and a residual cacheForNavigation that starts false for every newly-started Full request.
  • Automatic loading-boundary routes carry automaticFullPrefetchProbe: "loading-shell". The probe fetches the loading shell, and responseProvesAutomaticFullPrefetchEligibility gates the Full request on positive shared-cache evidence (s-maxage>0 or X-Vinext-Cache: HIT|STALE|STATIC). No proof → the Full request throws and the entry self-deletes, leaving only the loading shell.
  • A completed Full prefetch flips to cacheForNavigation: true after its snapshot reaches EOF, so a click never joins a still-open stream. findProvisionalFullPrefetchEntryForNavigation dedupes full-after-shell intent against a pending provisional entry.

Correctness checks I ran through

  • Reject/throw path is clean. The !eligible throw rejects fetchPromise; prefetchRscResponse's .catch calls deletePrefetchCacheEntry, so ineligible Full attempts don't linger. The separately-cached loading shell survives and remains the navigation path. ✓
  • Alias ordering. In the .then, entry.cacheForNavigation = true (line 1069) runs before addRenderedPathAndSearchPrefetchAlias (line 1071), so the cacheForNavigation === false early-return at nav.ts:1120 doesn't suppress the alias. ✓
  • promotePrefetchEntriesForNavigation now skips cacheForNavigationOnComplete === true entries (link.tsx:966), so a provisional Full entry can't be force-promoted mid-stream. ✓
  • learnOptimisticRouteTemplatesFromPrefetchCache skips ineligible probe shells (automaticFullPrefetchEligible === false), so a live RSC stream stays authoritative. The === false guard is scoped — non-probe shells keep automaticFullPrefetchEligible === undefined and are unaffected. ✓
  • s-maxage parsing. /^\s*s-maxage=(\d+)\s*$/i per comma-split directive correctly handles public, s-maxage=N and s-maxage=N, stale-while-revalidate, and correctly treats s-maxage=0 as not eligible (Number("0") > 0 is false). ✓
  • Single source of truth. The policy lives only in the client link shim; no server-side (prod-server, worker-entry) duplicate to keep in sync. The only server-side touch is the learnOptimistic… guard, which is the correct counterpart. ✓
  • Build-time cost. hasGenerateStaticParams does sync readFileSync + AST parse of the page and each layout, but only for dynamic routes and only during manifest generation (not per request), short-circuiting on the page file. Acceptable.

Parity note

The behavioral premise — a completed Full prefetch is reusable even for no-store/dynamic responses — is validated against the Next.js oracle: test/e2e/app-dir/navigation/navigation.test.ts reports 47/50 with both owned async-metadata rows (shows a fallback when prefetch was pending / ...completed) passing; the three remaining failures are the separately-owned #2104 hash/query rows.

Minor / non-blocking

  • responseProvesAutomaticFullPrefetchEligibility joins cache-control + cdn-cache-control with , then re-splits on ,. That's fine for the current regexes (each directive is matched in isolation) but is slightly indirect; a small comment noting the join is only to pool both header sources for the same per-directive scan would help future readers.
  • The synthetic metadata-await-promise/nested / personalized-loading / generated-posts/:slug manifest entries in the unit tests have no real fixtures, which is fine since fetch is stubbed and the E2E suite covers the real wiring.

No correctness or parity issues found. Approving.

@ask-bonk

ask-bonk Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2446.

Summary

Re-reviewed the current head 2464ce6 of PR #2446 (fix(app-router): full prefetch static loading routes). The PR has grown well beyond the original one-line fix into a full reworking of the App Router prefetch policy. No issues found — LGTM.

What I verified

  • vp check clean on all 5 changed files.
  • Focused suites pass with no regressions: link.test.ts (127), link-navigation.test.ts (74), prefetch-cache.test.ts (43), entry-templates.test.ts (48), plus app-router.test.ts + features.test.ts (349).
  • Traced the key correctness paths:
    • Reject/throw path for ineligible automatic Full prefetches self-deletes the entry (.catchdeletePrefetchCacheEntry), leaving the loading shell intact.
    • Alias orderingcacheForNavigation = true runs before addRenderedPathAndSearchPrefetchAlias, so the alias isn't suppressed.
    • Promotion guard correctly skips cacheForNavigationOnComplete entries so a click never joins a still-open stream.
    • s-maxage parsing correctly handles combined directives and rejects s-maxage=0.
    • Single source of truth — logic is client-side only; the sole server-side touch (learnOptimisticRouteTemplates guard) is the correct counterpart.
  • Parity validated against the Next.js oracle (navigation.test.ts 47/50, both owned async-metadata rows passing).

Minor / non-blocking

  • The cache-control + cdn-cache-control join-then-resplit in responseProvesAutomaticFullPrefetchEligibility is slightly indirect; a one-line comment would help.
  • Synthetic manifest entries in unit tests have no real fixtures — fine given fetch is stubbed and the E2E suite covers real wiring.

Approved.

github run

@james-elicx
james-elicx marked this pull request as ready for review July 22, 2026 02:44
@james-elicx

Copy link
Copy Markdown
Member Author

Fresh exact-head targeted verification after the final defer all full prefetch reuse source commit:

  • PR head: 2464ce6b72802b5c7104c9be8d64baeaa18546e6
  • Next.js oracle: v16.2.6 (ee6e79b1792a4d401ddf2480f40a83549fe8e722)
  • Target: test/e2e/app-dir/navigation/navigation.test.ts
  • Owned deploy-suite row: navigating to a page with async metadata > shows a fallback when prefetch completedpassed
  • Companion pending-prefetch assertion — passed
  • Aggregate: 46/50. The four failures were outside this PR: three hash/query navigation rows already tracked separately, plus the pixel-exact hash scroll-offset assertion (expected 7024, observed 7010).

The exact functional row mapped to #2446 remains fixed at the current live source head.

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