diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index f631f9a3264c..2093ac7a492a 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -7,18 +7,25 @@ * @flow */ -import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; +import type {BlockComponent, BlockRenderFunction} from 'react/src/ReactBlock'; +import type {LazyComponent} from 'react/src/ReactLazy'; -// import type { -// ModuleReference, -// ModuleMetaData, -// } from './ReactFlightClientHostConfig'; +import type { + ModuleReference, + ModuleMetaData, +} from './ReactFlightClientHostConfig'; -// import { -// resolveModuleReference, -// preloadModule, -// requireModule, -// } from './ReactFlightClientHostConfig'; +import { + resolveModuleReference, + preloadModule, + requireModule, +} from './ReactFlightClientHostConfig'; + +import { + REACT_LAZY_TYPE, + REACT_BLOCK_TYPE, + REACT_ELEMENT_TYPE, +} from 'shared/ReactSymbols'; export type ReactModelRoot = {| model: T, @@ -32,40 +39,43 @@ export type JSONValue = | {[key: string]: JSONValue} | Array; -const isArray = Array.isArray; - const PENDING = 0; const RESOLVED = 1; const ERRORED = 2; +const CHUNK_TYPE = Symbol('flight.chunk'); + type PendingChunk = {| + $$typeof: Symbol, status: 0, value: Promise, resolve: () => void, |}; -type ResolvedChunk = {| +type ResolvedChunk = {| + $$typeof: Symbol, status: 1, - value: mixed, + value: T, resolve: null, |}; type ErroredChunk = {| + $$typeof: Symbol, status: 2, value: Error, resolve: null, |}; -type Chunk = PendingChunk | ResolvedChunk | ErroredChunk; +type Chunk = PendingChunk | ResolvedChunk | ErroredChunk; export type Response = { partialRow: string, modelRoot: ReactModelRoot, - chunks: Map, + chunks: Map>, }; export function createResponse(): Response { let modelRoot: ReactModelRoot = ({}: any); - let rootChunk: Chunk = createPendingChunk(); + let rootChunk: Chunk = createPendingChunk(); definePendingProperty(modelRoot, 'model', rootChunk); - let chunks: Map = new Map(); + let chunks: Map> = new Map(); chunks.set(0, rootChunk); let response = { partialRow: '', @@ -79,6 +89,7 @@ function createPendingChunk(): PendingChunk { let resolve: () => void = (null: any); let promise = new Promise(r => (resolve = r)); return { + $$typeof: CHUNK_TYPE, status: PENDING, value: promise, resolve: resolve, @@ -87,13 +98,14 @@ function createPendingChunk(): PendingChunk { function createErrorChunk(error: Error): ErroredChunk { return { + $$typeof: CHUNK_TYPE, status: ERRORED, value: error, resolve: null, }; } -function triggerErrorOnChunk(chunk: Chunk, error: Error): void { +function triggerErrorOnChunk(chunk: Chunk, error: Error): void { if (chunk.status !== PENDING) { // We already resolved. We didn't expect to see this. return; @@ -106,21 +118,22 @@ function triggerErrorOnChunk(chunk: Chunk, error: Error): void { resolve(); } -function createResolvedChunk(value: mixed): ResolvedChunk { +function createResolvedChunk(value: T): ResolvedChunk { return { + $$typeof: CHUNK_TYPE, status: RESOLVED, value: value, resolve: null, }; } -function resolveChunk(chunk: Chunk, value: mixed): void { +function resolveChunk(chunk: Chunk, value: T): void { if (chunk.status !== PENDING) { // We already resolved. We didn't expect to see this. return; } let resolve = chunk.resolve; - let resolvedChunk: ResolvedChunk = (chunk: any); + let resolvedChunk: ResolvedChunk = (chunk: any); resolvedChunk.status = RESOLVED; resolvedChunk.value = value; resolvedChunk.resolve = null; @@ -138,10 +151,23 @@ export function reportGlobalError(response: Response, error: Error): void { }); } -function definePendingProperty( +function readMaybeChunk(maybeChunk: Chunk | T): T { + if ((maybeChunk: any).$$typeof !== CHUNK_TYPE) { + // $FlowFixMe + return maybeChunk; + } + let chunk: Chunk = (maybeChunk: any); + if (chunk.status === RESOLVED) { + return chunk.value; + } else { + throw chunk.value; + } +} + +function definePendingProperty( object: Object, key: string, - chunk: Chunk, + chunk: Chunk, ): void { Object.defineProperty(object, key, { configurable: false, @@ -197,6 +223,55 @@ function createElement(type, key, props): React$Element { return element; } +type UninitializedBlockPayload = [ + mixed, + ModuleMetaData | Chunk, + Data | Chunk, +]; + +type Thenable = { + then(resolve: (T) => mixed, reject?: (mixed) => mixed): Thenable, +}; + +function initializeBlock( + tuple: UninitializedBlockPayload, +): BlockComponent { + // Require module first and then data. The ordering matters. + let moduleMetaData: ModuleMetaData = readMaybeChunk(tuple[1]); + let moduleReference: ModuleReference< + BlockRenderFunction, + > = resolveModuleReference(moduleMetaData); + // TODO: Do this earlier, as the chunk is resolved. + preloadModule(moduleReference); + + let moduleExport = requireModule(moduleReference); + + // The ordering here is important because this call might suspend. + // We don't want that to prevent the module graph for being initialized. + let data: Data = readMaybeChunk(tuple[2]); + + return { + $$typeof: REACT_BLOCK_TYPE, + _status: -1, + _data: data, + _render: moduleExport, + }; +} + +function createLazyBlock( + tuple: UninitializedBlockPayload, +): LazyComponent, UninitializedBlockPayload> { + let lazyType: LazyComponent< + BlockComponent, + UninitializedBlockPayload, + > = { + $$typeof: REACT_LAZY_TYPE, + _payload: tuple, + _init: initializeBlock, + }; + return lazyType; +} + export function parseModelFromJSON( response: Response, targetObj: Object, @@ -217,20 +292,26 @@ export function parseModelFromJSON( if (!chunk) { chunk = createPendingChunk(); chunks.set(id, chunk); - } else if (chunk.status === RESOLVED) { - return chunk.value; } - definePendingProperty(targetObj, key, chunk); - return undefined; + return chunk; } } + if (value === '@') { + return REACT_BLOCK_TYPE; + } } - if (isArray(value)) { + if (typeof value === 'object' && value !== null) { let tuple: [mixed, mixed, mixed, mixed] = (value: any); - if (tuple[0] === REACT_ELEMENT_TYPE) { - // TODO: Consider having React just directly accept these arrays as elements. - // Or even change the ReactElement type to be an array. - return createElement(tuple[1], tuple[2], tuple[3]); + switch (tuple[0]) { + case REACT_ELEMENT_TYPE: { + // TODO: Consider having React just directly accept these arrays as elements. + // Or even change the ReactElement type to be an array. + return createElement(tuple[1], tuple[2], tuple[3]); + } + case REACT_BLOCK_TYPE: { + // TODO: Consider having React just directly accept these arrays as blocks. + return createLazyBlock((tuple: any)); + } } } return value; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 480e448fecc7..7b981aa04086 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -10,7 +10,11 @@ 'use strict'; +const ReactFeatureFlags = require('shared/ReactFeatureFlags'); + +let act; let React; +let ReactNoop; let ReactNoopFlightServer; let ReactNoopFlightClient; @@ -19,17 +23,32 @@ describe('ReactFlight', () => { jest.resetModules(); React = require('react'); + ReactNoop = require('react-noop-renderer'); ReactNoopFlightServer = require('react-noop-renderer/flight-server'); ReactNoopFlightClient = require('react-noop-renderer/flight-client'); + act = ReactNoop.act; }); - it('can resolve a model', () => { + function block(query, render) { + return function(...args) { + let curriedQuery = () => { + return query(...args); + }; + return [Symbol.for('react.server.block'), render, curriedQuery]; + }; + } + + it('can render a server component', () => { function Bar({text}) { return text.toUpperCase(); } function Foo() { return { - bar: [, ], + bar: ( +
+ , +
+ ), }; } let transport = ReactNoopFlightServer.render({ @@ -37,6 +56,45 @@ describe('ReactFlight', () => { }); let root = ReactNoopFlightClient.read(transport); let model = root.model; - expect(model).toEqual({foo: {bar: ['A', 'B']}}); + expect(model).toEqual({ + foo: { + bar: ( +
+ {'A'} + {', '} + {'B'} +
+ ), + }, + }); }); + + if (ReactFeatureFlags.enableBlocksAPI) { + it('can transfer a Block to the client and render there', () => { + function Query(firstName, lastName) { + return {name: firstName + ' ' + lastName}; + } + function User(props, data) { + return ( + + {props.greeting}, {data.name} + + ); + } + let loadUser = block(Query, User); + let model = { + User: loadUser('Seb', 'Smith'), + }; + + let transport = ReactNoopFlightServer.render(model); + let root = ReactNoopFlightClient.read(transport); + + act(() => { + let UserClient = root.model.User; + ReactNoop.render(); + }); + + expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith); + }); + } }); diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 80ad7d6077b4..7fc918d8bbeb 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -43,7 +43,7 @@ type JSONValue = | number | boolean | null - | {[key: string]: JSONValue} + | {+[key: string]: JSONValue} | Array; export type Chunk = @@ -78,19 +78,29 @@ export function processErrorChunk( }; } -function convertModelToJSON(request: Request, model: ReactModel): JSONValue { - let json = resolveModelToJSON(request, model); +function convertModelToJSON( + request: Request, + parent: {+[key: string]: ReactModel} | $ReadOnlyArray, + key: string, + model: ReactModel, +): JSONValue { + let json = resolveModelToJSON(request, parent, key, model); if (typeof json === 'object' && json !== null) { if (Array.isArray(json)) { let jsonArray: Array = []; for (let i = 0; i < json.length; i++) { - jsonArray[i] = convertModelToJSON(request, json[i]); + jsonArray[i] = convertModelToJSON(request, json, '' + i, json[i]); } return jsonArray; } else { let jsonObj: {[key: string]: JSONValue} = {}; - for (let key in json) { - jsonObj[key] = convertModelToJSON(request, json[key]); + for (let nextKey in json) { + jsonObj[nextKey] = convertModelToJSON( + request, + json, + nextKey, + json[nextKey], + ); } return jsonObj; } @@ -103,7 +113,7 @@ export function processModelChunk( id: number, model: ReactModel, ): Chunk { - let json = convertModelToJSON(request, model); + let json = convertModelToJSON(request, {}, '', model); return { type: 'json', id: id, diff --git a/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js index 1c286fea1afe..3a3543c0f0bf 100644 --- a/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js +++ b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js @@ -7,19 +7,13 @@ 'use strict'; -function getFakeModule() { - return function FakeModule(props, data) { - return data; - }; -} - const ReactFlightDOMRelayClientIntegration = { resolveModuleReference(moduleData) { return moduleData; }, preloadModule(moduleReference) {}, requireModule(moduleReference) { - return getFakeModule(); + return moduleReference; }, }; diff --git a/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js index 9b0f970c75e7..9d03885e8175 100644 --- a/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js +++ b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js @@ -23,7 +23,7 @@ const ReactFlightDOMRelayServerIntegration = { }); }, close(destination) {}, - resolveModuleMetaDataImpl(resource) { + resolveModuleMetaData(resource) { return resource; }, }; diff --git a/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js b/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js index 28395a18e2c7..046f9987428a 100644 --- a/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js +++ b/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js @@ -7,7 +7,9 @@ 'use strict'; +let act; let React; +let ReactDOM; let ReactDOMFlightRelayServer; let ReactDOMFlightRelayClient; @@ -15,28 +17,14 @@ describe('ReactFlightDOMRelay', () => { beforeEach(() => { jest.resetModules(); + act = require('react-dom/test-utils').act; React = require('react'); + ReactDOM = require('react-dom'); ReactDOMFlightRelayServer = require('react-flight-dom-relay/server'); ReactDOMFlightRelayClient = require('react-flight-dom-relay'); }); - it('can resolve a model', () => { - function Bar({text}) { - return text.toUpperCase(); - } - function Foo() { - return { - bar: [, ], - }; - } - let data = []; - ReactDOMFlightRelayServer.render( - { - foo: , - }, - data, - ); - + function readThrough(data) { let response = ReactDOMFlightRelayClient.createResponse(); for (let i = 0; i < data.length; i++) { let chunk = data[i]; @@ -53,6 +41,81 @@ describe('ReactFlightDOMRelay', () => { } let model = ReactDOMFlightRelayClient.getModelRoot(response).model; ReactDOMFlightRelayClient.close(response); - expect(model).toEqual({foo: {bar: ['A', 'B']}}); + return model; + } + + function block(query, render) { + return function(...args) { + let curriedQuery = () => { + return query(...args); + }; + return [Symbol.for('react.server.block'), render, curriedQuery]; + }; + } + + it('can render a server component', () => { + function Bar({text}) { + return text.toUpperCase(); + } + function Foo() { + return { + bar: ( +
+ , +
+ ), + }; + } + let transport = []; + ReactDOMFlightRelayServer.render( + { + foo: , + }, + transport, + ); + + let model = readThrough(transport); + expect(model).toEqual({ + foo: { + bar: ( +
+ {'A'} + {', '} + {'B'} +
+ ), + }, + }); + }); + + it.experimental('can transfer a Block to the client and render there', () => { + function Query(firstName, lastName) { + return {name: firstName + ' ' + lastName}; + } + function User(props, data) { + return ( + + {props.greeting}, {data.name} + + ); + } + let loadUser = block(Query, User); + let model = { + User: loadUser('Seb', 'Smith'), + }; + + let transport = []; + ReactDOMFlightRelayServer.render(model, transport); + + let modelClient = readThrough(transport); + + let container = document.createElement('div'); + let root = ReactDOM.createRoot(container); + act(() => { + let UserClient = modelClient.User; + root.render(); + }); + + expect(container.innerHTML).toEqual('Hello, Seb Smith'); }); }); diff --git a/packages/react-flight-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-flight-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index b51f8c93ac04..485b69f85d5f 100644 --- a/packages/react-flight-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-flight-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -33,9 +33,8 @@ type Thenable = { // replicate it in user space. null means that it has already loaded. const chunkCache: Map = new Map(); -// Returning null means that all dependencies are fulfilled and we -// can synchronously require the module now. A thenable is returned -// that when resolved, means we can try again. +// Start preloading the modules since we might need them soon. +// This function doesn't suspend. export function preloadModule(moduleData: ModuleReference): void { let chunks = moduleData.chunks; for (let i = 0; i < chunks.length; i++) { @@ -51,6 +50,8 @@ export function preloadModule(moduleData: ModuleReference): void { } } +// Actually require the module or suspend if it's not yet ready. +// Increase priority if necessary. export function requireModule(moduleData: ModuleReference): T { let chunks = moduleData.chunks; for (let i = 0; i < chunks.length; i++) { diff --git a/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 81011b63b3be..108a044eda16 100644 --- a/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -17,6 +17,13 @@ global.TextDecoder = require('util').TextDecoder; // TODO: we can replace this with FlightServer.act(). global.setImmediate = cb => cb(); +let webpackModuleIdx = 0; +let webpackModules = {}; +let webpackMap = {}; +global.__webpack_require__ = function(id) { + return webpackModules[id]; +}; + let act; let Stream; let React; @@ -27,6 +34,8 @@ let ReactFlightDOMClient; describe('ReactFlightDOM', () => { beforeEach(() => { jest.resetModules(); + webpackModules = {}; + webpackMap = {}; act = require('react-dom/test-utils').act; Stream = require('stream'); React = require('react'); @@ -53,6 +62,24 @@ describe('ReactFlightDOM', () => { }; } + function block(query, render) { + let idx = webpackModuleIdx++; + webpackModules[idx] = { + d: render, + }; + webpackMap['path/' + idx] = { + id: '' + idx, + chunks: [], + name: 'd', + }; + return function(...args) { + let curriedQuery = () => { + return query(...args); + }; + return [Symbol.for('react.server.block'), 'path/' + idx, curriedQuery]; + }; + } + async function waitForSuspense(fn) { while (true) { try { @@ -88,7 +115,7 @@ describe('ReactFlightDOM', () => { } let {writable, readable} = getTestStream(); - ReactFlightDOMServer.pipeToNodeWritable(, writable); + ReactFlightDOMServer.pipeToNodeWritable(, writable, webpackMap); let result = ReactFlightDOMClient.readFromReadableStream(readable); await waitForSuspense(() => { expect(result.model).toEqual({ @@ -136,7 +163,11 @@ describe('ReactFlightDOM', () => { } let {writable, readable} = getTestStream(); - ReactFlightDOMServer.pipeToNodeWritable(, writable); + ReactFlightDOMServer.pipeToNodeWritable( + , + writable, + webpackMap, + ); let result = ReactFlightDOMClient.readFromReadableStream(readable); let container = document.createElement('div'); @@ -170,7 +201,11 @@ describe('ReactFlightDOM', () => { } let {writable, readable} = getTestStream(); - ReactFlightDOMServer.pipeToNodeWritable(, writable); + ReactFlightDOMServer.pipeToNodeWritable( + , + writable, + webpackMap, + ); let result = ReactFlightDOMClient.readFromReadableStream(readable); let container = document.createElement('div'); @@ -202,7 +237,11 @@ describe('ReactFlightDOM', () => { } let {writable, readable} = getTestStream(); - ReactFlightDOMServer.pipeToNodeWritable(, writable); + ReactFlightDOMServer.pipeToNodeWritable( + , + writable, + webpackMap, + ); let result = ReactFlightDOMClient.readFromReadableStream(readable); let container = document.createElement('div'); @@ -213,7 +252,7 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

@div

'); }); - it.experimental('should progressively reveal chunks', async () => { + it.experimental('should progressively reveal Blocks', async () => { let {Suspense} = React; class ErrorBoundary extends React.Component { @@ -249,16 +288,20 @@ describe('ReactFlightDOM', () => { reject(e); }; }); - function DelayedText({children}) { + function Query() { if (promise) { throw promise; } if (error) { throw error; } + return 'data'; + } + function DelayedText({children}, data) { return {children}; } - return [DelayedText, _resolve, _reject]; + let _block = block(Query, DelayedText); + return [_block(), _resolve, _reject]; } const [FriendsModel, resolveFriendsModel] = makeDelayedText(); @@ -274,13 +317,11 @@ describe('ReactFlightDOM', () => { games: :games:, }; } - function ProfileModel() { - return { - photos: :photos:, - name: :name:, - more: , - }; - } + let profileModel = { + photos: :photos:, + name: :name:, + more: , + }; // View function ProfileDetails({result}) { @@ -327,7 +368,7 @@ describe('ReactFlightDOM', () => { } let {writable, readable} = getTestStream(); - ReactFlightDOMServer.pipeToNodeWritable(, writable); + ReactFlightDOMServer.pipeToNodeWritable(profileModel, writable, webpackMap); let result = ReactFlightDOMClient.readFromReadableStream(readable); let container = document.createElement('div'); diff --git a/packages/react-noop-renderer/flight-modules.js b/packages/react-noop-renderer/flight-modules.js new file mode 100644 index 000000000000..ae7b838078e7 --- /dev/null +++ b/packages/react-noop-renderer/flight-modules.js @@ -0,0 +1,23 @@ +/** + * 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. + * + * @flow + */ + +// This file is used as temporary storage for modules generated in Flight tests. +let moduleIdx = 0; +let modules: Map = new Map(); + +// This simulates what the compiler will do when it replaces render functions with server blocks. +export function saveModule(render: Function): string { + let idx = '' + moduleIdx++; + modules.set(idx, render); + return idx; +} + +export function readModule(idx: string): Function { + return modules.get(idx); +} diff --git a/packages/react-noop-renderer/npm/flight-modules.js b/packages/react-noop-renderer/npm/flight-modules.js new file mode 100644 index 000000000000..f4079d795383 --- /dev/null +++ b/packages/react-noop-renderer/npm/flight-modules.js @@ -0,0 +1,16 @@ +'use strict'; + +// This file is used as temporary storage for modules generated in Flight tests. +var moduleIdx = 0; +var modules = new Map(); + +// This simulates what the compiler will do when it replaces render functions with server blocks. +exports.saveModule = function saveModule(render) { + var idx = '' + moduleIdx++; + modules.set(idx, render); + return idx; +}; + +exports.readModule = function readModule(idx) { + return modules.get(idx); +}; diff --git a/packages/react-noop-renderer/package.json b/packages/react-noop-renderer/package.json index a8475ebaf5b0..eb67626f474a 100644 --- a/packages/react-noop-renderer/package.json +++ b/packages/react-noop-renderer/package.json @@ -28,6 +28,7 @@ "persistent.js", "server.js", "flight-client.js", + "flight-modules.js", "flight-server.js", "cjs/" ] diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 57ec3aeea160..e2f9ad0df2df 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -16,6 +16,8 @@ import type {ReactModelRoot} from 'react-client/flight'; +import {readModule} from 'react-noop-renderer/flight-modules'; + import ReactFlightClient from 'react-client/flight'; type Source = Array; @@ -27,14 +29,12 @@ const { close, } = ReactFlightClient({ supportsBinaryStreams: false, - resolveModuleReference(name: string) { - return name; + resolveModuleReference(idx: string) { + return idx; }, - preloadModule(name: string) {}, - requireModule(name: string) { - return function FakeModule() { - return name; - }; + preloadModule(idx: string) {}, + requireModule(idx: string) { + return readModule(idx); }, }); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index e33a664e0785..1ebf5229e0a2 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -16,6 +16,8 @@ import type {ReactModel} from 'react-server/src/ReactFlightServer'; +import {saveModule} from 'react-noop-renderer/flight-modules'; + import ReactFlightServer from 'react-server/flight'; type Destination = Array; @@ -40,8 +42,8 @@ const ReactNoopFlightServer = ReactFlightServer({ formatChunk(type: string, props: Object): Uint8Array { return Buffer.from(JSON.stringify({type, props}), 'utf8'); }, - renderHostChildrenToString(children: React$Element): string { - throw new Error('The noop rendered do not support host components'); + resolveModuleMetaData(config: void, renderFn: Function) { + return saveModule(renderFn); }, }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index e3df453485a0..78c2fc1e9d19 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -11,8 +11,8 @@ import type { Destination, Chunk, BundlerConfig, - // ModuleReference, - // ModuleMetaData, + ModuleMetaData, + ModuleReference, } from './ReactFlightServerConfig'; import { @@ -24,17 +24,25 @@ import { close, processModelChunk, processErrorChunk, - // resolveModuleMetaData, + resolveModuleMetaData, } from './ReactFlightServerConfig'; -import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; +import { + REACT_BLOCK_TYPE, + REACT_SERVER_BLOCK_TYPE, + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + REACT_LAZY_TYPE, +} from 'shared/ReactSymbols'; + +import invariant from 'shared/invariant'; type ReactJSONValue = | string | boolean | number | null - | Array + | $ReadOnlyArray | ReactModelObject; export type ReactModel = @@ -50,7 +58,7 @@ type ReactModelObject = {+[key: string]: ReactModel}; type Segment = { id: number, - model: ReactModel, + query: () => ReactModel, ping: () => void, }; @@ -81,26 +89,31 @@ export function createRequest( completedJSONChunks: [], completedErrorChunks: [], flowing: false, - toJSON: (key: string, value: ReactModel) => - resolveModelToJSON(request, value), + toJSON: function(key: string, value: ReactModel): ReactJSONValue { + return resolveModelToJSON(request, this, key, value); + }, }; request.pendingChunks++; - let rootSegment = createSegment(request, model); + let rootSegment = createSegment(request, () => model); pingedSegments.push(rootSegment); return request; } -function attemptResolveModelComponent(element: React$Element): ReactModel { +function attemptResolveElement(element: React$Element): ReactModel { let type = element.type; let props = element.props; if (typeof type === 'function') { - // This is a nested view model. + // This is a server-side component. return type(props); } else if (typeof type === 'string') { // This is a host element. E.g. HTML. return [REACT_ELEMENT_TYPE, type, element.key, element.props]; + } else if (type[0] === REACT_SERVER_BLOCK_TYPE) { + return [REACT_ELEMENT_TYPE, type, element.key, element.props]; + } else if (type === REACT_FRAGMENT_TYPE) { + return element.props.children; } else { - throw new Error('Unsupported type.'); + invariant(false, 'Unsupported type.'); } } @@ -112,11 +125,11 @@ function pingSegment(request: Request, segment: Segment): void { } } -function createSegment(request: Request, model: ReactModel): Segment { +function createSegment(request: Request, query: () => ReactModel): Segment { let id = request.nextChunkId++; let segment = { id, - model, + query, ping: () => pingSegment(request, segment), }; return segment; @@ -127,9 +140,9 @@ function serializeIDRef(id: number): string { } function escapeStringValue(value: string): string { - if (value[0] === '$') { - // We need to escape $ prefixed strings since we use that to encode - // references to IDs and as a special symbol value. + if (value[0] === '$' || value[0] === '@') { + // We need to escape $ or @ prefixed strings since we use those to encode + // references to IDs and as special symbol values. return '$' + value; } else { return value; @@ -138,39 +151,95 @@ function escapeStringValue(value: string): string { export function resolveModelToJSON( request: Request, + parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray, + key: string, value: ReactModel, ): ReactJSONValue { - if (typeof value === 'string') { - return escapeStringValue(value); + // Special Symbols + switch (value) { + case REACT_ELEMENT_TYPE: + return '$'; + case REACT_SERVER_BLOCK_TYPE: + return '@'; + case REACT_LAZY_TYPE: + case REACT_BLOCK_TYPE: + invariant( + false, + 'React Blocks (and Lazy Components) are expected to be replaced by a ' + + 'compiler on the server. Try configuring your compiler set up and avoid ' + + 'using React.lazy inside of Blocks.', + ); } - if (value === REACT_ELEMENT_TYPE) { - return '$'; + if (parent[0] === REACT_SERVER_BLOCK_TYPE) { + // We're currently encoding part of a Block. Look up which key. + switch (key) { + case '1': { + // Module reference + let moduleReference: ModuleReference = (value: any); + try { + let moduleMetaData: ModuleMetaData = resolveModuleMetaData( + request.bundlerConfig, + moduleReference, + ); + return (moduleMetaData: ReactJSONValue); + } catch (x) { + request.pendingChunks++; + let errorId = request.nextChunkId++; + emitErrorChunk(request, errorId, x); + return serializeIDRef(errorId); + } + } + case '2': { + // Query + let query: () => ReactModel = (value: any); + try { + // Attempt to resolve the query. + return query(); + } catch (x) { + if ( + typeof x === 'object' && + x !== null && + typeof x.then === 'function' + ) { + // Something suspended, we'll need to create a new segment and resolve it later. + request.pendingChunks++; + let newSegment = createSegment(request, query); + let ping = newSegment.ping; + x.then(ping, ping); + return serializeIDRef(newSegment.id); + } else { + // This query failed, encode the error as a separate row and reference that. + request.pendingChunks++; + let errorId = request.nextChunkId++; + emitErrorChunk(request, errorId, x); + return serializeIDRef(errorId); + } + } + } + default: { + invariant( + false, + 'A server block should never encode any other slots. This is a bug in React.', + ); + } + } + } + + if (typeof value === 'string') { + return escapeStringValue(value); } + // Resolve server components. while ( typeof value === 'object' && value !== null && value.$$typeof === REACT_ELEMENT_TYPE ) { + // TODO: Concatenate keys of parents onto children. + // TODO: Allow elements to suspend independently and serialize as references to future elements. let element: React$Element = (value: any); - try { - value = attemptResolveModelComponent(element); - } catch (x) { - if (typeof x === 'object' && x !== null && typeof x.then === 'function') { - // Something suspended, we'll need to create a new segment and resolve it later. - request.pendingChunks++; - let newSegment = createSegment(request, element); - let ping = newSegment.ping; - x.then(ping, ping); - return serializeIDRef(newSegment.id); - } else { - request.pendingChunks++; - let errorId = request.nextChunkId++; - emitErrorChunk(request, errorId, x); - return serializeIDRef(errorId); - } - } + value = attemptResolveElement(element); } return value; @@ -198,19 +267,9 @@ function emitErrorChunk(request: Request, id: number, error: mixed): void { } function retrySegment(request: Request, segment: Segment): void { - let value = segment.model; + let query = segment.query; try { - while ( - typeof value === 'object' && - value !== null && - value.$$typeof === REACT_ELEMENT_TYPE - ) { - // If this is a nested model, there's no need to create another chunk, - // we can reuse the existing one and try again. - let element: React$Element = (value: any); - segment.model = element; - value = attemptResolveModelComponent(element); - } + let value = query(); let processedChunk = processModelChunk(request, segment.id, value); request.completedJSONChunks.push(processedChunk); } catch (x) { diff --git a/packages/react/src/ReactBlock.js b/packages/react/src/ReactBlock.js index 901ee1e90cc5..70a876199569 100644 --- a/packages/react/src/ReactBlock.js +++ b/packages/react/src/ReactBlock.js @@ -17,7 +17,7 @@ import { } from 'shared/ReactSymbols'; type BlockQueryFunction, Data> = (...args: Args) => Data; -type BlockRenderFunction = ( +export type BlockRenderFunction = ( props: Props, data: Data, ) => React$Node; diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index 6e6dd6c30690..e05d8bf0f3bb 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -52,6 +52,9 @@ export const REACT_SUSPENSE_LIST_TYPE = hasSymbol export const REACT_MEMO_TYPE = hasSymbol ? Symbol.for('react.memo') : 0xead3; export const REACT_LAZY_TYPE = hasSymbol ? Symbol.for('react.lazy') : 0xead4; export const REACT_BLOCK_TYPE = hasSymbol ? Symbol.for('react.block') : 0xead9; +export const REACT_SERVER_BLOCK_TYPE = hasSymbol + ? Symbol.for('react.server.block') + : 0xeada; export const REACT_FUNDAMENTAL_TYPE = hasSymbol ? Symbol.for('react.fundamental') : 0xead5; diff --git a/packages/shared/isValidElementType.js b/packages/shared/isValidElementType.js index 187dcf8c8f98..fb71c2697128 100644 --- a/packages/shared/isValidElementType.js +++ b/packages/shared/isValidElementType.js @@ -23,6 +23,7 @@ import { REACT_RESPONDER_TYPE, REACT_SCOPE_TYPE, REACT_BLOCK_TYPE, + REACT_SERVER_BLOCK_TYPE, } from 'shared/ReactSymbols'; export default function isValidElementType(type: mixed) { @@ -46,6 +47,7 @@ export default function isValidElementType(type: mixed) { type.$$typeof === REACT_FUNDAMENTAL_TYPE || type.$$typeof === REACT_RESPONDER_TYPE || type.$$typeof === REACT_SCOPE_TYPE || - type.$$typeof === REACT_BLOCK_TYPE)) + type.$$typeof === REACT_BLOCK_TYPE || + type[(0: any)] === REACT_SERVER_BLOCK_TYPE)) ); } diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a2f0a1f9da59..0a995b22b45a 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -348,5 +348,8 @@ "347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead.", "348": "ensureListeningTo(): received a container that was not an element node. This is likely a bug in React.", "349": "Expected a work-in-progress root. This is a bug in React. Please file an issue.", - "350": "Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue." + "350": "Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue.", + "351": "Unsupported type.", + "352": "React Blocks (and Lazy Components) are expected to be replaced by a compiler on the server. Try configuring your compiler set up and avoid using React.lazy inside of Blocks.", + "353": "A server block should never encode any other slots. This is a bug in React." } diff --git a/scripts/flow/react-relay-hooks.js b/scripts/flow/react-relay-hooks.js index b5800d387f4e..9459af2acfd3 100644 --- a/scripts/flow/react-relay-hooks.js +++ b/scripts/flow/react-relay-hooks.js @@ -9,11 +9,11 @@ type JSONValue = | string - | number | boolean + | number | null - | {[key: string]: JSONValue} - | Array; + | {+[key: string]: JSONValue} + | $ReadOnlyArray; declare module 'ReactFlightDOMRelayServerIntegration' { declare export opaque type Destination; @@ -31,7 +31,7 @@ declare module 'ReactFlightDOMRelayServerIntegration' { declare export function close(destination: Destination): void; declare export opaque type ModuleReference; - declare export opaque type ModuleMetaData; + declare export type ModuleMetaData = JSONValue; declare export function resolveModuleMetaData( resourceReference: ModuleReference, ): ModuleMetaData; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 205f1acfbc41..87bebe424b25 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -405,7 +405,12 @@ const bundles = [ moduleType: RENDERER, entry: 'react-noop-renderer/flight-server', global: 'ReactNoopFlightServer', - externals: ['react', 'scheduler', 'expect'], + externals: [ + 'react', + 'scheduler', + 'expect', + 'react-noop-renderer/flight-modules', + ], }, /******* React Noop Flight Client (used for tests) *******/ @@ -414,7 +419,12 @@ const bundles = [ moduleType: RENDERER, entry: 'react-noop-renderer/flight-client', global: 'ReactNoopFlightClient', - externals: ['react', 'scheduler', 'expect'], + externals: [ + 'react', + 'scheduler', + 'expect', + 'react-noop-renderer/flight-modules', + ], }, /******* React Reconciler *******/ diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 725bb040e3eb..6e392ed42f49 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -32,6 +32,10 @@ module.exports = { // Flight Uint8Array: true, Promise: true, + + // Flight Webpack + __webpack_chunk_load__: true, + __webpack_require__: true, }, parserOptions: { ecmaVersion: 5, diff --git a/scripts/rollup/validate/eslintrc.umd.js b/scripts/rollup/validate/eslintrc.umd.js index 786f517de3a1..9537b41a357a 100644 --- a/scripts/rollup/validate/eslintrc.umd.js +++ b/scripts/rollup/validate/eslintrc.umd.js @@ -36,6 +36,10 @@ module.exports = { // Flight Uint8Array: true, Promise: true, + + // Flight Webpack + __webpack_chunk_load__: true, + __webpack_require__: true, }, parserOptions: { ecmaVersion: 5,