Skip to content

Commit

Permalink
[Flight] Detriplicate Objects (facebook#27537)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored and AndyPengc12 committed Apr 15, 2024
1 parent 1007b8e commit 50429f5
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 55 deletions.
46 changes: 41 additions & 5 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type RowParserState = 0 | 1 | 2 | 3 | 4;

const PENDING = 'pending';
const BLOCKED = 'blocked';
const CYCLIC = 'cyclic';
const RESOLVED_MODEL = 'resolved_model';
const RESOLVED_MODULE = 'resolved_module';
const INITIALIZED = 'fulfilled';
Expand All @@ -91,6 +92,13 @@ type BlockedChunk<T> = {
_response: Response,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type CyclicChunk<T> = {
status: 'cyclic',
value: null | Array<(T) => mixed>,
reason: null | Array<(mixed) => mixed>,
_response: Response,
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
status: 'resolved_model',
value: UninitializedModel,
Expand Down Expand Up @@ -122,6 +130,7 @@ type ErroredChunk<T> = {
type SomeChunk<T> =
| PendingChunk<T>
| BlockedChunk<T>
| CyclicChunk<T>
| ResolvedModelChunk<T>
| ResolvedModuleChunk<T>
| InitializedChunk<T>
Expand Down Expand Up @@ -160,6 +169,7 @@ Chunk.prototype.then = function <T>(
break;
case PENDING:
case BLOCKED:
case CYCLIC:
if (resolve) {
if (chunk.value === null) {
chunk.value = ([]: Array<(T) => mixed>);
Expand Down Expand Up @@ -211,6 +221,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
return chunk.value;
case PENDING:
case BLOCKED:
case CYCLIC:
// eslint-disable-next-line no-throw-literal
throw ((chunk: any): Thenable<T>);
default:
Expand Down Expand Up @@ -259,6 +270,7 @@ function wakeChunkIfInitialized<T>(
break;
case PENDING:
case BLOCKED:
case CYCLIC:
chunk.value = resolveListeners;
chunk.reason = rejectListeners;
break;
Expand Down Expand Up @@ -365,8 +377,19 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
const prevBlocked = initializingChunkBlockedModel;
initializingChunk = chunk;
initializingChunkBlockedModel = null;

const resolvedModel = chunk.value;

// We go to the CYCLIC state until we've fully resolved this.
// We do this before parsing in case we try to initialize the same chunk
// while parsing the model. Such as in a cyclic reference.
const cyclicChunk: CyclicChunk<T> = (chunk: any);
cyclicChunk.status = CYCLIC;
cyclicChunk.value = null;
cyclicChunk.reason = null;

try {
const value: T = parseModel(chunk._response, chunk.value);
const value: T = parseModel(chunk._response, resolvedModel);
if (
initializingChunkBlockedModel !== null &&
initializingChunkBlockedModel.deps > 0
Expand All @@ -379,9 +402,13 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
blockedChunk.value = null;
blockedChunk.reason = null;
} else {
const resolveListeners = cyclicChunk.value;
const initializedChunk: InitializedChunk<T> = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, value);
}
}
} catch (error) {
const erroredChunk: ErroredChunk<T> = (chunk: any);
Expand Down Expand Up @@ -491,15 +518,18 @@ function createModelResolver<T>(
chunk: SomeChunk<T>,
parentObject: Object,
key: string,
cyclic: boolean,
): (value: any) => void {
let blocked;
if (initializingChunkBlockedModel) {
blocked = initializingChunkBlockedModel;
blocked.deps++;
if (!cyclic) {
blocked.deps++;
}
} else {
blocked = initializingChunkBlockedModel = {
deps: 1,
value: null,
deps: cyclic ? 0 : 1,
value: (null: any),
};
}
return value => {
Expand Down Expand Up @@ -673,9 +703,15 @@ function parseModelString(
return chunk.value;
case PENDING:
case BLOCKED:
case CYCLIC:
const parentChunk = initializingChunk;
chunk.then(
createModelResolver(parentChunk, parentObject, key),
createModelResolver(
parentChunk,
parentObject,
key,
chunk.status === CYCLIC,
),
createModelReject(parentChunk),
);
return null;
Expand Down
29 changes: 25 additions & 4 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,13 @@ describe('ReactFlight', () => {
});

it('can transport Map', async () => {
function ComponentClient({prop}) {
function ComponentClient({prop, selected}) {
return `
map: ${prop instanceof Map}
size: ${prop.size}
greet: ${prop.get('hi').greet}
content: ${JSON.stringify(Array.from(prop))}
selected: ${prop.get(selected)}
`;
}
const Component = clientReference(ComponentClient);
Expand All @@ -389,7 +390,7 @@ describe('ReactFlight', () => {
['hi', {greet: 'world'}],
[objKey, 123],
]);
const model = <Component prop={map} />;
const model = <Component prop={map} selected={objKey} />;

const transport = ReactNoopFlightServer.render(model);

Expand All @@ -402,23 +403,25 @@ describe('ReactFlight', () => {
size: 2
greet: world
content: [["hi",{"greet":"world"}],[{"obj":"key"},123]]
selected: 123
`);
});

it('can transport Set', async () => {
function ComponentClient({prop}) {
function ComponentClient({prop, selected}) {
return `
set: ${prop instanceof Set}
size: ${prop.size}
hi: ${prop.has('hi')}
content: ${JSON.stringify(Array.from(prop))}
selected: ${prop.has(selected)}
`;
}
const Component = clientReference(ComponentClient);

const objKey = {obj: 'key'};
const set = new Set(['hi', objKey]);
const model = <Component prop={set} />;
const model = <Component prop={set} selected={objKey} />;

const transport = ReactNoopFlightServer.render(model);

Expand All @@ -431,9 +434,27 @@ describe('ReactFlight', () => {
size: 2
hi: true
content: ["hi",{"obj":"key"}]
selected: true
`);
});

it('can transport cyclic objects', async () => {
function ComponentClient({prop}) {
expect(prop.obj.obj.obj).toBe(prop.obj.obj);
}
const Component = clientReference(ComponentClient);

const cyclic = {obj: null};
cyclic.obj = cyclic;
const model = <Component prop={cyclic} />;

const transport = ReactNoopFlightServer.render(model);

await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
});

it('can render a lazy component as a shared component on the server', async () => {
function SharedComponent({text}) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,65 @@ describe('ReactFlightDOMEdge', () => {
expect(result.text2).toBe(testString2);
});

it('should encode repeated objects in a compact format by deduping', async () => {
const obj = {
this: {is: 'a large objected'},
with: {many: 'properties in it'},
};
const props = {
items: new Array(30).fill(obj),
};
const stream = ReactServerDOMServer.renderToReadableStream(props);
const [stream1, stream2] = passThrough(stream).tee();

const serializedContent = await readResult(stream1);
expect(serializedContent.length).toBeLessThan(400);

const result = await ReactServerDOMClient.createFromReadableStream(
stream2,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
// Should still match the result when parsed
expect(result).toEqual(props);
expect(result.items[5]).toBe(result.items[10]); // two random items are the same instance
// TODO: items[0] is not the same as the others in this case
});

it('should execute repeated server components only once', async () => {
const str = 'this is a long return value';
let timesRendered = 0;
function ServerComponent() {
timesRendered++;
return str;
}
const element = <ServerComponent />;
const children = new Array(30).fill(element);
const resolvedChildren = new Array(30).fill(str);
const stream = ReactServerDOMServer.renderToReadableStream(children);
const [stream1, stream2] = passThrough(stream).tee();

const serializedContent = await readResult(stream1);
expect(serializedContent.length).toBeLessThan(400);
expect(timesRendered).toBeLessThan(5);

const result = await ReactServerDOMClient.createFromReadableStream(
stream2,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
// Should still match the result when parsed
expect(result).toEqual(resolvedChildren);
});

// @gate enableBinaryFlight
it('should be able to serialize any kind of typed array', async () => {
const buffer = new Uint8Array([
Expand Down
Loading

0 comments on commit 50429f5

Please sign in to comment.