Skip to content

Commit

Permalink
[Flight] Encode references to existing objects by property path (#28996)
Browse files Browse the repository at this point in the history
Instead of forcing an object to be outlined to be able to refer to it
later we can refer to it by the property path inside another parent
object.

E.g. this encodes such a reference as `'$123:props:children:foo:bar'`.

That way we don't have to preemptively outline object and we can dedupe
after the first time we've found it.

There's no cost on the client if it's not used because we're not storing
any additional information preemptively.

This works mainly because we only have simple JSON objects from the root
reference. Complex objects like Map, FormData etc. are stored as their
entries array in the look up and not the complex object. Other complex
objects like TypedArrays or imports don't have deeply nested objects in
them that can be referenced.

This solves the problem that we only dedupe after the third instance.
This dedupes at the second instance. It also solves the problem where
all nested objects inside deduped instances also are outlined.

The property paths can get pretty large. This is why a test on payload
size increased. We could potentially outline the reference itself at the
first dupe. That way we get a shorter ID to refer to in the third
instance.
  • Loading branch information
sebmarkbage committed May 9, 2024
1 parent c334563 commit 7a78d03
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 96 deletions.
43 changes: 27 additions & 16 deletions packages/react-client/src/ReactFlightClient.js
Expand Up @@ -680,6 +680,7 @@ function createModelResolver<T>(
cyclic: boolean,
response: Response,
map: (response: Response, model: any) => T,
path: Array<string>,
): (value: any) => void {
let blocked;
if (initializingChunkBlockedModel) {
Expand All @@ -694,6 +695,9 @@ function createModelResolver<T>(
};
}
return value => {
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
parentObject[key] = map(response, value);

// If this is the root object for a model reference, where `blocked.value`
Expand Down Expand Up @@ -752,11 +756,13 @@ function createServerReferenceProxy<A: Iterable<any>, T>(

function getOutlinedModel<T>(
response: Response,
id: number,
reference: string,
parentObject: Object,
key: string,
map: (response: Response, model: any) => T,
): T {
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);
switch (chunk.status) {
case RESOLVED_MODEL:
Expand All @@ -769,7 +775,11 @@ function getOutlinedModel<T>(
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
const chunkValue = map(response, chunk.value);
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
const chunkValue = map(response, value);
if (__DEV__ && chunk._debugInfo) {
// If we have a direct reference to an object that was rendered by a synchronous
// server component, it might have some debug info about how it was rendered.
Expand Down Expand Up @@ -809,6 +819,7 @@ function getOutlinedModel<T>(
chunk.status === CYCLIC,
response,
map,
path,
),
createModelReject(parentChunk),
);
Expand Down Expand Up @@ -893,10 +904,10 @@ function parseModelString(
}
case 'F': {
// Server Reference
const id = parseInt(value.slice(2), 16);
const ref = value.slice(2);
return getOutlinedModel(
response,
id,
ref,
parentObject,
key,
createServerReferenceProxy,
Expand All @@ -916,39 +927,39 @@ function parseModelString(
}
case 'Q': {
// Map
const id = parseInt(value.slice(2), 16);
return getOutlinedModel(response, id, parentObject, key, createMap);
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createMap);
}
case 'W': {
// Set
const id = parseInt(value.slice(2), 16);
return getOutlinedModel(response, id, parentObject, key, createSet);
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createSet);
}
case 'B': {
// Blob
if (enableBinaryFlight) {
const id = parseInt(value.slice(2), 16);
return getOutlinedModel(response, id, parentObject, key, createBlob);
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createBlob);
}
return undefined;
}
case 'K': {
// FormData
const id = parseInt(value.slice(2), 16);
const ref = value.slice(2);
return getOutlinedModel(
response,
id,
ref,
parentObject,
key,
createFormData,
);
}
case 'i': {
// Iterator
const id = parseInt(value.slice(2), 16);
const ref = value.slice(2);
return getOutlinedModel(
response,
id,
ref,
parentObject,
key,
extractIterator,
Expand Down Expand Up @@ -1000,8 +1011,8 @@ function parseModelString(
}
default: {
// We assume that anything else is a reference ID.
const id = parseInt(value.slice(1), 16);
return getOutlinedModel(response, id, parentObject, key, createModel);
const ref = value.slice(1);
return getOutlinedModel(response, ref, parentObject, key, createModel);
}
}
}
Expand Down
Expand Up @@ -231,7 +231,7 @@ describe('ReactFlightDOMEdge', () => {
const [stream1, stream2] = passThrough(stream).tee();

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

const result = await ReactServerDOMClient.createFromReadableStream(
stream2,
Expand Down Expand Up @@ -543,6 +543,55 @@ describe('ReactFlightDOMEdge', () => {
expect(await iterator.next()).toEqual({value: undefined, done: true});
});

// @gate enableFlightReadableStream
it('should ideally dedupe objects inside async iterables but does not yet', async () => {
const obj = {
this: {is: 'a large objected'},
with: {many: 'properties in it'},
};
const iterable = {
async *[Symbol.asyncIterator]() {
for (let i = 0; i < 30; i++) {
yield obj;
}
},
};

const stream = ReactServerDOMServer.renderToReadableStream({
iterable,
});
const [stream1, stream2] = passThrough(stream).tee();

const serializedContent = await readResult(stream1);
// TODO: Ideally streams should dedupe objects but because we never outline the objects
// they end up not having a row to reference them nor any of its nested objects.
// expect(serializedContent.length).toBeLessThan(400);
expect(serializedContent.length).toBeGreaterThan(400);

const result = await ReactServerDOMClient.createFromReadableStream(
stream2,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);

const items = [];
const iterator = result.iterable[Symbol.asyncIterator]();
let entry;
while (!(entry = await iterator.next()).done) {
items.push(entry.value);
}

// Should still match the result when parsed
expect(items.length).toBe(30);
// TODO: These should be the same
// expect(items[5]).toBe(items[10]); // two random items are the same instance
expect(items[5]).toEqual(items[10]);
});

it('warns if passing a this argument to bind() of a server reference', async () => {
const ServerModule = serverExports({
greet: function () {},
Expand Down

0 comments on commit 7a78d03

Please sign in to comment.