From ec15267a001086deb4ab5412d3f8b7e13573d6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 7 May 2024 22:19:59 -0400 Subject: [PATCH] [Flight Reply] Resolve outlined models async in Reply just like in Flight Client (#28988) This is the same change as #28780 but for the Flight Reply receiver. While it's not possible to create an "async module" reference in this case - resolving a server reference can still be async if loading it requires loading chunks like in a new server instance. Since extracting a typed array from a Blob is async, that's also a case where a dependency can be async. --- .../__tests__/ReactFlightDOMBrowser-test.js | 41 ++++++ .../__tests__/ReactFlightDOMReplyEdge-test.js | 17 +++ .../src/__tests__/utils/WebpackMock.js | 16 ++- .../src/ReactFlightReplyServer.js | 123 ++++++++++++------ 4 files changed, 154 insertions(+), 43 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 93f23e22ae93a..a02d2b0c0fd81 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js @@ -85,6 +85,23 @@ describe('ReactFlightDOMReplyEdge', () => { expect(new Uint8Array(result[0])).toEqual(new Uint8Array(buffers[0])); }); + // @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 1989936afe410..51badd3a27bc9 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -327,7 +327,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. @@ -406,19 +413,24 @@ 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, + 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. @@ -446,16 +458,61 @@ 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; } - return chunk.value; +} + +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 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( @@ -481,10 +538,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; @@ -728,7 +792,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, @@ -745,14 +809,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 @@ -774,8 +836,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 @@ -873,27 +934,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; }