Skip to content

Commit

Permalink
[Flight] Move around the Server side a bit (facebook#17251)
Browse files Browse the repository at this point in the history
* Rename ReactFlightStreamer -> ReactFlightServer

* Unify Browser/Node stream tests into one file and use the client reader

* Defer to the actual ReactDOM for HTML rendering for now

This will need to use a variant of Fizz to do inline SSR in Flight.
However, I don't want to build the whole impl right now but also don't
want to exclude the use case yet. So I outsource it to the existing
renderer. Ofc, this doesn't work with Suspense atm.
  • Loading branch information
sebmarkbage committed Nov 2, 2019
1 parent fadc971 commit f4148b2
Show file tree
Hide file tree
Showing 15 changed files with 154 additions and 114 deletions.
1 change: 1 addition & 0 deletions fixtures/flight-browser/index.html
Expand Up @@ -18,6 +18,7 @@ <h1>Flight Example</h1>
</div>
<script src="../../build/dist/react.development.js"></script>
<script src="../../build/dist/react-dom.development.js"></script>
<script src="../../build/dist/react-dom-server.browser.development.js"></script>
<script src="../../build/dist/react-dom-unstable-flight-server.browser.development.js"></script>
<script src="../../build/dist/react-dom-unstable-flight-client.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
Expand Down
92 changes: 92 additions & 0 deletions packages/react-dom/src/__tests__/ReactFlightDOM-test.js
@@ -0,0 +1,92 @@
/**
* 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';

// Polyfills for test environment
global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextDecoder = require('util').TextDecoder;

let Stream;
let React;
let ReactFlightDOMServer;
let ReactFlightDOMClient;

describe('ReactFlightDOM', () => {
beforeEach(() => {
jest.resetModules();
Stream = require('stream');
React = require('react');
ReactFlightDOMServer = require('react-dom/unstable-flight-server');
ReactFlightDOMClient = require('react-dom/unstable-flight-client');
});

function getTestStream() {
let writable = new Stream.PassThrough();
let readable = new ReadableStream({
start(controller) {
writable.on('data', chunk => {
controller.enqueue(chunk);
});
writable.on('end', () => {
controller.close();
});
},
});
return {
writable,
readable,
};
}

async function waitForSuspense(fn) {
while (true) {
try {
return fn();
} catch (promise) {
if (typeof promise.then === 'function') {
await promise;
} else {
throw promise;
}
}
}
}

it('should resolve HTML using Node streams', async () => {
function Text({children}) {
return <span>{children}</span>;
}
function HTML() {
return (
<div>
<Text>hello</Text>
<Text>world</Text>
</div>
);
}

function App() {
let model = {
html: <HTML />,
};
return model;
}

let {writable, readable} = getTestStream();
ReactFlightDOMServer.pipeToNodeWritable(<App />, writable);
let result = ReactFlightDOMClient.readFromReadableStream(readable);
await waitForSuspense(() => {
expect(result.model).toEqual({
html: '<div><span>hello</span><span>world</span></div>',
});
});
});
});
45 changes: 28 additions & 17 deletions packages/react-dom/src/__tests__/ReactFlightDOMBrowser-test.js
Expand Up @@ -5,37 +5,43 @@
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/

'use strict';

// Polyfills for test environment
global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;

let React;
let ReactFlightDOMServer;
let ReactFlightDOMClient;

describe('ReactFlightDOM', () => {
describe('ReactFlightDOMBrowser', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactFlightDOMServer = require('react-dom/unstable-flight-server.browser');
ReactFlightDOMClient = require('react-dom/unstable-flight-client');
});

async function readResult(stream) {
let reader = stream.getReader();
let result = '';
async function waitForSuspense(fn) {
while (true) {
let {done, value} = await reader.read();
if (done) {
return result;
try {
return fn();
} catch (promise) {
if (typeof promise.then === 'function') {
await promise;
} else {
throw promise;
}
}
result += Buffer.from(value).toString('utf8');
}
}

it('should resolve HTML', async () => {
it('should resolve HTML using W3C streams', async () => {
function Text({children}) {
return <span>{children}</span>;
}
Expand All @@ -48,14 +54,19 @@ describe('ReactFlightDOM', () => {
);
}

let model = {
html: <HTML />,
};
let stream = ReactFlightDOMServer.renderToReadableStream(model);
jest.runAllTimers();
let result = JSON.parse(await readResult(stream));
expect(result).toEqual({
html: '<div><span>hello</span><span>world</span></div>',
function App() {
let model = {
html: <HTML />,
};
return model;
}

let stream = ReactFlightDOMServer.renderToReadableStream(<App />);
let result = ReactFlightDOMClient.readFromReadableStream(stream);
await waitForSuspense(() => {
expect(result.model).toEqual({
html: '<div><span>hello</span><span>world</span></div>',
});
});
});
});
57 changes: 0 additions & 57 deletions packages/react-dom/src/__tests__/ReactFlightDOMNode-test.js

This file was deleted.

12 changes: 12 additions & 0 deletions packages/react-dom/src/server/ReactDOMServerFormatConfig.js
Expand Up @@ -9,6 +9,8 @@

import {convertStringToBuffer} from 'react-server/src/ReactServerHostConfig';

import ReactDOMServer from 'react-dom/server';

export function formatChunkAsString(type: string, props: Object): string {
let str = '<' + type + '>';
if (typeof props.children === 'string') {
Expand All @@ -21,3 +23,13 @@ export function formatChunkAsString(type: string, props: Object): string {
export function formatChunk(type: string, props: Object): Uint8Array {
return convertStringToBuffer(formatChunkAsString(type, props));
}

export function renderHostChildrenToString(
children: React$Element<any>,
): string {
// TODO: This file is used to actually implement a server renderer
// so we can't actually reference the renderer here. Instead, we
// should replace this method with a reference to Fizz which
// then uses this file to implement the server renderer.
return ReactDOMServer.renderToStaticMarkup(children);
}
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

import type {ReactModel} from 'react-server/src/ReactFlightStreamer';
import type {ReactModel} from 'react-server/flight.inline-typed';
import type {Writable} from 'stream';

import {
Expand Down
7 changes: 5 additions & 2 deletions packages/react-noop-renderer/src/ReactNoopFlightServer.js
Expand Up @@ -16,11 +16,11 @@

import type {ReactModel} from 'react-server/flight.inline-typed';

import ReactFlightStreamer from 'react-server/flight';
import ReactFlightServer from 'react-server/flight';

type Destination = Array<string>;

const ReactNoopFlightServer = ReactFlightStreamer({
const ReactNoopFlightServer = ReactFlightServer({
scheduleWork(callback: () => void) {
callback();
},
Expand All @@ -40,6 +40,9 @@ const ReactNoopFlightServer = ReactFlightStreamer({
formatChunk(type: string, props: Object): Uint8Array {
return Buffer.from(JSON.stringify({type, props}), 'utf8');
},
renderHostChildrenToString(children: React$Element<any>): string {
throw new Error('The noop rendered do not support host components');
},
});

function render(model: ReactModel): Destination {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/flight.inline-typed.js
Expand Up @@ -21,4 +21,4 @@
// renderers have different host config types. So we check them one by one.
// We run Flow on all renderers on CI.

export * from './src/ReactFlightStreamer';
export * from './src/ReactFlightServer';
2 changes: 1 addition & 1 deletion packages/react-server/flight.inline.dom-browser.js
Expand Up @@ -8,4 +8,4 @@
// This file intentionally does *not* have the Flow annotation.
// Don't add it. See `./inline-typed.js` for an explanation.

export * from './src/ReactFlightStreamer';
export * from './src/ReactFlightServer';
2 changes: 1 addition & 1 deletion packages/react-server/flight.inline.dom.js
Expand Up @@ -8,4 +8,4 @@
// This file intentionally does *not* have the Flow annotation.
// Don't add it. See `./inline-typed.js` for an explanation.

export * from './src/ReactFlightStreamer';
export * from './src/ReactFlightServer';
4 changes: 2 additions & 2 deletions packages/react-server/flight.js
Expand Up @@ -19,8 +19,8 @@

'use strict';

const ReactFlightStreamer = require('./src/ReactFlightStreamer');
const ReactFlightServer = require('./src/ReactFlightServer');

// TODO: decide on the top-level export form.
// This is hacky but makes it work with both Rollup and Jest.
module.exports = ReactFlightStreamer.default || ReactFlightStreamer;
module.exports = ReactFlightServer.default || ReactFlightServer;
Expand Up @@ -18,7 +18,7 @@ import {
close,
convertStringToBuffer,
} from './ReactServerHostConfig';
import {formatChunkAsString} from './ReactServerFormatConfig';
import {renderHostChildrenToString} from './ReactServerFormatConfig';
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';

export type ReactModel =
Expand Down Expand Up @@ -56,32 +56,6 @@ export function createRequest(
return {destination, model, completedChunks: [], flowing: false};
}

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<ReactModel>)
.map(c => resolveChildToHostFormat(resolveModelToJSON('', c)))
.join('');
} else {
throw new Error('Object models are not valid as children of host nodes.');
}
}

function resolveElementToHostFormat(type: string, props: Object): string {
let child = resolveModelToJSON('', props.children);
let childString = resolveChildToHostFormat(child);
return formatChunkAsString(
type,
Object.assign({}, props, {children: childString}),
);
}

function resolveModelToJSON(key: string, value: ReactModel): ReactJSONValue {
while (value && value.$$typeof === REACT_ELEMENT_TYPE) {
let element: React$Element<any> = (value: any);
Expand All @@ -93,7 +67,7 @@ function resolveModelToJSON(key: string, value: ReactModel): ReactJSONValue {
continue;
} else if (typeof type === 'string') {
// This is a host element. E.g. HTML.
return resolveElementToHostFormat(type, props);
return renderHostChildrenToString(element);
} else {
throw new Error('Unsupported type.');
}
Expand Down
Expand Up @@ -28,3 +28,5 @@ export opaque type Destination = mixed; // eslint-disable-line no-undef

export const formatChunkAsString = $$$hostConfig.formatChunkAsString;
export const formatChunk = $$$hostConfig.formatChunk;
export const renderHostChildrenToString =
$$$hostConfig.renderHostChildrenToString;

0 comments on commit f4148b2

Please sign in to comment.