Change .model getter to .readRoot method (#18382)

Originally the idea was to hide all suspending behind getters or proxies.
However, this has some issues with perf on hot code like React elements.

It also makes it too easy to accidentally access it the first time in an
effect or callback where things aren't allowed to suspend. Making it
an explicit method call avoids this issue.

All other suspending has moved to explicit lazy blocks (and soon elements).
The only thing remaining is the root. We could require the root to be an
element or block but that creates an unfortunate indirection unnecessarily.

Instead, I expose a readRoot method on the response. Typically we try to
avoid virtual dispatch but in this case, it's meant that you build
abstractions on top of a Flight response so passing it a round is useful.
This commit is contained in:
Sebastian Markbåge
2020-03-25 11:47:55 -07:00
committed by GitHub
parent bd5781962a
commit a6924d77b1
12 changed files with 122 additions and 135 deletions

View File

@@ -70,20 +70,20 @@
let blob = await responseToDisplay.blob();
let url = URL.createObjectURL(blob);
let data = ReactFlightDOMClient.readFromFetch(
let data = ReactFlightDOMClient.createFromFetch(
fetch(url)
);
// The client also supports XHR streaming.
// var xhr = new XMLHttpRequest();
// xhr.open('GET', url);
// let data = ReactFlightDOMClient.readFromXHR(xhr);
// let data = ReactFlightDOMClient.createFromXHR(xhr);
// xhr.send();
renderResult(data);
}
function Shell({ data }) {
let model = data.model;
let model = data.readRoot();
return <div>
<Suspense fallback="...">
<h1>{model.title}</h1>

View File

@@ -1,7 +1,7 @@
import React, {Suspense} from 'react';
function Content({data}) {
return data.model.content;
return data.readRoot().content;
}
function App({data}) {

View File

@@ -3,5 +3,6 @@ import ReactDOM from 'react-dom';
import ReactFlightDOMClient from 'react-flight-dom-webpack';
import App from './App';
let data = ReactFlightDOMClient.readFromFetch(fetch('http://localhost:3001'));
let data = ReactFlightDOMClient.createFromFetch(fetch('http://localhost:3001'));
ReactDOM.render(<App data={data} />, document.getElementById('root'));

View File

@@ -27,10 +27,6 @@ import {
REACT_ELEMENT_TYPE,
} from 'shared/ReactSymbols';
export type ReactModelRoot<T> = {|
model: T,
|};
export type JSONValue =
| number
| null
@@ -65,22 +61,32 @@ type ErroredChunk = {|
|};
type Chunk<T> = PendingChunk | ResolvedChunk<T> | ErroredChunk;
export type Response = {
export type Response<T> = {
partialRow: string,
modelRoot: ReactModelRoot<any>,
rootChunk: Chunk<T>,
chunks: Map<number, Chunk<any>>,
readRoot(): T,
};
export function createResponse(): Response {
let modelRoot: ReactModelRoot<any> = ({}: any);
function readRoot<T>(): T {
let response: Response<T> = this;
let rootChunk = response.rootChunk;
if (rootChunk.status === RESOLVED) {
return rootChunk.value;
} else {
throw rootChunk.value;
}
}
export function createResponse<T>(): Response<T> {
let rootChunk: Chunk<any> = createPendingChunk();
definePendingProperty(modelRoot, 'model', rootChunk);
let chunks: Map<number, Chunk<any>> = new Map();
chunks.set(0, rootChunk);
let response = {
partialRow: '',
modelRoot,
rootChunk,
chunks: chunks,
readRoot: readRoot,
};
return response;
}
@@ -142,7 +148,10 @@ function resolveChunk<T>(chunk: Chunk<T>, value: T): 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: Response, error: Error): void {
export function reportGlobalError<T>(
response: Response<T>,
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
@@ -164,24 +173,6 @@ function readMaybeChunk<T>(maybeChunk: Chunk<T> | T): T {
}
}
function definePendingProperty<T>(
object: Object,
key: string,
chunk: Chunk<T>,
): void {
Object.defineProperty(object, key, {
configurable: false,
enumerable: true,
get() {
if (chunk.status === RESOLVED) {
return chunk.value;
} else {
throw chunk.value;
}
},
});
}
function createElement(type, key, props): React$Element<any> {
const element: any = {
// This tag allows us to uniquely identify this as a React Element
@@ -272,8 +263,8 @@ function createLazyBlock<Props, Data>(
return lazyType;
}
export function parseModelFromJSON(
response: Response,
export function parseModelFromJSON<T>(
response: Response<T>,
targetObj: Object,
key: string,
value: JSONValue,
@@ -317,10 +308,10 @@ export function parseModelFromJSON(
return value;
}
export function resolveModelChunk<T>(
response: Response,
export function resolveModelChunk<T, M>(
response: Response<T>,
id: number,
model: T,
model: M,
): void {
let chunks = response.chunks;
let chunk = chunks.get(id);
@@ -331,8 +322,8 @@ export function resolveModelChunk<T>(
}
}
export function resolveErrorChunk(
response: Response,
export function resolveErrorChunk<T>(
response: Response<T>,
id: number,
message: string,
stack: string,
@@ -348,14 +339,10 @@ export function resolveErrorChunk(
}
}
export function close(response: Response): void {
export function close<T>(response: Response<T>): 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
// ref count of pending chunks.
reportGlobalError(response, new Error('Connection closed.'));
}
export function getModelRoot<T>(response: Response): ReactModelRoot<T> {
return response.modelRoot;
}

View File

@@ -25,17 +25,13 @@ import {
readFinalStringChunk,
} from './ReactFlightClientHostConfig';
export type ReactModelRoot<T> = {|
model: T,
|};
type Response = ResponseBase & {
export type Response<T> = ResponseBase<T> & {
fromJSON: (key: string, value: JSONValue) => any,
stringDecoder: StringDecoder,
};
export function createResponse(): Response {
let response: Response = (createResponseImpl(): any);
export function createResponse<T>(): Response<T> {
let response: Response<T> = (createResponseImpl(): any);
response.fromJSON = function(key: string, value: JSONValue) {
return parseModelFromJSON(response, this, key, value);
};
@@ -45,7 +41,7 @@ export function createResponse(): Response {
return response;
}
function processFullRow(response: Response, row: string): void {
function processFullRow<T>(response: Response<T>, row: string): void {
if (row === '') {
return;
}
@@ -76,8 +72,8 @@ function processFullRow(response: Response, row: string): void {
}
}
export function processStringChunk(
response: Response,
export function processStringChunk<T>(
response: Response<T>,
chunk: string,
offset: number,
): void {
@@ -92,8 +88,8 @@ export function processStringChunk(
response.partialRow += chunk.substring(offset);
}
export function processBinaryChunk(
response: Response,
export function processBinaryChunk<T>(
response: Response<T>,
chunk: Uint8Array,
): void {
if (!supportsBinaryStreams) {
@@ -113,4 +109,4 @@ export function processBinaryChunk(
response.partialRow += readPartialStringChunk(stringDecoder, chunk);
}
export {reportGlobalError, close, getModelRoot} from './ReactFlightClient';
export {reportGlobalError, close} from './ReactFlightClient';

View File

@@ -57,8 +57,7 @@ describe('ReactFlight', () => {
let transport = ReactNoopFlightServer.render({
foo: <Foo />,
});
let root = ReactNoopFlightClient.read(transport);
let model = root.model;
let model = ReactNoopFlightClient.read(transport);
expect(model).toEqual({
foo: {
bar: (
@@ -87,10 +86,10 @@ describe('ReactFlight', () => {
};
let transport = ReactNoopFlightServer.render(model);
let root = ReactNoopFlightClient.read(transport);
act(() => {
let UserClient = root.model.User;
let rootModel = ReactNoopFlightClient.read(transport);
let UserClient = rootModel.User;
ReactNoop.render(<UserClient greeting="Hello" />);
});
@@ -114,10 +113,10 @@ describe('ReactFlight', () => {
};
let transport = ReactNoopFlightServer.render(model);
let root = ReactNoopFlightClient.read(transport);
act(() => {
let UserClient = root.model.User;
let rootModel = ReactNoopFlightClient.read(transport);
let UserClient = rootModel.User;
ReactNoop.render(<UserClient greeting="Hello" />);
});

View File

@@ -11,14 +11,13 @@ import type {Response, JSONValue} from 'react-client/src/ReactFlightClient';
import {
createResponse,
getModelRoot,
parseModelFromJSON,
resolveModelChunk,
resolveErrorChunk,
close,
} from 'react-client/src/ReactFlightClient';
function parseModel(response, targetObj, key, value) {
function parseModel<T>(response: Response<T>, targetObj, key, value) {
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
@@ -38,14 +37,18 @@ function parseModel(response, targetObj, key, value) {
return parseModelFromJSON(response, targetObj, key, value);
}
export {createResponse, getModelRoot, close};
export {createResponse, close};
export function resolveModel(response: Response, id: number, json: JSONValue) {
export function resolveModel<T>(
response: Response<T>,
id: number,
json: JSONValue,
) {
resolveModelChunk(response, id, parseModel(response, {}, '', json));
}
export function resolveError(
response: Response,
export function resolveError<T>(
response: Response<T>,
id: number,
message: string,
stack: string,

View File

@@ -39,8 +39,8 @@ describe('ReactFlightDOMRelay', () => {
);
}
}
let model = ReactDOMFlightRelayClient.getModelRoot(response).model;
ReactDOMFlightRelayClient.close(response);
let model = response.readRoot();
return model;
}

View File

@@ -7,18 +7,20 @@
* @flow
*/
import type {ReactModelRoot} from 'react-client/src/ReactFlightClientStream';
import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream';
import {
createResponse,
getModelRoot,
reportGlobalError,
processStringChunk,
processBinaryChunk,
close,
} from 'react-client/src/ReactFlightClientStream';
function startReadingFromStream(response, stream: ReadableStream): void {
function startReadingFromStream<T>(
response: FlightResponse<T>,
stream: ReadableStream,
): void {
let reader = stream.getReader();
function progress({done, value}) {
if (done) {
@@ -35,16 +37,18 @@ function startReadingFromStream(response, stream: ReadableStream): void {
reader.read().then(progress, error);
}
function readFromReadableStream<T>(stream: ReadableStream): ReactModelRoot<T> {
let response = createResponse();
function createFromReadableStream<T>(
stream: ReadableStream,
): FlightResponse<T> {
let response: FlightResponse<T> = createResponse();
startReadingFromStream(response, stream);
return getModelRoot(response);
return response;
}
function readFromFetch<T>(
function createFromFetch<T>(
promiseForResponse: Promise<Response>,
): ReactModelRoot<T> {
let response = createResponse();
): FlightResponse<T> {
let response: FlightResponse<T> = createResponse();
promiseForResponse.then(
function(r) {
startReadingFromStream(response, (r.body: any));
@@ -53,11 +57,11 @@ function readFromFetch<T>(
reportGlobalError(response, e);
},
);
return getModelRoot(response);
return response;
}
function readFromXHR<T>(request: XMLHttpRequest): ReactModelRoot<T> {
let response = createResponse();
function createFromXHR<T>(request: XMLHttpRequest): FlightResponse<T> {
let response: FlightResponse<T> = createResponse();
let processedLength = 0;
function progress(e: ProgressEvent): void {
let chunk = request.responseText;
@@ -76,7 +80,7 @@ function readFromXHR<T>(request: XMLHttpRequest): ReactModelRoot<T> {
request.addEventListener('error', error);
request.addEventListener('abort', error);
request.addEventListener('timeout', error);
return getModelRoot(response);
return response;
}
export {readFromXHR, readFromFetch, readFromReadableStream};
export {createFromXHR, createFromFetch, createFromReadableStream};

View File

@@ -119,9 +119,10 @@ describe('ReactFlightDOM', () => {
let {writable, readable} = getTestStream();
ReactFlightDOMServer.pipeToNodeWritable(<App />, writable, webpackMap);
let result = ReactFlightDOMClient.readFromReadableStream(readable);
let response = ReactFlightDOMClient.createFromReadableStream(readable);
await waitForSuspense(() => {
expect(result.model).toEqual({
let model = response.readRoot();
expect(model).toEqual({
html: (
<div>
<span>hello</span>
@@ -154,13 +155,13 @@ describe('ReactFlightDOM', () => {
}
// View
function Message({result}) {
return <section>{result.model.html}</section>;
function Message({response}) {
return <section>{response.readRoot().html}</section>;
}
function App({result}) {
function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Message result={result} />
<Message response={response} />
</Suspense>
);
}
@@ -171,12 +172,12 @@ describe('ReactFlightDOM', () => {
writable,
webpackMap,
);
let result = ReactFlightDOMClient.readFromReadableStream(readable);
let response = ReactFlightDOMClient.createFromReadableStream(readable);
let container = document.createElement('div');
let root = ReactDOM.createRoot(container);
await act(async () => {
root.render(<App result={result} />);
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe(
'<section><div><span>hello</span><span>world</span></div></section>',
@@ -192,13 +193,13 @@ describe('ReactFlightDOM', () => {
}
// View
function Message({result}) {
return <p>{result.model.text}</p>;
function Message({response}) {
return <p>{response.readRoot().text}</p>;
}
function App({result}) {
function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Message result={result} />
<Message response={response} />
</Suspense>
);
}
@@ -209,12 +210,12 @@ describe('ReactFlightDOM', () => {
writable,
webpackMap,
);
let result = ReactFlightDOMClient.readFromReadableStream(readable);
let response = ReactFlightDOMClient.createFromReadableStream(readable);
let container = document.createElement('div');
let root = ReactDOM.createRoot(container);
await act(async () => {
root.render(<App result={result} />);
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe('<p>$1</p>');
});
@@ -228,13 +229,13 @@ describe('ReactFlightDOM', () => {
}
// View
function Message({result}) {
return <p>{result.model.text}</p>;
function Message({response}) {
return <p>{response.readRoot().text}</p>;
}
function App({result}) {
function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Message result={result} />
<Message response={response} />
</Suspense>
);
}
@@ -245,12 +246,12 @@ describe('ReactFlightDOM', () => {
writable,
webpackMap,
);
let result = ReactFlightDOMClient.readFromReadableStream(readable);
let response = ReactFlightDOMClient.createFromReadableStream(readable);
let container = document.createElement('div');
let root = ReactDOM.createRoot(container);
await act(async () => {
root.render(<App result={result} />);
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe('<p>@div</p>');
});
@@ -327,42 +328,44 @@ describe('ReactFlightDOM', () => {
};
// View
function ProfileDetails({result}) {
function ProfileDetails({response}) {
let model = response.readRoot();
return (
<div>
{result.model.name}
{result.model.more.avatar}
{model.name}
{model.more.avatar}
</div>
);
}
function ProfileSidebar({result}) {
function ProfileSidebar({response}) {
let model = response.readRoot();
return (
<div>
{result.model.photos}
{result.model.more.friends}
{model.photos}
{model.more.friends}
</div>
);
}
function ProfilePosts({result}) {
return <div>{result.model.more.posts}</div>;
function ProfilePosts({response}) {
return <div>{response.readRoot().more.posts}</div>;
}
function ProfileGames({result}) {
return <div>{result.model.more.games}</div>;
function ProfileGames({response}) {
return <div>{response.readRoot().more.games}</div>;
}
function ProfilePage({result}) {
function ProfilePage({response}) {
return (
<>
<Suspense fallback={<p>(loading)</p>}>
<ProfileDetails result={result} />
<ProfileDetails response={response} />
<Suspense fallback={<p>(loading sidebar)</p>}>
<ProfileSidebar result={result} />
<ProfileSidebar response={response} />
</Suspense>
<Suspense fallback={<p>(loading posts)</p>}>
<ProfilePosts result={result} />
<ProfilePosts response={response} />
</Suspense>
<ErrorBoundary fallback={e => <p>{e.message}</p>}>
<Suspense fallback={<p>(loading games)</p>}>
<ProfileGames result={result} />
<ProfileGames response={response} />
</Suspense>
</ErrorBoundary>
</Suspense>
@@ -372,12 +375,12 @@ describe('ReactFlightDOM', () => {
let {writable, readable} = getTestStream();
ReactFlightDOMServer.pipeToNodeWritable(profileModel, writable, webpackMap);
let result = ReactFlightDOMClient.readFromReadableStream(readable);
let response = ReactFlightDOMClient.createFromReadableStream(readable);
let container = document.createElement('div');
let root = ReactDOM.createRoot(container);
await act(async () => {
root.render(<ProfilePage result={result} />);
root.render(<ProfilePage response={response} />);
});
expect(container.innerHTML).toBe('<p>(loading)</p>');

View File

@@ -62,9 +62,10 @@ describe('ReactFlightDOMBrowser', () => {
}
let stream = ReactFlightDOMServer.renderToReadableStream(<App />);
let result = ReactFlightDOMClient.readFromReadableStream(stream);
let response = ReactFlightDOMClient.createFromReadableStream(stream);
await waitForSuspense(() => {
expect(result.model).toEqual({
let model = response.readRoot();
expect(model).toEqual({
html: (
<div>
<span>hello</span>

View File

@@ -14,20 +14,13 @@
* environment.
*/
import type {ReactModelRoot} from 'react-client/flight';
import {readModule} from 'react-noop-renderer/flight-modules';
import ReactFlightClient from 'react-client/flight';
type Source = Array<string>;
const {
createResponse,
getModelRoot,
processStringChunk,
close,
} = ReactFlightClient({
const {createResponse, processStringChunk, close} = ReactFlightClient({
supportsBinaryStreams: false,
resolveModuleReference(idx: string) {
return idx;
@@ -38,13 +31,13 @@ const {
},
});
function read<T>(source: Source): ReactModelRoot<T> {
function read<T>(source: Source): T {
let response = createResponse(source);
for (let i = 0; i < source.length; i++) {
processStringChunk(response, source[i], 0);
}
close(response);
return getModelRoot(response);
return response.readRoot();
}
export {read};