Skip to content

perf(router): warm prefetched navigations by decoding eagerly - #554

Merged
ivogt merged 1 commit into
mainfrom
feat/prefetch-warm-decode
Jun 9, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/prefetch-warm-decode

Conversation

@ivogt

@ivogt ivogt commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

A consumer who adds prefetch to a <Link> expects a clicked-while-visible route to be warm — instant, no loading work. Today it is only half-warm. The prefetch fetches and buffers the route's RSC bytes, but it does not decode them. Decoding the Flight stream is what resolves the route's client references and imports its JS chunk — and that was deferred to click time. So in DevTools the consumer sees the route's app-*.js chunk download on click, not on hover/scroll, and the navigation stalls behind a code fetch + RSC decode that the prefetch was supposed to have already paid for.

This is the same gap waku hit in #2099 / #2100. This PR closes it for rango: prefetch now decodes eagerly, so the route's chunk is imported during the prefetch and the click loads no new code.

Before vs. after

The consumer writes the same code — only the runtime behavior changes:

<Link to="/dashboard" prefetch="hover">Dashboard</Link>
Phase Before After
hover fetch /dashboard?_rsc_partial → buffer bytes fetch + decode → import app-dashboard-*.js now
click consume bytes → decode → import chunk → render reuse decoded payload → render
net effect route JS + RSC decode happen on click route JS + RSC decode happen during hover; click is free

Observable in DevTools Network: the route's .js chunk now appears on hover/scroll/render, and no new JS request fires on click.

End-to-end example

Nothing to adopt — warming is automatic for every prefetch strategy (hover, viewport, render, adaptive). A realistic product nav:

export function ProductCard({ id }: { id: string }) {
  // viewport-prefetched as the card scrolls into view: by the time the user
  // taps, /product/:id's client chunk is already imported and decoded.
  return (
    <Link to={`/product/${id}`} prefetch="viewport">
      <img src={`/img/${id}.webp`} loading="lazy" />
      View details
    </Link>
  );
}

Semantics

The prefetch decode happens up front; navigation reuses the result. Each prefetch outcome:

Prefetch outcome Stored? Navigation behavior
2xx, no control header decoded payload cached (scope from X-RSC-Prefetch-Scope) warm hit — reuse payload, no createFromFetch, no network
in-flight when nav arrives inflight promise resolves to the decoded entry adopt it; await the same decode
inflight adopted via wildcard, turns out scope: "source" not for this source page discard, fresh fetch
carries X-RSC-Reload / X-RSC-Redirect dropped (not decoded/cached) fresh fetch re-fetches and acts on the header
!response.ok / aborted / decode error resolves null fresh fetch
staleRevalidation / hmr / intercept nav n/a (cache bypassed — unchanged) fresh fetch

A speculative prefetch never reloads the page or throws a redirect — only a real navigation acts on control headers.

Code changes

  • browser/prefetch/fetch.ts — executePrefetchFetch decodes the response with the injected createFromFetch (new setPrefetchDecoder, wired once in rsc-router.tsx init like setServerCallback). Tees off a silent completion tracker; drops control-header responses; stores a DecodedPrefetch.
  • browser/prefetch/cache.ts — entries are now DecodedPrefetch { payload, streamComplete, scope } instead of a raw Response. Decoded payloads are reusable (safer than a single-use stream body); TTL/FIFO/generation/inflight-alias machinery unchanged.
  • browser/navigation-client.ts — warm hits reuse entry.payload / entry.streamComplete and skip createFromFetch; cross-source discard keys off entry.scope. Fresh path (control-header validation + decode) is unchanged.
  • browser/response-adapter.ts — teeWithCompletion(..., silent): prefetch suppresses benign speculative-stream-error logs; the fresh-navigation path still logs.

Why no waku-style store-rebinding: rango dispatches server actions through the global __viteRscCallServer (set once via setServerCallback, read live at invocation), and the nav/prefetch decode passes no per-call callServer — so nothing navigation-specific is baked into the decoded tree. Why waku's issue #1 (wrong-chunk preload table) is N/A: rango resolves chunks by the reference id embedded in the payload (virtual:vite-rsc/client-references), not a per-route index table. Both waku fixes collapse to the single eager-decode change here.

Test plan

  • Unit (pnpm test:unit) — 3387 passed. New: prefetch-fetch asserts the decode runs exactly once at prefetch and control-header responses are not warmed; navigation-client asserts warm hits skip createFromFetch + the source-scoped-wildcard discard; prefetch-cache updated to the decoded-entry contract.
  • e2e — dev + production — new mini test clientChunks: prefetch warms the route's client chunk before the click (nameless /warm route, render-prefetch to /widgets): asserts the /widget/ resource loads during the prefetch and no new .js loads on click, in both modes. Existing link-behavior + prefetch-cache-control green dev + prod.
  • Full e2e — rangojs-router 1664 passed, vite-rsc-demo 221 passed, cloudflare-basic 435 passed (isolated). The only failures across runs were flaky HMR watcher tests (route-types-hmr, cloudflare hmr.test.ts), unrelated to this change and confirmed passing in isolation.
  • typecheck, lint (0/0), format — clean.

Notes

  • Warming applies to all strategies, including viewport — visible links now also import their route chunk, not just buffer bytes. This is intentional (matches waku) and bounded by the existing 5-min TTL + 50-entry FIFO cap.
  • setPrefetchDecoder / DecodedPrefetch are internal (browser runtime), not public API — no export-surface change.

Prefetch now decodes the RSC stream up front (createFromFetch) instead of on
click. Parsing the Flight stream resolves the route's client references, so its
JS chunk is imported during the prefetch; the cached decoded payload is reused
on navigation and the click loads no new code. Applies to all prefetch
strategies. Ports waku #2099 / #2100.

The prefetch cache now stores a DecodedPrefetch { payload, streamComplete,
scope } instead of a raw Response; navigation warm-hits reuse the payload and
skip createFromFetch. No server-action store-rebinding is needed -- rango
dispatches actions through the global __viteRscCallServer (set once via
setServerCallback, read live at invocation), so nothing navigation-specific is
baked into the decoded tree -- and rango has no per-route preload table to
misalign, so waku's two fixes collapse to just the eager decode here.

Speculative prefetches carrying a control header (X-RSC-Reload/Redirect) are
dropped rather than warmed, so a prefetch never reloads or throws; navigation
re-fetches and acts on the header. teeWithCompletion gains a silent flag so
benign speculative-stream errors don't log.
@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: e5f8a26

@ivogt
ivogt merged commit ffac83e into main Jun 9, 2026
25 checks passed
@ivogt
ivogt deleted the feat/prefetch-warm-decode branch June 9, 2026 05:49
ivogt added a commit that referenced this pull request Jun 9, 2026
Bring in main (#553 ctx.rendered() on streaming/loading() trees, #554 prefetch
warm-decode, release 0.0.0-experimental.117). Clean merge — no conflicts.

Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3544 + 6 todo, RSC 27),
lint 0/0, format.
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