Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [Flight] Fix `encodeReply` for JSX with temporary references (#35730) · react/react@b07aa7d · GitHub
Skip to content

Commit b07aa7d

Browse files
authored
[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#34984closes#35690
1 parent 2dd9b7c commit b07aa7d

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,14 +552,19 @@ function moveDebugInfoFromChunkToInnerValue<T>(
552552
resolvedValue._debugInfo,
553553
debugInfo,
554554
);
555-
} else {
555+
} else if (!Object.isFrozen(resolvedValue)) {
556556
Object.defineProperty((resolvedValue: any),'_debugInfo',{
557557
configurable: false,
558558
enumerable: false,
559559
writable: true,
560560
value: debugInfo,
561561
});
562562
}
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.
563568
}
564569
}
565570

@@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
29002905
if(isArray(value._debugInfo)){
29012906
// $FlowFixMe[method-unbinding]
29022907
value._debugInfo.push(asyncInfo);
2903-
}else{
2908+
}elseif(!Object.isFrozen(value)){
2909+
// TODO: Debug info is dropped for frozen elements. See the TODO in
2910+
// moveDebugInfoFromChunkToInnerValue.
29042911
Object.defineProperty((value: any),'_debugInfo',{
29052912
configurable: false,
29062913
enumerable: false,

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ export function processReply(
429429
returnserializeTemporaryReferenceMarker();
430430
}
431431
}
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+
returnserializeTemporaryReferenceMarker();
439+
}
432440
thrownewError(
433441
'React Element cannot be passed to Server Functions from the Client without a '+
434442
'temporary reference set. Pass a TemporaryReferenceSet to the options.'+

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3941,4 +3941,61 @@ describe('ReactFlight', () => {
39413941
constmodel=awaitReactNoopFlightClient.read(transport);
39423942
expect(model.element.key).toBe(React.optimisticKey);
39433943
});
3944+
3945+
it('can use a JSX element exported as a client reference in multiple server components',async()=>{
3946+
constClientReference=clientReference(React.createElement('span'));
3947+
3948+
functionFoo(){
3949+
returnClientReference;
3950+
}
3951+
3952+
functionBar(){
3953+
returnClientReference;
3954+
}
3955+
3956+
functionApp(){
3957+
returnReactServer.createElement(
3958+
'div',
3959+
null,
3960+
ReactServer.createElement(Foo),
3961+
ReactServer.createElement(Bar),
3962+
);
3963+
}
3964+
3965+
consttransport=ReactNoopFlightServer.render(
3966+
ReactServer.createElement(App),
3967+
);
3968+
3969+
awaitact(async()=>{
3970+
constresult=awaitReactNoopFlightClient.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+
});
39444001
});

‎packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => {
394394
expect(response.children).toBe(children);
395395
});
396396

397+
it('can pass JSX as root model through a round trip using temporary references',async()=>{
398+
constjsx=<div/>;
399+
400+
consttemporaryReferences=
401+
ReactServerDOMClient.createTemporaryReferenceSet();
402+
constbody=awaitReactServerDOMClient.encodeReply(jsx,{
403+
temporaryReferences,
404+
});
405+
406+
consttemporaryReferencesServer=
407+
ReactServerDOMServer.createTemporaryReferenceSet();
408+
constserverPayload=awaitReactServerDOMServer.decodeReply(
409+
body,
410+
webpackServerMap,
411+
{temporaryReferences: temporaryReferencesServer},
412+
);
413+
conststream=awaitserverAct(()=>
414+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
415+
temporaryReferences: temporaryReferencesServer,
416+
}),
417+
);
418+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
419+
stream,
420+
{
421+
temporaryReferences,
422+
},
423+
);
424+
425+
// This should be the same reference that we already saw.
426+
awaitexpect(response).toBe(jsx);
427+
});
428+
429+
it('can pass a promise that resolves to JSX through a round trip using temporary references',async()=>{
430+
constjsx=<div/>;
431+
constpromise=Promise.resolve(jsx);
432+
433+
consttemporaryReferences=
434+
ReactServerDOMClient.createTemporaryReferenceSet();
435+
constbody=awaitReactServerDOMClient.encodeReply(
436+
{promise},
437+
{
438+
temporaryReferences,
439+
},
440+
);
441+
442+
consttemporaryReferencesServer=
443+
ReactServerDOMServer.createTemporaryReferenceSet();
444+
constserverPayload=awaitReactServerDOMServer.decodeReply(
445+
body,
446+
webpackServerMap,
447+
{temporaryReferences: temporaryReferencesServer},
448+
);
449+
conststream=awaitserverAct(()=>
450+
ReactServerDOMServer.renderToReadableStream(serverPayload,null,{
451+
temporaryReferences: temporaryReferencesServer,
452+
}),
453+
);
454+
constresponse=awaitReactServerDOMClient.createFromReadableStream(
455+
stream,
456+
{
457+
temporaryReferences,
458+
},
459+
);
460+
461+
// This should resolve to the same reference that we already saw.
462+
awaitexpect(response.promise).resolves.toBe(jsx);
463+
});
464+
397465
it('can return the same object using temporary references',async()=>{
398466
constobj={
399467
this: {is: 'a large object'},

0 commit comments

Comments
 (0)