Skip to content

[Flight] Keep an element pending while a referenced row is resolving - #37542

Merged
unstubbable merged 3 commits into
react:mainfrom
unstubbable:hl/fix-premature-initialize-element
Sep 8, 2026
Merged

unstubbable merged 3 commits into
react:mainfrom
unstubbable:hl/fix-premature-initialize-element

Conversation

@unstubbable

Copy link
Copy Markdown
Collaborator

A row that is still parsing can hand its partially built value to references that were registered on it during that parse. initializeModelChunk fulfilled every such listener, on the assumption that all of them are cyclic references back into the parsing row. Only some are. A listener from a nested parse that is not part of a cycle belongs to a handler that does not wait on the parsing row, so fulfilling it early completes that handler with an object that still has references outstanding.

When that handler owns an element, initializeElement runs on incomplete props. In DEV the props are frozen, so the write that arrives later throws Cannot assign to read only property, and rejectReference escalates the error into the rows that wait on the element, up to the root. Only the debug tree can produce this shape. The RSC stream writes element props inline, while the debug channel outlines a props object that an element shares with its own componentInfo into a separate row, and that row can still wait on a client module.

resolveBlockedCycle already tells a cyclic reference from any other, but it returned null for mid-parse listeners because handler.chunk was assigned after the drain loop. This change assigns it before the loop and classifies each listener. A reference whose handler is transitively waiting on the parsing row is a genuine cycle and receives the value now, because neither side can complete before the other. Every other listener is queued back on the parsing row and fulfilled when that row completes, like any reference into a blocked row.

A row whose own parse fails used to hand the partial value to its mid-parse listeners before it threw. It now errors through triggerErrorOnChunk, which rejects them the same way a reference into any other errored row is rejected. The if (handler.errored) throw after the loop is removed. It also caught a rejection during the loop, which now reaches triggerErrorOnChunk on its own because handler.chunk is set.

One behaviour changes beyond the reported bug. When initializeDebugChunk errors a chunk before parseModel runs, the old code set INITIALIZED over that status if the model had no pending references, and left it ERRORED otherwise. The chunk now stays ERRORED in both cases. That is what the triggerErrorOnChunk call in initializeDebugChunk intends, and the TODO above the parseModel call already notes that the chunk can be ERRORED there.

PR #37398 deferred Object.freeze(element.props) until the outstanding references have resolved. That removes the exception but not the cause: the element is still initialized on an incomplete object and is visible through _debugInfo with a null placeholder until the late write lands. With the early release fixed, the freeze needs no change.

Alternatives Considered

  • Deferring every listener whose handler is not the parsing row's own deadlocks foo ↔ bar in can deduped outlined references inside promises. One side of a genuine cycle has to accept the partial object.
  • Holding an element back while its props row is BLOCKED breaks should handle deduped props of re-used elements in fragments, where the row is blocked on an unrelated module and the props object itself is complete.
  • A per-object count of pending writes plus a reverse dependents edge works, but adds a second dependency graph next to deps and special-cases elements.

Fixes #37361
Closes #37398

A row that is still parsing can hand its partially built value to
references that were registered on it during that parse.
`initializeModelChunk` fulfilled every such listener, on the assumption
that all of them are cyclic references back into the parsing row. Only
some are. A listener from a nested parse that is not part of a cycle
belongs to a handler that does not wait on the parsing row, so
fulfilling it early completes that handler with an object that still has
references outstanding.

When that handler owns an element, `initializeElement` runs on
incomplete props. In DEV the props are frozen, so the write that arrives
later throws `Cannot assign to read only property`, and
`rejectReference` escalates the error into the rows that wait on the
element, up to the root. Only the debug tree can produce this shape. The
RSC stream writes element props inline, while the debug channel outlines
a props object that an element shares with its own componentInfo into a
separate row, and that row can still wait on a client module.

