Repository navigation
perf(client): fold the prefetch runtime back into the eager router chunk - #809
Merged
Merged
Conversation
The #766 lazy-chunk split of browser/prefetch/ created a document -> router -> runtime request waterfall: the runtime chunk is reachable only through loader.ts's dynamic import, so the browser discovers it after the router chunk downloads AND executes. Production's defaultPrefetch: "viewport" loads it on almost every page, so the split deferred nothing — it serialized a third round trip (measured 502 ms critical-path latency in a deployed worker). Removing the getManualChunks exclusion places all prefetch modules in the eager router chunk; the dynamic import compiles to Promise.resolve().then() with no fetch, evaluation stays deferred, and the merged chunk gzips 637 B smaller than the pair (cloudflare-basic: router 34776 -> 36463, runtime 2324 -> gone). build-test-app.setup.ts guard flipped to pin the new invariant (prefetch marker must land in the router chunk); manual-chunks unit tests updated; AGENTS.md rejected-optimizations entry added so the split is not redone.
commit: |
ivogt
marked this pull request as ready for review
July 23, 2026 12:18
ivogt
added a commit
that referenced
this pull request
Jul 25, 2026
…d waterfall default-prefetch-none's intersection test asserted that scrolling an offscreen viewport-override Link into view triggers exactly one /assets/runtime-*.js fetch. #809 (6de9117) folded the prefetch runtime into the eager router chunk precisely so that fetch would stop happening ("runtime 2324 -> gone"), which makes the production assertion unsatisfiable: it now gets 0. #809 updated build-test-app.setup.ts and the manual-chunks unit tests but not this e2e assertion, and the merge-time run skipped cloudflare-basic-e2e via path filtering, so it landed unnoticed. main's scheduled runs have failed cloudflare-basic-e2e (production, shard 1) on 2026-07-24 and 2026-07-25 with this test, all three attempts, at 486c400. The count is mode-dependent, so flip it rather than delete it: production must see 0 (a separate runtime chunk means the #766 document -> router -> runtime waterfall is back), and dev still sees 1 because Vite serves browser/prefetch/runtime.ts as its own unbundled module. Keeping the assertion as a mode-aware check turns a stale expectation into a guard for the invariant #809 established and AGENTS.md's rejected-optimizations entry protects. Verified locally: default-prefetch-none 3 passed in dev and 3 passed in production; the production case fails identically on main without this change. Unrelated to the revalidation fix in this PR; carried here because it blocks the branch from going green.
7 tasks done
ivogt
added a commit
that referenced
this pull request
Jul 25, 2026
…an unrendered parallel slot (#814) A parallel() slot carrying revalidate(() => false) never appeared when the route owning it was reached by soft navigation, while a direct load of the same URL rendered it fine. Reported against tests/vite-rsc-demo: land on /shop/product/:slug, click through to /shop, and the category sidebar stays blank until a full reload. Whether the slot showed up depended on the order the user happened to navigate in. The slot id is absent from the partial request's client-segment set because the product page never rendered it. resolveParallelSegmentsWithRevalidation seeds that case "new-segment" (render it), then passes the seed to evaluateRevalidation, where the user's hard `false` overrode it — emitting component:null for a slot the client had nothing cached to fall back on. The three sibling resolvers (loaders, layout/route entries, orphan layouts) never had this bug because they express the same invariant as an unconditional early return that never consults user predicates. The parallel path cannot copy that shape: #482 deliberately made it consult predicates for an unknown slot so a "skip-parent-chain" seed could still be raised, and because evaluateRevalidation re-throws a Response from a predicate as control flow, an early return would silently drop revalidate(() => { throw redirect(...) }). So the seed is floored rather than short-circuited. defaultOverride gains an optional `floor`; evaluateRevalidation applies it at all three exits through a new local decide(), so predicates still run and may only raise the decision. Applying it inside evaluateRevalidation rather than clamping the returned boolean at the call site keeps the revalidation trace to one entry per segment — an outer clamp pushed a second, contradicting entry, and flushRevalidationTrace buckets by finalShouldRevalidate with no dedup by segmentId, so the slot was counted as both revalidated and skipped and `total` was inflated. revalidate() now means "render once, then never refetch" rather than "sometimes never appear at all". Once the slot is on the client the seed is unfloored and a `false` is honored exactly as before, as are a `true` and a thrown Response in every case. Regression tests proven red before green: parallel-revalidate-fns-invocation.test.ts gains six tests covering the floored seeds (belongsToRoute, isNewParent, orphan force-render, and soft {defaultShouldRevalidate: false}, which exits by a different path than a hard false) plus the two cases that must NOT floor — client already has the slot, and the skip-parent-chain seed — without which "floor to true" could widen into "parallel slots always render". revalidation-trace-pipeline.test.ts gains two tests pinning one trace entry per decision, verified to fail against the double-push shape. Dev and production e2e in three apps: the router test-app and cloudflare-basic (workerd) via a reduced fixture, and vite-rsc-demo driving the reported /shop routes with no new fixture; each has a direct-load control so a broken fixture is distinguishable from a broken soft nav. The client-knowledge seed invariant, previously implicit in five differently shaped code blocks with no name, is now stated in docs/internal/execution-model.md with a table of which resolver uses which shape and why the parallel path diverges. skills/parallel/SKILL.md covers the consumer half. Not done: a general floor adopted by the sibling resolvers. Each would have to start calling evaluateRevalidation unconditionally, invoking user predicates on paths that never have — reversing #482's deliberate sibling audit. No userland test through @rangojs/router/testing either: dispatch is RSC-free by design and rejects partial requests to component routes, and renderRoute is client-only, so no primitive can reach a partial component-route render. Also carries an unrelated test fix. default-prefetch-none's intersection test asserted one /assets/runtime-*.js fetch, but #809 folded the prefetch runtime into the eager router chunk precisely so that fetch would stop happening, making the production assertion unsatisfiable. #809 updated build-test-app.setup.ts and the manual-chunks unit tests but not this assertion, and its merge-time run skipped cloudflare-basic-e2e via path filtering, so it landed unnoticed; main's scheduled runs had been red on it since 2026-07-24. The count is mode-dependent — dev still serves browser/prefetch/runtime.ts as its own unbundled module — so it is flipped to a mode-aware check rather than deleted, turning a stale expectation into a guard for the invariant #809 established.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every production Rango app ships the prefetch runtime as a separate lazy chunk that the browser can only discover after the router chunk has downloaded and executed, producing a serialized
document -> router-*.js -> runtime-*.jsrequest chain. A deployed worker (production pilot) measured it as the page's maximum critical-path latency: 502 ms for a 2.82 KiB chunk (Lighthouse "network dependency tree", finding #3 of the pilot report). Because production'sdefaultPrefetchis"viewport", any page with visible links loads this chunk moments after hydration — the lazy split (introduced in #766 to trim eager bytes) deferred essentially nothing and cost a third round trip on virtually every page view.Before vs. after
Consumer-visible network behavior on a production page load (no code changes required in apps):
Before:
After:
Measured on
tests/cloudflare-basic(gzip bytes):router-*.jsruntime-*.jsThe merged chunk gzips 637 B smaller than the pair, and the serialized round trip disappears entirely.
Semantics
defaultPrefetch: "none"/ no prefetch triggersloader.tsdecoder/concurrency config before first triggerThe dynamic import in
browser/prefetch/loader.tsis untouched; rolldown compiles a same-chunkimport()toPromise.resolve().then(...)(verified in the emitted chunk), so module evaluation order and the loader's deferral semantics are identical — only the network fetch is gone.runtime.tsis a pure re-export barrel with no top-level side effects.Why not modulepreload instead
Keeping the split and emitting
<link rel="modulepreload">from SSR was considered and rejected: plugin-rsc exposes no client-chunk-graph API to the server environments, so the SSR handler cannot learn the hashedruntime-*.jsURL without custom cross-build plumbing — real complexity to preserve a split that saves bytes only for apps that disable prefetch. This follows the existing rejected-optimizations doctrine (AGENTS.md): the action bridge was kept eager for the same reason ("adds a chunk fetch on first call; fundamental to almost every app"). Note the static import graph needs no preload work — FizzbootstrapModulesplus the client-reference preinit path already flatten it (the pilot waterfall shows router/react/worker-entry as parallel direct children of the document).Code changes
src/vite/utils/shared-utils.ts— removed thebrowser/prefetch/(runtime|fetch|queue|observer|policy|resource-ready)exclusion fromgetManualChunks; all prefetch modules now match theroutermanual chunk. Scar-tissue JSDoc added with the waterfall numbers:e2e/build-test-app.setup.ts— the bundle guard previously enforced the split ("must be wired through dynamic import()"); flipped to pin the new invariant: theX-Rango-Prefetchmarker (owned bybrowser/prefetch/fetch.ts) must land in exactly one client chunk and that chunk must berouter-*.src/vite/__tests__/manual-chunks.test.ts— flipped from pinning the exclusion to pinning"router"placement for all eight prefetch modules, including installed-package paths with?worker/#clientsuffixes.AGENTS.md— added the split to "Rejected client-runtime optimizations" so it is not reintroduced.docs/internal/feature-map.md,docs/internal/feature-file-map.md— prefetch narrative updated (deferral is evaluation-only, not fetch);loader.ts/runtime.tsadded to the Prefetching row.Test plan
manual-chunks.test.ts— 10/10; the previous version of this file fails against the fix and the new version fails against the old chunking (the guard inversion is the regression proof, same for the build guard).playwright test --project=build --no-deps— build guard passes against the folded output (the old guard fails against it:router-*.jsis statically imported).default-prefetch,prefetch-cache-control,prefetch-transition— 29/29.tests/cloudflare-basicande2e/test-app: noruntime-*.jschunk in either;Promise.resolve().thenin-chunk resolution verified in the emitted router chunk.pnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format— all green.Notes
defaultPrefetch: "none"now carry the prefetch code eagerly (+1.7 KiB gzip on the router chunk). This is the same trade the action bridge makes; a config-gated re-split was deliberately not added (see AGENTS.md entry).rolldown-runtime-*.jschunk in builds is rolldown's 452 B module helper — unrelated to this change.loader.ts, discovered at execution time — which is why a transitive-static-imports modulepreload pass would not have fixed it.