@samitouri / QOS-React / commits / 6baff7ac76

[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 }