Skip to content

Bug: Flight Client wraps elements in lazy chunks when debugChannel is enabled, causing isValidElement() to return false #36097

@Gyeonghun-Park

Description

@Gyeonghun-Park

React version: 19.3.0-canary (tested from f93b9fd4-20251217 through b4546cd0-20260318)

Steps To Reproduce

  1. Run the standalone reproduction:
git clone https://github.com/Gyeonghun-Park/react-flight-debugchannel-repro
cd react-flight-debugchannel-repro
npm install
npm test
  1. 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() returns false
  • React.cloneElement() throws
  • Hydration mismatches occur (SSR renders with true, client re-renders with false)

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions