[Flight] Allow cyclic references to be serialized when unwrapping lazy elements (#35471)
When `renderModelDestructive` unwraps a lazy element and subsequently calls `renderModelDestructive` again with the resolved model, we should preserve the parent connection so that cyclic references can be serialized properly. This can occur in an advanced scenario where the result from the Flight Client is serialized again with the Flight Server, e.g. for slicing a precomputed payload into multiple parts. Note: The added test only fails when run with `--prod`. In dev mode, the component info outlining prevents the issue from occurring.
Hendrik Liebau committed
Jan 16, 2026 at 18:42 UTC
6baff7ac763c475087ab5ebf7eef5d0b9f4436df
2 files changed
+70
-2
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+68
@@ -321,6 +321,74 @@ describe('ReactFlightDOMEdge', () => {
321
expect(result).toEqual('<span>Client Component</span>');
322
});
323
324
+ it('should resolve cyclic references in client component props after two rounds of serialization and deserialization', async () => {
325
+ const ClientComponent = clientExports(function ClientComponent({data}) {
326
+ return (
327
+ <div>{data.self === data ? 'Cycle resolved' : 'Cycle broken'}</div>
328
+ );
329
+ });
330
+ const clientModuleMetadata = webpackMap[ClientComponent.$$id];
331
+ const consumerModuleId = 'consumer-' + clientModuleMetadata.id;
332
+ const clientReference = Object.defineProperties(ClientComponent, {
333
+ $$typeof: {value: Symbol.for('react.client.reference')},
334
+ $$id: {value: ClientComponent.$$id},
335
+ });
336
+ webpackModules[consumerModuleId] = clientReference;
337
+
338
+ const cyclic = {self: null};
339
+ cyclic.self = cyclic;
340
+
341
+ const stream1 = ReactServerDOMServer.renderToReadableStream(
342
+ <React.Fragment key="this-key-is-important-to-repro-a-prior-cycle-serialization-bug">
343
+ <ClientComponent data={cyclic} />
344
+ </React.Fragment>,
345
+ webpackMap,
346
+ );
347
+
348
+ const promise = ReactServerDOMClient.createFromReadableStream(stream1, {
349
+ serverConsumerManifest: {
350
+ moduleMap: {
351
+ [clientModuleMetadata.id]: {
352
+ '*': {
353
+ id: consumerModuleId,
354
+ chunks: [],
355
+ name: '*',
356
+ },
357
+ },
358
+ },
359
+ moduleLoading: webpackModuleLoading,
360
+ serverModuleMap: null,
361
+ },
362
+ });
363
+
364
+ const errors = [];
365
+ const stream2 = await serverAct(() =>
366
+ ReactServerDOMServer.renderToReadableStream(promise, webpackMap, {
367
+ onError(error) {
368
+ errors.push(error);
369
+ },
370
+ }),
371
+ );
372
+
373
+ expect(errors).toEqual([]);
374
+
375
+ const element = await serverAct(() =>
376
+ ReactServerDOMClient.createFromReadableStream(stream2, {
377
+ serverConsumerManifest: {
378
+ moduleMap: null,
379
+ moduleLoading: null,
380
+ },
381
+ }),
382
+ );
383
+
384
+ const ssrStream = await serverAct(() =>
385
+ ReactDOMServer.renderToReadableStream(element),
386
+ );
387
+ const result = await readResult(ssrStream);
388
+
389
+ expect(result).toBe('<div>Cycle resolved</div>');
390
+ });
391
+
392
it('should be able to load a server reference on a consuming server if a mapping exists', async () => {
393
function greet(name) {
394
return 'hi, ' + name;
packages/react-server/src/ReactFlightServer.js
+2
-2
@@ -3622,8 +3622,8 @@ function renderModelDestructive(
3622
return renderModelDestructive(
3623
request,
3624
task,
3625
- emptyRoot,
3626
- '',
3625
+ parent,
3626
+ parentPropertyName,
3627
resolvedModel,
3628
);
3629
}