Skip to content

Fix prefetched parallel navigation streaming - #418

Closed
ivogt wants to merge 5 commits into
mainfrom
fix/prefetch-streaming-navigation
Closed

ivogt wants to merge 5 commits into
mainfrom
fix/prefetch-streaming-navigation

Conversation

@ivogt

@ivogt ivogt commented Mar 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • stop reusing in-flight prefetch bodies for navigation so Flight can parse a fresh response stream
  • restore parallel loader markers in client rendering so prefetched blog sidebar loaders stream through the parallel slot instead of gating navigation commit
  • add a small blog action probe to verify action-driven refreshes keep the sidebar stable during revalidation

Validation

  • pnpm run typecheck
  • pnpm run test:unit
  • pnpm run lint
  • pnpm run format
  • pnpm --filter @rangojs/router exec vitest run src/tests/segment-system.test.tsx src/tests/navigation-client.test.ts
  • pnpm --filter vite-rsc-demo exec tsc --noEmit

ivogt added 5 commits March 18, 2026 22:15
Parallel loaders (standalone segments keyed to parent shortCode) were
grouped with the layout in segmentTreeWalk. When the layout has no
loading(), renderSegments awaited them, blocking SSR for the full
loader duration.

Fix at the rendering layer with two small changes:

1. resolveParallelEntry: tag loader segments with parallelLoading
   (the owning parallel's loading fallback) when the parallel has
   loading() set.

2. renderSegments: in the "has loaders but no loading" branch, split
   loaders into layout-owned (await as before) and parallel-owned
   (attach as loaderDataPromise to the parallel segment so
   ParallelOutlet wraps in LoaderBoundary for streaming).

No changes to resolution paths, cache-hit paths, segment IDs,
partial navigation, or the segment tree walk. The fix is purely
in how renderSegments handles the tagged loaders.
Inflight prefetches buffer the entire response body (arrayBuffer) before
resolving. For routes with slow streaming loaders (e.g., parallel sidebar
with 5.5s loader), this blocks SPA navigation for the full loader
duration even though createFromFetch can consume streams incrementally.

When an inflight prefetch exists, skip it and do a fresh fetch instead.
The fresh fetch gives createFromFetch a streaming Response for
incremental consumption. The inflight prefetch completes in the
background and populates the cache for future navigations.
Prefetch fetch was calling response.arrayBuffer() to fully buffer the
body before resolving the inflight promise. This blocked navigation
for the entire stream duration when reusing inflight prefetches for
routes with slow streaming loaders.

Remove the arrayBuffer() call — return the streaming Response directly.
Cache storage uses response.clone() so the original body stays
consumable by navigation's createFromFetch for incremental streaming.
@pkg-pr-new

pkg-pr-new Bot commented Mar 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: afad3b3

@ivogt

ivogt commented Mar 22, 2026

Copy link
Copy Markdown
Collaborator Author

Merged via #420 (squash)

@ivogt ivogt closed this Mar 22, 2026
@ivogt
ivogt deleted the fix/prefetch-streaming-navigation branch March 22, 2026 13:17
ivogt added a commit that referenced this pull request Oct 4, 2026
…dle data its shell was rendered from (#1035) (#1054)

* feat(testing): serveShellRequest reads a response's handle channels (#1035)

readHandles() decodes the payload with the Flight client and drains its two
handle channels as browser/rsc-router.tsx does: hydration (metadata.handles,
what the document hydrates with), late (each metadata.handlesLate state,
applied after hydration) and, on a HIT, prelude (the handle data the stored
shell was rendered from). Before, a test could only match handle values as
text in the Flight payload and could not tell the two channels apart.

The controls for #1035 use it: a HIT with settled pushes only, a document
MISS, a navigation replay and a document without ppr, as they are today.

* fix(router): a shell capture renders its prelude from the handle data its record keeps (#1035)

A PPR shell capture stores a doc record with the handle pushes a HIT can
restore, and leaves out the ones it cannot keep: a deferred (thenable) loader
push and one holding a masked promise. Its own render still read the whole
handle store, so the prelude had an element for a push no HIT's record
supplies.

For a deferred push replayed from the loader's cache() entry that element was
empty: Flight decodes a promise to a thenable whose then() returns nothing,
so the funnel's value.then(mask) stored undefined in the slot. For one made
by a body that ran at capture the element held the resolved value, a
promise-shaped value baked into a shell every visitor shares.

- resolvedHandleStream(handleStore, recordedOnly): a capture's payload
  (buildFullPayload under _shellCaptureRun, both producers) carries the
  pushes the record keeps, by the filter the record is written with. Which
  pushes a record keeps stays the funnel's decision.
- The funnel keeps a deferred push as a promise of its value
  (Promise.resolve(value).then(mask)).

One existing assertion changed meaning: 'a deferred push by a runs: 1 loader
reaches the HIT' asserted the prelude contained the deferred value.

* fix(router): a shell HIT hydrates from its record and delivers the request's pushes after hydration (#1035)

A document served from a PPR shell hydrated with whatever its handle store
held when the document stream was first read: the record's pushes plus any a
loader of this request had made by then. The prelude was rendered from the
record, so every such push was an element the HTML did not have (or a value
it did not show), and whether it was there was a race. A deferred push by an
ssr: false loader failed hydration on every HIT; a settled push by a
live-lane loader made before its first await did too, and so did the run of
an entry without loader pins.

- HandleStore.freezeDocumentSnapshot(): the document lane (stream('settled'))
  yields the frozen state and nothing newer, and streamLate() starts from it,
  so a change made before the handler barrier is late too.
- serveShellHit freezes the store when the tail's render barrier resolves:
  the record is replayed and no loader has run. The same point for a runtime
  record and a prerender store entry, so match code is untouched.

What the request's loaders push, replace or drop rides metadata.handlesLate,
which the client already applies after hydration. No stored format and no
client change.

Tests: a HIT's Flight payload now carries two handle states (hydration and
late), so the two serve-shell-request suites read the state the client ends
with through readHandles() instead of matching the payload text. What each
asserts is unchanged. expectReplayDeliversDeferredPush gets the hydration
guard it could not have before.

* docs: what a document hydrates with on a shell HIT (#1035)

The design section in docs/design/handle-push-ownership.md: the rule, the
trace of how #1035 broke, the path table (origin/main against now), the two
halves as built, the row left open (a useHandle reader that hydrates after
the root), and what recording a deferred push would take. The ppr and loader
skills, the execution model, the feature map and the testing docs
(readHandles) state the HIT rule; the changelog lists what an app can
notice.

* docs: the open row of the shell HIT rule fails an existing e2e test (#1035)

shell-cache.test.ts 'live dep: a live loader a running ssr false loader
awaits keeps its live push on a HIT' reads useHandle rows inside a live
loader's loading() boundary. It passes on origin/main and fails with the HIT
rule, in dev and in production, because a reader that hydrates after the
root reads the controller's live state. The design section names the test
and what a useHandle patch measured.

* fix(router): a hydrating useHandle reader reads the handle data its HTML was rendered from (#1035)

The late handle channel is released when the root hydrates. A useHandle
reader inside a boundary that hydrates after the root (a live loader's
loading(), a streamed <Suspense>) initialized from the controller's live
state, which by then held values the boundary's HTML was rendered without:
'Hydration failed' in dev, React error #418 in production. On main that was
a loader push made after an await, on any document. With the shell HIT rule
(every loader push of the request is late) it was every push of a live
loader read inside its own boundary, which turned
shell-cache.test.ts 'live dep: ... keeps its live push on a HIT' red.

- EventController.freezeHydrationHandleState() keeps the state the document
  was rendered with; initBrowserApp calls it before hydrateRoot.
  getHydrationHandleState() returns it once the live state has moved on,
  undefined while the live state is still it. A partial update copies the
  containers first, so the frozen state is never written to.
- useHandle reads it in a hydrating render (useSyncExternalStore for its
  server snapshot, as useLocationState since #992); its mount effect moves
  the reader on to the live state. The snapshot is 'has the live state
  moved on', so a reader that hydrated with nothing late is not rendered
  again: the hook-render-stability pins are unchanged.
- renderRoute({ hydrate: true }) freezes like initBrowserApp, and gains
  lateHandles: handle values applied once the root has hydrated, where
  production releases the late channel. Without it no test could put a
  late update between the root's hydration and a boundary's.

Tests: use-handle-hydration.test.tsx and event-controller.test.ts (unit),
render-route-hydrate.test.tsx (userland, both StrictMode settings),
expectLateBoundaryHandleReaderHydratesClean in both apps, dev and
production, on a new fixture route (/ppr-push/live, /shell-push/live: a
live loader that pushes after an await, read inside its loading()).

expectPinlessHitKeepsRunPushWithRunData now records the handle rows over
time: on a HIT of a shell without loader pins React repairs the loader data
on hydration (the documented drift), and the handle rows are not part of
it. They hydrate as the shell has them and change after.

* docs: the shell HIT rule is complete with useHandle; options A and C decided against (#1035)

- docs/design/handle-push-ownership.md: rule 4 (a hydrating reader reads the
  document's handle data) as built, the one-sentence rule for app authors,
  option A (bake a deferred push at capture) and option C (a pending promise
  in the slot) recorded as decided against with their reasons, and the
  follow-up that is not done: the capture still waits for a deferred loader
  push it no longer renders.
- The ppr, loader and hooks skills and the changelog state the scope in
  order: what is in a shell's HTML, what a HIT delivers after hydration, and
  useHandle on every document, then the one-line app change.
- renderRoute's lateHandles in the testing skill and docs/testing.md; the
  execution model and the feature map name the new controller methods.

* refactor(router): review round on the shell HIT handle rule (#1035)

No behavior change for an app; edits to the three pieces already on the
branch.

- useHandle: the useSyncExternalStore server snapshot is the frozen handle
  state itself (undefined while the live state is still it) and the client
  snapshot is a constant undefined. The same readers re-render after
  hydrating as before (those whose server snapshot was not undefined). The
  previous client snapshot read the controller through an inline function:
  a store effect on every render of every reader, and a boolean that
  flipped once per document, which made React redo an in-flight transition
  render synchronously.
- EventController.setHandleData: a partial update merges into a copy of the
  containers, always (cloneHandleData), instead of copying only when the
  frozen state shared the object. The live object is never written in
  place: a back/forward restore installs the history cache's own object as
  the live state, and a later partial update wrote into that entry.
- HandleStore: stream('settled') serves a frozen snapshot before the
  batching setTimeout(0), and yields the frozen object without a second
  clone (its one consumer, resolvedHandleStream, builds a new object from a
  yield). In development a freeze that comes after the document stream was
  first read is reported through onError: that stream stays on the live
  state and the old race would be back.
- serveShellRequest: readHandles() returns { hydration, late }; prelude is
  gone (on a HIT hydration is what the prelude was rendered from) and with
  it decodePayloadHandles' late parameter. The evidence suite decodes
  hit.prelude itself.
- One subscribeToNothing (browser/react/subscribe-to-nothing.ts) for
  useHandle, useLocationState and Link.
- The hydration freeze is unconditional: after the handles block in
  initBrowserApp (a payload without handles is covered), and in renderRoute
  for a mounted tree too.
- rsc-router.tsx: the two comments on hydrationCommitted give the reason
  that remains for waiting for the root's commit.
ivogt added a commit that referenced this pull request Oct 4, 2026
…hes (#1057) (#1061)

* test(router): serveShellRequest serves a Prerender route from its build-time artifact

A Prerender route rendered its handler live under serveShellRequest, so a
Prerender + ppr route captured a doc record like a plain ppr route and no
test could reach the prerender-served shell production serves.

serveShellRequest now bakes the URL's artifact with router.matchForPrerender
(as the dev /__rsc_prerender endpoint does) and serves it through the
production prerender store over globalThis.__loadPrerenderManifestModule,
as the build-time shell phase installs it. cache-lookup gains an internal
setPrerenderStoreForTests seam; resetShellTestState drops baked artifacts.

* fix(router): a Prerender + ppr shell HIT hydrates with its loader pushes (#1057)

On a route that combines Prerender with ppr, a settled handle push from an
ssr: false loader was in the shell's HTML but not in the data a HIT hydrated
with: React #418 / "Hydration failed" on every HIT, and the element never
came back. The prerender store supplies such a route's handler layer, so its
capture writes no doc record, and the build-time prerender entry holds no
loader push; a promise-free loader is served from its pin and does not run.

The capture now stores the loader-owned handle arrays its prelude rendered
in a `handles` snapshot record (the doc record's handles + handleOwners
format, exempt from maxSnapshotBytes), and yieldFromStore restores it after
the prerender store's handles with the doc record's rule (setRecordAuthority
from loaderPins, then restoreHandles), before the render barrier freezes the
document snapshot. An entry without the record (v0.21) serves as before.

On the Cloudflare preset the dev /__rsc_shell capture ran in a temp Node
realm without __PRERENDER_DEV_URL, rendered the Prerender handler live into
a doc record the HIT never reads, and lost the same pushes: the plugin now
sets the global for that realm, so the capture replays the prerender store
as production's build phase does.

Tests: serveShellRequest suite for Prerender + ppr (settled push, promise
push, maxSnapshotBytes, a 0.21 entry, plain ppr control); unit tests next to
handle-snapshot, shell-snapshot, shell-capture and cache-lookup; dev and
production browser e2e in the router test-app and cloudflare-basic, for a
runtime-captured shell and the build-time shell.

* test(cloudflare-basic): give the e2e preview server its own KV state

The dev and preview servers both persisted to .wrangler/state. In a local
run with both projects, the dev "updateTag evicts the build-time shell"
test wrote the ppr-pp-evict-shell tag marker into the shared KV after the
build, and the production twin of that test then found its build-time
shell already evicted (MISS where it expects HIT). Reproduced with
--workers=1 on origin/main (5b7d02a) as well. CI runs each project in
its own job, so it never shared the state there.

* refactor(router): share the record handle restore; trim the #1057 capture
ivogt added a commit that referenced this pull request Oct 12, 2026
…erver HTML when location state changes beside it (#1116)

The reveal transition now changes only the hydration barrier, which React holds until every boundary has hydrated; location state and the hydration-lock release follow after its commit. While the hydration window is open, a commit with no new page (shallow navigation, a traversal keeping every segment, an action's or a fill's location state) leaves its state in the event controller for the barrier to apply. Before, a useLocationState reader beside a boundary still suspended on the client (React.lazy chunk, deferred-hydration gate) got new state first, and the boundary then failed to hydrate (dev: Hydration failed; prod: #418).
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