From a1be51642a910282996302b64ac7152f84fe3b56 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 6 Mar 2020 19:59:25 -0800 Subject: [PATCH 1/5] Add ReactFlightServerConfig intermediate This just forwards to the stream version of Flight which is itself forked between Node and W3C streams. The dom-relay goes directly to the Relay config though which allows it to avoid the stream part of Flight. --- .../src/ReactFlightServerConfig.js | 22 ++++++++++++ .../src/ReactFlightServerConfigStream.js | 25 +++++++++++++ .../forks/ReactFlightServerConfig.custom.js | 35 +++++++++++++++++++ .../ReactFlightServerConfig.dom-browser.js | 10 ++++++ .../ReactFlightServerConfig.dom-relay.js | 10 ++++++ .../src/forks/ReactFlightServerConfig.dom.js | 10 ++++++ scripts/flow/createFlowConfigs.js | 1 + scripts/jest/setupHostConfigs.js | 3 ++ scripts/rollup/forks.js | 28 +++++++++++++++ 9 files changed, 144 insertions(+) create mode 100644 packages/react-server/src/ReactFlightServerConfig.js create mode 100644 packages/react-server/src/ReactFlightServerConfigStream.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.custom.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom.js diff --git a/packages/react-server/src/ReactFlightServerConfig.js b/packages/react-server/src/ReactFlightServerConfig.js new file mode 100644 index 0000000000000..49c9752540528 --- /dev/null +++ b/packages/react-server/src/ReactFlightServerConfig.js @@ -0,0 +1,22 @@ +/** + * 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 + */ + +/* eslint-disable react-internal/invariant-args */ + +import invariant from 'shared/invariant'; + +// We expect that our Rollup, Jest, and Flow configurations +// always shim this module with the corresponding host config +// (either provided by a renderer, or a generic shim for npm). +// +// We should never resolve to this file, but it exists to make +// sure that if we *do* accidentally break the configuration, +// the failure isn't silent. + +invariant(false, 'This module must be shimmed by a specific renderer.'); diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js new file mode 100644 index 0000000000000..f9923b6c48795 --- /dev/null +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -0,0 +1,25 @@ +/** + * 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 an intermediate layer to translate between Flight +// calls to stream output. + +import type {Destination as DestinationT} from './ReactServerStreamConfig'; + +export type Destination = DestinationT; + +export { + scheduleWork, + flushBuffered, + beginWriting, + writeChunk, + completeWriting, + close, + convertStringToBuffer, +} from './ReactServerStreamConfig'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js new file mode 100644 index 0000000000000..c3dc6d83d64b0 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js @@ -0,0 +1,35 @@ +/** + * 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 is a host config that's used for the `react-server` package on npm. +// It is only used by third-party renderers. +// +// Its API lets you pass the host config as an argument. +// However, inside the `react-server` we treat host config as a module. +// This file is a shim between two worlds. +// +// It works because the `react-server` bundle is wrapped in something like: +// +// module.exports = function ($$$config) { +// /* renderer code */ +// } +// +// So `$$$config` looks like a global variable, but it's +// really an argument to a top-level wrapping function. + +declare var $$$hostConfig: any; +export opaque type Destination = mixed; // eslint-disable-line no-undef + +export const scheduleWork = $$$hostConfig.scheduleWork; +export const beginWriting = $$$hostConfig.beginWriting; +export const writeChunk = $$$hostConfig.writeChunk; +export const completeWriting = $$$hostConfig.completeWriting; +export const flushBuffered = $$$hostConfig.flushBuffered; +export const close = $$$hostConfig.close; +export const convertStringToBuffer = $$$hostConfig.convertStringToBuffer; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js new file mode 100644 index 0000000000000..2ade60a042904 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from '../ReactFlightServerConfigStream'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js new file mode 100644 index 0000000000000..e283cca637c43 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from 'react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom.js new file mode 100644 index 0000000000000..2ade60a042904 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from '../ReactFlightServerConfigStream'; diff --git a/scripts/flow/createFlowConfigs.js b/scripts/flow/createFlowConfigs.js index 434368af00424..6f57c2f58e7a9 100644 --- a/scripts/flow/createFlowConfigs.js +++ b/scripts/flow/createFlowConfigs.js @@ -51,6 +51,7 @@ function writeConfig(renderer, rendererInfo, isServerSupported) { module.name_mapper='ReactFiberHostConfig$$' -> 'forks/ReactFiberHostConfig.${renderer}' module.name_mapper='ReactServerStreamConfig$$' -> 'forks/ReactServerStreamConfig.${serverRenderer}' module.name_mapper='ReactServerFormatConfig$$' -> 'forks/ReactServerFormatConfig.${serverRenderer}' +module.name_mapper='ReactFlightServerConfig$$' -> 'forks/ReactFlightServerConfig.${serverRenderer}' module.name_mapper='ReactFlightClientHostConfig$$' -> 'forks/ReactFlightClientHostConfig.${serverRenderer}' `.trim(), ) diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index d11330db5522b..20fc46eeb573a 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -13,6 +13,7 @@ jest.mock('react-reconciler', () => { }); const shimServerStreamConfigPath = 'react-server/src/ReactServerStreamConfig'; const shimServerFormatConfigPath = 'react-server/src/ReactServerFormatConfig'; +const shimFlightServerConfigPath = 'react-server/src/ReactFlightServerConfig'; jest.mock('react-server', () => { return config => { jest.mock(shimServerStreamConfigPath, () => config); @@ -24,6 +25,7 @@ jest.mock('react-server/flight', () => { return config => { jest.mock(shimServerStreamConfigPath, () => config); jest.mock(shimServerFormatConfigPath, () => config); + jest.mock(shimFlightServerConfigPath, () => config); return require.requireActual('react-server/flight'); }; }); @@ -41,6 +43,7 @@ const configPaths = [ 'react-client/src/ReactFlightClientHostConfig', 'react-server/src/ReactServerStreamConfig', 'react-server/src/ReactServerFormatConfig', + 'react-server/src/ReactFlightServerConfig', ]; function mockAllConfigs(rendererInfo) { diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 105eb2585333d..35a7977093431 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -354,6 +354,34 @@ const forks = Object.freeze({ ); }, + 'react-server/src/ReactFlightServerConfig': ( + bundleType, + entry, + dependencies, + moduleType + ) => { + if (dependencies.indexOf('react-server') !== -1) { + return null; + } + if (moduleType !== RENDERER && moduleType !== RECONCILER) { + return null; + } + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (let rendererInfo of inlinedHostConfigs) { + if (rendererInfo.entryPoints.indexOf(entry) !== -1) { + if (!rendererInfo.isServerSupported) { + return null; + } + return `react-server/src/forks/ReactFlightServerConfig.${rendererInfo.shortName}.js`; + } + } + throw new Error( + 'Expected ReactFlightServerConfig to always be replaced with a shim, but ' + + `found no mention of "${entry}" entry point in ./scripts/shared/inlinedHostConfigs.js. ` + + 'Did you mean to add it there to associate it with a specific renderer?' + ); + }, + 'react-client/src/ReactFlightClientHostConfig': ( bundleType, entry, From 32ff34bc1ce3f7d86b9d4309b06212a810ab21d8 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 8 Mar 2020 18:33:03 -0700 Subject: [PATCH 2/5] Separate streaming protocol into the Stream config --- .../react-server/src/ReactFlightServer.js | 63 +------------------ .../src/ReactFlightServerConfigStream.js | 56 ++++++++++++++++- .../forks/ReactFlightServerConfig.custom.js | 27 +------- scripts/jest/setupHostConfigs.js | 6 +- 4 files changed, 64 insertions(+), 88 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 3cc0733598605..1d0447fe5eb5c 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -7,7 +7,7 @@ * @flow */ -import type {Destination} from './ReactServerStreamConfig'; +import type {Destination} from './ReactFlightServerConfig'; import { scheduleWork, @@ -17,65 +17,10 @@ import { flushBuffered, close, convertStringToBuffer, -} from './ReactServerStreamConfig'; +} from './ReactFlightServerConfig'; import {renderHostChildrenToString} from './ReactServerFormatConfig'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; -/* - -FLIGHT PROTOCOL GRAMMAR - -Response -- JSONData RowSequence -- JSONData - -RowSequence -- Row RowSequence -- Row - -Row -- "J" RowID JSONData -- "H" RowID HTMLData -- "B" RowID BlobData -- "U" RowID URLData -- "E" RowID ErrorData - -RowID -- HexDigits ":" - -HexDigits -- HexDigit HexDigits -- HexDigit - -HexDigit -- 0-F - -URLData -- (UTF8 encoded URL) "\n" - -ErrorData -- (UTF8 encoded JSON: {message: "...", stack: "..."}) "\n" - -JSONData -- (UTF8 encoded JSON) "\n" - - String values that begin with $ are escaped with a "$" prefix. - - References to other rows are encoding as JSONReference strings. - -JSONReference -- "$" HexDigits - -HTMLData -- ByteSize (UTF8 encoded HTML) - -BlobData -- ByteSize (Binary Data) - -ByteSize -- (unsigned 32-bit integer) -*/ - -// TODO: Implement HTMLData, BlobData and URLData. - const stringify = JSON.stringify; export type ReactModel = @@ -95,13 +40,12 @@ type ReactJSONValue = | Array | ReactModelObject; -type ReactModelObject = {+[key: string]: ReactModel, ...}; +type ReactModelObject = {+[key: string]: ReactModel}; type Segment = { id: number, model: ReactModel, ping: () => void, - ... }; type OpaqueRequest = { @@ -113,7 +57,6 @@ type OpaqueRequest = { completedErrorChunks: Array, flowing: boolean, toJSON: (key: string, value: ReactModel) => ReactJSONValue, - ... }; export function createRequest( diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js index f9923b6c48795..31772146900c7 100644 --- a/packages/react-server/src/ReactFlightServerConfigStream.js +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -8,7 +8,61 @@ */ // This file is an intermediate layer to translate between Flight -// calls to stream output. +// calls to stream output over a binary stream. + +/* +FLIGHT PROTOCOL GRAMMAR + +Response +- JSONData RowSequence +- JSONData + +RowSequence +- Row RowSequence +- Row + +Row +- "J" RowID JSONData +- "H" RowID HTMLData +- "B" RowID BlobData +- "U" RowID URLData +- "E" RowID ErrorData + +RowID +- HexDigits ":" + +HexDigits +- HexDigit HexDigits +- HexDigit + +HexDigit +- 0-F + +URLData +- (UTF8 encoded URL) "\n" + +ErrorData +- (UTF8 encoded JSON: {message: "...", stack: "..."}) "\n" + +JSONData +- (UTF8 encoded JSON) "\n" + - String values that begin with $ are escaped with a "$" prefix. + - References to other rows are encoding as JSONReference strings. + +JSONReference +- "$" HexDigits + +HTMLData +- ByteSize (UTF8 encoded HTML) + +BlobData +- ByteSize (Binary Data) + +ByteSize +- (unsigned 32-bit integer) +*/ + +// TODO: Implement HTMLData, BlobData and URLData. import type {Destination as DestinationT} from './ReactServerStreamConfig'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js index c3dc6d83d64b0..2ade60a042904 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js @@ -7,29 +7,4 @@ * @flow */ -// This is a host config that's used for the `react-server` package on npm. -// It is only used by third-party renderers. -// -// Its API lets you pass the host config as an argument. -// However, inside the `react-server` we treat host config as a module. -// This file is a shim between two worlds. -// -// It works because the `react-server` bundle is wrapped in something like: -// -// module.exports = function ($$$config) { -// /* renderer code */ -// } -// -// So `$$$config` looks like a global variable, but it's -// really an argument to a top-level wrapping function. - -declare var $$$hostConfig: any; -export opaque type Destination = mixed; // eslint-disable-line no-undef - -export const scheduleWork = $$$hostConfig.scheduleWork; -export const beginWriting = $$$hostConfig.beginWriting; -export const writeChunk = $$$hostConfig.writeChunk; -export const completeWriting = $$$hostConfig.completeWriting; -export const flushBuffered = $$$hostConfig.flushBuffered; -export const close = $$$hostConfig.close; -export const convertStringToBuffer = $$$hostConfig.convertStringToBuffer; +export * from '../ReactFlightServerConfigStream'; diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 20fc46eeb573a..7ec000b847228 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -25,7 +25,11 @@ jest.mock('react-server/flight', () => { return config => { jest.mock(shimServerStreamConfigPath, () => config); jest.mock(shimServerFormatConfigPath, () => config); - jest.mock(shimFlightServerConfigPath, () => config); + jest.mock(shimFlightServerConfigPath, () => + require.requireActual( + 'react-server/src/forks/ReactFlightServerConfig.custom' + ) + ); return require.requireActual('react-server/flight'); }; }); From 2162d25015e5f58b9f8f98af4efbe3e1a5c0ae33 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 9 Mar 2020 17:39:00 -0700 Subject: [PATCH 3/5] Split streaming parts into the ReactFlightServerConfigStream This decouples it so that the Relay implementation doesn't have to encode the JSON to strings. Instead it can be fed the values as JSON objects and do its own encoding. --- .../src/ReactFlightDOMRelayServer.js | 3 +- .../ReactFlightDOMRelayServerHostConfig.js | 90 ++++++++++++++++--- .../react-server/src/ReactFlightServer.js | 70 ++++++--------- .../src/ReactFlightServerConfigStream.js | 43 ++++++++- .../ReactServerStreamConfig.dom-relay.js | 2 +- 5 files changed, 149 insertions(+), 59 deletions(-) diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js index 8945e0e1d9526..337111f08168c 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js @@ -8,10 +8,11 @@ */ import type {ReactModel} from 'react-server/src/ReactFlightServer'; +import type {Chunk} from './ReactFlightDOMRelayServerHostConfig'; import {createRequest, startWork} from 'react-server/src/ReactFlightServer'; -type EncodedData = Array; +type EncodedData = Array; function render(model: ReactModel): EncodedData { let data: EncodedData = []; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 5ede8b9503fed..f3a0f6f2c9238 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -7,7 +7,84 @@ * @flow */ -export type Destination = Array; +import type {Request, ReactModel} from 'react-server/src/ReactFlightServer'; + +import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; + +export type Destination = Array; + +type JSONValue = + | string + | number + | boolean + | null + | {[key: string]: JSONValue} + | Array; + +export type Chunk = + | { + type: 'json', + id: number, + json: JSONValue, + } + | { + type: 'error', + id: number, + json: { + message: string, + stack: string, + ... + }, + }; + +export function processErrorChunk( + request: Request, + id: number, + message: string, + stack: string, +): Chunk { + return { + type: 'error', + id: id, + json: { + message, + stack, + }, + }; +} + +function convertModelToJSON(request: Request, model: ReactModel): JSONValue { + let json = resolveModelToJSON(request, 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]); + } + return jsonArray; + } else { + let jsonObj: {[key: string]: JSONValue} = {}; + for (let key in json) { + jsonObj[key] = convertModelToJSON(request, json[key]); + } + return jsonObj; + } + } + return json; +} + +export function processModelChunk( + request: Request, + id: number, + model: ReactModel, +): Chunk { + let json = convertModelToJSON(request, model); + return { + type: 'json', + id: id, + json: json, + }; +} export function scheduleWork(callback: () => void) { callback(); @@ -17,18 +94,11 @@ export function flushBuffered(destination: Destination) {} export function beginWriting(destination: Destination) {} -export function writeChunk( - destination: Destination, - buffer: Uint8Array, -): boolean { - destination.push(Buffer.from((buffer: any)).toString('utf8')); +export function writeChunk(destination: Destination, chunk: Chunk): boolean { + destination.push(chunk); return true; } export function completeWriting(destination: Destination) {} export function close(destination: Destination) {} - -export function convertStringToBuffer(content: string): Uint8Array { - return Buffer.from(content, 'utf8'); -} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 1d0447fe5eb5c..5fba89e63d4ab 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -7,7 +7,7 @@ * @flow */ -import type {Destination} from './ReactFlightServerConfig'; +import type {Destination, Chunk} from './ReactFlightServerConfig'; import { scheduleWork, @@ -16,28 +16,27 @@ import { completeWriting, flushBuffered, close, - convertStringToBuffer, + processModelChunk, + processErrorChunk, } from './ReactFlightServerConfig'; import {renderHostChildrenToString} from './ReactServerFormatConfig'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; -const stringify = JSON.stringify; - -export type ReactModel = - | React$Element +type ReactJSONValue = | string | boolean | number | null - | Iterable + | Array | ReactModelObject; -type ReactJSONValue = +export type ReactModel = + | React$Element | string | boolean | number | null - | Array + | Iterable | ReactModelObject; type ReactModelObject = {+[key: string]: ReactModel}; @@ -48,13 +47,13 @@ type Segment = { ping: () => void, }; -type OpaqueRequest = { +export type Request = { destination: Destination, nextChunkId: number, pendingChunks: number, pingedSegments: Array, - completedJSONChunks: Array, - completedErrorChunks: Array, + completedJSONChunks: Array, + completedErrorChunks: Array, flowing: boolean, toJSON: (key: string, value: ReactModel) => ReactJSONValue, }; @@ -62,7 +61,7 @@ type OpaqueRequest = { export function createRequest( model: ReactModel, destination: Destination, -): OpaqueRequest { +): Request { let pingedSegments = []; let request = { destination, @@ -95,7 +94,7 @@ function attemptResolveModelComponent(element: React$Element): ReactModel { } } -function pingSegment(request: OpaqueRequest, segment: Segment): void { +function pingSegment(request: Request, segment: Segment): void { let pingedSegments = request.pingedSegments; pingedSegments.push(segment); if (pingedSegments.length === 1) { @@ -103,7 +102,7 @@ function pingSegment(request: OpaqueRequest, segment: Segment): void { } } -function createSegment(request: OpaqueRequest, model: ReactModel): Segment { +function createSegment(request: Request, model: ReactModel): Segment { let id = request.nextChunkId++; let segment = { id, @@ -117,10 +116,6 @@ function serializeIDRef(id: number): string { return '$' + id.toString(16); } -function serializeRowHeader(tag: string, id: number) { - return tag + id.toString(16) + ':'; -} - function escapeStringValue(value: string): string { if (value[0] === '$') { // We need to escape $ prefixed strings since we use that to encode @@ -131,8 +126,8 @@ function escapeStringValue(value: string): string { } } -function resolveModelToJSON( - request: OpaqueRequest, +export function resolveModelToJSON( + request: Request, value: ReactModel, ): ReactJSONValue { if (typeof value === 'string') { @@ -167,11 +162,7 @@ function resolveModelToJSON( return value; } -function emitErrorChunk( - request: OpaqueRequest, - id: number, - error: mixed, -): void { +function emitErrorChunk(request: Request, id: number, error: mixed): void { // TODO: We should not leak error messages to the client in prod. // Give this an error code instead and log on the server. // We can serialize the error in DEV as a convenience. @@ -187,12 +178,12 @@ function emitErrorChunk( } catch (x) { message = 'An error occurred but serializing the error message failed.'; } - let errorInfo = {message, stack}; - let row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n'; - request.completedErrorChunks.push(convertStringToBuffer(row)); + + let processedChunk = processErrorChunk(request, id, message, stack); + request.completedErrorChunks.push(processedChunk); } -function retrySegment(request: OpaqueRequest, segment: Segment): void { +function retrySegment(request: Request, segment: Segment): void { let value = segment.model; try { while ( @@ -206,15 +197,8 @@ function retrySegment(request: OpaqueRequest, segment: Segment): void { segment.model = element; value = attemptResolveModelComponent(element); } - let json = stringify(value, request.toJSON); - let row; - let id = segment.id; - if (id === 0) { - row = json + '\n'; - } else { - row = serializeRowHeader('J', id) + json + '\n'; - } - request.completedJSONChunks.push(convertStringToBuffer(row)); + let processedChunk = processModelChunk(request, segment.id, value); + request.completedJSONChunks.push(processedChunk); } catch (x) { if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // Something suspended again, let's pick it back up later. @@ -228,7 +212,7 @@ function retrySegment(request: OpaqueRequest, segment: Segment): void { } } -function performWork(request: OpaqueRequest): void { +function performWork(request: Request): void { let pingedSegments = request.pingedSegments; request.pingedSegments = []; for (let i = 0; i < pingedSegments.length; i++) { @@ -241,7 +225,7 @@ function performWork(request: OpaqueRequest): void { } let reentrant = false; -function flushCompletedChunks(request: OpaqueRequest): void { +function flushCompletedChunks(request: Request): void { if (reentrant) { return; } @@ -284,12 +268,12 @@ function flushCompletedChunks(request: OpaqueRequest): void { } } -export function startWork(request: OpaqueRequest): void { +export function startWork(request: Request): void { request.flowing = true; scheduleWork(() => performWork(request)); } -export function startFlowing(request: OpaqueRequest): void { +export function startFlowing(request: Request): void { request.flowing = true; flushCompletedChunks(request); } diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js index 31772146900c7..dc7dece497396 100644 --- a/packages/react-server/src/ReactFlightServerConfigStream.js +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -64,9 +64,45 @@ ByteSize // TODO: Implement HTMLData, BlobData and URLData. -import type {Destination as DestinationT} from './ReactServerStreamConfig'; - -export type Destination = DestinationT; +import type {Request, ReactModel} from 'react-server/src/ReactFlightServer'; + +import {convertStringToBuffer} from './ReactServerStreamConfig'; + +export type {Destination} from './ReactServerStreamConfig'; + +export type Chunk = Uint8Array; + +const stringify = JSON.stringify; + +function serializeRowHeader(tag: string, id: number) { + return tag + id.toString(16) + ':'; +} + +export function processErrorChunk( + request: Request, + id: number, + message: string, + stack: string, +): Chunk { + let errorInfo = {message, stack}; + let row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n'; + return convertStringToBuffer(row); +} + +export function processModelChunk( + request: Request, + id: number, + model: ReactModel, +): Chunk { + let json = stringify(model, request.toJSON); + let row; + if (id === 0) { + row = json + '\n'; + } else { + row = serializeRowHeader('J', id) + json + '\n'; + } + return convertStringToBuffer(row); +} export { scheduleWork, @@ -75,5 +111,4 @@ export { writeChunk, completeWriting, close, - convertStringToBuffer, } from './ReactServerStreamConfig'; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js index e283cca637c43..7b6480120ee82 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js @@ -7,4 +7,4 @@ * @flow */ -export * from 'react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig'; +export * from '../ReactServerStreamConfigNode'; From a64c5e1b179a315554c26ac06eaa5ab8283ebf9b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 9 Mar 2020 19:54:52 -0700 Subject: [PATCH 4/5] Split FlightClient into a basic part and a stream part Same split as the server. --- packages/react-client/flight.js | 2 +- .../react-client/src/ReactFlightClient.js | 141 ++++-------------- .../src/ReactFlightClientHostConfigBrowser.js | 2 - .../src/ReactFlightClientStream.js | 116 ++++++++++++++ .../src/ReactFlightDOMRelayClient.js | 50 ++++++- .../ReactFlightDOMRelayClientHostConfig.js | 24 ++- .../src/ReactFlightDOMClient.js | 16 +- .../src/ReactNoopFlightClient.js | 4 +- 8 files changed, 222 insertions(+), 133 deletions(-) create mode 100644 packages/react-client/src/ReactFlightClientStream.js diff --git a/packages/react-client/flight.js b/packages/react-client/flight.js index 7d0a0b03ba920..2b9b3f45d67bb 100644 --- a/packages/react-client/flight.js +++ b/packages/react-client/flight.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightClient'; +export * from './src/ReactFlightClientStream'; diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 3adab738369df..b158c0039d1ba 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -7,25 +7,17 @@ * @flow */ -import type {Source, StringDecoder} from './ReactFlightClientHostConfig'; - -import { - supportsBinaryStreams, - createStringDecoder, - readPartialStringChunk, - readFinalStringChunk, -} from './ReactFlightClientHostConfig'; - export type ReactModelRoot = {| model: T, |}; -type JSONValue = +export type JSONValue = | number | null | boolean | string - | {[key: string]: JSONValue, ...}; + | {[key: string]: JSONValue} + | Array; const PENDING = 0; const RESOLVED = 1; @@ -48,39 +40,23 @@ type ErroredChunk = {| |}; type Chunk = PendingChunk | ResolvedChunk | ErroredChunk; -type OpaqueResponseWithoutDecoder = { - source: Source, +export type Response = { partialRow: string, modelRoot: ReactModelRoot, chunks: Map, - fromJSON: (key: string, value: JSONValue) => any, - ... }; -type OpaqueResponse = OpaqueResponseWithoutDecoder & { - stringDecoder: StringDecoder, - ... -}; - -export function createResponse(source: Source): OpaqueResponse { +export function createResponse(): Response { let modelRoot: ReactModelRoot = ({}: any); let rootChunk: Chunk = createPendingChunk(); definePendingProperty(modelRoot, 'model', rootChunk); let chunks: Map = new Map(); chunks.set(0, rootChunk); - - let response: OpaqueResponse = (({ - source, + let response = { partialRow: '', modelRoot, chunks: chunks, - fromJSON: function(key, value) { - return parseFromJSON(response, this, key, value); - }, - }: OpaqueResponseWithoutDecoder): any); - if (supportsBinaryStreams) { - response.stringDecoder = createStringDecoder(); - } + }; return response; } @@ -138,10 +114,7 @@ function resolveChunk(chunk: Chunk, value: mixed): void { // 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: OpaqueResponse, - error: Error, -): void { +export function reportGlobalError(response: Response, error: Error): void { response.chunks.forEach(chunk => { // If this chunk was already resolved or errored, it won't // trigger an error but if it wasn't then we need to @@ -168,8 +141,8 @@ function definePendingProperty( }); } -function parseFromJSON( - response: OpaqueResponse, +export function parseModelFromJSON( + response: Response, targetObj: Object, key: string, value: JSONValue, @@ -195,12 +168,11 @@ function parseFromJSON( return value; } -function resolveJSONRow( - response: OpaqueResponse, +export function resolveModelChunk( + response: Response, id: number, - json: string, + model: T, ): void { - let model = JSON.parse(json, response.fromJSON); let chunks = response.chunks; let chunk = chunks.get(id); if (!chunk) { @@ -210,81 +182,24 @@ function resolveJSONRow( } } -function processFullRow(response: OpaqueResponse, row: string): void { - if (row === '') { - return; - } - let tag = row[0]; - switch (tag) { - case 'J': { - let colon = row.indexOf(':', 1); - let id = parseInt(row.substring(1, colon), 16); - let json = row.substring(colon + 1); - resolveJSONRow(response, id, json); - return; - } - case 'E': { - let colon = row.indexOf(':', 1); - let id = parseInt(row.substring(1, colon), 16); - let json = row.substring(colon + 1); - let errorInfo = JSON.parse(json); - let error = new Error(errorInfo.message); - error.stack = errorInfo.stack; - let chunks = response.chunks; - let chunk = chunks.get(id); - if (!chunk) { - chunks.set(id, createErrorChunk(error)); - } else { - triggerErrorOnChunk(chunk, error); - } - return; - } - default: { - // Assume this is the root model. - resolveJSONRow(response, 0, row); - return; - } - } -} - -export function processStringChunk( - response: OpaqueResponse, - chunk: string, - offset: number, -): void { - let linebreak = chunk.indexOf('\n', offset); - while (linebreak > -1) { - let fullrow = response.partialRow + chunk.substring(offset, linebreak); - processFullRow(response, fullrow); - response.partialRow = ''; - offset = linebreak + 1; - linebreak = chunk.indexOf('\n', offset); - } - response.partialRow += chunk.substring(offset); -} - -export function processBinaryChunk( - response: OpaqueResponse, - chunk: Uint8Array, +export function resolveErrorChunk( + response: Response, + id: number, + message: string, + stack: string, ): void { - if (!supportsBinaryStreams) { - throw new Error("This environment don't support binary chunks."); - } - let stringDecoder = response.stringDecoder; - let linebreak = chunk.indexOf(10); // newline - while (linebreak > -1) { - let fullrow = - response.partialRow + - readFinalStringChunk(stringDecoder, chunk.subarray(0, linebreak)); - processFullRow(response, fullrow); - response.partialRow = ''; - chunk = chunk.subarray(linebreak + 1); - linebreak = chunk.indexOf(10); // newline + let error = new Error(message); + error.stack = stack; + let chunks = response.chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, createErrorChunk(error)); + } else { + triggerErrorOnChunk(chunk, error); } - response.partialRow += readPartialStringChunk(stringDecoder, chunk); } -export function complete(response: OpaqueResponse): void { +export function close(response: Response): void { // In case there are any remaining unresolved chunks, they won't // be resolved now. So we need to issue an error to those. // Ideally we should be able to early bail out if we kept a @@ -292,6 +207,6 @@ export function complete(response: OpaqueResponse): void { reportGlobalError(response, new Error('Connection closed.')); } -export function getModelRoot(response: OpaqueResponse): ReactModelRoot { +export function getModelRoot(response: Response): ReactModelRoot { return response.modelRoot; } diff --git a/packages/react-client/src/ReactFlightClientHostConfigBrowser.js b/packages/react-client/src/ReactFlightClientHostConfigBrowser.js index a3ba45faee0e3..d5aef79df514d 100644 --- a/packages/react-client/src/ReactFlightClientHostConfigBrowser.js +++ b/packages/react-client/src/ReactFlightClientHostConfigBrowser.js @@ -7,8 +7,6 @@ * @flow */ -export type Source = Promise | ReadableStream | XMLHttpRequest; - export type StringDecoder = TextDecoder; export const supportsBinaryStreams = true; diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js new file mode 100644 index 0000000000000..27e5eabaa8f8f --- /dev/null +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -0,0 +1,116 @@ +/** + * 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 + */ + +import type {Response as ResponseBase, JSONValue} from './ReactFlightClient'; + +import type {StringDecoder} from './ReactFlightClientHostConfig'; + +import { + createResponse as createResponseImpl, + resolveModelChunk, + resolveErrorChunk, + parseModelFromJSON, +} from './ReactFlightClient'; + +import { + supportsBinaryStreams, + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from './ReactFlightClientHostConfig'; + +export type ReactModelRoot = {| + model: T, +|}; + +type Response = ResponseBase & { + fromJSON: (key: string, value: JSONValue) => any, + stringDecoder: StringDecoder, +}; + +export function createResponse(): Response { + let response: Response = (createResponseImpl(): any); + response.fromJSON = function(key: string, value: JSONValue) { + return parseModelFromJSON(response, this, key, value); + }; + if (supportsBinaryStreams) { + response.stringDecoder = createStringDecoder(); + } + return response; +} + +function processFullRow(response: Response, row: string): void { + if (row === '') { + return; + } + let tag = row[0]; + switch (tag) { + case 'J': { + let colon = row.indexOf(':', 1); + let id = parseInt(row.substring(1, colon), 16); + let json = row.substring(colon + 1); + let model = JSON.parse(json, response.fromJSON); + resolveModelChunk(response, id, model); + return; + } + case 'E': { + let colon = row.indexOf(':', 1); + let id = parseInt(row.substring(1, colon), 16); + let json = row.substring(colon + 1); + let errorInfo = JSON.parse(json); + resolveErrorChunk(response, id, errorInfo.message, errorInfo.stack); + return; + } + default: { + // Assume this is the root model. + let model = JSON.parse(row, response.fromJSON); + resolveModelChunk(response, 0, model); + return; + } + } +} + +export function processStringChunk( + response: Response, + chunk: string, + offset: number, +): void { + let linebreak = chunk.indexOf('\n', offset); + while (linebreak > -1) { + let fullrow = response.partialRow + chunk.substring(offset, linebreak); + processFullRow(response, fullrow); + response.partialRow = ''; + offset = linebreak + 1; + linebreak = chunk.indexOf('\n', offset); + } + response.partialRow += chunk.substring(offset); +} + +export function processBinaryChunk( + response: Response, + chunk: Uint8Array, +): void { + if (!supportsBinaryStreams) { + throw new Error("This environment don't support binary chunks."); + } + let stringDecoder = response.stringDecoder; + let linebreak = chunk.indexOf(10); // newline + while (linebreak > -1) { + let fullrow = + response.partialRow + + readFinalStringChunk(stringDecoder, chunk.subarray(0, linebreak)); + processFullRow(response, fullrow); + response.partialRow = ''; + chunk = chunk.subarray(linebreak + 1); + linebreak = chunk.indexOf(10); // newline + } + response.partialRow += readPartialStringChunk(stringDecoder, chunk); +} + +export {reportGlobalError, close, getModelRoot} from './ReactFlightClient'; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js index 9507e654d607d..f26373b8f9266 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js @@ -9,21 +9,59 @@ import type {ReactModelRoot} from 'react-client/src/ReactFlightClient'; +import type {Chunk} from './ReactFlightDOMRelayClientHostConfig'; + import { createResponse, getModelRoot, - processStringChunk, - complete, + parseModelFromJSON, + resolveModelChunk, + resolveErrorChunk, + close, } from 'react-client/src/ReactFlightClient'; -type EncodedData = Array; +type EncodedData = Array; + +function parseModel(response, targetObj, key, value) { + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + value[i] = parseModel(response, value, '' + i, value[i]); + } + } else { + for (let innerKey in value) { + value[innerKey] = parseModel( + response, + value, + innerKey, + value[innerKey], + ); + } + } + } + return parseModelFromJSON(response, targetObj, key, value); +} function read(data: EncodedData): ReactModelRoot { - let response = createResponse(data); + let response = createResponse(); for (let i = 0; i < data.length; i++) { - processStringChunk(response, data[i], 0); + let chunk = data[i]; + if (chunk.type === 'json') { + resolveModelChunk( + response, + chunk.id, + parseModel(response, {}, '', chunk.json), + ); + } else { + resolveErrorChunk( + response, + chunk.id, + chunk.json.message, + chunk.json.stack, + ); + } } - complete(response); + close(response); return getModelRoot(response); } diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index 22aa7844c44ad..89dbc53fb0b0d 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -7,7 +7,29 @@ * @flow */ -export type Source = Array; +type JSONValue = + | string + | number + | boolean + | null + | {[key: string]: JSONValue} + | Array; + +export type Chunk = + | { + type: 'json', + id: number, + json: JSONValue, + } + | { + type: 'error', + id: number, + json: { + message: string, + stack: string, + ... + }, + }; export type StringDecoder = void; diff --git a/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js b/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js index 6a3e42e60ac09..38bdec83baac2 100644 --- a/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js +++ b/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactModelRoot} from 'react-client/src/ReactFlightClient'; +import type {ReactModelRoot} from 'react-client/src/ReactFlightClientStream'; import { createResponse, @@ -15,14 +15,14 @@ import { reportGlobalError, processStringChunk, processBinaryChunk, - complete, -} from 'react-client/src/ReactFlightClient'; + close, +} from 'react-client/src/ReactFlightClientStream'; function startReadingFromStream(response, stream: ReadableStream): void { let reader = stream.getReader(); function progress({done, value}) { if (done) { - complete(response); + close(response); return; } let buffer: Uint8Array = (value: any); @@ -36,7 +36,7 @@ function startReadingFromStream(response, stream: ReadableStream): void { } function readFromReadableStream(stream: ReadableStream): ReactModelRoot { - let response = createResponse(stream); + let response = createResponse(); startReadingFromStream(response, stream); return getModelRoot(response); } @@ -44,7 +44,7 @@ function readFromReadableStream(stream: ReadableStream): ReactModelRoot { function readFromFetch( promiseForResponse: Promise, ): ReactModelRoot { - let response = createResponse(promiseForResponse); + let response = createResponse(); promiseForResponse.then( function(r) { startReadingFromStream(response, (r.body: any)); @@ -57,7 +57,7 @@ function readFromFetch( } function readFromXHR(request: XMLHttpRequest): ReactModelRoot { - let response = createResponse(request); + let response = createResponse(); let processedLength = 0; function progress(e: ProgressEvent): void { let chunk = request.responseText; @@ -66,7 +66,7 @@ function readFromXHR(request: XMLHttpRequest): ReactModelRoot { } function load(e: ProgressEvent): void { progress(e); - complete(response); + close(response); } function error(e: ProgressEvent): void { reportGlobalError(response, new TypeError('Network error')); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 99a062017b812..51c5d73fc3c37 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -24,7 +24,7 @@ const { createResponse, getModelRoot, processStringChunk, - complete, + close, } = ReactFlightClient({ supportsBinaryStreams: false, }); @@ -34,7 +34,7 @@ function read(source: Source): ReactModelRoot { for (let i = 0; i < source.length; i++) { processStringChunk(response, source[i], 0); } - complete(response); + close(response); return getModelRoot(response); } From b9062354cc480296d31871ccd24e8610241ce6b0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 9 Mar 2020 22:55:14 -0700 Subject: [PATCH 5/5] Expose lower level async hooks to Relay This requires an external helper file that we'll wire up internally. --- .../src/ReactFlightDOMRelayClient.js | 40 ++++++------------- .../ReactFlightDOMRelayClientHostConfig.js | 24 ----------- .../src/ReactFlightDOMRelayServer.js | 10 ++--- .../ReactFlightDOMRelayServerHostConfig.js | 14 +++++-- .../ReactFlightDOMRelayServerIntegration.js | 28 +++++++++++++ .../ReactFlightDOMRelay-test.internal.js | 32 +++++++++++---- scripts/flow/config/flowconfig | 1 + scripts/flow/react-relay-hooks.js | 32 +++++++++++++++ scripts/rollup/bundles.js | 6 ++- 9 files changed, 117 insertions(+), 70 deletions(-) create mode 100644 packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js create mode 100644 scripts/flow/react-relay-hooks.js diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js index f26373b8f9266..2a9f7623fe8c9 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js @@ -7,9 +7,7 @@ * @flow */ -import type {ReactModelRoot} from 'react-client/src/ReactFlightClient'; - -import type {Chunk} from './ReactFlightDOMRelayClientHostConfig'; +import type {Response, JSONValue} from 'react-client/src/ReactFlightClient'; import { createResponse, @@ -20,8 +18,6 @@ import { close, } from 'react-client/src/ReactFlightClient'; -type EncodedData = Array; - function parseModel(response, targetObj, key, value) { if (typeof value === 'object' && value !== null) { if (Array.isArray(value)) { @@ -42,27 +38,17 @@ function parseModel(response, targetObj, key, value) { return parseModelFromJSON(response, targetObj, key, value); } -function read(data: EncodedData): ReactModelRoot { - let response = createResponse(); - for (let i = 0; i < data.length; i++) { - let chunk = data[i]; - if (chunk.type === 'json') { - resolveModelChunk( - response, - chunk.id, - parseModel(response, {}, '', chunk.json), - ); - } else { - resolveErrorChunk( - response, - chunk.id, - chunk.json.message, - chunk.json.stack, - ); - } - } - close(response); - return getModelRoot(response); +export {createResponse, getModelRoot, close}; + +export function resolveModel(response: Response, id: number, json: JSONValue) { + resolveModelChunk(response, id, parseModel(response, {}, '', json)); } -export {read}; +export function resolveError( + response: Response, + id: number, + message: string, + stack: string, +) { + resolveErrorChunk(response, id, message, stack); +} diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index 89dbc53fb0b0d..17f29a9f26c50 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -7,30 +7,6 @@ * @flow */ -type JSONValue = - | string - | number - | boolean - | null - | {[key: string]: JSONValue} - | Array; - -export type Chunk = - | { - type: 'json', - id: number, - json: JSONValue, - } - | { - type: 'error', - id: number, - json: { - message: string, - stack: string, - ... - }, - }; - export type StringDecoder = void; export const supportsBinaryStreams = false; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js index 337111f08168c..9c589a9f61daa 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js @@ -8,17 +8,13 @@ */ import type {ReactModel} from 'react-server/src/ReactFlightServer'; -import type {Chunk} from './ReactFlightDOMRelayServerHostConfig'; +import type {Destination} from './ReactFlightDOMRelayServerHostConfig'; import {createRequest, startWork} from 'react-server/src/ReactFlightServer'; -type EncodedData = Array; - -function render(model: ReactModel): EncodedData { - let data: EncodedData = []; - let request = createRequest(model, data); +function render(model: ReactModel, destination: Destination): void { + let request = createRequest(model, destination); startWork(request); - return data; } export {render}; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index f3a0f6f2c9238..bd6166ca95d99 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -9,9 +9,13 @@ import type {Request, ReactModel} from 'react-server/src/ReactFlightServer'; +import type {Destination} from 'ReactFlightDOMRelayServerIntegration'; + import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; -export type Destination = Array; +import {emitModel, emitError} from 'ReactFlightDOMRelayServerIntegration'; + +export type {Destination} from 'ReactFlightDOMRelayServerIntegration'; type JSONValue = | string @@ -95,10 +99,14 @@ export function flushBuffered(destination: Destination) {} export function beginWriting(destination: Destination) {} export function writeChunk(destination: Destination, chunk: Chunk): boolean { - destination.push(chunk); + if (chunk.type === 'json') { + emitModel(destination, chunk.id, chunk.json); + } else { + emitError(destination, chunk.id, chunk.json.message, chunk.json.stack); + } return true; } export function completeWriting(destination: Destination) {} -export function close(destination: Destination) {} +export {close} from 'ReactFlightDOMRelayServerIntegration'; diff --git a/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js new file mode 100644 index 0000000000000..212586b30b16e --- /dev/null +++ b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js @@ -0,0 +1,28 @@ +/** + * 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'; + +const ReactFlightDOMRelayServerIntegration = { + emitModel(destination, id, json) { + destination.push({ + type: 'json', + id: id, + json: json, + }); + }, + emitError(destination, id, message, stack) { + destination.push({ + type: 'error', + id: id, + json: {message, stack}, + }); + }, + close(destination) {}, +}; + +module.exports = ReactFlightDOMRelayServerIntegration; 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 2c109eedaf87e..28395a18e2c70 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,9 +7,6 @@ 'use strict'; -// Polyfills for test environment -global.TextDecoder = require('util').TextDecoder; - let React; let ReactDOMFlightRelayServer; let ReactDOMFlightRelayClient; @@ -32,11 +29,30 @@ describe('ReactFlightDOMRelay', () => { bar: [, ], }; } - let data = ReactDOMFlightRelayServer.render({ - foo: , - }); - let root = ReactDOMFlightRelayClient.read(data); - let model = root.model; + let data = []; + ReactDOMFlightRelayServer.render( + { + foo: , + }, + data, + ); + + let response = ReactDOMFlightRelayClient.createResponse(); + for (let i = 0; i < data.length; i++) { + let chunk = data[i]; + if (chunk.type === 'json') { + ReactDOMFlightRelayClient.resolveModel(response, chunk.id, chunk.json); + } else { + ReactDOMFlightRelayClient.resolveError( + response, + chunk.id, + chunk.json.message, + chunk.json.stack, + ); + } + } + let model = ReactDOMFlightRelayClient.getModelRoot(response).model; + ReactDOMFlightRelayClient.close(response); expect(model).toEqual({foo: {bar: ['A', 'B']}}); }); }); diff --git a/scripts/flow/config/flowconfig b/scripts/flow/config/flowconfig index 4790c78cf38e3..9d7f15c98a053 100644 --- a/scripts/flow/config/flowconfig +++ b/scripts/flow/config/flowconfig @@ -29,6 +29,7 @@ ../environment.js ../react-devtools.js ../react-native-host-hooks.js +../react-relay-hooks.js [lints] untyped-type-import=error diff --git a/scripts/flow/react-relay-hooks.js b/scripts/flow/react-relay-hooks.js new file mode 100644 index 0000000000000..0b42a2572340b --- /dev/null +++ b/scripts/flow/react-relay-hooks.js @@ -0,0 +1,32 @@ +/** + * 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 + */ + +type JSONValue = + | string + | number + | boolean + | null + | {[key: string]: JSONValue} + | Array; + +declare module 'ReactFlightDOMRelayServerIntegration' { + declare export opaque type Destination; + declare export function emitModel( + destination: Destination, + id: number, + json: JSONValue, + ): void; + declare export function emitError( + destination: Destination, + id: number, + message: string, + stack: string, + ): void; + declare export function close(destination: Destination): void; +} diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 7a355bc46d33f..767d2600cb5a2 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -203,7 +203,11 @@ const bundles = [ moduleType: RENDERER, entry: 'react-flight-dom-relay/server', global: 'ReactFlightDOMRelayServer', - externals: ['react', 'react-dom/server'], + externals: [ + 'react', + 'react-dom/server', + 'ReactFlightDOMRelayServerIntegration', + ], }, /******* React DOM Flight Client Relay *******/