diff --git a/fixtures/flight-browser/index.html b/fixtures/flight-browser/index.html index 5f522a9b3a1a0..07489d30bea64 100644 --- a/fixtures/flight-browser/index.html +++ b/fixtures/flight-browser/index.html @@ -18,9 +18,12 @@

Flight Example

+ diff --git a/packages/react-dom/package.json b/packages/react-dom/package.json index b413c885be8d0..95797e1c4d8d8 100644 --- a/packages/react-dom/package.json +++ b/packages/react-dom/package.json @@ -39,6 +39,7 @@ "unstable-fizz.js", "unstable-fizz.browser.js", "unstable-fizz.node.js", + "unstable-flight-client.js", "unstable-flight-server.js", "unstable-flight-server.browser.js", "unstable-flight-server.node.js", diff --git a/packages/react-dom/src/client/flight/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-dom/src/__tests__/ReactFlightDOMBrowser-test.js similarity index 100% rename from packages/react-dom/src/client/flight/__tests__/ReactFlightDOMBrowser-test.js rename to packages/react-dom/src/__tests__/ReactFlightDOMBrowser-test.js diff --git a/packages/react-dom/src/client/flight/__tests__/ReactFlightDOMNode-test.js b/packages/react-dom/src/__tests__/ReactFlightDOMNode-test.js similarity index 100% rename from packages/react-dom/src/client/flight/__tests__/ReactFlightDOMNode-test.js rename to packages/react-dom/src/__tests__/ReactFlightDOMNode-test.js diff --git a/packages/react-dom/src/client/flight/ReactFlightDOMClient.js b/packages/react-dom/src/client/flight/ReactFlightDOMClient.js index 18bc4753a5294..c642bbc68a2ee 100644 --- a/packages/react-dom/src/client/flight/ReactFlightDOMClient.js +++ b/packages/react-dom/src/client/flight/ReactFlightDOMClient.js @@ -7,28 +7,80 @@ * @flow */ -import type {ReactModel} from 'react-flight/src/ReactFlightClient'; +import type {ReactModelRoot} from 'react-flight/src/ReactFlightClient'; import { - createRequest, - startWork, - startFlowing, -} from 'react-flight/inline.dom-browser'; + createResponse, + getModelRoot, + reportGlobalError, + processStringChunk, + processBinaryChunk, + complete, +} from 'react-flight/inline.dom'; -function renderToReadableStream(model: ReactModel): ReadableStream { - let request; - return new ReadableStream({ - start(controller) { - request = createRequest(model, controller); - startWork(request); +function startReadingFromStream(response, stream: ReadableStream): void { + let reader = stream.getReader(); + function progress({done, value}) { + if (done) { + complete(response); + return; + } + let buffer: Uint8Array = (value: any); + processBinaryChunk(response, buffer, 0); + return reader.read().then(progress, error); + } + function error(e) { + reportGlobalError(response, e); + } + reader.read().then(progress, error); +} + +function readFromReadableStream(stream: ReadableStream): ReactModelRoot { + let response = createResponse(stream); + startReadingFromStream(response, stream); + return getModelRoot(response); +} + +function readFromFetch( + promiseForResponse: Promise, +): ReactModelRoot { + let response = createResponse(promiseForResponse); + promiseForResponse.then( + function(r) { + startReadingFromStream(response, (r.body: any)); }, - pull(controller) { - startFlowing(request, controller.desiredSize); + function(e) { + reportGlobalError(response, e); }, - cancel(reason) {}, - }); + ); + return getModelRoot(response); +} + +function readFromXHR(request: XMLHttpRequest): ReactModelRoot { + let response = createResponse(request); + let processedLength = 0; + function progress(e: ProgressEvent): void { + let chunk = request.responseText; + processStringChunk(response, chunk, processedLength); + processedLength = chunk.length; + } + function load(e: ProgressEvent): void { + progress(e); + complete(response); + } + function error(e: ProgressEvent): void { + reportGlobalError(response, new TypeError('Network error')); + } + request.addEventListener('progress', progress); + request.addEventListener('load', load); + request.addEventListener('error', error); + request.addEventListener('abort', error); + request.addEventListener('timeout', error); + return getModelRoot(response); } export default { - renderToReadableStream, + readFromXHR, + readFromFetch, + readFromReadableStream, }; diff --git a/packages/react-dom/src/client/flight/ReactFlightDOMHostConfig.js b/packages/react-dom/src/client/flight/ReactFlightDOMHostConfig.js index 9dde4993ad6ad..a3ba45faee0e3 100644 --- a/packages/react-dom/src/client/flight/ReactFlightDOMHostConfig.js +++ b/packages/react-dom/src/client/flight/ReactFlightDOMHostConfig.js @@ -7,44 +7,28 @@ * @flow */ -export type Destination = ReadableStreamController; +export type Source = Promise | ReadableStream | XMLHttpRequest; -export function scheduleWork(callback: () => void) { - callback(); -} - -export function flushBuffered(destination: Destination) { - // WHATWG Streams do not yet have a way to flush the underlying - // transform streams. https://github.com/whatwg/streams/issues/960 -} - -export function beginWriting(destination: Destination) {} +export type StringDecoder = TextDecoder; -export function writeChunk(destination: Destination, buffer: Uint8Array) { - destination.enqueue(buffer); -} - -export function completeWriting(destination: Destination) {} +export const supportsBinaryStreams = true; -export function close(destination: Destination) { - destination.close(); +export function createStringDecoder(): StringDecoder { + return new TextDecoder(); } -const textEncoder = new TextEncoder(); - -export function convertStringToBuffer(content: string): Uint8Array { - return textEncoder.encode(content); -} +const decoderOptions = {stream: true}; -export function formatChunkAsString(type: string, props: Object): string { - let str = '<' + type + '>'; - if (typeof props.children === 'string') { - str += props.children; - } - str += ''; - return str; +export function readPartialStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer, decoderOptions); } -export function formatChunk(type: string, props: Object): Uint8Array { - return convertStringToBuffer(formatChunkAsString(type, props)); +export function readFinalStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer); } diff --git a/packages/react-dom/src/server/flight/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-dom/src/server/flight/__tests__/ReactFlightDOMBrowser-test.js deleted file mode 100644 index 39b0956553558..0000000000000 --- a/packages/react-dom/src/server/flight/__tests__/ReactFlightDOMBrowser-test.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 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. - * - * @emails react-core - */ - -'use strict'; - -// Polyfills for test environment -global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream; -global.TextEncoder = require('util').TextEncoder; - -let React; -let ReactFlightDOMServer; - -describe('ReactFlightDOM', () => { - beforeEach(() => { - jest.resetModules(); - React = require('react'); - ReactFlightDOMServer = require('react-dom/unstable-flight-server.browser'); - }); - - async function readResult(stream) { - let reader = stream.getReader(); - let result = ''; - while (true) { - let {done, value} = await reader.read(); - if (done) { - return result; - } - result += Buffer.from(value).toString('utf8'); - } - } - - it('should resolve HTML', async () => { - function Text({children}) { - return {children}; - } - function HTML() { - return ( -
- hello - world -
- ); - } - - let model = { - html: , - }; - let stream = ReactFlightDOMServer.renderToReadableStream(model); - jest.runAllTimers(); - let result = JSON.parse(await readResult(stream)); - expect(result).toEqual({ - html: '
helloworld
', - }); - }); -}); diff --git a/packages/react-dom/src/server/flight/__tests__/ReactFlightDOMNode-test.js b/packages/react-dom/src/server/flight/__tests__/ReactFlightDOMNode-test.js deleted file mode 100644 index 79494a49164eb..0000000000000 --- a/packages/react-dom/src/server/flight/__tests__/ReactFlightDOMNode-test.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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. - * - * @emails react-core - * @jest-environment node - */ - -'use strict'; - -let Stream; -let React; -let ReactFlightDOMServer; - -describe('ReactFlightDOM', () => { - beforeEach(() => { - jest.resetModules(); - React = require('react'); - ReactFlightDOMServer = require('react-dom/unstable-flight-server'); - Stream = require('stream'); - }); - - function getTestWritable() { - let writable = new Stream.PassThrough(); - writable.setEncoding('utf8'); - writable.result = ''; - writable.on('data', chunk => (writable.result += chunk)); - return writable; - } - - it('should resolve HTML', () => { - function Text({children}) { - return {children}; - } - function HTML() { - return ( -
- hello - world -
- ); - } - - let writable = getTestWritable(); - let model = { - html: , - }; - ReactFlightDOMServer.pipeToNodeWritable(model, writable); - jest.runAllTimers(); - let result = JSON.parse(writable.result); - expect(result).toEqual({ - html: '
helloworld
', - }); - }); -}); diff --git a/packages/react-flight/src/ReactFlightClient.js b/packages/react-flight/src/ReactFlightClient.js index bc0c47a3adf4e..22ddebcc43bfa 100644 --- a/packages/react-flight/src/ReactFlightClient.js +++ b/packages/react-flight/src/ReactFlightClient.js @@ -7,138 +7,115 @@ * @flow */ -import type {Destination} from './ReactFlightClientHostConfig'; +import type {Source, StringDecoder} from './ReactFlightClientHostConfig'; import { - scheduleWork, - beginWriting, - writeChunk, - completeWriting, - flushBuffered, - close, - convertStringToBuffer, - formatChunkAsString, + supportsBinaryStreams, + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, } from './ReactFlightClientHostConfig'; -import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; -export type ReactModel = - | React$Element - | string - | boolean - | number - | null - | Iterable - | ReactModelObject; +export type ReactModelRoot = {| + model: T, +|}; -type ReactJSONValue = - | string - | boolean - | number - | null - | Array - | ReactModelObject; - -type ReactModelObject = { - +[key: string]: ReactModel, -}; - -type OpaqueRequest = { - destination: Destination, - model: ReactModel, - completedChunks: Array, - flowing: boolean, +type OpaqueResponse = { + source: Source, + modelRoot: ReactModelRoot, + partialRow: string, + stringDecoder: StringDecoder, + rootPing: () => void, }; -export function createRequest( - model: ReactModel, - destination: Destination, -): OpaqueRequest { - return {destination, model, completedChunks: [], flowing: false}; -} +export function createResponse(source: Source): OpaqueResponse { + let modelRoot = {}; + Object.defineProperty( + modelRoot, + 'model', + ({ + configurable: true, + enumerable: true, + get() { + throw rootPromise; + }, + }: any), + ); -function resolveChildToHostFormat(child: ReactJSONValue): string { - if (typeof child === 'string') { - return child; - } else if (typeof child === 'number') { - return '' + child; - } else if (typeof child === 'boolean' || child === null) { - // Booleans are like null when they're React children. - return ''; - } else if (Array.isArray(child)) { - return (child: Array) - .map(c => resolveChildToHostFormat(resolveModelToJSON('', c))) - .join(''); - } else { - throw new Error('Object models are not valid as children of host nodes.'); + let rootPing; + let rootPromise = new Promise(resolve => { + rootPing = resolve; + }); + + let response: OpaqueResponse = ({ + source, + modelRoot, + partialRow: '', + rootPing, + }: any); + if (supportsBinaryStreams) { + response.stringDecoder = createStringDecoder(); } + return response; } -function resolveElementToHostFormat(type: string, props: Object): string { - let child = resolveModelToJSON('', props.children); - let childString = resolveChildToHostFormat(child); - return formatChunkAsString( - type, - Object.assign({}, props, {children: childString}), +// 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 { + Object.defineProperty( + response.modelRoot, + 'model', + ({ + configurable: true, + enumerable: true, + get() { + throw error; + }, + }: any), ); + response.rootPing(); } -function resolveModelToJSON(key: string, value: ReactModel): ReactJSONValue { - while (value && value.$$typeof === REACT_ELEMENT_TYPE) { - let element: React$Element = (value: any); - let type = element.type; - let props = element.props; - if (typeof type === 'function') { - // This is a nested view model. - value = type(props); - continue; - } else if (typeof type === 'string') { - // This is a host element. E.g. HTML. - return resolveElementToHostFormat(type, props); - } else { - throw new Error('Unsupported type.'); - } - } - return value; +export function processStringChunk( + response: OpaqueResponse, + chunk: string, + offset: number, +): void { + response.partialRow += chunk.substr(offset); } -function performWork(request: OpaqueRequest): void { - let rootModel = request.model; - request.model = null; - let json = JSON.stringify(rootModel, resolveModelToJSON); - request.completedChunks.push(convertStringToBuffer(json)); - if (request.flowing) { - flushCompletedChunks(request); +export function processBinaryChunk( + response: OpaqueResponse, + chunk: Uint8Array, + offset: number, +): void { + if (!supportsBinaryStreams) { + throw new Error("This environment don't support binary chunks."); } - - flushBuffered(request.destination); + response.partialRow += readPartialStringChunk(response.stringDecoder, chunk); } -function flushCompletedChunks(request: OpaqueRequest) { - let destination = request.destination; - let chunks = request.completedChunks; - request.completedChunks = []; - - beginWriting(destination); - try { - for (let i = 0; i < chunks.length; i++) { - let chunk = chunks[i]; - writeChunk(destination, chunk); - } - } finally { - completeWriting(destination); +let emptyBuffer = new Uint8Array(0); +export function complete(response: OpaqueResponse): void { + if (supportsBinaryStreams) { + // This should never be needed since we're expected to have complete + // code units at the end of JSON. + response.partialRow += readFinalStringChunk( + response.stringDecoder, + emptyBuffer, + ); } - close(destination); + let modelRoot = response.modelRoot; + let model = JSON.parse(response.partialRow); + Object.defineProperty(modelRoot, 'model', { + value: model, + }); + response.rootPing(); } -export function startWork(request: OpaqueRequest): void { - request.flowing = true; - scheduleWork(() => performWork(request)); -} - -export function startFlowing( - request: OpaqueRequest, - desiredBytes: number, -): void { - request.flowing = false; - flushCompletedChunks(request); +export function getModelRoot(response: OpaqueResponse): ReactModelRoot { + return response.modelRoot; } diff --git a/packages/react-flight/src/__tests__/ReactFlightClient-test.js b/packages/react-flight/src/__tests__/ReactFlight-test.js similarity index 68% rename from packages/react-flight/src/__tests__/ReactFlightClient-test.js rename to packages/react-flight/src/__tests__/ReactFlight-test.js index a020dc8b97d94..480e448fecc7c 100644 --- a/packages/react-flight/src/__tests__/ReactFlightClient-test.js +++ b/packages/react-flight/src/__tests__/ReactFlight-test.js @@ -11,13 +11,15 @@ 'use strict'; let React; +let ReactNoopFlightServer; let ReactNoopFlightClient; -describe('ReactFlightClient', () => { +describe('ReactFlight', () => { beforeEach(() => { jest.resetModules(); React = require('react'); + ReactNoopFlightServer = require('react-noop-renderer/flight-server'); ReactNoopFlightClient = require('react-noop-renderer/flight-client'); }); @@ -30,9 +32,11 @@ describe('ReactFlightClient', () => { bar: [, ], }; } - let result = ReactNoopFlightClient.render({ + let transport = ReactNoopFlightServer.render({ foo: , }); - expect(result).toEqual([{foo: {bar: ['A', 'B']}}]); + let root = ReactNoopFlightClient.read(transport); + let model = root.model; + expect(model).toEqual({foo: {bar: ['A', 'B']}}); }); }); diff --git a/packages/react-flight/src/forks/ReactFlightClientHostConfig.custom.js b/packages/react-flight/src/forks/ReactFlightClientHostConfig.custom.js index 1e0fbd5738e74..ea0037f25ff84 100644 --- a/packages/react-flight/src/forks/ReactFlightClientHostConfig.custom.js +++ b/packages/react-flight/src/forks/ReactFlightClientHostConfig.custom.js @@ -24,14 +24,10 @@ // really an argument to a top-level wrapping function. declare var $$$hostConfig: any; -export opaque type Destination = mixed; // eslint-disable-line no-undef +export opaque type Source = mixed; // eslint-disable-line no-undef +export opaque type StringDecoder = mixed; // eslint-disable-line no-undef -export const formatChunkAsString = $$$hostConfig.formatChunkAsString; -export const formatChunk = $$$hostConfig.formatChunk; -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 const supportsBinaryStreams = $$$hostConfig.supportsBinaryStreams; +export const createStringDecoder = $$$hostConfig.createStringDecoder; +export const readPartialStringChunk = $$$hostConfig.readPartialStringChunk; +export const readFinalStringChunk = $$$hostConfig.readFinalStringChunk; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 44b41cd7f788c..3e21dcad320aa 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -14,41 +14,30 @@ * environment. */ -import type {ReactModel} from 'react-flight/inline-typed'; +import type {ReactModelRoot} from 'react-flight/inline-typed'; import ReactFlightClient from 'react-flight'; -type Destination = Array; +type Source = Array; -const ReactNoopFlightClient = ReactFlightClient({ - scheduleWork(callback: () => void) { - callback(); - }, - beginWriting(destination: Destination): void {}, - writeChunk(destination: Destination, buffer: Uint8Array): void { - destination.push(JSON.parse(Buffer.from((buffer: any)).toString('utf8'))); - }, - completeWriting(destination: Destination): void {}, - close(destination: Destination): void {}, - flushBuffered(destination: Destination): void {}, - convertStringToBuffer(content: string): Uint8Array { - return Buffer.from(content, 'utf8'); - }, - formatChunkAsString(type: string, props: Object): string { - return JSON.stringify({type, props}); - }, - formatChunk(type: string, props: Object): Uint8Array { - return Buffer.from(JSON.stringify({type, props}), 'utf8'); - }, +const { + createResponse, + getModelRoot, + processStringChunk, + complete, +} = ReactFlightClient({ + supportsBinaryStreams: false, }); -function render(model: ReactModel): Destination { - let destination: Destination = []; - let request = ReactNoopFlightClient.createRequest(model, destination); - ReactNoopFlightClient.startWork(request); - return destination; +function read(source: Source): ReactModelRoot { + let response = createResponse(source); + for (let i = 0; i < source.length; i++) { + processStringChunk(response, source[i], 0); + } + complete(response); + return getModelRoot(response); } export default { - render, + read, }; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 569c45e62c154..70e54ef24cac1 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -26,7 +26,7 @@ const ReactNoopFlightServer = ReactFlightStreamer({ }, beginWriting(destination: Destination): void {}, writeChunk(destination: Destination, buffer: Uint8Array): void { - destination.push(JSON.parse(Buffer.from((buffer: any)).toString('utf8'))); + destination.push(Buffer.from((buffer: any)).toString('utf8')); }, completeWriting(destination: Destination): void {}, close(destination: Destination): void {}, diff --git a/packages/react-server/src/__tests__/ReactFlightServer-test.js b/packages/react-server/src/__tests__/ReactFlightServer-test.js deleted file mode 100644 index afa8fdb4bc7e8..0000000000000 --- a/packages/react-server/src/__tests__/ReactFlightServer-test.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * 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. - * - * @emails react-core - * @jest-environment node - */ - -'use strict'; - -let React; -let ReactNoopFlight; - -describe('ReactFlightServer', () => { - beforeEach(() => { - jest.resetModules(); - - React = require('react'); - ReactNoopFlight = require('react-noop-renderer/flight-server'); - }); - - it('can resolve a model', () => { - function Bar({text}) { - return text.toUpperCase(); - } - function Foo() { - return { - bar: [, ], - }; - } - let result = ReactNoopFlight.render({ - foo: , - }); - expect(result).toEqual([{foo: {bar: ['A', 'B']}}]); - }); -}); diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 6d1089b73788e..725bb040e3eb8 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -28,6 +28,10 @@ module.exports = { SharedArrayBuffer: true, Int32Array: true, ArrayBuffer: true, + + // Flight + Uint8Array: true, + Promise: true, }, parserOptions: { ecmaVersion: 5, diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index 57daf502c2218..f8965a81ff49a 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -29,6 +29,10 @@ module.exports = { SharedArrayBuffer: true, Int32Array: true, ArrayBuffer: true, + + // Flight + Uint8Array: true, + Promise: true, }, parserOptions: { ecmaVersion: 5, diff --git a/scripts/rollup/validate/eslintrc.umd.js b/scripts/rollup/validate/eslintrc.umd.js index 57645c7a34c67..ffaeb9488e0b5 100644 --- a/scripts/rollup/validate/eslintrc.umd.js +++ b/scripts/rollup/validate/eslintrc.umd.js @@ -31,6 +31,10 @@ module.exports = { SharedArrayBuffer: true, Int32Array: true, ArrayBuffer: true, + + // Flight + Uint8Array: true, + Promise: true, }, parserOptions: { ecmaVersion: 5,