Repository navigation
Conversation
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.
…h fetch" This reverts commit 9699109.
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.
commit: |
Collaborator
Author
|
Merged via #420 (squash) |
5 tasks done
19 tasks done
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
11 tasks done
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).
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.
Summary
Validation