Skip to content

Commit f172fa7

Browse files
authored
[Flight] Detriplicate Objects (#27537)
Now that we no longer support Server Context, we can now deduplicate objects. It's not completely safe for useId but only in the same way as it's not safe if you reuse elements on the client, so it's not a new issue. This also solves cyclic object references. The issue is that we prefer to inline objects into a plain JSON format when an object is not going to get reused. In this case the object doesn't have an id. We could potentially serialize a reference to an existing model + a path to it but it bloats the format and complicates the client. In a smarter flush phase like we have in Fizz we could choose to inline or outline depending on what we've discovered so far before a flush. We can't do that here since we use native stringify. However, even in that solution you might not know that you're going to discover the same object later so it's not perfect deduping anyway. Instead, I use a heuristic where I mark previously seen objects and if I ever see that object again, then I'll outline it. The idea is that most objects are just going to be emitted once and if it's more than once it's fairly likely you have a shared reference to it somewhere and it might be more than two. The third object gets deduplicated (or "detriplicated"). It's not a perfect heuristic because when we write the second object we will have already visited all the nested objects inside of it, which causes us to outline every nested object too even those weren't reference more than by that parent. Not sure how to solve for that. If we for some other reason outline an object such as if it suspends, then it's truly deduplicated since it already has an id.
1 parent 2eeb9f9 commit f172fa7

File tree

4 files changed

+282
-55
lines changed

4 files changed

+282
-55
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type RowParserState = 0 | 1 | 2 | 3 | 4;
7272

7373
const PENDING = 'pending';
7474
const BLOCKED = 'blocked';
75+
const CYCLIC = 'cyclic';
7576
const RESOLVED_MODEL = 'resolved_model';
7677
const RESOLVED_MODULE = 'resolved_module';
7778
const INITIALIZED = 'fulfilled';
@@ -91,6 +92,13 @@ type BlockedChunk<T> = {
9192
_response: Response,
9293
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
9394
};
95+
type CyclicChunk<T> = {
96+
status: 'cyclic',
97+
value: null | Array<(T) => mixed>,
98+
reason: null | Array<(mixed) => mixed>,
99+
_response: Response,
100+
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
101+
};
94102
type ResolvedModelChunk<T> = {
95103
status: 'resolved_model',
96104
value: UninitializedModel,
@@ -122,6 +130,7 @@ type ErroredChunk<T> = {
122130
type SomeChunk<T> =
123131
| PendingChunk<T>
124132
| BlockedChunk<T>
133+
| CyclicChunk<T>
125134
| ResolvedModelChunk<T>
126135
| ResolvedModuleChunk<T>
127136
| InitializedChunk<T>
@@ -160,6 +169,7 @@ Chunk.prototype.then = function <T>(
160169
break;
161170
case PENDING:
162171
case BLOCKED:
172+
case CYCLIC:
163173
if (resolve) {
164174
if (chunk.value === null) {
165175
chunk.value = ([]: Array<(T) => mixed>);
@@ -211,6 +221,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
211221
return chunk.value;
212222
case PENDING:
213223
case BLOCKED:
224+
case CYCLIC:
214225
// eslint-disable-next-line no-throw-literal
215226
throw ((chunk: any): Thenable<T>);
216227
default:
@@ -259,6 +270,7 @@ function wakeChunkIfInitialized<T>(
259270
break;
260271
case PENDING:
261272
case BLOCKED:
273+
case CYCLIC:
262274
chunk.value = resolveListeners;
263275
chunk.reason = rejectListeners;
264276
break;
@@ -365,8 +377,19 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
365377
const prevBlocked = initializingChunkBlockedModel;
366378
initializingChunk = chunk;
367379
initializingChunkBlockedModel = null;
380+
381+
const resolvedModel = chunk.value;
382+
383+
// We go to the CYCLIC state until we've fully resolved this.
384+
// We do this before parsing in case we try to initialize the same chunk
385+
// while parsing the model. Such as in a cyclic reference.
386+
const cyclicChunk: CyclicChunk<T> = (chunk: any);
387+
cyclicChunk.status = CYCLIC;
388+
cyclicChunk.value = null;
389+
cyclicChunk.reason = null;
390+
368391
try {
369-
const value: T = parseModel(chunk._response, chunk.value);
392+
const value: T = parseModel(chunk._response, resolvedModel);
370393
if (
371394
initializingChunkBlockedModel !== null &&
372395
initializingChunkBlockedModel.deps > 0
@@ -379,9 +402,13 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
379402
blockedChunk.value = null;
380403
blockedChunk.reason = null;
381404
} else {
405+
const resolveListeners = cyclicChunk.value;
382406
const initializedChunk: InitializedChunk<T> = (chunk: any);
383407
initializedChunk.status = INITIALIZED;
384408
initializedChunk.value = value;
409+
if (resolveListeners !== null) {
410+
wakeChunk(resolveListeners, value);
411+
}
385412
}
386413
} catch (error) {
387414
const erroredChunk: ErroredChunk<T> = (chunk: any);
@@ -491,15 +518,18 @@ function createModelResolver<T>(
491518
chunk: SomeChunk<T>,
492519
parentObject: Object,
493520
key: string,
521+
cyclic: boolean,
494522
): (value: any) => void {
495523
let blocked;
496524
if (initializingChunkBlockedModel) {
497525
blocked = initializingChunkBlockedModel;
498-
blocked.deps++;
526+
if (!cyclic) {
527+
blocked.deps++;
528+
}
499529
} else {
500530
blocked = initializingChunkBlockedModel = {
501-
deps: 1,
502-
value: null,
531+
deps: cyclic ? 0 : 1,
532+
value: (null: any),
503533
};
504534
}
505535
return value => {
@@ -673,9 +703,15 @@ function parseModelString(
673703
return chunk.value;
674704
case PENDING:
675705
case BLOCKED:
706+
case CYCLIC:
676707
const parentChunk = initializingChunk;
677708
chunk.then(
678-
createModelResolver(parentChunk, parentObject, key),
709+
createModelResolver(
710+
parentChunk,
711+
parentObject,
712+
key,
713+
chunk.status === CYCLIC,
714+
),
679715
createModelReject(parentChunk),
680716
);
681717
return null;

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,13 @@ describe('ReactFlight', () => {
374374
});
375375

376376
it('can transport Map', async () => {
377-
function ComponentClient({prop}) {
377+
function ComponentClient({prop, selected}) {
378378
return `
379379
map: ${prop instanceof Map}
380380
size: ${prop.size}
381381
greet: ${prop.get('hi').greet}
382382
content: ${JSON.stringify(Array.from(prop))}
383+
selected: ${prop.get(selected)}
383384
`;
384385
}
385386
const Component = clientReference(ComponentClient);
@@ -389,7 +390,7 @@ describe('ReactFlight', () => {
389390
['hi', {greet: 'world'}],
390391
[objKey, 123],
391392
]);
392-
const model = <Component prop={map} />;
393+
const model = <Component prop={map} selected={objKey} />;
393394

394395
const transport = ReactNoopFlightServer.render(model);
395396

@@ -402,23 +403,25 @@ describe('ReactFlight', () => {
402403
size: 2
403404
greet: world
404405
content: [["hi",{"greet":"world"}],[{"obj":"key"},123]]
406+
selected: 123
405407
`);
406408
});
407409

408410
it('can transport Set', async () => {
409-
function ComponentClient({prop}) {
411+
function ComponentClient({prop, selected}) {
410412
return `
411413
set: ${prop instanceof Set}
412414
size: ${prop.size}
413415
hi: ${prop.has('hi')}
414416
content: ${JSON.stringify(Array.from(prop))}
417+
selected: ${prop.has(selected)}
415418
`;
416419
}
417420
const Component = clientReference(ComponentClient);
418421

419422
const objKey = {obj: 'key'};
420423
const set = new Set(['hi', objKey]);
421-
const model = <Component prop={set} />;
424+
const model = <Component prop={set} selected={objKey} />;
422425

423426
const transport = ReactNoopFlightServer.render(model);
424427

@@ -431,9 +434,27 @@ describe('ReactFlight', () => {
431434
size: 2
432435
hi: true
433436
content: ["hi",{"obj":"key"}]
437+
selected: true
434438
`);
435439
});
436440

441+
it('can transport cyclic objects', async () => {
442+
function ComponentClient({prop}) {
443+
expect(prop.obj.obj.obj).toBe(prop.obj.obj);
444+
}
445+
const Component = clientReference(ComponentClient);
446+
447+
const cyclic = {obj: null};
448+
cyclic.obj = cyclic;
449+
const model = <Component prop={cyclic} />;
450+
451+
const transport = ReactNoopFlightServer.render(model);
452+
453+
await act(async () => {
454+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
455+
});
456+
});
457+
437458
it('can render a lazy component as a shared component on the server', async () => {
438459
function SharedComponent({text}) {
439460
return (

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,65 @@ describe('ReactFlightDOMEdge', () => {
183183
expect(result.text2).toBe(testString2);
184184
});
185185

186+
it('should encode repeated objects in a compact format by deduping', async () => {
187+
const obj = {
188+
this: {is: 'a large objected'},
189+
with: {many: 'properties in it'},
190+
};
191+
const props = {
192+
items: new Array(30).fill(obj),
193+
};
194+
const stream = ReactServerDOMServer.renderToReadableStream(props);
195+
const [stream1, stream2] = passThrough(stream).tee();
196+
197+
const serializedContent = await readResult(stream1);
198+
expect(serializedContent.length).toBeLessThan(400);
199+
200+
const result = await ReactServerDOMClient.createFromReadableStream(
201+
stream2,
202+
{
203+
ssrManifest: {
204+
moduleMap: null,
205+
moduleLoading: null,
206+
},
207+
},
208+
);
209+
// Should still match the result when parsed
210+
expect(result).toEqual(props);
211+
expect(result.items[5]).toBe(result.items[10]); // two random items are the same instance
212+
// TODO: items[0] is not the same as the others in this case
213+
});
214+
215+
it('should execute repeated server components only once', async () => {
216+
const str = 'this is a long return value';
217+
let timesRendered = 0;
218+
function ServerComponent() {
219+
timesRendered++;
220+
return str;
221+
}
222+
const element = <ServerComponent />;
223+
const children = new Array(30).fill(element);
224+
const resolvedChildren = new Array(30).fill(str);
225+
const stream = ReactServerDOMServer.renderToReadableStream(children);
226+
const [stream1, stream2] = passThrough(stream).tee();
227+
228+
const serializedContent = await readResult(stream1);
229+
expect(serializedContent.length).toBeLessThan(400);
230+
expect(timesRendered).toBeLessThan(5);
231+
232+
const result = await ReactServerDOMClient.createFromReadableStream(
233+
stream2,
234+
{
235+
ssrManifest: {
236+
moduleMap: null,
237+
moduleLoading: null,
238+
},
239+
},
240+
);
241+
// Should still match the result when parsed
242+
expect(result).toEqual(resolvedChildren);
243+
});
244+
186245
// @gate enableBinaryFlight
187246
it('should be able to serialize any kind of typed array', async () => {
188247
const buffer = new Uint8Array([

0 commit comments

Comments
 (0)