Skip to content

perf(client): fold the prefetch runtime back into the eager router chunk - #809

Merged
ivogt merged 1 commit into
mainfrom
perf/fold-prefetch-runtime-chunk
Jul 23, 2026
Merged

ivogt merged 1 commit into
mainfrom
perf/fold-prefetch-runtime-chunk

Conversation

@ivogt

@ivogt ivogt commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator

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-*.js request 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's defaultPrefetch is "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:

document
├── router-*.js      (parallel)
│   └── runtime-*.js (serialized: discovered only after router executes) <- 502 ms critical path
├── react-*.js       (parallel)
└── worker-entry-*.js

After:

document
├── router-*.js      (parallel, prefetch runtime included)
├── react-*.js       (parallel)
└── worker-entry-*.js

Measured on tests/cloudflare-basic (gzip bytes):

chunk before after
router-*.js 34,776 36,463
runtime-*.js 2,324 (separate request) gone
total on prefetching pages 37,100 / 2 requests 36,463 / 1 request

The merged chunk gzips 637 B smaller than the pair, and the serialized round trip disappears entirely.

Semantics

Case Before After
Production page with viewport links (the default) fetches runtime chunk after router executes (waterfall) no extra fetch; prefetch code already resident
App with defaultPrefetch: "none" / no prefetch triggers prefetch runtime never fetched (saves 2.3 KiB) prefetch runtime ships eagerly (+1.7 KiB on the router chunk)
Prefetch runtime evaluation deferred until first trigger (chunk fetch + eval) still deferred (same dynamic import, resolves in-chunk)
loader.ts decoder/concurrency config before first trigger held without loading fetch/queue code unchanged

The dynamic import in browser/prefetch/loader.ts is untouched; rolldown compiles a same-chunk import() to Promise.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.ts is 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 hashed runtime-*.js URL 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 — Fizz bootstrapModules plus 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 the browser/prefetch/(runtime|fetch|queue|observer|policy|resource-ready) exclusion from getManualChunks; all prefetch modules now match the router manual chunk. Scar-tissue JSDoc added with the waterfall numbers:

    export function getManualChunks(id: string): string | undefined {
      const normalized = Vite.normalizePath(id);
      // (prefetch exclusion deleted — everything under the package matches "router")
  • 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: the X-Rango-Prefetch marker (owned by browser/prefetch/fetch.ts) must land in exactly one client chunk and that chunk must be router-*.

  • 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/#client suffixes.

  • 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.ts added 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-*.js is statically imported).
  • Production prefetch e2e: default-prefetch, prefetch-cache-control, prefetch-transition — 29/29.
  • Dev prefetch e2e — 29/29.
  • Built tests/cloudflare-basic and e2e/test-app: no runtime-*.js chunk in either; Promise.resolve().then in-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

  • Apps that configure 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).
  • The remaining rolldown-runtime-*.js chunk in builds is rolldown's 452 B module helper — unrelated to this change.
  • Origin: finding Loading states 11 28 #3 of the production pilot report. The report attributed the chain to a static import discoverable at parse time; it is actually the dynamic import in loader.ts, discovered at execution time — which is why a transitive-static-imports modulepreload pass would not have fixed it.

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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@809

commit: cb1a4c1

@ivogt
ivogt marked this pull request as ready for review July 23, 2026 12:18
@ivogt
ivogt merged commit 6de9117 into main Jul 23, 2026
20 checks passed
@ivogt
ivogt deleted the perf/fold-prefetch-runtime-chunk branch 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.
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.
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