From eb7b8d5e1540c479cb4f07a0ce651e9bd8e9891e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 17 Oct 2023 18:05:48 -0400 Subject: [PATCH 1/3] Gate Server Context code --- .../react-server/src/ReactFlightServer.js | 85 ++++++++++--------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 6c08dd9948838..c9094ef8d0cb8 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -15,6 +15,7 @@ import { enableBinaryFlight, enablePostpone, enableTaint, + enableServerContext, } from 'shared/ReactFeatureFlags'; import { @@ -581,28 +582,31 @@ function attemptResolveElement( ); } case REACT_PROVIDER_TYPE: { - pushProvider(type._context, props.value); - if (__DEV__) { - const extraKeys = Object.keys(props).filter(value => { - if (value === 'children' || value === 'value') { - return false; + if (enableServerContext) { + pushProvider(type._context, props.value); + if (__DEV__) { + const extraKeys = Object.keys(props).filter(value => { + if (value === 'children' || value === 'value') { + return false; + } + return true; + }); + if (extraKeys.length !== 0) { + console.error( + 'ServerContext can only have a value prop and children. Found: %s', + JSON.stringify(extraKeys), + ); } - return true; - }); - if (extraKeys.length !== 0) { - console.error( - 'ServerContext can only have a value prop and children. Found: %s', - JSON.stringify(extraKeys), - ); } + return [ + REACT_ELEMENT_TYPE, + type, + key, + // Rely on __popProvider being serialized last to pop the provider. + {value: props.value, children: props.children, __pop: POP}, + ]; } - return [ - REACT_ELEMENT_TYPE, - type, - key, - // Rely on __popProvider being serialized last to pop the provider. - {value: props.value, children: props.children, __pop: POP}, - ]; + // Fallthrough } } } @@ -913,6 +917,7 @@ function resolveModelToJSON( if (__DEV__) { if ( + enableServerContext && parent[0] === REACT_ELEMENT_TYPE && parent[1] && (parent[1]: any).$$typeof === REACT_PROVIDER_TYPE && @@ -934,7 +939,7 @@ function resolveModelToJSON( (value: any).$$typeof === REACT_LAZY_TYPE) ) { if (__DEV__) { - if (isInsideContextValue) { + if (enableServerContext && isInsideContextValue) { console.error('React elements are not allowed in ServerContext'); } } @@ -1028,25 +1033,27 @@ function resolveModelToJSON( // or a Promise type. Either of which can be represented by a Promise. const promiseId = serializeThenable(request, (value: any)); return serializePromiseID(promiseId); - } else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { - const providerKey = ((value: any): ReactProviderType)._context - ._globalName; - const writtenProviders = request.writtenProviders; - let providerId = writtenProviders.get(key); - if (providerId === undefined) { - request.pendingChunks++; - providerId = request.nextChunkId++; - writtenProviders.set(providerKey, providerId); - emitProviderChunk(request, providerId, providerKey); - } - return serializeByValueID(providerId); - } else if (value === POP) { - popProvider(); - if (__DEV__) { - insideContextProps = null; - isInsideContextValue = false; + } else if (enableServerContext) { + if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { + const providerKey = ((value: any): ReactProviderType)._context + ._globalName; + const writtenProviders = request.writtenProviders; + let providerId = writtenProviders.get(key); + if (providerId === undefined) { + request.pendingChunks++; + providerId = request.nextChunkId++; + writtenProviders.set(providerKey, providerId); + emitProviderChunk(request, providerId, providerKey); + } + return serializeByValueID(providerId); + } else if (value === POP) { + popProvider(); + if (__DEV__) { + insideContextProps = null; + isInsideContextValue = false; + } + return (undefined: any); } - return (undefined: any); } if (isArray(value)) { @@ -1703,7 +1710,7 @@ export function abort(request: Request, reason: mixed): void { function importServerContexts( contexts?: Array<[string, ServerContextJSONValue]>, ) { - if (contexts) { + if (enableServerContext && contexts) { const prevContext = getActiveContext(); switchContext(rootContextSnapshot); for (let i = 0; i < contexts.length; i++) { From 19504e78142a81b256c2107ab2b2d62294f2da45 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 17 Oct 2023 21:02:05 -0400 Subject: [PATCH 2/3] Track already written objects that has an id so they can be reused Outline objects that has been seen at least once before so they can be deduped. --- .../react-client/src/ReactFlightClient.js | 46 ++++++++- .../src/__tests__/ReactFlight-test.js | 17 ++++ .../src/__tests__/ReactFlightDOMEdge-test.js | 59 ++++++++++++ .../react-server/src/ReactFlightServer.js | 94 +++++++++++++++++-- 4 files changed, 204 insertions(+), 12 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 3fb97089f52be..8c584bbc59622 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -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'; @@ -91,6 +92,13 @@ type BlockedChunk = { _response: Response, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, }; +type CyclicChunk = { + status: 'cyclic', + value: null | Array<(T) => mixed>, + reason: null | Array<(mixed) => mixed>, + _response: Response, + then(resolve: (T) => mixed, reject: (mixed) => mixed): void, +}; type ResolvedModelChunk = { status: 'resolved_model', value: UninitializedModel, @@ -122,6 +130,7 @@ type ErroredChunk = { type SomeChunk = | PendingChunk | BlockedChunk + | CyclicChunk | ResolvedModelChunk | ResolvedModuleChunk | InitializedChunk @@ -160,6 +169,7 @@ Chunk.prototype.then = function ( break; case PENDING: case BLOCKED: + case CYCLIC: if (resolve) { if (chunk.value === null) { chunk.value = ([]: Array<(T) => mixed>); @@ -211,6 +221,7 @@ function readChunk(chunk: SomeChunk): T { return chunk.value; case PENDING: case BLOCKED: + case CYCLIC: // eslint-disable-next-line no-throw-literal throw ((chunk: any): Thenable); default: @@ -259,6 +270,7 @@ function wakeChunkIfInitialized( break; case PENDING: case BLOCKED: + case CYCLIC: chunk.value = resolveListeners; chunk.reason = rejectListeners; break; @@ -365,8 +377,19 @@ function initializeModelChunk(chunk: ResolvedModelChunk): 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 = (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 @@ -379,9 +402,13 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { blockedChunk.value = null; blockedChunk.reason = null; } else { + const resolveListeners = cyclicChunk.value; const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = value; + if (resolveListeners !== null) { + wakeChunk(resolveListeners, value); + } } } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); @@ -491,15 +518,18 @@ function createModelResolver( chunk: SomeChunk, 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 => { @@ -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; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index c68630e5b23e5..01912104ad6f0 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -434,6 +434,23 @@ describe('ReactFlight', () => { `); }); + 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 = ; + + 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 ( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index a81ec52583fe6..2f643fbbfbfa0 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -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 = ; + 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([ diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index c9094ef8d0cb8..72348f16fd752 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -181,6 +181,8 @@ type Task = { thenableState: ThenableState | null, }; +interface Reference {} + export type Request = { status: 0 | 1 | 2, flushScheduled: boolean, @@ -201,6 +203,7 @@ export type Request = { writtenClientReferences: Map, writtenServerReferences: Map, number>, writtenProviders: Map, + writtenObjects: WeakMap, // -1 means "seen" but not outlined. identifierPrefix: string, identifierCount: number, taintCleanupQueue: Array, @@ -299,6 +302,7 @@ export function createRequest( writtenClientReferences: new Map(), writtenServerReferences: new Map(), writtenProviders: new Map(), + writtenObjects: new WeakMap(), identifierPrefix: identifierPrefix || '', identifierCount: 1, taintCleanupQueue: cleanupQueue, @@ -763,10 +767,14 @@ function serializeClientReference( function outlineModel(request: Request, value: any): number { request.pendingChunks++; - const outlinedId = request.nextChunkId++; - // We assume that this object doesn't suspend, but a child might. - emitModelChunk(request, outlinedId, value); - return outlinedId; + const newTask = createTask( + request, + value, + getActiveContext(), + request.abortableTasks, + ); + retryTask(request, newTask); + return newTask.id; } function serializeServerReference( @@ -864,6 +872,7 @@ function escapeStringValue(value: string): string { let insideContextProps = null; let isInsideContextValue = false; +let modelRoot: null | ReactClientValue = false; function resolveModelToJSON( request: Request, @@ -947,6 +956,28 @@ function resolveModelToJSON( try { switch ((value: any).$$typeof) { case REACT_ELEMENT_TYPE: { + const writtenObjects = request.writtenObjects; + const existingId = writtenObjects.get(value); + if (existingId !== undefined) { + if (existingId === -1) { + // Seen but not yet outlined. + const newId = outlineModel(request, value); + return serializeByValueID(newId); + } else 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 serializeByValueID(existingId); + } + } else { + // This is the first time we've seen this object. We may never see it again + // so we'll inline it. Mark it as seen. If we see it again, we'll outline. + writtenObjects.set(value, -1); + } + // TODO: Concatenate keys of parents onto children. const element: React$Element = (value: any); // Attempt to render the Server Component. @@ -1027,13 +1058,30 @@ function resolveModelToJSON( } if (isClientReference(value)) { return serializeClientReference(request, parent, key, (value: any)); - // $FlowFixMe[method-unbinding] - } else if (typeof value.then === 'function') { + } + + const writtenObjects = request.writtenObjects; + const existingId = writtenObjects.get(value); + // $FlowFixMe[method-unbinding] + if (typeof value.then === 'function') { + if (existingId !== 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 seen this promise before, so we can just refer to the same result. + return serializePromiseID(existingId); + } + } // We assume that any object with a .then property is a "Thenable" type, // or a Promise type. Either of which can be represented by a Promise. const promiseId = serializeThenable(request, (value: any)); + writtenObjects.set(value, promiseId); return serializePromiseID(promiseId); - } else if (enableServerContext) { + } + + if (enableServerContext) { if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { const providerKey = ((value: any): ReactProviderType)._context ._globalName; @@ -1056,6 +1104,26 @@ function resolveModelToJSON( } } + if (existingId !== undefined) { + if (existingId === -1) { + // Seen but not yet outlined. + const newId = outlineModel(request, value); + return serializeByValueID(newId); + } else 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 serializeByValueID(existingId); + } + } else { + // This is the first time we've seen this object. We may never see it again + // so we'll inline it. Mark it as seen. If we see it again, we'll outline. + writtenObjects.set(value, -1); + } + if (isArray(value)) { // $FlowFixMe[incompatible-return] return value; @@ -1408,6 +1476,10 @@ function emitModelChunk( id: number, model: ReactClientValue, ): void { + // Track the root so we know that we have to emit this object even though it + // already has an ID. This is needed because we might see this object twice + // in the same toJSON if it is cyclic. + modelRoot = model; // $FlowFixMe[incompatible-type] stringify can return null const json: string = stringify(model, request.toJSON); const row = id.toString(16) + ':' + json + '\n'; @@ -1429,6 +1501,8 @@ function retryTask(request: Request, task: Task): void { value !== null && (value: any).$$typeof === REACT_ELEMENT_TYPE ) { + request.writtenObjects.set(value, task.id); + // TODO: Concatenate keys of parents onto children. const element: React$Element = (value: any); @@ -1461,6 +1535,7 @@ function retryTask(request: Request, task: Task): void { value !== null && (value: any).$$typeof === REACT_ELEMENT_TYPE ) { + request.writtenObjects.set(value, task.id); // TODO: Concatenate keys of parents onto children. const nextElement: React$Element = (value: any); task.model = value; @@ -1475,6 +1550,11 @@ function retryTask(request: Request, task: Task): void { } } + // Track that this object is outlined and has an id. + if (typeof value === 'object' && value !== null) { + request.writtenObjects.set(value, task.id); + } + emitModelChunk(request, task.id, value); request.abortableTasks.delete(task); task.status = COMPLETED; From b74a7184f2ba84d6b07e1e1198de40a03a0599c0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 17 Oct 2023 23:30:04 -0400 Subject: [PATCH 3/3] Always outline object keys in Map/Set This won't always work but it'll work if we discover the Map/Set before the key. The idea is that you'd pass the Map to something like a parent context and then select out of it with a key passed to a child. --- .../src/__tests__/ReactFlight-test.js | 12 +++++--- .../react-server/src/ReactFlightServer.js | 28 +++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 01912104ad6f0..57c54a82b4b91 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -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); @@ -389,7 +390,7 @@ describe('ReactFlight', () => { ['hi', {greet: 'world'}], [objKey, 123], ]); - const model = ; + const model = ; const transport = ReactNoopFlightServer.render(model); @@ -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 = ; + const model = ; const transport = ReactNoopFlightServer.render(model); @@ -431,6 +434,7 @@ describe('ReactFlight', () => { size: 2 hi: true content: ["hi",{"obj":"key"}] + selected: true `); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 72348f16fd752..5da20fcddae45 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -822,12 +822,36 @@ function serializeMap( request: Request, map: Map, ): string { - const id = outlineModel(request, Array.from(map)); + const entries = Array.from(map); + for (let i = 0; i < entries.length; i++) { + const key = entries[i][0]; + if (typeof key === 'object' && key !== null) { + const writtenObjects = request.writtenObjects; + const existingId = writtenObjects.get(key); + if (existingId === undefined) { + // Mark all object keys as seen so that they're always outlined. + writtenObjects.set(key, -1); + } + } + } + const id = outlineModel(request, entries); return '$Q' + id.toString(16); } function serializeSet(request: Request, set: Set): string { - const id = outlineModel(request, Array.from(set)); + const entries = Array.from(set); + for (let i = 0; i < entries.length; i++) { + const key = entries[i]; + if (typeof key === 'object' && key !== null) { + const writtenObjects = request.writtenObjects; + const existingId = writtenObjects.get(key); + if (existingId === undefined) { + // Mark all object keys as seen so that they're always outlined. + writtenObjects.set(key, -1); + } + } + } + const id = outlineModel(request, entries); return '$W' + id.toString(16); }