[Flight] Add Client Infrastructure (#17234)

* Change demo to server

* Expose client in package.json

* Reorganize tests

We don't want unit tests but instead test how both server and clients work
together. So this merges server/client test files.

* Fill in the client implementation a bit

* Use new client in fixture

* Add Promise/Uint8Array to lint rule

I'll probably end up deleting these deps later but they're here for now.
This commit is contained in:
Sebastian Markbåge 2019-11-01 16:05:07 -07:00 committed by GitHub
parent 36fd29f09f
commit fadc97167f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 251 additions and 389 deletions

View File

@ -18,9 +18,12 @@
</div>
<script src="../../build/dist/react.development.js"></script>
<script src="../../build/dist/react-dom.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>
<script type="text/babel">
let Suspense = React.Suspense;
function Text({children}) {
return <span>{children}</span>;
}
@ -40,7 +43,7 @@
}
};
let stream = ReactFlightDOMClient.renderToReadableStream(model);
let stream = ReactFlightDOMServer.renderToReadableStream(model);
let response = new Response(stream, {
headers: {'Content-Type': 'text/html'},
});
@ -49,35 +52,35 @@
async function display(responseToDisplay) {
let blob = await responseToDisplay.blob();
let url = URL.createObjectURL(blob);
let response = await fetch(url);
let body = await response.body;
let reader = body.getReader();
let charsReceived = 0;
let decoder = new TextDecoder();
let data = ReactFlightDOMClient.readFromFetch(
fetch(url)
);
// The client also supports XHR streaming.
// var xhr = new XMLHttpRequest();
// xhr.open('GET', url);
// let data = ReactFlightDOMClient.readFromXHR(xhr);
// xhr.send();
let json = '';
reader.read().then(function processChunk({ done, value }) {
if (done) {
renderResult(json);
return;
}
json += decoder.decode(value);
return reader.read().then(processChunk);
});
renderResult(data);
}
function Shell({ model }) {
function Shell({ data }) {
let model = data.model;
return <div>
<h1>{model.title}</h1>
<div dangerouslySetInnerHTML={model.content} />
</div>;
}
function renderResult(json) {
let model = JSON.parse(json);
function renderResult(data) {
let container = document.getElementById('container');
ReactDOM.render(<Shell model={model} />, container);
ReactDOM.render(
<Suspense fallback="Loading...">
<Shell data={data} />
</Suspense>,
container
);
}
</script>
</body>

View File

@ -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",

View File

@ -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<T>(stream: ReadableStream): ReactModelRoot<T> {
let response = createResponse(stream);
startReadingFromStream(response, stream);
return getModelRoot(response);
}
function readFromFetch<T>(
promiseForResponse: Promise<Response>,
): ReactModelRoot<T> {
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<T>(request: XMLHttpRequest): ReactModelRoot<T> {
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,
};

View File

@ -7,44 +7,28 @@
* @flow
*/
export type Destination = ReadableStreamController;
export type Source = Promise<Response> | ReadableStream | XMLHttpRequest;
export function scheduleWork(callback: () => void) {
callback();
export type StringDecoder = TextDecoder;
export const supportsBinaryStreams = true;
export function createStringDecoder(): StringDecoder {
return new TextDecoder();
}
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
const decoderOptions = {stream: true};
export function readPartialStringChunk(
decoder: StringDecoder,
buffer: Uint8Array,
): string {
return decoder.decode(buffer, decoderOptions);
}
export function beginWriting(destination: Destination) {}
export function writeChunk(destination: Destination, buffer: Uint8Array) {
destination.enqueue(buffer);
}
export function completeWriting(destination: Destination) {}
export function close(destination: Destination) {
destination.close();
}
const textEncoder = new TextEncoder();
export function convertStringToBuffer(content: string): Uint8Array {
return textEncoder.encode(content);
}
export function formatChunkAsString(type: string, props: Object): string {
let str = '<' + type + '>';
if (typeof props.children === 'string') {
str += props.children;
}
str += '</' + type + '>';
return str;
}
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);
}

View File

@ -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 <span>{children}</span>;
}
function HTML() {
return (
<div>
<Text>hello</Text>
<Text>world</Text>
</div>
);
}
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>',
});
});
});

View File

@ -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 <span>{children}</span>;
}
function HTML() {
return (
<div>
<Text>hello</Text>
<Text>world</Text>
</div>
);
}
let writable = getTestWritable();
let model = {
html: <HTML />,
};
ReactFlightDOMServer.pipeToNodeWritable(model, writable);
jest.runAllTimers();
let result = JSON.parse(writable.result);
expect(result).toEqual({
html: '<div><span>hello</span><span>world</span></div>',
});
});
});

