From 709f9484122f5913fcdf2520eab230dbb0ae6bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 22 Apr 2021 19:54:29 -0400 Subject: [PATCH] [Fizz] Add FB specific streaming API and build (#21337) Add FB specific streaming API and build --- packages/react-server-dom-relay/package.json | 3 +- .../src/ReactDOMServerFB.js | 87 +++++++++ .../src/ReactServerStreamConfigFB.js | 54 ++++++ .../ReactDOMServerFB-test.internal.js | 182 ++++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 4 +- .../ReactServerStreamConfig.dom-relay.js | 2 +- scripts/rollup/bundles.js | 18 +- scripts/shared/inlinedHostConfigs.js | 6 +- 8 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 packages/react-server-dom-relay/src/ReactDOMServerFB.js create mode 100644 packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js create mode 100644 packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js diff --git a/packages/react-server-dom-relay/package.json b/packages/react-server-dom-relay/package.json index 5353bf9a1e5ef..eeceab0da3e04 100644 --- a/packages/react-server-dom-relay/package.json +++ b/packages/react-server-dom-relay/package.json @@ -12,7 +12,6 @@ "scheduler": "^0.11.0" }, "peerDependencies": { - "react": "^17.0.0", - "react-dom": "^17.0.0" + "react": "^17.0.0" } } diff --git a/packages/react-server-dom-relay/src/ReactDOMServerFB.js b/packages/react-server-dom-relay/src/ReactDOMServerFB.js new file mode 100644 index 0000000000000..290a1f227e612 --- /dev/null +++ b/packages/react-server-dom-relay/src/ReactDOMServerFB.js @@ -0,0 +1,87 @@ +/** + * 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 {ReactNodeList} from 'shared/ReactTypes'; + +import type {Request} from 'react-server/src/ReactFizzServer'; + +import type {Destination} from 'react-server/src/ReactServerStreamConfig'; + +import { + createRequest, + startWork, + performWork, + startFlowing, + abort, +} from 'react-server/src/ReactFizzServer'; + +import { + createResponseState, + createRootFormatContext, +} from 'react-server/src/ReactServerFormatConfig'; + +type Options = { + identifierPrefix?: string, + progressiveChunkSize?: number, + onError: (error: mixed) => void, +}; + +opaque type Stream = { + destination: Destination, + request: Request, +}; + +function renderToStream(children: ReactNodeList, options: Options): Stream { + const destination = { + buffer: '', + done: false, + fatal: false, + error: null, + }; + const request = createRequest( + children, + destination, + createResponseState(options ? options.identifierPrefix : undefined), + createRootFormatContext(undefined), + options ? options.progressiveChunkSize : undefined, + options.onError, + undefined, + undefined, + ); + startWork(request); + if (destination.fatal) { + throw destination.error; + } + return { + destination, + request, + }; +} + +function abortStream(stream: Stream): void { + abort(stream.request); +} + +function renderNextChunk(stream: Stream): string { + const {request, destination} = stream; + performWork(request); + startFlowing(request); + if (destination.fatal) { + throw destination.error; + } + const chunk = destination.buffer; + destination.buffer = ''; + return chunk; +} + +function hasFinished(stream: Stream): boolean { + return stream.destination.done; +} + +export {renderToStream, renderNextChunk, hasFinished, abortStream}; diff --git a/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js new file mode 100644 index 0000000000000..e0477fd6197e7 --- /dev/null +++ b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js @@ -0,0 +1,54 @@ +/** + * 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 type Destination = { + buffer: string, + done: boolean, + fatal: boolean, + error: mixed, +}; + +export type PrecomputedChunk = string; +export type Chunk = string; + +export function scheduleWork(callback: () => void) { + // We don't schedule work in this model, and instead expect performWork to always be called repeatedly. +} + +export function flushBuffered(destination: Destination) {} + +export function beginWriting(destination: Destination) {} + +export function writeChunk( + destination: Destination, + chunk: Chunk | PrecomputedChunk, +): boolean { + destination.buffer += chunk; + return true; +} + +export function completeWriting(destination: Destination) {} + +export function close(destination: Destination) { + destination.done = true; +} + +export function stringToChunk(content: string): Chunk { + return content; +} + +export function stringToPrecomputedChunk(content: string): PrecomputedChunk { + return content; +} + +export function closeWithError(destination: Destination, error: mixed): void { + destination.done = true; + destination.fatal = true; + destination.error = error; +} diff --git a/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js b/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js new file mode 100644 index 0000000000000..f04a030276e76 --- /dev/null +++ b/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js @@ -0,0 +1,182 @@ +/** + * 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'; + +let React; +let ReactDOMServer; +let Suspense; + +describe('ReactDOMServerFB', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMServer = require('../ReactDOMServerFB'); + Suspense = React.Suspense; + }); + + const theError = new Error('This is an error'); + function Throw() { + throw theError; + } + const theInfinitePromise = new Promise(() => {}); + function InfiniteSuspend() { + throw theInfinitePromise; + } + + function readResult(stream) { + let result = ''; + while (!ReactDOMServer.hasFinished(stream)) { + result += ReactDOMServer.renderNextChunk(stream); + } + return result; + } + + it('should be able to render basic HTML', async () => { + const stream = ReactDOMServer.renderToStream(
hello world
, { + onError(x) { + console.error(x); + }, + }); + const result = readResult(stream); + expect(result).toMatchInlineSnapshot( + `"
hello world
"`, + ); + }); + + it('emits all HTML as one unit if we wait until the end to start', async () => { + let hasLoaded = false; + let resolve; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + return 'Done'; + } + const stream = ReactDOMServer.renderToStream( +
+ + + +
, + { + onError(x) { + console.error(x); + }, + }, + ); + await jest.runAllTimers(); + // Resolve the loading. + hasLoaded = true; + await resolve(); + + await jest.runAllTimers(); + + const result = readResult(stream); + expect(result).toMatchInlineSnapshot( + `"
Done
"`, + ); + }); + + it('should throw an error when an error is thrown at the root', () => { + const reportedErrors = []; + const stream = ReactDOMServer.renderToStream( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + + let caughtError = null; + let result = ''; + try { + result = readResult(stream); + } catch (x) { + caughtError = x; + } + expect(caughtError).toBe(theError); + expect(result).toBe(''); + expect(reportedErrors).toEqual([theError]); + }); + + it('should throw an error when an error is thrown inside a fallback', () => { + const reportedErrors = []; + const stream = ReactDOMServer.renderToStream( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + + let caughtError = null; + let result = ''; + try { + result = readResult(stream); + } catch (x) { + caughtError = x; + } + expect(caughtError).toBe(theError); + expect(result).toBe(''); + expect(reportedErrors).toEqual([theError]); + }); + + it('should not throw an error when an error is thrown inside suspense boundary', async () => { + const reportedErrors = []; + const stream = ReactDOMServer.renderToStream( +
+ Loading
}> + + + , + { + onError(x) { + reportedErrors.push(x); + }, + }, + ); + + const result = readResult(stream); + expect(result).toContain('Loading'); + expect(reportedErrors).toEqual([theError]); + }); + + it('should be able to complete by aborting even if the promise never resolves', () => { + const stream = ReactDOMServer.renderToStream( +
+ Loading
}> + + + , + { + onError(x) { + console.error(x); + }, + }, + ); + + const partial = ReactDOMServer.renderNextChunk(stream); + expect(partial).toContain('Loading'); + + ReactDOMServer.abortStream(stream); + + const remaining = readResult(stream); + expect(remaining).toEqual(''); + }); +}); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index bc3b06449f79c..9409b57bbbf18 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -161,7 +161,7 @@ const BUFFERING = 0; const FLOWING = 1; const CLOSED = 2; -type Request = { +export opaque type Request = { +destination: Destination, +responseState: ResponseState, +progressiveChunkSize: number, @@ -1361,7 +1361,7 @@ function retryTask(request: Request, task: Task): void { } } -function performWork(request: Request): void { +export function performWork(request: Request): void { if (request.status === CLOSED) { return; } diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js index 7b6480120ee82..1fb93e24b8776 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 '../ReactServerStreamConfigNode'; +export * from 'react-server-dom-relay/src/ReactServerStreamConfigFB'; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index e37e7bbfe644a..2890c5518104e 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -238,14 +238,9 @@ const bundles = [ /******* React DOM Server *******/ { - bundleTypes: [ - UMD_DEV, - UMD_PROD, - NODE_DEV, - NODE_PROD, - FB_WWW_DEV, - FB_WWW_PROD, - ], + bundleTypes: __EXPERIMENTAL__ + ? [UMD_DEV, UMD_PROD, NODE_DEV, NODE_PROD] + : [UMD_DEV, UMD_PROD, NODE_DEV, NODE_PROD, FB_WWW_DEV, FB_WWW_PROD], moduleType: NON_FIBER_RENDERER, entry: 'react-dom/server.browser', global: 'ReactDOMServer', @@ -287,6 +282,13 @@ const bundles = [ global: 'ReactDOMFizzServer', externals: ['react', 'react-dom/server'], }, + { + bundleTypes: __EXPERIMENTAL__ ? [FB_WWW_DEV, FB_WWW_PROD] : [], + moduleType: RENDERER, + entry: 'react-server-dom-relay/src/ReactDOMServerFB', + global: 'ReactDOMServer', + externals: ['react', 'react-dom/server'], + }, /******* React Server DOM Webpack Writer *******/ { diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 7f96ae163ad63..eef4d1fa9a0ec 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -83,7 +83,11 @@ module.exports = [ }, { shortName: 'dom-relay', - entryPoints: ['react-server-dom-relay', 'react-server-dom-relay/server'], + entryPoints: [ + 'react-server-dom-relay', + 'react-server-dom-relay/server', + 'react-server-dom-relay/src/ReactDOMServerFB', + ], paths: ['react-dom', 'react-server-dom-relay'], isFlowTyped: true, isServerSupported: true,