`resolveBlockedCycle` already tells a cyclic reference from any other,
but it returned `null` for mid-parse listeners because `handler.chunk`
was assigned after the drain loop. This change assigns it before the
loop and classifies each listener. A reference whose handler is
transitively waiting on the parsing row is a genuine cycle and receives
the value now, because neither side can complete before the other. Every
other listener is queued back on the parsing row and fulfilled when that
row completes, like any reference into a blocked row.

A row whose own parse fails used to hand the partial value to its
mid-parse listeners before it threw. It now errors through
`triggerErrorOnChunk`, which rejects them the same way a reference into
any other errored row is rejected. The `if (handler.errored) throw`
after the loop is removed. It also caught a rejection during the loop,
which now reaches `triggerErrorOnChunk` on its own because
`handler.chunk` is set.

One behaviour changes beyond the reported bug. When
`initializeDebugChunk` errors a chunk before `parseModel` runs, the old
code set `INITIALIZED` over that status if the model had no pending
references, and left it `ERRORED` otherwise. The chunk now stays
`ERRORED` in both cases. That is what the `triggerErrorOnChunk` call in
`initializeDebugChunk` intends, and the TODO above the `parseModel` call
already notes that the chunk can be `ERRORED` there.

PR react#37398 deferred `Object.freeze(element.props)` until the outstanding
references have resolved. That removes the exception but not the cause:
the element is still initialized on an incomplete object and is visible
through `_debugInfo` with a `null` placeholder until the late write
lands. With the early release fixed, the freeze needs no change.

**Alternatives Considered**

- Deferring every listener whose handler is not the parsing row's own
deadlocks `foo ↔ bar` in `can deduped outlined references inside
promises`. One side of a genuine cycle has to accept the partial object.
- Holding an element back while its props row is `BLOCKED` breaks
`should handle deduped props of re-used elements in fragments`, where
the row is blocked on an unrelated module and the props object itself is
complete.
- A per-object count of pending writes plus a reverse `dependents` edge
works, but adds a second dependency graph next to `deps` and
special-cases elements.

