From e3d4729088f6d5f124dcca6225329ef06f9ba3e3 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 2 May 2024 20:02:02 +0200 Subject: [PATCH] Resolve outlined models async in Reply just like in Flight Client --- .../__tests__/ReactFlightDOMBrowser-test.js | 41 +++++ .../__tests__/ReactFlightDOMReplyEdge-test.js | 17 ++ .../src/__tests__/utils/WebpackMock.js | 16 +- .../src/ReactFlightReplyServer.js | 147 +++++++++++++----- 4 files changed, 177 insertions(+), 44 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 58833f3654a5d..55ccb44fb041c 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -1130,6 +1130,47 @@ describe('ReactFlightDOMBrowser', () => { expect(result).toBe('Hello world'); }); + it('can pass an async server exports that resolves later to an outline object like a Map', async () => { + let resolve; + const chunkPromise = new Promise(r => (resolve = r)); + + function action() {} + const serverModule = serverExports( + { + action: action, + }, + chunkPromise, + ); + + // Send the action to the client + const stream = ReactServerDOMServer.renderToReadableStream( + {action: serverModule.action}, + webpackMap, + ); + const response = + await ReactServerDOMClient.createFromReadableStream(stream); + + // Pass the action back to the server inside a Map + + const map = new Map(); + map.set('action', response.action); + + const body = await ReactServerDOMClient.encodeReply(map); + const resultPromise = ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + + // We couldn't yet resolve the server reference because we haven't loaded + // its chunk yet in the new server instance. We now resolve it which loads + // it asynchronously. + await resolve(); + + const result = await resultPromise; + expect(result instanceof Map).toBe(true); + expect(result.get('action')).toBe(action); + }); + it('supports Float hints before the first await in server components in Fiber', async () => { function Component() { return

hello world

; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js index 00a53a590c5e1..6d1d2ad32b6f9 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js @@ -91,6 +91,23 @@ describe('ReactFlightDOMReplyEdge', () => { expect(result).toEqual(buffers); }); + // @gate enableBinaryFlight + it('should be able to serialize a typed array inside a Map', async () => { + const array = new Uint8Array([ + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, + ]); + const map = new Map(); + map.set('array', array); + + const body = await ReactServerDOMClient.encodeReply(map); + const result = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + + expect(result.get('array')).toEqual(array); + }); + // @gate enableBinaryFlight it('should be able to serialize a blob', async () => { const bytes = new Uint8Array([ diff --git a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js index 7c7678860db6b..cf21030834438 100644 --- a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js +++ b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js @@ -11,11 +11,16 @@ const url = require('url'); const Module = require('module'); let webpackModuleIdx = 0; +let webpackChunkIdx = 0; const webpackServerModules = {}; const webpackClientModules = {}; const webpackErroredModules = {}; const webpackServerMap = {}; const webpackClientMap = {}; +const webpackChunkMap = {}; +global.__webpack_chunk_load__ = function (id) { + return webpackChunkMap[id]; +}; global.__webpack_require__ = function (id) { if (webpackErroredModules[id]) { throw webpackErroredModules[id]; @@ -117,13 +122,20 @@ exports.clientExports = function clientExports( }; // This tests server to server references. There's another case of client to server references. -exports.serverExports = function serverExports(moduleExports) { +exports.serverExports = function serverExports(moduleExports, blockOnChunk) { const idx = '' + webpackModuleIdx++; webpackServerModules[idx] = moduleExports; const path = url.pathToFileURL(idx).href; + + const chunks = []; + if (blockOnChunk) { + const chunkId = webpackChunkIdx++; + webpackChunkMap[chunkId] = blockOnChunk; + chunks.push(chunkId); + } webpackServerMap[path] = { id: idx, - chunks: [], + chunks: chunks, name: '*', }; // We only add this if this test is testing ESM compat. diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 4a447a5ee18da..df99e31fa3e4c 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -255,7 +255,14 @@ function loadServerReference( } } promise.then( - createModelResolver(parentChunk, parentObject, key), + createModelResolver( + parentChunk, + parentObject, + key, + false, + response, + createModel, + ), createModelReject(parentChunk), ); // We need a placeholder value that will be replaced later. @@ -334,19 +341,31 @@ function createModelResolver( chunk: SomeChunk, parentObject: Object, key: string, + cyclic: boolean, + response: Response, + map: (response: Response, model: any) => T, ): (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 => { - parentObject[key] = value; + parentObject[key] = map(response, value); + + // If this is the root object for a model reference, where `blocked.value` + // is a stale `null`, the resolved value can be used directly. + if (key === '' && blocked.value === null) { + blocked.value = parentObject[key]; + } + blocked.deps--; if (blocked.deps === 0) { if (chunk.status !== BLOCKED) { @@ -367,16 +386,76 @@ function createModelReject(chunk: SomeChunk): (error: mixed) => void { return (error: mixed) => triggerErrorOnChunk(chunk, error); } -function getOutlinedModel(response: Response, id: number): any { +function getOutlinedModel( + response: Response, + id: number, + parentObject: Object, + key: string, + map: (response: Response, model: any) => T, +): T { const chunk = getChunk(response, id); - if (chunk.status === RESOLVED_MODEL) { - initializeModelChunk(chunk); + switch (chunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(chunk); + break; } - if (chunk.status !== INITIALIZED) { - // We know that this is emitted earlier so otherwise it's an error. - throw chunk.reason; + // The status might have changed after initialization. + switch (chunk.status) { + case INITIALIZED: + return map(response, chunk.value); + case PENDING: + case BLOCKED: + const parentChunk = initializingChunk; + chunk.then( + createModelResolver( + parentChunk, + parentObject, + key, + false, + response, + map, + ), + createModelReject(parentChunk), + ); + return (null: any); + default: + throw chunk.reason; + } +} + +function createMap( + response: Response, + model: Array<[any, any]>, +): Map { + return new Map(model); +} + +function createSet(response: Response, model: Array): Set { + return new Set(model); +} + +function createBlob(response: Response, model: Array): Blob { + return new Blob(model.slice(1), {type: model[0]}); +} + +function createFormData( + response: Response, + model: Array<[any, any]>, +): FormData { + const formData = new FormData(); + for (let i = 0; i < model.length; i++) { + formData.append(model[i][0], model[i][1]); } - return chunk.value; + return formData; +} + +function extractIterator(response: Response, model: Array): Iterator { + // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array. + return model[Symbol.iterator](); +} + +function createModel(response: Response, model: any): any { + return model; } function parseTypedArray( @@ -402,10 +481,17 @@ function parseTypedArray( }); // Since loading the buffer is an async operation we'll be blocking the parent - // chunk. TODO: This is not safe if the parent chunk needs a mapper like Map. + // chunk. const parentChunk = initializingChunk; promise.then( - createModelResolver(parentChunk, parentObject, parentKey), + createModelResolver( + parentChunk, + parentObject, + parentKey, + false, + response, + createModel, + ), createModelReject(parentChunk), ); return null; @@ -434,7 +520,7 @@ function parseModelString( const id = parseInt(value.slice(2), 16); // TODO: Just encode this in the reference inline instead of as a model. const metaData: {id: ServerReferenceId, bound: Thenable>} = - getOutlinedModel(response, id); + getOutlinedModel(response, id, obj, key, createModel); return loadServerReference( response, metaData.id, @@ -451,14 +537,12 @@ function parseModelString( case 'Q': { // Map const id = parseInt(value.slice(2), 16); - const data = getOutlinedModel(response, id); - return new Map(data); + return getOutlinedModel(response, id, obj, key, createMap); } case 'W': { // Set const id = parseInt(value.slice(2), 16); - const data = getOutlinedModel(response, id); - return new Set(data); + return getOutlinedModel(response, id, obj, key, createSet); } case 'K': { // FormData @@ -480,8 +564,7 @@ function parseModelString( case 'i': { // Iterator const id = parseInt(value.slice(2), 16); - const data = getOutlinedModel(response, id); - return data[Symbol.iterator](); + return getOutlinedModel(response, id, obj, key, extractIterator); } case 'I': { // $Infinity @@ -563,27 +646,7 @@ function parseModelString( // We assume that anything else is a reference ID. const id = parseInt(value.slice(1), 16); - const chunk = getChunk(response, id); - switch (chunk.status) { - case RESOLVED_MODEL: - initializeModelChunk(chunk); - break; - } - // The status might have changed after initialization. - switch (chunk.status) { - case INITIALIZED: - return chunk.value; - case PENDING: - case BLOCKED: - const parentChunk = initializingChunk; - chunk.then( - createModelResolver(parentChunk, obj, key), - createModelReject(parentChunk), - ); - return null; - default: - throw chunk.reason; - } + return getOutlinedModel(response, id, obj, key, createModel); } return value; }