Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Flight Reply] Dedupe Objects and Support Cyclic References #28997

Merged
merged 1 commit into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 47 additions & 12 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ function escapeStringValue(value: string): string {
}
}

interface Reference {}

export function processReply(
root: ReactServerValue,
formFieldPrefix: string,
Expand All @@ -186,6 +188,8 @@ export function processReply(
let nextPartId = 1;
let pendingParts = 0;
let formData: null | FormData = null;
const writtenObjects: WeakMap<Reference, string> = new WeakMap();
let modelRoot: null | ReactServerValue = root;

function serializeTypedArray(
tag: string,
Expand Down Expand Up @@ -427,7 +431,7 @@ export function processReply(
// We always outline this as a separate part even though we could inline it
// because it ensures a more deterministic encoding.
const lazyId = nextPartId++;
const partJSON = JSON.stringify(resolvedModel, resolveToJSON);
const partJSON = serializeModel(resolvedModel, lazyId);
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
const data: FormData = formData;
// eslint-disable-next-line react-internal/safe-string-coercion
Expand All @@ -447,7 +451,7 @@ export function processReply(
// While the first promise resolved, its value isn't necessarily what we'll
// resolve into because we might suspend again.
try {
const partJSON = JSON.stringify(value, resolveToJSON);
const partJSON = serializeModel(value, lazyId);
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
const data: FormData = formData;
// eslint-disable-next-line react-internal/safe-string-coercion
Expand Down Expand Up @@ -488,7 +492,7 @@ export function processReply(
thenable.then(
partValue => {
try {
const partJSON = JSON.stringify(partValue, resolveToJSON);
const partJSON = serializeModel(partValue, promiseId);
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
const data: FormData = formData;
// eslint-disable-next-line react-internal/safe-string-coercion
Expand All @@ -507,6 +511,28 @@ export function processReply(
);
return serializePromiseID(promiseId);
}

const existingReference = writtenObjects.get(value);
if (existingReference !== undefined) {
if (modelRoot === value) {
// This is the ID we're currently emitting so we need to write it
// once but if we discover it again, we refer to it by id.
modelRoot = null;
} else {
// We've already emitted this as an outlined object, so we can
// just refer to that by its existing ID.
return existingReference;
}
} else if (key.indexOf(':') === -1) {
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.
const parentReference = writtenObjects.get(parent);
if (parentReference !== undefined) {
// If the parent has a reference, we can refer to this object indirectly
// through the property name inside that parent.
writtenObjects.set(value, parentReference + ':' + key);
}
}

if (isArray(value)) {
// $FlowFixMe[incompatible-return]
return value;
Expand All @@ -530,20 +556,20 @@ export function processReply(
return serializeFormDataReference(refId);
}
if (value instanceof Map) {
const partJSON = JSON.stringify(Array.from(value), resolveToJSON);
const mapId = nextPartId++;
const partJSON = serializeModel(Array.from(value), mapId);
if (formData === null) {
formData = new FormData();
}
const mapId = nextPartId++;
formData.append(formFieldPrefix + mapId, partJSON);
return serializeMapID(mapId);
}
if (value instanceof Set) {
const partJSON = JSON.stringify(Array.from(value), resolveToJSON);
const setId = nextPartId++;
const partJSON = serializeModel(Array.from(value), setId);
if (formData === null) {
formData = new FormData();
}
const setId = nextPartId++;
formData.append(formFieldPrefix + setId, partJSON);
return serializeSetID(setId);
}
Expand Down Expand Up @@ -622,14 +648,14 @@ export function processReply(
const iterator = iteratorFn.call(value);
if (iterator === value) {
// Iterator, not Iterable
const partJSON = JSON.stringify(
const iteratorId = nextPartId++;
const partJSON = serializeModel(
Array.from((iterator: any)),
resolveToJSON,
iteratorId,
);
if (formData === null) {
formData = new FormData();
}
const iteratorId = nextPartId++;
formData.append(formFieldPrefix + iteratorId, partJSON);
return serializeIteratorID(iteratorId);
}
Expand Down Expand Up @@ -784,8 +810,17 @@ export function processReply(
);
}

// $FlowFixMe[incompatible-type] it's not going to be undefined because we'll encode it.
const json: string = JSON.stringify(root, resolveToJSON);
function serializeModel(model: ReactServerValue, id: number): string {
if (typeof model === 'object' && model !== null) {
writtenObjects.set(model, serializeByValueID(id));
}
modelRoot = model;
// $FlowFixMe[incompatible-return] it's not going to be undefined because we'll encode it.
return JSON.stringify(model, resolveToJSON);
}

const json = serializeModel(root, 0);

if (formData === null) {
// If it's a simple data structure, we just use plain JSON.
resolve(json);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,4 +537,13 @@ describe('ReactFlightDOMReply', () => {
'Values cannot be passed to next() of AsyncIterables passed to Client Components.',
);
});

it('can transport cyclic objects', async () => {
const cyclic = {obj: null};
cyclic.obj = cyclic;

const body = await ReactServerDOMClient.encodeReply({prop: cyclic});
const root = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
expect(root.prop.obj).toBe(root.prop);
});
});
68 changes: 54 additions & 14 deletions packages/react-server/src/ReactFlightReplyServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type JSONValue =

const PENDING = 'pending';
const BLOCKED = 'blocked';
const CYCLIC = 'cyclic';
const RESOLVED_MODEL = 'resolved_model';
const INITIALIZED = 'fulfilled';
const ERRORED = 'rejected';
Expand All @@ -65,6 +66,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: string,
Expand Down Expand Up @@ -98,6 +106,7 @@ type ErroredChunk<T> = {
type SomeChunk<T> =
| PendingChunk<T>
| BlockedChunk<T>
| CyclicChunk<T>
| ResolvedModelChunk<T>
| InitializedChunk<T>
| ErroredChunk<T>;
Expand Down Expand Up @@ -132,6 +141,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 @@ -187,6 +197,7 @@ function wakeChunkIfInitialized<T>(
break;
case PENDING:
case BLOCKED:
case CYCLIC:
chunk.value = resolveListeners;
chunk.reason = rejectListeners;
break;
Expand Down Expand Up @@ -334,6 +345,7 @@ function loadServerReference<T>(
false,
response,
createModel,
[],
),
createModelReject(parentChunk),
);
Expand All @@ -348,8 +360,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 = JSON.parse(chunk.value, chunk._response._fromJSON);
const value: T = JSON.parse(resolvedModel, chunk._response._fromJSON);
if (
initializingChunkBlockedModel !== null &&
initializingChunkBlockedModel.deps > 0
Expand All @@ -362,9 +385,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 @@ -416,6 +443,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 @@ -430,6 +458,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 @@ -460,11 +491,13 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {

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 @@ -474,18 +507,24 @@ function getOutlinedModel<T>(
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
return map(response, chunk.value);
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
return map(response, value);
case PENDING:
case BLOCKED:
case CYCLIC:
const parentChunk = initializingChunk;
chunk.then(
createModelResolver(
parentChunk,
parentObject,
key,
false,
chunk.status === CYCLIC,
response,
map,
path,
),
createModelReject(parentChunk),
);
Expand Down Expand Up @@ -548,6 +587,7 @@ function parseTypedArray(
false,
response,
createModel,
[],
),
createModelReject(parentChunk),
);
Expand Down Expand Up @@ -789,10 +829,10 @@ function parseModelString(
}
case 'F': {
// Server Reference
const id = parseInt(value.slice(2), 16);
const ref = value.slice(2);
// TODO: Just encode this in the reference inline instead of as a model.
const metaData: {id: ServerReferenceId, bound: Thenable<Array<any>>} =
getOutlinedModel(response, id, obj, key, createModel);
getOutlinedModel(response, ref, obj, key, createModel);
return loadServerReference(
response,
metaData.id,
Expand All @@ -808,13 +848,13 @@ function parseModelString(
}
case 'Q': {
// Map
const id = parseInt(value.slice(2), 16);
return getOutlinedModel(response, id, obj, key, createMap);
const ref = value.slice(2);
return getOutlinedModel(response, ref, obj, key, createMap);
}
case 'W': {
// Set
const id = parseInt(value.slice(2), 16);
return getOutlinedModel(response, id, obj, key, createSet);
const ref = value.slice(2);
return getOutlinedModel(response, ref, obj, key, createSet);
}
case 'K': {
// FormData
Expand All @@ -835,8 +875,8 @@ function parseModelString(
}
case 'i': {
// Iterator
const id = parseInt(value.slice(2), 16);
return getOutlinedModel(response, id, obj, key, extractIterator);
const ref = value.slice(2);
return getOutlinedModel(response, ref, obj, key, extractIterator);
}
case 'I': {
// $Infinity
Expand Down Expand Up @@ -933,8 +973,8 @@ function parseModelString(
}

// We assume that anything else is a reference ID.
const id = parseInt(value.slice(1), 16);
return getOutlinedModel(response, id, obj, key, createModel);
const ref = value.slice(1);
return getOutlinedModel(response, ref, obj, key, createModel);
}
return value;
}
Expand Down