Fixes react#37361
Closes react#37398
@meta-cla meta-cla Bot added the CLA Signed label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Comparing: 9b7a0d4...291625b

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 7.35 kB 7.35 kB +0.05% 1.96 kB 1.96 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 625.29 kB 625.29 kB = 110.54 kB 110.54 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 7.36 kB 7.36 kB = 1.96 kB 1.96 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 696.51 kB 696.51 kB = 121.97 kB 121.97 kB
facebook-www/ReactDOM-prod.classic.js = 716.36 kB 716.36 kB = 125.38 kB 125.38 kB
facebook-www/ReactDOM-prod.modern.js = 706.67 kB 706.67 kB = 123.81 kB 123.81 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable-semver/react-server-dom-esm/esm/react-server-dom-esm-client.browser.development.js +1.41% 248.42 kB 251.92 kB +2.22% 55.98 kB 57.23 kB
oss-stable/react-server-dom-esm/esm/react-server-dom-esm-client.browser.development.js +1.41% 248.45 kB 251.94 kB +2.22% 56.01 kB 57.25 kB
oss-experimental/react-server-dom-esm/esm/react-server-dom-esm-client.browser.development.js +1.40% 249.39 kB 252.89 kB +2.21% 56.11 kB 57.35 kB
oss-stable-semver/react-server-dom-esm/esm/react-server-dom-esm-client.browser.production.js +1.20% 106.84 kB 108.12 kB +2.87% 22.39 kB 23.04 kB
oss-stable/react-server-dom-esm/esm/react-server-dom-esm-client.browser.production.js +1.20% 106.84 kB 108.12 kB +2.87% 22.39 kB 23.04 kB
oss-experimental/react-server-dom-esm/esm/react-server-dom-esm-client.browser.production.js +1.19% 107.58 kB 108.86 kB +2.90% 22.46 kB 23.11 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.production.js +1.18% 63.06 kB 63.80 kB +1.24% 11.99 kB 12.14 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.production.js +1.18% 63.06 kB 63.80 kB +1.24% 11.99 kB 12.14 kB
facebook-www/ReactFlightClient-prod.classic.js +1.18% 63.13 kB 63.88 kB +1.26% 12.27 kB 12.42 kB
facebook-www/ReactFlightClient-prod.modern.js +1.18% 63.13 kB 63.88 kB +1.26% 12.27 kB 12.42 kB
oss-stable-semver/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.production.js +1.18% 63.25 kB 63.99 kB +1.28% 12.02 kB 12.18 kB
oss-stable/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.production.js +1.18% 63.25 kB 63.99 kB +1.28% 12.02 kB 12.18 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.production.js +1.17% 63.63 kB 64.37 kB +1.31% 12.05 kB 12.21 kB
oss-experimental/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.production.js +1.17% 63.82 kB 64.56 kB +1.32% 12.10 kB 12.26 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.production.js +1.14% 65.01 kB 65.76 kB +1.32% 12.39 kB 12.56 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.production.js +1.14% 65.01 kB 65.76 kB +1.32% 12.39 kB 12.56 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js +1.14% 65.38 kB 66.12 kB +1.27% 12.49 kB 12.65 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js +1.14% 65.38 kB 66.12 kB +1.27% 12.49 kB 12.65 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.production.js +1.13% 65.58 kB 66.32 kB +1.26% 12.47 kB 12.63 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js +1.13% 65.95 kB 66.69 kB +1.22% 12.56 kB 12.71 kB
oss-stable-semver/react-client/cjs/react-client-flight.production.js +1.07% 72.85 kB 73.63 kB +1.21% 13.00 kB 13.16 kB
oss-stable/react-client/cjs/react-client-flight.production.js +1.07% 72.87 kB 73.65 kB +1.21% 13.03 kB 13.18 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.production.js +1.07% 69.52 kB 70.26 kB +1.13% 13.67 kB 13.83 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.production.js +1.07% 69.52 kB 70.26 kB +1.13% 13.67 kB 13.83 kB
oss-experimental/react-client/cjs/react-client-flight.production.js +1.06% 73.47 kB 74.25 kB +1.24% 13.10 kB 13.26 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.production.js +1.06% 70.09 kB 70.83 kB +1.19% 13.75 kB 13.91 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.production.js +1.02% 72.64 kB 73.39 kB +1.09% 14.28 kB 14.44 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.production.js +1.02% 72.64 kB 73.39 kB +1.09% 14.28 kB 14.44 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js +1.02% 72.69 kB 73.43 kB +1.11% 14.27 kB 14.43 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js +1.02% 72.69 kB 73.43 kB +1.11% 14.27 kB 14.43 kB
oss-stable-semver/react-server-dom-esm/cjs/react-server-dom-esm-client.node.production.js +1.02% 73.20 kB 73.94 kB +1.09% 14.17 kB 14.33 kB
oss-stable/react-server-dom-esm/cjs/react-server-dom-esm-client.node.production.js +1.02% 73.20 kB 73.94 kB +1.09% 14.17 kB 14.33 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.production.js +1.02% 73.21 kB 73.95 kB +1.13% 14.36 kB 14.52 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js +1.02% 73.25 kB 74.00 kB +1.15% 14.35 kB 14.52 kB
oss-experimental/react-server-dom-esm/cjs/react-server-dom-esm-client.node.production.js +1.01% 73.77 kB 74.51 kB +1.16% 14.24 kB 14.41 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.production.js +1.01% 73.96 kB 74.70 kB +1.05% 14.30 kB 14.45 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.production.js +1.01% 73.96 kB 74.70 kB +1.05% 14.30 kB 14.45 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.production.js +1.00% 74.53 kB 75.27 kB +1.11% 14.37 kB 14.53 kB
oss-stable-semver/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.production.js +0.98% 76.16 kB 76.90 kB +1.07% 14.71 kB 14.86 kB
oss-stable/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.production.js +0.98% 76.16 kB 76.90 kB +1.07% 14.71 kB 14.86 kB
oss-experimental/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.production.js +0.97% 76.73 kB 77.47 kB +1.12% 14.78 kB 14.94 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.production.js +0.96% 77.29 kB 78.03 kB +1.03% 14.96 kB 15.11 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.production.js +0.96% 77.29 kB 78.03 kB +1.03% 14.96 kB 15.11 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js +0.96% 77.33 kB 78.07 kB +1.04% 14.95 kB 15.11 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js +0.96% 77.33 kB 78.07 kB +1.04% 14.95 kB 15.11 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.production.js +0.96% 77.85 kB 78.60 kB +1.08% 15.03 kB 15.20 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js +0.96% 77.90 kB 78.64 kB +1.10% 15.03 kB 15.19 kB
oss-stable-semver/react-client/cjs/react-client-flight.development.js +0.28% 187.02 kB 187.54 kB +0.34% 32.54 kB 32.65 kB
oss-stable/react-client/cjs/react-client-flight.development.js +0.28% 187.04 kB 187.57 kB +0.34% 32.56 kB 32.67 kB
oss-experimental/react-client/cjs/react-client-flight.development.js +0.28% 187.84 kB 188.36 kB +0.41% 32.62 kB 32.76 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.development.js +0.27% 192.42 kB 192.94 kB +0.37% 33.83 kB 33.96 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.development.js +0.27% 192.47 kB 192.99 kB +0.38% 33.85 kB 33.98 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.development.js +0.27% 193.03 kB 193.56 kB +0.32% 34.21 kB 34.32 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.development.js +0.27% 193.03 kB 193.56 kB +0.32% 34.21 kB 34.32 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.development.js +0.27% 193.27 kB 193.79 kB +0.42% 33.91 kB 34.06 kB
facebook-www/ReactFlightClient-dev.modern.js +0.27% 193.58 kB 194.10 kB +0.37% 34.28 kB 34.41 kB
facebook-www/ReactFlightClient-dev.classic.js +0.27% 193.58 kB 194.10 kB +0.37% 34.28 kB 34.41 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.development.js +0.27% 193.82 kB 194.34 kB +0.40% 34.27 kB 34.41 kB
oss-stable-semver/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.development.js +0.27% 194.45 kB 194.97 kB +0.38% 34.26 kB 34.39 kB
oss-stable/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.development.js +0.27% 194.50 kB 195.02 kB +0.38% 34.29 kB 34.42 kB
oss-experimental/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.development.js +0.27% 195.30 kB 195.82 kB +0.40% 34.34 kB 34.48 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.development.js +0.27% 196.46 kB 196.99 kB +0.32% 34.77 kB 34.88 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.development.js +0.27% 196.46 kB 196.99 kB +0.32% 34.77 kB 34.88 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js +0.27% 196.50 kB 197.03 kB +0.33% 34.77 kB 34.88 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js +0.27% 196.50 kB 197.03 kB +0.33% 34.77 kB 34.88 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.development.js +0.26% 197.18 kB 197.71 kB +0.38% 34.81 kB 34.95 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.development.js +0.26% 197.23 kB 197.76 kB +0.38% 34.84 kB 34.97 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.development.js +0.26% 197.25 kB 197.77 kB +0.36% 34.83 kB 34.96 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js +0.26% 197.29 kB 197.81 kB +0.36% 34.83 kB 34.96 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js +0.26% 197.62 kB 198.14 kB +0.38% 34.93 kB 35.06 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js +0.26% 197.67 kB 198.19 kB +0.38% 34.95 kB 35.09 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.development.js +0.26% 198.03 kB 198.56 kB +0.38% 34.90 kB 35.03 kB
oss-stable-semver/react-server-dom-esm/cjs/react-server-dom-esm-client.node.development.js +0.26% 198.45 kB 198.97 kB +0.38% 35.07 kB 35.21 kB
oss-stable/react-server-dom-esm/cjs/react-server-dom-esm-client.node.development.js +0.26% 198.45 kB 198.97 kB +0.38% 35.07 kB 35.21 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js +0.26% 198.47 kB 198.99 kB +0.39% 35.02 kB 35.15 kB
oss-experimental/react-server-dom-esm/cjs/react-server-dom-esm-client.node.development.js +0.26% 199.23 kB 199.76 kB +0.40% 35.13 kB 35.27 kB
oss-experimental/react-markup/cjs/react-markup.react-server.production.js +0.26% 377.02 kB 378.01 kB +0.26% 70.02 kB 70.20 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.development.js +0.26% 199.83 kB 200.35 kB +0.38% 35.03 kB 35.16 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.development.js +0.26% 199.83 kB 200.35 kB +0.38% 35.03 kB 35.16 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.development.js +0.26% 200.62 kB 201.14 kB +0.37% 35.09 kB 35.22 kB
oss-stable-semver/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.development.js +0.26% 201.58 kB 202.10 kB +0.39% 35.28 kB 35.42 kB
oss-stable/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.development.js +0.26% 201.58 kB 202.10 kB +0.39% 35.28 kB 35.42 kB
oss-experimental/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.development.js +0.26% 202.37 kB 202.89 kB +0.39% 35.34 kB 35.48 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.development.js +0.26% 202.96 kB 203.48 kB +0.38% 35.58 kB 35.71 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.development.js +0.26% 202.96 kB 203.48 kB +0.38% 35.58 kB 35.71 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js +0.26% 203.00 kB 203.52 kB +0.38% 35.57 kB 35.71 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js +0.26% 203.00 kB 203.52 kB +0.38% 35.57 kB 35.71 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.development.js +0.26% 203.75 kB 204.27 kB +0.41% 35.64 kB 35.79 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js +0.26% 203.79 kB 204.31 kB +0.41% 35.64 kB 35.78 kB

