Repository navigation
fix(router): a document served from a PPR shell hydrates with the handle data its shell was rendered from (#1035) - #1054
Merged
Merged
Conversation
…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.
… 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.
…quest'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.
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.
…ndle-snapshot # Conflicts: # packages/rangojs-router/docs/internal/feature-map.md # packages/rangojs-router/src/testing/serve-shell-request.ts
…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.
…TML 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.
…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.
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.
commit: |
This was referenced Oct 4, 2026
Merged
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.
Closes #1035.
Problem
On a
pprroute, a handle value that anssr: falseloader pushes as a promise broke hydration on every document served from the shell (x-rango-shell: HIT): React error #418 in production, "Hydration failed because the server rendered HTML didn't match the client" in dev. The first load of the page, before a shell existed, was clean.The shell's HTML had an element for the push with nothing in it (
<li>settled-note@g1</li><li></li>), and the HIT's data had the value in it or one row fewer, depending on timing.Two more mismatches of the same kind were found on the way and are fixed here: a settled push by a loader without
ssr: false, read in the static part of a shell page; and, on any document, auseHandlereader inside a loader'sloading()boundary when that loader pushes after anawait.The rule
A promise pushed from a loader is live and is never in a shell. To have a value in the shell's HTML, push it settled (await it) from the
ssr: falseloader, or push it from the handler.metadata.handlesis the shell record's handle data as restored, standing copies and placeholders alike.metadata.handlesLate, which the client applies after the root hydrates.useHandlereader that React is hydrating reads the handle data its HTML was rendered from, whenever its boundary hydrates, and moves on to the live data in its mount effect. This one holds on every document.Root cause
It is not that the HIT awaits the loader. The loader's data is its pin, resolved at once, and the deferred push comes from a background
executeLoaderDatanobody awaits (loader-cache.ts:555-560onede1367f), which here hits the loader's owncache()entry and replays its pushes. Whether that replay is in the pre-hydration data was a race with thesetTimeout(0)the document handle stream starts with (handle-store.ts:937): production on workerd and both modes on Node won it (text mismatch), dev on workerd lost it (one row fewer). Both fail, because the mismatch was made at capture:cache()entry too. The deferred push comes back as the thenable Flight decoded, whosethen()returns nothing, so the push funnel'svalue.then(mask)storedundefinedin the slot (shell-capture.ts:2051).full-payload.ts:56): an element for a push no HIT's record supplies.cache(), or a cold entry) the promise is a real one and the element held the resolved value: a promise-shaped value baked into a shell every visitor shares.Instrumented run, cloudflare-basic, production (dev is the same up to the last two lines, in the other order):
Before and after
<li>Widget</li><li></li><li>Widget</li>["Widget", "3 left"]or["Widget"], by timing["Widget"]["Widget", "3 left"], after a hydration error["Widget", "3 left"], no errorppr<li>Widget</li><li>3 left</li>, cleanFor a value that has to be in the shell's HTML, one line changes:
Path table
What the HTML was rendered from, what the client hydrates with, what arrives after. "record" is the shell record's handle data.
origin/maincache()entry warm at capture (#1035)cache(), or a cold entry)@g2next to@g1), or notppr.maxSnapshotBytes), the run pushespprA HIT of a
Prerenderroute withpprfreezes at the same point (read from the code, not probed).The pinless row: that page still reports a hydration error, and it is the loader DATA, the documented drift of an entry without pins. Dev names the element:
<p data-testid="push-value">,+ pinned@g2/- pinned@g1. The handle rows are not part of it. Recorded over time in both modes:["pinned-note@g1"]in the HTML and when the root hydrated,["pinned-note@g2"]after.expectPinlessHitKeepsRunPushWithRunDatanow asserts that.Code change
Non-test source, against
origin/main(6c93f7b0): 317 lines added, 37 removed, in 15 files. 210 / 28 of that is runtime, 107 / 9 is the two testing primitives. 143 of the 317 added lines are code, the rest comments (server/handle-store.ts: 66 added, 30 of them code).handles/deferred-resolution.ts,rsc/full-payload.ts,rsc/shell-capture.tsserver/handle-store.ts,rsc/rsc-rendering.tsuseHandlebrowser/event-controller.ts,browser/react/use-handle.ts,browser/react/subscribe-to-nothing.ts,browser/react/location-state.ts,browser/react/Link.tsx,browser/rsc-router.tsx,ssr/ssr-root.tsxtesting/serve-shell-request.ts,testing/flight.entry.ts,testing/render-route.tsx(i) The capture renders from what its record keeps (
7037260d):recordedHandleDatafilters withgetDataForSegment(id, true), the filtercaptureHandleswrites the record with, so the prelude and the record cannot disagree.(ii) The HIT hydrates from the record (
8f0890e5,4a863836):The tail's render barrier resolves once the record is replayed and before a loader starts, on both hit paths, so match code is untouched. Nothing in this piece knows what a deferred push is. The frozen state is served without the timer tick the live lane batches pushes with (SSR and the pre-hydration drain block on this stream) and without a second copy: the lane's one consumer,
resolvedHandleStream, takes the last yield and builds a new object from it (resolveDeferredHandleValues, orrecordedHandleDataon a capture) and never writes to it.(iii) A hydrating reader reads the document's handle data (
4fd269b8,4a863836):initBrowserAppfreezes beforehydrateRoot, for a payload without handles too.useSyncExternalStoreis used for its server snapshot only, asuseLocationStatesince #992. The server snapshot is the frozen state itself,undefinedwhile the live state is still it; the client snapshot is a constantundefined. React re-renders a reader after it hydrated only when the two differ, which is exactly when something arrived late, and the store is constant as far as React can tell: no per-render store effect, no transition render redone synchronously. A partial update merges into a copy of the containers, always, so no object the controller handed out is written to: not the frozen state, and not a history entry's data, which a back/forward restore installs by reference. Both controller methods are required; the two implementers are the browser controller and the SSR one (renderRouteuses the browser controller). The three no-opsubscribefunctions ofuseHandle,useLocationStateandLinkare one (subscribe-to-nothing.ts).No stored format changes.
Routes. One fixture route was added to each app for the
useHandlee2e (/ppr-push/livein cloudflare-basic,/shell-push/livein the router test-app): cloudflare-basic had no reader inside a live loader's boundary.router.named-routes.gen.tsis committed for both; no other*.gen.tschanged.Testing primitives.
serveShellRequest's result gainsreadHandles():{ hydration, late }, decoded with the Flight client the waybrowser/rsc-router.tsxreads the two channels (a6f388ff,4a863836).renderRoutegainslateHandles, handle values applied once the root has hydrated, where production releases the late channel; it also freezes likeinitBrowserApp(4fd269b8). WithoutlateHandlesno test could put an update between the root's hydration and a boundary's, so the userland test for (iii) could not exist.End-to-end example
And for a shell, through
serveShellRequest:Independent review
An independent consultation read this branch when it held pieces (i) and (ii) and recommended finishing this design complete, with the
useHandlepiece, in one PR. Two other designs were decided against:ppr.captureTimeout. It contradicts the liveness rule that already holds for loader data and for a promise nested in a loader's push (mask-nested.ts), baking only one shape. And it would freeze a value its author shaped as per-request into a shell every visitor shares.use()under<Suspense>, filled by the HIT's resume). It changes whatuseHandlereturns.Both are recorded in
docs/design/handle-push-ownership.md, "Options decided against".Two independent reviews then read the complete branch (
2898e606) and found no functional defect. Their findings are applied in4a863836:useHandle: the server snapshot is the frozen state itself and the client snapshot a constantundefined. The earlier client snapshot read the controller through an inline function (a store effect on every render of every reader) and flipped once per document (one in-flight transition render redone synchronously). The render sequences of all five unit cases are identical before and after, with and without StrictMode.setHandleDatanever writes into the live object in place.stream("settled")serves a frozen snapshot before the batching timer and without a second clone; a development-only report when a freeze comes after the document stream was first read.readHandles()lostprelude(on a HIThydrationis what the prelude was rendered from).subscribe; the hydration freeze is unconditional; the twohydrationCommittedcomments give the reason that remains.Left from those reviews, not done here:
stream("settled")andstreamLate()each decide where the document ends, and the freeze is a third input to both. One owner for that boundary would be cleaner. It is not behavior-neutral (it changes which pushes a MISS delivers in the snapshot and which late), so it is its own change.recordedHandleData). Then the prelude would be rendered from the bytes a HIT restores, by construction. Larger: the capture would have to decode its own record before it renders.Test plan
Red before, green now.
Pieces (i) and (ii), red on
ede1367f:serve-shell-request-push-ownership.rsc-test.tsx, "a shell HIT hydrates from its record": 17 red, 5 controls.a deferred push is not in the shell (... is warm at capture):expected [ 'settled-note-1', undefined ] to deeply equal [ 'settled-note-1' ]... (misses at capture):expected [ 'settled-note-2', 'deferred-note-2' ] to deeply equal [ 'settled-note-2' ]a deferred push by a loader that runs at capture is not in the shell:expected [ 'plain-settled@g1', 'plain-deferred@g1' ] to deeply equal [ 'plain-settled@g1' ]a push that holds a promise is not in the shell:expected [ 'nested@g1' ] to deeply equal []a deferred push arrives after hydration(2 variants): hydration[settled, deferred], nothing afteran entry without loader pins hydrates with the capture's push ...:expected { hydration: [ 'eb-after@g2' ], … } to deeply equal { hydration: [ 'eb-after@g1' ], … }a live-lane loader's push arrives after hydration ...: hydration[ 'handler-note', 'live-note@g2' ]; expected[ 'handler-note' ]<path>: the client hydrates with exactly what the shell was rendered from(6 routes)rsc/__tests__/shell-capture-deferred-push.test.ts:expected undefined to be 'deferred'handles/__tests__/deferred-resolution.test.ts(recordedOnly),server/__tests__/handle-store.test.ts(freezeDocumentSnapshot, 7 tests:TypeError: store.freezeDocumentSnapshot is not a function)expectShellHitHydratesFromRecord, dev and(production), both apps: received["settled-note@g1", ""], expected["settled-note@g1"]expectReplayDeliversDeferredPushwith its newguardHydrationErrors: "Hydration failed ..." (dev), "Minified React error Fix prefetched parallel navigation streaming #418" (production)Piece (iii), red with
origin/main's versions of the runtime files:testing/__tests__/render-route-hydrate.test.tsx, "PPR shell HIT: the shell bakes an empty element for a deferred handle push, so the page fails hydration on every hit #1035: a useHandle reader in a Suspense boundary that hydrates after a late handle update reads the values its HTML was rendered from, then the update" (both StrictMode settings)::216expected [ Array(1) ] to deeply equal [](recoverableErrorsheld "Hydration failed ...")__tests__/use-handle-hydration.test.tsx::122and:132, the same assertion ("... rendered HTML didn't match", "... rendered text didn't match"). Three more cases pass before and after: nothing late renders once, a late update after hydration costs no extra render, a mounted reader reads the live state.__tests__/event-controller.test.ts, "hydration handle state" (4 tests):TypeError: ctrl.freezeHydrationHandleState is not a functionexpectLateBoundaryHandleReaderHydratesClean(a document MISS, then a shell HIT), dev and(production), both apps. Red in all four with two errors per run: "Hydration failed because the server rendered HTML didn't match the client" (dev), "Minified React error Fix prefetched parallel navigation streaming #418 ... args[]=HTML" (production).expectPinlessHitKeepsRunPushWithRunData, the new rows-at-hydration assertion. Red in all four:- "pinned-note@g1"/+ "pinned-note@g2".e2e/shell-cache.test.ts:883"live dep: a live loader a running ssr false loader awaits keeps its live push on a HIT": green again in dev and production, unchanged. (It was red on pieces (i) and (ii) alone.)Added with the review round:
handle-store.test.ts(the frozen stream needs no timer tick; the development report, and its absence when the freeze comes first),event-controller.test.ts("a partial update never writes into the object the state was set from"), and the full render sequences inuse-handle-hydration.test.tsx.On the final tree (
4a863836,origin/mainat6c93f7b0merged):pnpm run typecheck: 16 of 16 taskspnpm run test:unit:all: router 7566 passed, router rsc506 passed | 8 expected fail, every consumer suite passedpnpm run lint,pnpm run format,pnpm check:e2e-bucketing,pnpm check:e2e-parity --strict,pnpm run check:docs-apipnpm check:bundle-guards: client router chunk 47373 B gzip (limit 48128 B)shell ppr handle semantic-matrix hook-render-stability,--no-deps: dev 272 passed, production 239 passed. Semantic matrix 25 and 28.ppr handle hook-render-stability: dev 117 passed; production 134 passed in two full runs. A first production run had 133 passed and 1 failed:ppr-capture-timeout.test.ts:262withError: apiRequestContext.get: socket hang uponGET /ppr-short-meta?probe=ct715neg. It did not reproduce in those two full runs nor in three runs of that file alone. It was not run onorigin/main.hook-render-stability8 dev and 8 production in the router app, 4 and 4 in cloudflare-basic. No pin file was touched.git status --porcelainempty,*.gen.tsincluded.What an existing app can notice
In order of scope:
ssr: falseloaders and the loaders they await. Such a value was in a HIT's HTML when the loader's body ran at capture (frozen there for every visitor), or an empty element when itscache()entry replayed it. It is now in the HTML of a document MISS and of a route withoutppronly. A crawler that reads a HIT does not see it. Live loaders never ran at capture, so nothing of theirs was ever in a shell.awaitwas in a HIT's hydration data; now there is one frame without it, where it used to be a hydration error (read outside the loader's boundary) or already in the boundary's HTML (read inside it). A shell without loader pins shows the capture's handle values until hydration, then the run's.useHandleon every document: a hydrating reader reads the data its HTML was rendered from. This fixes the mismatch for a push made after anawaitand read inside the loader's boundary. Such a reader renders once more after it hydrates when data arrived in between; a reader that hydrates with nothing new renders exactly as often as before.ctx.use(Notes)(await value)instead ofctx.use(Notes)(value).Notes
Follow-up, not done here: the capture still waits for a deferred loader push it no longer renders (
settleCaptureRecord,src/rsc/shell-capture.ts), underppr.captureTimeout. A push that does not settle in time refuses the shell (probed: "produced no usable shell ... did not settle within ppr.captureTimeout") for a value the shell does not contain. A deferred HANDLER push is baked and has to keep waiting, so this is its own change.Follow-up, read from the code and not probed: on a route that combines
Prerenderwithppr, a SETTLED push from anssr: falseloader is kept in the prelude byrecordedHandleData(the capture's store holds it untagged), while the HIT restores only the prerender entry's handles (yieldFromStore) and serves the loader from its pin without running it. The HTML would then hold an element the hydration data lacks, and nothing delivers it later. The same onmain, where the capture rendered its whole store.Existing tests whose reading changed: a HIT's Flight payload now holds two handle states, so "the last row with the value" is no longer what the client ends with.
final()in the push-ownership suite (29 call sites) and 19 assertions inserve-shell-request.rsc-test.tsxread the end state throughreadHandles()instead. What each asserts is unchanged. One assertion flipped: "a deferred push by a runs: 1 loader reaches the HIT" asserted that the prelude contained the deferred value.A router upgrade changes an app's cache versions (feat(router): per-app cache versions, a deploy keeps the cache of code it did not change #1034: a dependency changed), so no shell captured before the upgrade is served after it. An app that sets
createRouter({ version })itself keeps its shells: one captured before still has the extra element until it is recaptured.origin/mainmoved four times while this was built (feat(router): per-app cache versions, a deploy keeps the cache of code it did not change #1034, fix(router): buildEnv "auto" reads the wrangler config of the Vite root #1045, test(router): make the HMR recovery test deterministic and its cleanup failure-safe #1048 and fix(router): on Back/Forward, usePathname and useSearchParams change with the page, not at popstate (#1031) #1049, then fix(router): resolve the plugin-rsc static.edge vendor entry through @rangojs/router under pnpm #1051 to chore(router): declare @vitejs/plugin-rsc as a dependency only, not also a peer #1053). All are merged; the gate and the e2e subsets above ran on the result.Docs in this change:
docs/design/handle-push-ownership.md(the rule, the trace, the path table, the three pieces, the options decided against, the follow-up),skills/ppr/SKILL.md("Handles on a shell HIT"),skills/loader/SKILL.md,skills/hooks/handle-and-actions.md,skills/testing/serve-shell-request.md,skills/testing/client-components.md,skills/testing/handles.md,docs/testing.md,docs/internal/execution-model.md,docs/internal/feature-map.md,docs/design/caching.md,docs/design/shell-fast-path.md, and the changelog entry.Second run by the reviewer on the final commit (
4a863836): gate green (router 7566 unit, 506 RSC with 8 expected failures; every consumer package green). E2e,--no-deps: router appshell-push-ownership,shell-cacheandhook-render-stability: 77 passed in dev and 77 in production; cloudflare-basicppr-push-ownershipandhook-render-stability: 9 and 9. Withmain's versions of the 14 modified runtime files, the cloudflare dev run ofppr-push-ownershipfails at the deferred-push body's hydration guard (1 failed, 3 did not run in the serial describe).