From ffd84233568fd9d277eaba7c01d53d516f6f132f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 29 Oct 2020 20:57:31 -0400 Subject: [PATCH] [Flight] Add support for Module References in transport protocol (#20121) * Refactor Flight to require a module reference to be brand checked This exposes a host environment (bundler) specific hook to check if an object is a module reference. This will be used so that they can be passed directly into Flight without needing additional wrapper objects. * Emit module references as a special type of value We already have JSON and errors as special types of "rows". This encodes module references as a special type of row value. This was always the intention because it allows those values to be emitted first in the stream so that as a large models stream down, we can start preloading as early as possible. We preload the module when they resolve but we lazily require them as they are referenced. * Emit module references where ever they occur This emits module references where ever they occur. In blocks or even directly in elements. * Don't special case the root row I originally did this so that a simple stream is also just plain JSON. However, since we might want to emit things like modules before the root module in the stream, this gets unnecessarily complicated. We could add this back as a special case if it's the first byte written but meh. * Update the protocol * Add test for using a module reference as a client component * Relax element type check Since Flight now accepts a module reference as returned by any bundler system, depending on the renderer running. We need to drastically relax the check to include all of them. We can add more as we discover them. * Move flow annotation Seems like our compiler is not happy with stripping this. * Some bookkeeping bug * Can't use the private field to check --- .../react-client/src/ReactFlightClient.js | 81 ++++++++++++++++--- .../src/ReactFlightClientStream.js | 14 +++- .../src/__tests__/ReactFlight-test.js | 45 ++++++++++- .../src/ReactNoopFlightServer.js | 10 ++- .../react-server/src/ReactFlightServer.js | 74 +++++++++++++---- .../ReactFlightServerBundlerConfigCustom.js | 1 + .../src/ReactFlightServerConfigStream.js | 21 +++-- .../src/ReactFlightDOMRelayClient.js | 1 + .../ReactFlightDOMRelayClientHostConfig.js | 9 ++- .../ReactFlightDOMRelayServerHostConfig.js | 31 ++++++- .../src/__mocks__/JSResourceReference.js | 19 +++++ .../ReactFlightDOMRelayClientIntegration.js | 6 +- .../ReactFlightDOMRelayServerIntegration.js | 9 ++- .../ReactFlightDOMRelay-test.internal.js | 45 ++++++++++- .../ReactFlightServerWebpackBundlerConfig.js | 15 +++- .../src/__tests__/ReactFlightDOM-test.js | 3 +- packages/shared/isValidElementType.js | 11 +++ scripts/flow/react-relay-hooks.js | 23 ++++-- scripts/jest/setupHostConfigs.js | 1 + scripts/rollup/bundles.js | 13 ++- 20 files changed, 367 insertions(+), 65 deletions(-) create mode 100644 packages/react-transport-dom-relay/src/__mocks__/JSResourceReference.js diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index cda19b8e505b6..2b5a692b2e4a4 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -41,8 +41,9 @@ export type JSONValue = const PENDING = 0; const RESOLVED_MODEL = 1; -const INITIALIZED = 2; -const ERRORED = 3; +const RESOLVED_MODULE = 2; +const INITIALIZED = 3; +const ERRORED = 4; type PendingChunk = { _status: 0, @@ -56,14 +57,20 @@ type ResolvedModelChunk = { _response: Response, then(resolve: () => mixed): void, }; -type InitializedChunk = { +type ResolvedModuleChunk = { _status: 2, + _value: ModuleReference, + _response: Response, + then(resolve: () => mixed): void, +}; +type InitializedChunk = { + _status: 3, _value: T, _response: Response, then(resolve: () => mixed): void, }; type ErroredChunk = { - _status: 3, + _status: 4, _value: Error, _response: Response, then(resolve: () => mixed): void, @@ -71,6 +78,7 @@ type ErroredChunk = { type SomeChunk = | PendingChunk | ResolvedModelChunk + | ResolvedModuleChunk | InitializedChunk | ErroredChunk; @@ -105,6 +113,8 @@ function readChunk(chunk: SomeChunk): T { return chunk._value; case RESOLVED_MODEL: return initializeModelChunk(chunk); + case RESOLVED_MODULE: + return initializeModuleChunk(chunk); case PENDING: // eslint-disable-next-line no-throw-literal throw (chunk: Wakeable); @@ -155,6 +165,13 @@ function createResolvedModelChunk( return new Chunk(RESOLVED_MODEL, value, response); } +function createResolvedModuleChunk( + response: Response, + value: ModuleReference, +): ResolvedModuleChunk { + return new Chunk(RESOLVED_MODULE, value, response); +} + function resolveModelChunk( chunk: SomeChunk, value: UninitializedModel, @@ -170,6 +187,21 @@ function resolveModelChunk( wakeChunk(listeners); } +function resolveModuleChunk( + chunk: SomeChunk, + value: ModuleReference, +): void { + if (chunk._status !== PENDING) { + // We already resolved. We didn't expect to see this. + return; + } + const listeners = chunk._value; + const resolvedChunk: ResolvedModuleChunk = (chunk: any); + resolvedChunk._status = RESOLVED_MODULE; + resolvedChunk._value = value; + wakeChunk(listeners); +} + function initializeModelChunk(chunk: ResolvedModelChunk): T { const value: T = parseModel(chunk._response, chunk._value); const initializedChunk: InitializedChunk = (chunk: any); @@ -178,6 +210,14 @@ function initializeModelChunk(chunk: ResolvedModelChunk): T { return value; } +function initializeModuleChunk(chunk: ResolvedModuleChunk): T { + const value: T = requireModule(chunk._value); + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk._status = INITIALIZED; + initializedChunk._value = value; + return value; +} + // Report that any missing chunks in the model is now going to throw this // error upon read. Also notify any pending promises. export function reportGlobalError(response: Response, error: Error): void { @@ -241,7 +281,7 @@ function createElement(type, key, props): React$Element { type UninitializedBlockPayload = [ mixed, - ModuleMetaData | SomeChunk, + BlockRenderFunction | SomeChunk>, Data | SomeChunk, Response, ]; @@ -250,14 +290,7 @@ function initializeBlock( tuple: UninitializedBlockPayload, ): BlockComponent { // Require module first and then data. The ordering matters. - const moduleMetaData: ModuleMetaData = readMaybeChunk(tuple[1]); - const moduleReference: ModuleReference< - BlockRenderFunction, - > = resolveModuleReference(moduleMetaData); - // TODO: Do this earlier, as the chunk is resolved. - preloadModule(moduleReference); - - const moduleExport = requireModule(moduleReference); + const moduleExport = readMaybeChunk(tuple[1]); // The ordering here is important because this call might suspend. // We don't want that to prevent the module graph for being initialized. @@ -363,6 +396,28 @@ export function resolveModel( } } +export function resolveModule( + response: Response, + id: number, + model: UninitializedModel, +): void { + const chunks = response._chunks; + const chunk = chunks.get(id); + const moduleMetaData: ModuleMetaData = parseModel(response, model); + const moduleReference = resolveModuleReference(moduleMetaData); + + // TODO: Add an option to encode modules that are lazy loaded. + // For now we preload all modules as early as possible since it's likely + // that we'll need them. + preloadModule(moduleReference); + + if (!chunk) { + chunks.set(id, createResolvedModuleChunk(response, moduleReference)); + } else { + resolveModuleChunk(chunk, moduleReference); + } +} + export function resolveError( response: Response, id: number, diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index af73c297ec377..4bb493a66ddb1 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -10,6 +10,7 @@ import type {Response} from './ReactFlightClientHostConfigStream'; import { + resolveModule, resolveModel, resolveError, createResponse as createResponseBase, @@ -39,6 +40,13 @@ function processFullRow(response: Response, row: string): void { resolveModel(response, id, json); return; } + case 'M': { + const colon = row.indexOf(':', 1); + const id = parseInt(row.substring(1, colon), 16); + const json = row.substring(colon + 1); + resolveModule(response, id, json); + return; + } case 'E': { const colon = row.indexOf(':', 1); const id = parseInt(row.substring(1, colon), 16); @@ -48,9 +56,9 @@ function processFullRow(response: Response, row: string): void { return; } default: { - // Assume this is the root model. - resolveModel(response, 0, row); - return; + throw new Error( + "Error parsing the data. It's probably an error code or network corruption.", + ); } } } diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 865686b7e0772..3ba54c51262ee 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -53,17 +53,29 @@ describe('ReactFlight', () => { }; }); + function moduleReference(value) { + return { + $$typeof: Symbol.for('react.module.reference'), + value: value, + }; + } + function block(render, load) { if (load === undefined) { return () => { - return ReactNoopFlightServerRuntime.serverBlockNoData(render); + return ReactNoopFlightServerRuntime.serverBlockNoData( + moduleReference(render), + ); }; } return function(...args) { const curriedLoad = () => { return load(...args); }; - return ReactNoopFlightServerRuntime.serverBlock(render, curriedLoad); + return ReactNoopFlightServerRuntime.serverBlock( + moduleReference(render), + curriedLoad, + ); }; } @@ -97,6 +109,35 @@ describe('ReactFlight', () => { }); }); + it('can render a client component using a module reference and render there', () => { + function UserClient(props) { + return ( + + {props.greeting}, {props.name} + + ); + } + const User = moduleReference(UserClient); + + function Greeting({firstName, lastName}) { + return ; + } + + const model = { + greeting: , + }; + + const transport = ReactNoopFlightServer.render(model); + + act(() => { + const rootModel = ReactNoopFlightClient.read(transport); + const greeting = rootModel.greeting; + ReactNoop.render(greeting); + }); + + expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith); + }); + if (ReactFeatureFlags.enableBlocksAPI) { it('can transfer a Block to the client and render there, without data', () => { function User(props, data) { diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 4220936602f54..f6e8845c96811 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -42,8 +42,14 @@ const ReactNoopFlightServer = ReactFlightServer({ formatChunk(type: string, props: Object): Uint8Array { return Buffer.from(JSON.stringify({type, props}), 'utf8'); }, - resolveModuleMetaData(config: void, renderFn: Function) { - return saveModule(renderFn); + isModuleReference(reference: Object): boolean { + return reference.$$typeof === Symbol.for('react.module.reference'); + }, + resolveModuleMetaData( + config: void, + reference: {$$typeof: Symbol, value: any}, + ) { + return saveModule(reference.value); }, }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index eccc17c2a193a..51e5d56583dcf 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -24,8 +24,10 @@ import { flushBuffered, close, processModelChunk, + processModuleChunk, processErrorChunk, resolveModuleMetaData, + isModuleReference, } from './ReactFlightServerConfig'; import { @@ -83,6 +85,7 @@ export type Request = { nextChunkId: number, pendingChunks: number, pingedSegments: Array, + completedModuleChunks: Array, completedJSONChunks: Array, completedErrorChunks: Array, flowing: boolean, @@ -103,6 +106,7 @@ export function createRequest( nextChunkId: 0, pendingChunks: 0, pingedSegments: pingedSegments, + completedModuleChunks: [], completedJSONChunks: [], completedErrorChunks: [], flowing: false, @@ -151,6 +155,10 @@ function attemptResolveElement(element: React$Element): ReactModel { ) { return element.props.children; } else if (type != null && typeof type === 'object') { + if (isModuleReference(type)) { + // This is a reference to a client component. + return [REACT_ELEMENT_TYPE, type, element.key, element.props]; + } switch (type.$$typeof) { case REACT_FORWARD_REF_TYPE: { const render = type.render; @@ -391,19 +399,8 @@ export function resolveModelToJSON( switch (key) { case '1': { // Module reference - const moduleReference: ModuleReference = (value: any); - try { - const moduleMetaData: ModuleMetaData = resolveModuleMetaData( - request.bundlerConfig, - moduleReference, - ); - return (moduleMetaData: ReactJSONValue); - } catch (x) { - request.pendingChunks++; - const errorId = request.nextChunkId++; - emitErrorChunk(request, errorId, x); - return serializeIDRef(errorId); - } + // Encode as a normal value. + break; } case '2': { // Load function @@ -467,7 +464,30 @@ export function resolveModelToJSON( } } + if (value === null) { + return null; + } + if (typeof value === 'object') { + if (isModuleReference(value)) { + const moduleReference: ModuleReference = (value: any); + try { + const moduleMetaData: ModuleMetaData = resolveModuleMetaData( + request.bundlerConfig, + moduleReference, + ); + request.pendingChunks++; + const moduleId = request.nextChunkId++; + emitModuleChunk(request, moduleId, moduleMetaData); + return serializeIDRef(moduleId); + } catch (x) { + request.pendingChunks++; + const errorId = request.nextChunkId++; + emitErrorChunk(request, errorId, x); + return serializeIDRef(errorId); + } + } + if (__DEV__) { if (value !== null && !isArray(value)) { // Verify that this is a simple plain object. @@ -595,6 +615,15 @@ function emitErrorChunk(request: Request, id: number, error: mixed): void { request.completedErrorChunks.push(processedChunk); } +function emitModuleChunk( + request: Request, + id: number, + moduleMetaData: ModuleMetaData, +): void { + const processedChunk = processModuleChunk(request, id, moduleMetaData); + request.completedModuleChunks.push(processedChunk); +} + function retrySegment(request: Request, segment: Segment): void { const query = segment.query; let value; @@ -654,8 +683,22 @@ function flushCompletedChunks(request: Request): void { const destination = request.destination; beginWriting(destination); try { - const jsonChunks = request.completedJSONChunks; + // We emit module chunks first in the stream so that + // they can be preloaded as early as possible. + const moduleChunks = request.completedModuleChunks; let i = 0; + for (; i < moduleChunks.length; i++) { + request.pendingChunks--; + const chunk = moduleChunks[i]; + if (!writeChunk(destination, chunk)) { + request.flowing = false; + i++; + break; + } + } + // Next comes model data. + const jsonChunks = request.completedJSONChunks; + i = 0; for (; i < jsonChunks.length; i++) { request.pendingChunks--; const chunk = jsonChunks[i]; @@ -666,6 +709,9 @@ function flushCompletedChunks(request: Request): void { } } jsonChunks.splice(0, i); + // Finally, errors are sent. The idea is that it's ok to delay + // any error messages and prioritize display of other parts of + // the page. const errorChunks = request.completedErrorChunks; i = 0; for (; i < errorChunks.length; i++) { diff --git a/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js b/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js index 9e2e01a3b5c7a..c0db2f6b2892a 100644 --- a/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js +++ b/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js @@ -12,4 +12,5 @@ declare var $$$hostConfig: any; export opaque type BundlerConfig = mixed; // eslint-disable-line no-undef export opaque type ModuleReference = mixed; // eslint-disable-line no-undef export opaque type ModuleMetaData: any = mixed; // eslint-disable-line no-undef +export const isModuleReference = $$$hostConfig.isModuleReference; export const resolveModuleMetaData = $$$hostConfig.resolveModuleMetaData; diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js index 698eb17c39829..01c0dea4c0c0e 100644 --- a/packages/react-server/src/ReactFlightServerConfigStream.js +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -14,8 +14,7 @@ FLIGHT PROTOCOL GRAMMAR Response -- JSONData RowSequence -- JSONData +- RowSequence RowSequence - Row RowSequence @@ -23,6 +22,7 @@ RowSequence Row - "J" RowID JSONData +- "M" RowID JSONModuleData - "H" RowID HTMLData - "B" RowID BlobData - "U" RowID URLData @@ -95,12 +95,17 @@ export function processModelChunk( model: ReactModel, ): Chunk { const json = stringify(model, request.toJSON); - let row; - if (id === 0) { - row = json + '\n'; - } else { - row = serializeRowHeader('J', id) + json + '\n'; - } + const row = serializeRowHeader('J', id) + json + '\n'; + return convertStringToBuffer(row); +} + +export function processModuleChunk( + request: Request, + id: number, + moduleMetaData: ReactModel, +): Chunk { + const json = stringify(moduleMetaData); + const row = serializeRowHeader('M', id) + json + '\n'; return convertStringToBuffer(row); } diff --git a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js index 3ca80460c8d5f..7a5e51ff63112 100644 --- a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClient.js @@ -10,6 +10,7 @@ export { createResponse, resolveModel, + resolveModule, resolveError, close, } from 'react-client/src/ReactFlightClient'; diff --git a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index 81c86e94ad96e..1dde710b4af81 100644 --- a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -9,6 +9,10 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient'; +import type JSResourceReference from 'JSResourceReference'; + +export type ModuleReference = JSResourceReference; + import { parseModelString, parseModelTuple, @@ -20,10 +24,7 @@ export { requireModule, } from 'ReactFlightDOMRelayClientIntegration'; -export type { - ModuleReference, - ModuleMetaData, -} from 'ReactFlightDOMRelayClientIntegration'; +export type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration'; export opaque type UninitializedModel = JSONValue; diff --git a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 9c6f67af92891..73bc60d4eb896 100644 --- a/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-transport-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -9,10 +9,13 @@ import type {Request, ReactModel} from 'react-server/src/ReactFlightServer'; +import JSResourceReference from 'JSResourceReference'; + +export type ModuleReference = JSResourceReference; + import type { Destination, BundlerConfig, - ModuleReference, ModuleMetaData, } from 'ReactFlightDOMRelayServerIntegration'; @@ -20,6 +23,7 @@ import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; import { emitModel, + emitModule, emitError, resolveModuleMetaData as resolveModuleMetaDataImpl, } from 'ReactFlightDOMRelayServerIntegration'; @@ -27,10 +31,13 @@ import { export type { Destination, BundlerConfig, - ModuleReference, ModuleMetaData, } from 'ReactFlightDOMRelayServerIntegration'; +export function isModuleReference(reference: Object): boolean { + return reference instanceof JSResourceReference; +} + export function resolveModuleMetaData( config: BundlerConfig, resource: ModuleReference, @@ -52,6 +59,11 @@ export type Chunk = id: number, json: JSONValue, } + | { + type: 'module', + id: number, + json: ModuleMetaData, + } | { type: 'error', id: number, @@ -121,6 +133,19 @@ export function processModelChunk( }; } +export function processModuleChunk( + request: Request, + id: number, + moduleMetaData: ModuleMetaData, +): Chunk { + // The moduleMetaData is already a JSON serializable value. + return { + type: 'module', + id: id, + json: moduleMetaData, + }; +} + export function scheduleWork(callback: () => void) { callback(); } @@ -132,6 +157,8 @@ export function beginWriting(destination: Destination) {} export function writeChunk(destination: Destination, chunk: Chunk): boolean { if (chunk.type === 'json') { emitModel(destination, chunk.id, chunk.json); + } else if (chunk.type === 'module') { + emitModule(destination, chunk.id, chunk.json); } else { emitError(destination, chunk.id, chunk.json.message, chunk.json.stack); } diff --git a/packages/react-transport-dom-relay/src/__mocks__/JSResourceReference.js b/packages/react-transport-dom-relay/src/__mocks__/JSResourceReference.js new file mode 100644 index 0000000000000..cf0cc624a9816 --- /dev/null +++ b/packages/react-transport-dom-relay/src/__mocks__/JSResourceReference.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +class JSResourceReference { + constructor(exportedValue) { + this._moduleId = exportedValue; + } + getModuleID() { + return this._moduleId; + } +} + +module.exports = JSResourceReference; diff --git a/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js b/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js index 3a3543c0f0bf4..25403877da25c 100644 --- a/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js +++ b/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js @@ -7,13 +7,15 @@ 'use strict'; +import JSResourceReference from 'JSResourceReference'; + const ReactFlightDOMRelayClientIntegration = { resolveModuleReference(moduleData) { - return moduleData; + return new JSResourceReference(moduleData); }, preloadModule(moduleReference) {}, requireModule(moduleReference) { - return moduleReference; + return moduleReference._moduleId; }, }; diff --git a/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js b/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js index 8ace12a35d917..3a873ffcad392 100644 --- a/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js +++ b/packages/react-transport-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js @@ -15,6 +15,13 @@ const ReactFlightDOMRelayServerIntegration = { json: json, }); }, + emitModule(destination, id, json) { + destination.push({ + type: 'module', + id: id, + json: json, + }); + }, emitError(destination, id, message, stack) { destination.push({ type: 'error', @@ -24,7 +31,7 @@ const ReactFlightDOMRelayServerIntegration = { }, close(destination) {}, resolveModuleMetaData(config, resource) { - return resource; + return resource._moduleId; }, }; diff --git a/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js b/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js index 7109a7270a993..a9dd37c2eddcb 100644 --- a/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js +++ b/packages/react-transport-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js @@ -10,6 +10,7 @@ let act; let React; let ReactDOM; +let JSResourceReference; let ReactDOMFlightRelayServer; let ReactDOMFlightRelayServerRuntime; let ReactDOMFlightRelayClient; @@ -24,6 +25,7 @@ describe('ReactFlightDOMRelay', () => { ReactDOMFlightRelayServer = require('react-transport-dom-relay/server'); ReactDOMFlightRelayServerRuntime = require('react-transport-dom-relay/server-runtime'); ReactDOMFlightRelayClient = require('react-transport-dom-relay'); + JSResourceReference = require('JSResourceReference'); }); function readThrough(data) { @@ -32,6 +34,8 @@ describe('ReactFlightDOMRelay', () => { const chunk = data[i]; if (chunk.type === 'json') { ReactDOMFlightRelayClient.resolveModel(response, chunk.id, chunk.json); + } else if (chunk.type === 'module') { + ReactDOMFlightRelayClient.resolveModule(response, chunk.id, chunk.json); } else { ReactDOMFlightRelayClient.resolveError( response, @@ -47,14 +51,18 @@ describe('ReactFlightDOMRelay', () => { } function block(render, load) { + const reference = new JSResourceReference(render); if (load === undefined) { - return ReactDOMFlightRelayServerRuntime.serverBlock(render); + return ReactDOMFlightRelayServerRuntime.serverBlock(reference); } return function(...args) { const curriedLoad = () => { return load(...args); }; - return ReactDOMFlightRelayServerRuntime.serverBlock(render, curriedLoad); + return ReactDOMFlightRelayServerRuntime.serverBlock( + reference, + curriedLoad, + ); }; } @@ -93,6 +101,39 @@ describe('ReactFlightDOMRelay', () => { }); }); + // @gate experimental + it('can render a client component using a module reference and render there', () => { + function UserClient(props) { + return ( + + {props.greeting}, {props.name} + + ); + } + const User = new JSResourceReference(UserClient); + + function Greeting({firstName, lastName}) { + return ; + } + + const model = { + greeting: , + }; + + const transport = []; + ReactDOMFlightRelayServer.render(model, transport); + + const modelClient = readThrough(transport); + + const container = document.createElement('div'); + const root = ReactDOM.createRoot(container); + act(() => { + root.render(modelClient.greeting); + }); + + expect(container.innerHTML).toEqual('Hello, Seb Smith'); + }); + // @gate experimental it('can transfer a Block to the client and render there', () => { function load(firstName, lastName) { diff --git a/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js b/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js index 6c5bf3d4554cb..b8bb0d5cbee7d 100644 --- a/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js +++ b/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js @@ -14,7 +14,10 @@ type WebpackMap = { export type BundlerConfig = WebpackMap; // eslint-disable-next-line no-unused-vars -export type ModuleReference = string; +export type ModuleReference = { + $$typeof: Symbol, + name: string, +}; export type ModuleMetaData = { id: string, @@ -22,9 +25,15 @@ export type ModuleMetaData = { name: string, }; +const MODULE_TAG = Symbol.for('react.module.reference'); + +export function isModuleReference(reference: Object): boolean { + return reference.$$typeof === MODULE_TAG; +} + export function resolveModuleMetaData( config: BundlerConfig, - modulePath: ModuleReference, + moduleReference: ModuleReference, ): ModuleMetaData { - return config[modulePath]; + return config[moduleReference.name]; } diff --git a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js index ecd71cea82d9f..ebabf4e75f5fd 100644 --- a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -83,8 +83,9 @@ describe('ReactFlightDOM', () => { const curriedLoad = () => { return load(...args); }; + const MODULE_TAG = Symbol.for('react.module.reference'); return ReactTransportDOMServerRuntime.serverBlock( - 'path/' + idx, + {$$typeof: MODULE_TAG, name: 'path/' + idx}, curriedLoad, ); }; diff --git a/packages/shared/isValidElementType.js b/packages/shared/isValidElementType.js index e433cb374f8bf..75c5fa74c5b7f 100644 --- a/packages/shared/isValidElementType.js +++ b/packages/shared/isValidElementType.js @@ -27,6 +27,11 @@ import { } from 'shared/ReactSymbols'; import {enableScopeAPI} from './ReactFeatureFlags'; +let REACT_MODULE_REFERENCE: number | Symbol = 0; +if (typeof Symbol === 'function') { + REACT_MODULE_REFERENCE = Symbol.for('react.module.reference'); +} + export default function isValidElementType(type: mixed) { if (typeof type === 'string' || typeof type === 'function') { return true; @@ -54,6 +59,12 @@ export default function isValidElementType(type: mixed) { type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || type.$$typeof === REACT_FUNDAMENTAL_TYPE || + // This needs to include all possible module reference object + // types supported by any Flight configuration anywhere since + // we don't know which Flight build this will end up being used + // with. + type.$$typeof === REACT_MODULE_REFERENCE || + type.getModuleID !== undefined || type.$$typeof === REACT_BLOCK_TYPE || type[(0: any)] === REACT_SERVER_BLOCK_TYPE ) { diff --git a/scripts/flow/react-relay-hooks.js b/scripts/flow/react-relay-hooks.js index 285327b1c839e..0a4eae04cd4d7 100644 --- a/scripts/flow/react-relay-hooks.js +++ b/scripts/flow/react-relay-hooks.js @@ -15,6 +15,14 @@ type JSONValue = | {+[key: string]: JSONValue} | $ReadOnlyArray; +declare class JSResourceReference { + _moduleId: T; +} + +declare module 'JSResourceReference' { + declare export default typeof JSResourceReference; +} + declare module 'ReactFlightDOMRelayServerIntegration' { declare export opaque type Destination; declare export opaque type BundlerConfig; @@ -23,6 +31,11 @@ declare module 'ReactFlightDOMRelayServerIntegration' { id: number, json: JSONValue, ): void; + declare export function emitModule( + destination: Destination, + id: number, + json: ModuleMetaData, + ): void; declare export function emitError( destination: Destination, id: number, @@ -31,24 +44,22 @@ declare module 'ReactFlightDOMRelayServerIntegration' { ): void; declare export function close(destination: Destination): void; - declare export opaque type ModuleReference; declare export type ModuleMetaData = JSONValue; declare export function resolveModuleMetaData( config: BundlerConfig, - resourceReference: ModuleReference, + resourceReference: JSResourceReference, ): ModuleMetaData; } declare module 'ReactFlightDOMRelayClientIntegration' { - declare export opaque type ModuleReference; declare export opaque type ModuleMetaData; declare export function resolveModuleReference( moduleData: ModuleMetaData, - ): ModuleReference; + ): JSResourceReference; declare export function preloadModule( - moduleReference: ModuleReference, + moduleReference: JSResourceReference, ): void; declare export function requireModule( - moduleReference: ModuleReference, + moduleReference: JSResourceReference, ): T; } diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 9a4af65cf0c59..78b4de4164170 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -34,6 +34,7 @@ jest.mock('react-server/flight', () => { jest.mock(shimServerStreamConfigPath, () => config); jest.mock(shimServerFormatConfigPath, () => config); jest.mock('react-server/src/ReactFlightServerBundlerConfigCustom', () => ({ + isModuleReference: config.isModuleReference, resolveModuleMetaData: config.resolveModuleMetaData, })); jest.mock(shimFlightServerConfigPath, () => diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index baf93fd578155..d4e8b06a2322d 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -301,6 +301,7 @@ const bundles = [ 'react', 'react-dom/server', 'ReactFlightDOMRelayServerIntegration', + 'JSResourceReference', ], }, { @@ -308,7 +309,11 @@ const bundles = [ moduleType: RENDERER, entry: 'react-transport-dom-relay/server-runtime', global: 'ReactFlightDOMRelayServerRuntime', - externals: ['react', 'ReactFlightDOMRelayServerIntegration'], + externals: [ + 'react', + 'ReactFlightDOMRelayServerIntegration', + 'JSResourceReference', + ], }, /******* React DOM Flight Client Relay *******/ @@ -317,7 +322,11 @@ const bundles = [ moduleType: RENDERER, entry: 'react-transport-dom-relay', global: 'ReactFlightDOMRelayClient', - externals: ['react', 'ReactFlightDOMRelayClientIntegration'], + externals: [ + 'react', + 'ReactFlightDOMRelayClientIntegration', + 'JSResourceReference', + ], }, /******* React ART *******/