-
Notifications
You must be signed in to change notification settings - Fork 50.8k
Description
React version: 19.3.0-canary (tested from f93b9fd4-20251217 through b4546cd0-20260318)
Steps To Reproduce
- Run the standalone reproduction:
git clone https://github.com/Gyeonghun-Park/react-flight-debugchannel-repro
cd react-flight-debugchannel-repro
npm install
npm test- Observe the output:
React 19.3.0-canary-f93b9fd4-20251217 (NODE_ENV=development)
Without debugChannel:
isValidElement: true
$$typeof: Symbol(react.transitional.element)
With debugChannel (debug data delayed 50 ms):
isValidElement: false
$$typeof: Symbol(react.lazy)
✗ Bug reproduced: Flight Client returned a lazy wrapper
instead of a React element when debugChannel is enabled.
Link to code example: https://github.com/Gyeonghun-Park/react-flight-debugchannel-repro
The test renders <ClientComponent><div>hello</div></ClientComponent> via Flight Server, deserializes it via Flight Client, then checks isValidElement() on the result. A 50ms delay on the debug channel stream simulates real-world conditions where debug info is delivered via a separate transport (e.g. WebSocket).
Real-world reproduction (Next.js)
This issue was originally discovered via Next.js 16.2.0, which recently changed the default of experimental.reactDebugChannel from false to true. A visual reproduction with browser hydration is available at:
https://github.com/Gyeonghun-Park/next-isvalidelement-repro
Setting experimental: { reactDebugChannel: false } in next.config.ts immediately resolves the issue, which is what led us to identify debugChannel as the trigger.
The current behavior
When debugChannel is passed to createFromNodeStream (or createFromReadableStream) and the debug data arrives after the element data, deserialized React elements are wrapped in createLazyChunkWrapper with $$typeof: Symbol(react.lazy). As a result:
React.isValidElement()returnsfalseReact.cloneElement()throws- Hydration mismatches occur (SSR renders with
true, client re-renders withfalse)
This affects any code that calls isValidElement() or cloneElement() on children crossing the RSC boundary, including patterns like Radix UI's asChild / Slot.
The issue is dev mode only — production builds are unaffected.
The expected behavior
Elements deserialized by the Flight Client should be recognized by isValidElement() regardless of whether debugChannel is enabled. Dev-only debug metadata (owner/stack at element positions 4/5) should not change the observable type of deserialized elements.
Analysis
Note: This analysis is based on reading the compiled Flight Client source code. I may have some details wrong — happy to be corrected.
In ReactFlightClient.js, waitForReference has a guard that normally skips incrementing deps for pending debug-only element fields (positions 4 and 5):
function waitForReference(referencedChunk, parentObject, key, response, ...) {
if (!(
(response._debugChannel && response._debugChannel.hasReadable) ||
"pending" !== referencedChunk.status ||
parentObject[0] !== REACT_ELEMENT_TYPE ||
("4" !== key && "5" !== key)
))
return null; // skip — don't increment deps
initializingHandler.deps++;
}When debugChannel is active (hasReadable = true), the first condition short-circuits the guard, so deps gets incremented for all pending references — including the dev-only debug fields. Later, during element construction, deps > 0 causes the element to be wrapped in createLazyChunkWrapper instead of being returned directly.
With debugChannel, the Flight Server sends debug info on a separate stream. In real-world streaming (e.g. Next.js delivers debug data via WebSocket), this data can arrive after the main element data. When the Flight Client processes the element chunk, the debug info chunks at positions 4/5 are still "pending" — and with the guard bypassed, this causes lazy wrapping.
Production builds exclude positions 4/5 entirely, so deps stays 0 and elements are returned directly.
Additional confirmation
Patching the compiled Flight Client in Next.js to skip the if (0 < key.deps) block resolved the issue entirely — all isValidElement() calls returned true and hydration succeeded without mismatches. (Obviously not a real fix, just validation of the code path.)
Tested canaries
| Canary | Without debugChannel |
With debugChannel + delay |
|---|---|---|
19.3.0-canary-f93b9fd4-20251217 |
✅ works | ❌ lazy wrapper |
19.3.0-canary-3a2bee26-20260218 |
✅ works | ❌ lazy wrapper |
19.3.0-canary-2ba30655-20260219 |
✅ works | ❌ lazy wrapper |
19.3.0-canary-3f0b9e61-20260317 |
✅ works | ❌ lazy wrapper |
19.3.0-canary-b4546cd0-20260318 |
✅ works | ❌ lazy wrapper |
The behavior is consistent across all tested versions, which suggests this is a pre-existing issue in the debugChannel code path rather than a regression from a specific PR.
Environment
- Dev mode only (production unaffected)
- Reproducible with both Turbopack and Webpack (via Next.js)
- Node.js 24.14.0, macOS arm64
Thank you for your time! Happy to provide additional information, test patches, or adjust the reproduction if needed.