@samitouri / QOS-React / commits / b07aa7d643

[Flight] Fix `encodeReply` for JSX with temporary references (#35730)

`encodeReply` throws "React Element cannot be passed to Server Functions from the Client without a temporary reference set" when a React element is the root value of a `serializeModel` call (either passed directly or resolved from a promise), even when a temporary reference set is provided. The cause is that `resolveToJSON` hits the `REACT_ELEMENT_TYPE` switch case before reaching the `existingReference`/`modelRoot` check that regular objects benefit from. The synthetic JSON root created by `JSON.stringify` is never tracked in `writtenObjects`, so `parentReference` is `undefined` and the code falls through to the throw. This adds a `modelRoot` check in the `REACT_ELEMENT_TYPE` case, following the same pattern used for promises and plain objects. The added `JSX as root model` test also uncovered a pre-existing crash in the Flight Client: when the JSX element round-trips back, it arrives as a frozen object (client-created elements are frozen in DEV), and `Object.defineProperty` for `_debugInfo` fails because frozen objects are non-configurable. The same crash can occur with JSX exported as a client reference. For now, we're adding `!Object.isFrozen()` guards in `moveDebugInfoFromChunkToInnerValue` and `addAsyncInfo` to prevent the crash, which means debug info is silently dropped for frozen elements. The proper fix would likely be to clone the element so each rendering context gets its own mutable copy with correct debug info. closes #34984 closes #35690

Hendrik Liebau committed Feb 9, 2026 at 16:17 UTC b07aa7d643ec9028e452612c3ff2c17a6cee6bb7
4 files changed +142 -2
packages/react-client/src/ReactFlightClient.js
+9 -2
@@ -552,7 +552,7 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552 resolvedValue._debugInfo,
553 debugInfo,
554 );
555 - } else {
555 + } else if (!Object.isFrozen(resolvedValue)) {
556 Object.defineProperty((resolvedValue: any), '_debugInfo', {
557 configurable: false,
558 enumerable: false,
@@ -560,6 +560,11 @@ function moveDebugInfoFromChunkToInnerValue<T>(
560 value: debugInfo,
561 });
562 }
563 + // TODO: If the resolved value is a frozen element (e.g. a client-created
564 + // element from a temporary reference, or a JSX element exported as a client
565 + // reference), server debug info is currently dropped because the element
566 + // can't be mutated. We should probably clone the element so each rendering
567 + // context gets its own mutable copy with the correct debug info.
568 }
569 }
570
@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
2905 if (isArray(value._debugInfo)) {
2906 // $FlowFixMe[method-unbinding]
2907 value._debugInfo.push(asyncInfo);
2903 - } else {
2908 + } else if (!Object.isFrozen(value)) {
2909 + // TODO: Debug info is dropped for frozen elements. See the TODO in
2910 + // moveDebugInfoFromChunkToInnerValue.
2911 Object.defineProperty((value: any), '_debugInfo', {
2912 configurable: false,
2913 enumerable: false,
packages/react-client/src/ReactFlightReplyClient.js
+8
@@ -429,6 +429,14 @@ export function processReply(
429 return serializeTemporaryReferenceMarker();
430 }
431 }
432 + // This element is the root of a serializeModel call (e.g. JSX
433 + // passed directly to encodeReply, or a promise that resolved to
434 + // JSX). It was already registered as a temporary reference by
435 + // serializeModel so we just need to emit the marker.
436 + if (temporaryReferences !== undefined && modelRoot === value) {
437 + modelRoot = null;
438 + return serializeTemporaryReferenceMarker();
439 + }
440 throw new Error(
441 'React Element cannot be passed to Server Functions from the Client without a ' +
442 'temporary reference set. Pass a TemporaryReferenceSet to the options.' +
packages/react-client/src/__tests__/ReactFlight-test.js
+57
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
3941 const model = await ReactNoopFlightClient.read(transport);
3942 expect(model.element.key).toBe(React.optimisticKey);
3943 });
3944 +
3945 + it('can use a JSX element exported as a client reference in multiple server components', async () => {
3946 + const ClientReference = clientReference(React.createElement('span'));
3947 +
3948 + function Foo() {
3949 + return ClientReference;
3950 + }
3951 +
3952 + function Bar() {
3953 + return ClientReference;
3954 + }
3955 +
3956 + function App() {
3957 + return ReactServer.createElement(
3958 + 'div',
3959 + null,
3960 + ReactServer.createElement(Foo),
3961 + ReactServer.createElement(Bar),
3962 + );
3963 + }
3964 +
3965 + const transport = ReactNoopFlightServer.render(
3966 + ReactServer.createElement(App),
3967 + );
3968 +
3969 + await act(async () => {
3970 + const result = await ReactNoopFlightClient.read(transport);
3971 + ReactNoop.render(result);
3972 +
3973 + if (__DEV__) {
3974 + // TODO: Debug info is dropped for frozen elements (client-created JSX
3975 + // exported as a client reference in this case). Ideally we'd clone the
3976 + // element so that each context gets its own mutable copy with correct
3977 + // debug info. When fixed, foo should have Foo's debug info and bar should
3978 + // have Bar's debug info.
3979 + const [foo, bar] = result.props.children;
3980 + expect(getDebugInfo(foo)).toBe(null);
3981 + expect(getDebugInfo(bar)).toBe(null);
3982 + }
3983 + });
3984 +
3985 + // TODO: With cloning, each context would get its own element copy, so this
3986 + // key warning should go away.
3987 + assertConsoleErrorDev([
3988 + 'Each child in a list should have a unique "key" prop.\n\n' +
3989 + 'Check the top-level render call using <div>. ' +
3990 + 'See https://react.dev/link/warning-keys for more information.\n' +
3991 + ' in span (at **)',
3992 + ]);
3993 +
3994 + expect(ReactNoop).toMatchRenderedOutput(
3995 + <div>
3996 + <span />
3997 + <span />
3998 + </div>,
3999 + );
4000 + });
4001 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+68
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394 expect(response.children).toBe(children);
395 });
396
397 + it('can pass JSX as root model through a round trip using temporary references', async () => {
398 + const jsx = <div />;
399 +
400 + const temporaryReferences =
401 + ReactServerDOMClient.createTemporaryReferenceSet();
402 + const body = await ReactServerDOMClient.encodeReply(jsx, {
403 + temporaryReferences,
404 + });
405 +
406 + const temporaryReferencesServer =
407 + ReactServerDOMServer.createTemporaryReferenceSet();
408 + const serverPayload = await ReactServerDOMServer.decodeReply(
409 + body,
410 + webpackServerMap,
411 + {temporaryReferences: temporaryReferencesServer},
412 + );
413 + const stream = await serverAct(() =>
414 + ReactServerDOMServer.renderToReadableStream(serverPayload, null, {
415 + temporaryReferences: temporaryReferencesServer,
416 + }),
417 + );
418 + const response = await ReactServerDOMClient.createFromReadableStream(
419 + stream,
420 + {
421 + temporaryReferences,
422 + },
423 + );
424 +
425 + // This should be the same reference that we already saw.
426 + await expect(response).toBe(jsx);
427 + });
428 +
429 + it('can pass a promise that resolves to JSX through a round trip using temporary references', async () => {
430 + const jsx = <div />;
431 + const promise = Promise.resolve(jsx);
432 +
433 + const temporaryReferences =
434 + ReactServerDOMClient.createTemporaryReferenceSet();
435 + const body = await ReactServerDOMClient.encodeReply(
436 + {promise},
437 + {
438 + temporaryReferences,
439 + },
440 + );
441 +
442 + const temporaryReferencesServer =
443 + ReactServerDOMServer.createTemporaryReferenceSet();
444 + const serverPayload = await ReactServerDOMServer.decodeReply(
445 + body,
446 + webpackServerMap,
447 + {temporaryReferences: temporaryReferencesServer},
448 + );
449 + const stream = await serverAct(() =>
450 + ReactServerDOMServer.renderToReadableStream(serverPayload, null, {
451 + temporaryReferences: temporaryReferencesServer,
452 + }),
453 + );
454 + const response = await ReactServerDOMClient.createFromReadableStream(
455 + stream,
456 + {
457 + temporaryReferences,
458 + },
459 + );
460 +
461 + // This should resolve to the same reference that we already saw.
462 + await expect(response.promise).resolves.toBe(jsx);
463 + });
464 +
465 it('can return the same object using temporary references', async () => {
466 const obj = {
467 this: {is: 'a large object'},