Generated by sizebot against 291625b

@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label Sep 8, 2026
@unstubbable
unstubbable marked this pull request as ready for review September 8, 2026 09:32
response: Response,
chunk: BlockedChunk<T>,
value: T,
reason: any,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we type this to match InitializedChunk['reason']? The callsite already has a comment saying why it's sound but that Flow would consider it unsound is hidden.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, together with the split below so the call site is actually checked: handler.reason narrows to null | FlightStreamController after the errored guard, and Flow verifies it against the parameter.

Comment on lines +1720 to +1722
if (chunk === null || chunk.status !== BLOCKED) {
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably an existing type-issue but according to types InitializationHandler can only have a BlockedChunk so why do we need to check status here at runtime?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a real runtime condition. BlockedChunk is the chunk's type when the handler recorded it, but the object is mutated in place: it can have been errored by rejectReference (any of the handler's references failing) or completed by a cyclic reference during the drain loop before the last deps-- arrives here. Both inline blocks this replaces had the same check on main. I made it explicit with the SomeChunk cast that
wakeChunkIfInitialized uses, and a comment.

Comment on lines +1723 to +1724
// For a stream chunk, `handler.reason` holds its controller.
initializeBlockedChunk(response, chunk, handler.value, handler.reason);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But don't we have a BlockedChunk here?