View File

@ -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<any>
| string
| boolean
| number
| null
| Iterable<ReactModel>
| ReactModelObject;
export type ReactModelRoot<T> = {|
model: T,
|};
type ReactJSONValue =
| string
| boolean
| number
| null
| Array<ReactModel>
| ReactModelObject;
type ReactModelObject = {
+[key: string]: ReactModel,
type OpaqueResponse = {
source: Source,
modelRoot: ReactModelRoot<any>,
partialRow: string,
stringDecoder: StringDecoder,
rootPing: () => void,
};
type OpaqueRequest = {
destination: Destination,
model: ReactModel,
completedChunks: Array<Uint8Array>,
flowing: boolean,
};
export function createRequest(
model: ReactModel,
destination: Destination,
): OpaqueRequest {
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}),
export function createResponse(source: Source): OpaqueResponse {
let modelRoot = {};
Object.defineProperty(
modelRoot,
'model',
({
configurable: true,
enumerable: true,
get() {
throw rootPromise;
},
}: any),
);
}
function resolveModelToJSON(key: string, value: ReactModel): ReactJSONValue {
while (value && value.$$typeof === REACT_ELEMENT_TYPE) {
let element: React$Element<any> = (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.');
}
let rootPing;
let rootPromise = new Promise(resolve => {
rootPing = resolve;
});
let response: OpaqueResponse = ({
source,
modelRoot,
partialRow: '',
rootPing,
}: any);
if (supportsBinaryStreams) {
response.stringDecoder = createStringDecoder();
}
return value;
return response;
}
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);
}
flushBuffered(request.destination);
}
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);
}
close(destination);
}
export function startWork(request: OpaqueRequest): void {
request.flowing = true;
scheduleWork(() => performWork(request));
}
export function startFlowing(
request: OpaqueRequest,
desiredBytes: number,
// 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 {
request.flowing = false;
flushCompletedChunks(request);
Object.defineProperty(
response.modelRoot,
'model',
({
configurable: true,
enumerable: true,
get() {
throw error;
},
}: any),
);
response.rootPing();
}
export function processStringChunk(
response: OpaqueResponse,
chunk: string,
offset: number,
): void {
response.partialRow += chunk.substr(offset);
}
export function processBinaryChunk(
response: OpaqueResponse,
chunk: Uint8Array,
offset: number,
): void {
if (!supportsBinaryStreams) {
throw new Error("This environment don't support binary chunks.");
}
response.partialRow += readPartialStringChunk(response.stringDecoder, chunk);
}
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,
);
}
let modelRoot = response.modelRoot;
let model = JSON.parse(response.partialRow);
Object.defineProperty(modelRoot, 'model', {
value: model,
});
response.rootPing();
}
export function getModelRoot<T>(response: OpaqueResponse): ReactModelRoot<T> {
return response.modelRoot;
}

View File

@ -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: [<Bar text="a" />, <Bar text="b" />],
};
}
let result = ReactNoopFlightClient.render({
let transport = ReactNoopFlightServer.render({
foo: <Foo />,
});
expect(result).toEqual([{foo: {bar: ['A', 'B']}}]);
let root = ReactNoopFlightClient.read(transport);
let model = root.model;
expect(model).toEqual({foo: {bar: ['A', 'B']}});
});
});

View File

@ -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;

View File

@ -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<string>;
type Source = Array<string>;
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<T>(source: Source): ReactModelRoot<T> {
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,
};

View File

@ -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 {},

View File

@ -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: [<Bar text="a" />, <Bar text="b" />],
};
}
let result = ReactNoopFlight.render({
foo: <Foo />,
});
expect(result).toEqual([{foo: {bar: ['A', 'B']}}]);
});
});

View File

@ -28,6 +28,10 @@ module.exports = {
SharedArrayBuffer: true,
Int32Array: true,
ArrayBuffer: true,
// Flight
Uint8Array: true,
Promise: true,
},
parserOptions: {
ecmaVersion: 5,

View File

@ -29,6 +29,10 @@ module.exports = {
SharedArrayBuffer: true,
Int32Array: true,
ArrayBuffer: true,
// Flight
Uint8Array: true,
Promise: true,
},
parserOptions: {
ecmaVersion: 5,

View File

@ -31,6 +31,10 @@ module.exports = {
SharedArrayBuffer: true,
Int32Array: true,
ArrayBuffer: true,
// Flight
Uint8Array: true,
Promise: true,
},
parserOptions: {
ecmaVersion: 5,