facebook / react · Issue No. 37619
Cyclic references inside Map/Set values are silently corrupted to null when serialized with React Server Components (Flight) and parsed on the client. Cyclic references in plain objects/arrays work fine (and are officially tested), so the Map/Set case is a data-corruption bug, not a "unsupported input" case.
Affected versions: tested on react-server-dom-webpack@19.3.0 (latest stable).
// server
import {renderToPipeableStream} from 'react-server-dom-webpack/server.node';
// client
import {createFromNodeStream} from 'react-server-dom-webpack/client';
// Case 1: Map value self-cycle
const m = new Map();
m.set('self', m);
// Case 2: Set self-cycle
const s = new Set();
s.add(s);
// Case 3: Set contains an object that references the Set back
const s2 = new Set();
const o = {back: s2};
s2.add(o);
const model = {m, s, s2};
// renderToPipeableStream(model, webpackMap) -> stream
// createFromNodeStream(stream, webpackMap)
Result (client side):
result.m.get('self') === null; // expected: === m (the Map itself)
[...result.s][0] === null; // expected: === s (the Set itself)
[...result.s2][0].back === null; // expected: === s2 (the Set itself)
Note: m.get('self') returns null, not m. No error is thrown — the cycle is silently lost.
The same shapes with plain objects and arrays round-trip correctly (React officially supports cyclic objects/arrays):
const cycObj = {name: 'x'};
cycObj.self = cycObj; // ok, back: cycObj.self === cycObj
const cycArr = [1];
cycArr.push(cycArr); // ok
// A Map whose value points to an independent (non-cyclic) object path also works:
const foo = {};
const set = new Set([foo]);
foo.bar = set; // ok: set contains foo, foo.bar === set
The failing cases all have one thing in common: the cycle references back to the Map/Set's own serialization position (either the Map/Set is the root, or the reference goes through the property that holds the Map/Set, e.g. $0, $0:s, $0:m in the wire format).
serializeMap/serializeSet (packages/react-server/src/ReactFlightServer.js) outline the entries into a separate chunk/row:
function serializeMap(request, map) {
const entries = Array.from(map);
const id = outlineModel(request, entries);
return '$Q' + id.toString(16);
}
Wire format for {m: m} where m.set('self', m):
0:{"m":"$Q1"}
1:[["self","$0:m"]]
The client parses row 1 (the Map entries) while row 0 (the root) is still blocked waiting for row 1; row 1 references $0:m which requires row 0 to be initialized. This cycle is not broken by the client's cycle-handling logic, so the reference resolves to null. Plain objects/arrays are inlined into the parent row and go through the reviver/reify path, where cyclic references are handled correctly.
Related: #37542 fixed cyclic references for the case where the reference points to an independently-resolvable object (e.g. Set([foo]) + foo.bar = set + Promise-wrapped rows). The Map/Set cases above (references that close back onto the Map/Set's own row) are still broken after that fix.
Cyclic references inside Map/Set should resolve to the actual Map/Set instance, consistent with cyclic plain objects/arrays. At minimum, a corrupted cycle should not silently become null.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.