@unstubbable unstubbable Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, it is a BlockedChunk; the comment meant the chunk it becomes. Reworded: handler.reason is the controller of a stream whose chunk was blocked on its debug info (set where resolveStream waits on
initializeDebugChunk), null otherwise, and it becomes the initialized chunk's reason either way.

// `initializeModelChunk` handles that case when the parse ends.
function initializeChunkIfUnblocked(
response: Response,
handler: InitializationHandler,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we split the InitializationHandler type up into an errored and "normal" version to narrow down its reason?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done. The four places that flip a handler cast to ErroredInitializationHandler before writing, like the chunk transitions do. The refinements fall out: handler.reason is mixed inside the errored branches and null | FlightStreamController after the guard in initializeChunkIfUnblocked, so the typed initializeBlockedChunk parameter is now checked.

Comment on lines +2671 to +2675
const getDebugInfoWithProps =
require('internal-test-utils').getDebugInfo.bind(null, {
ignoreProps: false,
useFixedTime: true,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDebugInfo should really be hoisted into the beforeEach to match our existing test patterns. getDebugInfoWithProps can also be inlined since it's only used once.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. getDebugInfo is bound in beforeEach and the test calls it directly. Hoisting shifts the hardcoded source locations in the with real timers tests by 27 lines, so those are updated too; while at it I moved the two helpers up next to the existing ones instead of leaving them at the bottom, which was only there to avoid that shift.

Comment on lines +2693 to +2696
// The props row still waits on the module, so the element must still be a
// lazy. Without the fix it is already initialized here, with the null
// placeholder where `ClientModule` belongs.
expect(asyncServerElement.$$typeof).toBe(Symbol.for('react.lazy'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this important? Isn't it sufficient to let the test run without throwing because props are frozen?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The throw is only the DEV symptom. A test that just checks the response resolves also passes with #37398's deferred freeze, which keeps handing the element the incomplete props; DevTools then reads the null placeholder. This assertion is the one that pins the actual invariant (the element stays a lazy until its props row completes) independent of the freeze; the assertions after the module resolves cover the no-throw outcome. I reworded the comment to say that.

@unstubbable
unstubbable requested a review from eps1lon September 8, 2026 10:14
The test now checks the frozen-props `TypeError` (the symptom) and the
early resolution of the debug-tree element (the cause), in that order.
It records the payload status of the lazy that wraps the element before
the module resolves and asserts it after the behaviour assertions, so on
`main` the first failure is the `TypeError`. With the client from
element having resolved before its props row did. Without that assertion
the test passes with react#37398 and cannot tell the fix from the alternative
it supersedes.

The `$$typeof` check is gone; the element is read through
`_init(_payload)`, as `ReactFlightDOMEdge-test.js` does, which is also
what DevTools reads.
@unstubbable
unstubbable merged commit 6c0e104 into react:main Sep 8, 2026
239 checks passed
@unstubbable
unstubbable deleted the hl/fix-premature-initialize-element branch September 8, 2026 11:31
github-actions Bot pushed a commit that referenced this pull request Sep 8, 2026
…37542)

A row that is still parsing can hand its partially built value to
references that were registered on it during that parse.
`initializeModelChunk` fulfilled every such listener, on the assumption
that all of them are cyclic references back into the parsing row. Only
some are. A listener from a nested parse that is not part of a cycle
belongs to a handler that does not wait on the parsing row, so
fulfilling it early completes that handler with an object that still has
references outstanding.

When that handler owns an element, `initializeElement` runs on
incomplete props. In DEV the props are frozen, so the write that arrives
later throws `Cannot assign to read only property`, and
`rejectReference` escalates the error into the rows that wait on the
element, up to the root. Only the debug tree can produce this shape. The
RSC stream writes element props inline, while the debug channel outlines
a props object that an element shares with its own componentInfo into a
separate row, and that row can still wait on a client module.

`resolveBlockedCycle` already tells a cyclic reference from any other,
but it returned `null` for mid-parse listeners because `handler.chunk`
was assigned after the drain loop. This change assigns it before the
loop and classifies each listener. A reference whose handler is
transitively waiting on the parsing row is a genuine cycle and receives
the value now, because neither side can complete before the other. Every
other listener is queued back on the parsing row and fulfilled when that
row completes, like any reference into a blocked row.

A row whose own parse fails used to hand the partial value to its
mid-parse listeners before it threw. It now errors through
`triggerErrorOnChunk`, which rejects them the same way a reference into
any other errored row is rejected. The `if (handler.errored) throw`
after the loop is removed. It also caught a rejection during the loop,
which now reaches `triggerErrorOnChunk` on its own because
`handler.chunk` is set.

One behaviour changes beyond the reported bug. When
`initializeDebugChunk` errors a chunk before `parseModel` runs, the old
code set `INITIALIZED` over that status if the model had no pending
references, and left it `ERRORED` otherwise. The chunk now stays
`ERRORED` in both cases. That is what the `triggerErrorOnChunk` call in
`initializeDebugChunk` intends, and the TODO above the `parseModel` call
already notes that the chunk can be `ERRORED` there.

PR #37398 deferred `Object.freeze(element.props)` until the outstanding
references have resolved. That removes the exception but not the cause:
the element is still initialized on an incomplete object and is visible
through `_debugInfo` with a `null` placeholder until the late write
lands. With the early release fixed, the freeze needs no change.

**Alternatives Considered**

- Deferring every listener whose handler is not the parsing row's own
deadlocks `foo ↔ bar` in `can deduped outlined references inside
promises`. One side of a genuine cycle has to accept the partial object.
- Holding an element back while its props row is `BLOCKED` breaks
`should handle deduped props of re-used elements in fragments`, where
the row is blocked on an unrelated module and the props object itself is
complete.
- A per-object count of pending writes plus a reverse `dependents` edge
works, but adds a second dependency graph next to `deps` and
special-cases elements.

Fixes #37361
Closes #37398

DiffTrain build for [6c0e104](6c0e104)
github-actions Bot pushed a commit to code/lib-react that referenced this pull request Sep 8, 2026
…eact#37542)

A row that is still parsing can hand its partially built value to
references that were registered on it during that parse.
`initializeModelChunk` fulfilled every such listener, on the assumption
that all of them are cyclic references back into the parsing row. Only
some are. A listener from a nested parse that is not part of a cycle
belongs to a handler that does not wait on the parsing row, so
fulfilling it early completes that handler with an object that still has
references outstanding.

When that handler owns an element, `initializeElement` runs on
incomplete props. In DEV the props are frozen, so the write that arrives
later throws `Cannot assign to read only property`, and
`rejectReference` escalates the error into the rows that wait on the
element, up to the root. Only the debug tree can produce this shape. The
RSC stream writes element props inline, while the debug channel outlines
a props object that an element shares with its own componentInfo into a
separate row, and that row can still wait on a client module.

`resolveBlockedCycle` already tells a cyclic reference from any other,
but it returned `null` for mid-parse listeners because `handler.chunk`
was assigned after the drain loop. This change assigns it before the
loop and classifies each listener. A reference whose handler is
transitively waiting on the parsing row is a genuine cycle and receives
the value now, because neither side can complete before the other. Every
other listener is queued back on the parsing row and fulfilled when that
row completes, like any reference into a blocked row.

A row whose own parse fails used to hand the partial value to its
mid-parse listeners before it threw. It now errors through
`triggerErrorOnChunk`, which rejects them the same way a reference into
any other errored row is rejected. The `if (handler.errored) throw`
after the loop is removed. It also caught a rejection during the loop,
which now reaches `triggerErrorOnChunk` on its own because
`handler.chunk` is set.

One behaviour changes beyond the reported bug. When
`initializeDebugChunk` errors a chunk before `parseModel` runs, the old
code set `INITIALIZED` over that status if the model had no pending
references, and left it `ERRORED` otherwise. The chunk now stays
`ERRORED` in both cases. That is what the `triggerErrorOnChunk` call in
`initializeDebugChunk` intends, and the TODO above the `parseModel` call
already notes that the chunk can be `ERRORED` there.

PR react#37398 deferred `Object.freeze(element.props)` until the outstanding
references have resolved. That removes the exception but not the cause:
the element is still initialized on an incomplete object and is visible
through `_debugInfo` with a `null` placeholder until the late write
lands. With the early release fixed, the freeze needs no change.

**Alternatives Considered**

- Deferring every listener whose handler is not the parsing row's own
deadlocks `foo ↔ bar` in `can deduped outlined references inside
promises`. One side of a genuine cycle has to accept the partial object.
- Holding an element back while its props row is `BLOCKED` breaks
`should handle deduped props of re-used elements in fragments`, where
the row is blocked on an unrelated module and the props object itself is
complete.
- A per-object count of pending writes plus a reverse `dependents` edge
works, but adds a second dependency graph next to `deps` and
special-cases elements.

Fixes react#37361
Closes react#37398

DiffTrain build for [6c0e104](react@6c0e104)
github-actions Bot pushed a commit to srpatcha/react that referenced this pull request Sep 9, 2026
…eact#37542)

A row that is still parsing can hand its partially built value to
references that were registered on it during that parse.
`initializeModelChunk` fulfilled every such listener, on the assumption
that all of them are cyclic references back into the parsing row. Only
some are. A listener from a nested parse that is not part of a cycle
belongs to a handler that does not wait on the parsing row, so
fulfilling it early completes that handler with an object that still has
references outstanding.

When that handler owns an element, `initializeElement` runs on
incomplete props. In DEV the props are frozen, so the write that arrives
later throws `Cannot assign to read only property`, and
`rejectReference` escalates the error into the rows that wait on the
element, up to the root. Only the debug tree can produce this shape. The
RSC stream writes element props inline, while the debug channel outlines
a props object that an element shares with its own componentInfo into a
separate row, and that row can still wait on a client module.

`resolveBlockedCycle` already tells a cyclic reference from any other,
but it returned `null` for mid-parse listeners because `handler.chunk`
was assigned after the drain loop. This change assigns it before the
loop and classifies each listener. A reference whose handler is
transitively waiting on the parsing row is a genuine cycle and receives
the value now, because neither side can complete before the other. Every
other listener is queued back on the parsing row and fulfilled when that
row completes, like any reference into a blocked row.

A row whose own parse fails used to hand the partial value to its
mid-parse listeners before it threw. It now errors through
`triggerErrorOnChunk`, which rejects them the same way a reference into
any other errored row is rejected. The `if (handler.errored) throw`
after the loop is removed. It also caught a rejection during the loop,
which now reaches `triggerErrorOnChunk` on its own because
`handler.chunk` is set.

One behaviour changes beyond the reported bug. When
`initializeDebugChunk` errors a chunk before `parseModel` runs, the old
code set `INITIALIZED` over that status if the model had no pending
references, and left it `ERRORED` otherwise. The chunk now stays
`ERRORED` in both cases. That is what the `triggerErrorOnChunk` call in
`initializeDebugChunk` intends, and the TODO above the `parseModel` call
already notes that the chunk can be `ERRORED` there.

PR react#37398 deferred `Object.freeze(element.props)` until the outstanding
references have resolved. That removes the exception but not the cause:
the element is still initialized on an incomplete object and is visible
through `_debugInfo` with a `null` placeholder until the late write
lands. With the early release fixed, the freeze needs no change.

**Alternatives Considered**

- Deferring every listener whose handler is not the parsing row's own
deadlocks `foo ↔ bar` in `can deduped outlined references inside
promises`. One side of a genuine cycle has to accept the partial object.
- Holding an element back while its props row is `BLOCKED` breaks
`should handle deduped props of re-used elements in fragments`, where
the row is blocked on an unrelated module and the props object itself is
complete.
- A per-object count of pending writes plus a reverse `dependents` edge
works, but adds a second dependency graph next to `deps` and
special-cases elements.

Fixes react#37361
Closes react#37398

DiffTrain build for [6c0e104](react@6c0e104)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Flight client (DEV) freezes element props before late-resolving references are written into them → "Cannot assign to read only property"

3 participants