From a56309fb883fcb04d7c3d1e4bc40987175c473f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 23 Mar 2020 17:53:45 -0700 Subject: [PATCH] [Flight] Integrate Blocks into Flight (#18371) * Resolve Server-side Blocks instead of Components React elements should no longer be used to extract arbitrary data but only for prerendering trees. Blocks are used to create asynchronous behavior. * Resolve Blocks in the Client * Tests * Bug fix relay JSON traversal It's supposed to pass the original object and not the new one. * Lint * Move Noop Module Test Helpers to top level entry points This module has shared state. It needs to be external from builds. This lets us test the built versions of the Noop renderer. --- .../react-client/src/ReactFlightClient.js | 147 ++++++++++++---- .../src/__tests__/ReactFlight-test.js | 64 ++++++- .../ReactFlightDOMRelayServerHostConfig.js | 24 ++- .../ReactFlightDOMRelayClientIntegration.js | 8 +- .../ReactFlightDOMRelayServerIntegration.js | 2 +- .../ReactFlightDOMRelay-test.internal.js | 99 +++++++++-- .../ReactFlightClientWebpackBundlerConfig.js | 7 +- .../src/__tests__/ReactFlightDOM-test.js | 71 ++++++-- .../react-noop-renderer/flight-modules.js | 23 +++ .../react-noop-renderer/npm/flight-modules.js | 16 ++ packages/react-noop-renderer/package.json | 1 + .../src/ReactNoopFlightClient.js | 14 +- .../src/ReactNoopFlightServer.js | 6 +- .../react-server/src/ReactFlightServer.js | 159 ++++++++++++------ packages/react/src/ReactBlock.js | 2 +- packages/shared/ReactSymbols.js | 3 + packages/shared/isValidElementType.js | 4 +- scripts/error-codes/codes.json | 5 +- scripts/flow/react-relay-hooks.js | 8 +- scripts/rollup/bundles.js | 14 +- scripts/rollup/validate/eslintrc.cjs.js | 4 + scripts/rollup/validate/eslintrc.umd.js | 4 + 22 files changed, 530 insertions(+), 155 deletions(-) create mode 100644 packages/react-noop-renderer/flight-modules.js create mode 100644 packages/react-noop-renderer/npm/flight-modules.js 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,