Skip to content

Commit

Permalink
[Flight] Integrate Blocks into Flight (#18371)
Browse files Browse the repository at this point in the history
* Resolve Server-side Blocks instead of Components

React elements should no longer be used to extract arbitrary data but only
for prerendering trees.

Blocks are used to create asynchronous behavior.

* Resolve Blocks in the Client

* Tests

* Bug fix relay JSON traversal

It's supposed to pass the original object and not the new one.

* Lint

* Move Noop Module Test Helpers to top level entry points

This module has shared state. It needs to be external from builds.

This lets us test the built versions of the Noop renderer.
  • Loading branch information
sebmarkbage authored Mar 24, 2020
1 parent fc96a52 commit a56309f
Show file tree
Hide file tree
Showing 22 changed files with 530 additions and 155 deletions.
147 changes: 114 additions & 33 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,25 @@
* @flow
*/

import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import type {BlockComponent, BlockRenderFunction} from 'react/src/ReactBlock';
import type {LazyComponent} from 'react/src/ReactLazy';

// import type {
// ModuleReference,
// ModuleMetaData,
// } from './ReactFlightClientHostConfig';
import type {
ModuleReference,
ModuleMetaData,
} from './ReactFlightClientHostConfig';

// import {
// resolveModuleReference,
// preloadModule,
// requireModule,
// } from './ReactFlightClientHostConfig';
import {
resolveModuleReference,
preloadModule,
requireModule,
} from './ReactFlightClientHostConfig';

import {
REACT_LAZY_TYPE,
REACT_BLOCK_TYPE,
REACT_ELEMENT_TYPE,
} from 'shared/ReactSymbols';

export type ReactModelRoot<T> = {|
model: T,
Expand All @@ -32,40 +39,43 @@ export type JSONValue =
| {[key: string]: JSONValue}
| Array<JSONValue>;

const isArray = Array.isArray;

const PENDING = 0;
const RESOLVED = 1;
const ERRORED = 2;

const CHUNK_TYPE = Symbol('flight.chunk');

type PendingChunk = {|
$$typeof: Symbol,
status: 0,
value: Promise<void>,
resolve: () => void,
|};
type ResolvedChunk = {|
type ResolvedChunk<T> = {|
$$typeof: Symbol,
status: 1,
value: mixed,
value: T,
resolve: null,
|};
type ErroredChunk = {|
$$typeof: Symbol,
status: 2,
value: Error,
resolve: null,
|};
type Chunk = PendingChunk | ResolvedChunk | ErroredChunk;
type Chunk<T> = PendingChunk | ResolvedChunk<T> | ErroredChunk;

export type Response = {
partialRow: string,
modelRoot: ReactModelRoot<any>,
chunks: Map<number, Chunk>,
chunks: Map<number, Chunk<any>>,
};

export function createResponse(): Response {
let modelRoot: ReactModelRoot<any> = ({}: any);
let rootChunk: Chunk = createPendingChunk();
let rootChunk: Chunk<any> = createPendingChunk();
definePendingProperty(modelRoot, 'model', rootChunk);
let chunks: Map<number, Chunk> = new Map();
let chunks: Map<number, Chunk<any>> = new Map();
chunks.set(0, rootChunk);
let response = {
partialRow: '',
Expand All @@ -79,6 +89,7 @@ function createPendingChunk(): PendingChunk {
let resolve: () => void = (null: any);
let promise = new Promise(r => (resolve = r));
return {
$$typeof: CHUNK_TYPE,
status: PENDING,
value: promise,
resolve: resolve,
Expand All @@ -87,13 +98,14 @@ function createPendingChunk(): PendingChunk {

function createErrorChunk(error: Error): ErroredChunk {
return {
$$typeof: CHUNK_TYPE,
status: ERRORED,
value: error,
resolve: null,
};
}

function triggerErrorOnChunk(chunk: Chunk, error: Error): void {
function triggerErrorOnChunk<T>(chunk: Chunk<T>, error: Error): void {
if (chunk.status !== PENDING) {
// We already resolved. We didn't expect to see this.
return;
Expand All @@ -106,21 +118,22 @@ function triggerErrorOnChunk(chunk: Chunk, error: Error): void {
resolve();
}

function createResolvedChunk(value: mixed): ResolvedChunk {
function createResolvedChunk<T>(value: T): ResolvedChunk<T> {
return {
$$typeof: CHUNK_TYPE,
status: RESOLVED,
value: value,
resolve: null,
};
}

function resolveChunk(chunk: Chunk, value: mixed): void {
function resolveChunk<T>(chunk: Chunk<T>, value: T): void {
if (chunk.status !== PENDING) {
// We already resolved. We didn't expect to see this.
return;
}
let resolve = chunk.resolve;
let resolvedChunk: ResolvedChunk = (chunk: any);
let resolvedChunk: ResolvedChunk<T> = (chunk: any);
resolvedChunk.status = RESOLVED;
resolvedChunk.value = value;
resolvedChunk.resolve = null;
Expand All @@ -138,10 +151,23 @@ export function reportGlobalError(response: Response, error: Error): void {
});
}

function definePendingProperty(
function readMaybeChunk<T>(maybeChunk: Chunk<T> | T): T {
if ((maybeChunk: any).$$typeof !== CHUNK_TYPE) {
// $FlowFixMe
return maybeChunk;
}
let chunk: Chunk<T> = (maybeChunk: any);
if (chunk.status === RESOLVED) {
return chunk.value;
} else {
throw chunk.value;
}
}

function definePendingProperty<T>(
object: Object,
key: string,
chunk: Chunk,
chunk: Chunk<T>,
): void {
Object.defineProperty(object, key, {
configurable: false,
Expand Down Expand Up @@ -197,6 +223,55 @@ function createElement(type, key, props): React$Element<any> {
return element;
}

type UninitializedBlockPayload<Data> = [
mixed,
ModuleMetaData | Chunk<ModuleMetaData>,
Data | Chunk<Data>,
];

type Thenable<T> = {
then(resolve: (T) => mixed, reject?: (mixed) => mixed): Thenable<any>,
};

function initializeBlock<Props, Data>(
tuple: UninitializedBlockPayload<Data>,
): BlockComponent<Props, Data> {
// Require module first and then data. The ordering matters.
let moduleMetaData: ModuleMetaData = readMaybeChunk(tuple[1]);
let moduleReference: ModuleReference<
BlockRenderFunction<Props, Data>,
> = resolveModuleReference(moduleMetaData);
// TODO: Do this earlier, as the chunk is resolved.
preloadModule(moduleReference);

let moduleExport = requireModule(moduleReference);

// The ordering here is important because this call might suspend.
// We don't want that to prevent the module graph for being initialized.
let data: Data = readMaybeChunk(tuple[2]);

return {
$$typeof: REACT_BLOCK_TYPE,
_status: -1,
_data: data,
_render: moduleExport,
};
}

function createLazyBlock<Props, Data>(
tuple: UninitializedBlockPayload<Data>,
): LazyComponent<BlockComponent<Props, Data>, UninitializedBlockPayload<Data>> {
let lazyType: LazyComponent<
BlockComponent<Props, Data>,
UninitializedBlockPayload<Data>,
> = {
$$typeof: REACT_LAZY_TYPE,
_payload: tuple,
_init: initializeBlock,
};
return lazyType;
}

export function parseModelFromJSON(
response: Response,
targetObj: Object,
Expand All @@ -217,20 +292,26 @@ export function parseModelFromJSON(
if (!chunk) {
chunk = createPendingChunk();
chunks.set(id, chunk);
} else if (chunk.status === RESOLVED) {
return chunk.value;
}
definePendingProperty(targetObj, key, chunk);
return undefined;
return chunk;
}
}
if (value === '@') {
return REACT_BLOCK_TYPE;
}
}
if (isArray(value)) {
if (typeof value === 'object' && value !== null) {
let tuple: [mixed, mixed, mixed, mixed] = (value: any);
if (tuple[0] === REACT_ELEMENT_TYPE) {
// TODO: Consider having React just directly accept these arrays as elements.
// Or even change the ReactElement type to be an array.
return createElement(tuple[1], tuple[2], tuple[3]);
switch (tuple[0]) {
case REACT_ELEMENT_TYPE: {
// TODO: Consider having React just directly accept these arrays as elements.
// Or even change the ReactElement type to be an array.
return createElement(tuple[1], tuple[2], tuple[3]);
}
case REACT_BLOCK_TYPE: {
// TODO: Consider having React just directly accept these arrays as blocks.
return createLazyBlock((tuple: any));
}
}
}
return value;
Expand Down
64 changes: 61 additions & 3 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@

'use strict';

const ReactFeatureFlags = require('shared/ReactFeatureFlags');

let act;
let React;
let ReactNoop;
let ReactNoopFlightServer;
let ReactNoopFlightClient;

Expand All @@ -19,24 +23,78 @@ describe('ReactFlight', () => {
jest.resetModules();

React = require('react');
ReactNoop = require('react-noop-renderer');
ReactNoopFlightServer = require('react-noop-renderer/flight-server');
ReactNoopFlightClient = require('react-noop-renderer/flight-client');
act = ReactNoop.act;
});

it('can resolve a model', () => {
function block(query, render) {
return function(...args) {
let curriedQuery = () => {
return query(...args);
};
return [Symbol.for('react.server.block'), render, curriedQuery];
};
}

it('can render a server component', () => {
function Bar({text}) {
return text.toUpperCase();
}
function Foo() {
return {
bar: [<Bar text="a" />, <Bar text="b" />],
bar: (
<div>
<Bar text="a" />, <Bar text="b" />
</div>
),
};
}
let transport = ReactNoopFlightServer.render({
foo: <Foo />,
});
let root = ReactNoopFlightClient.read(transport);
let model = root.model;
expect(model).toEqual({foo: {bar: ['A', 'B']}});
expect(model).toEqual({
foo: {
bar: (
<div>
{'A'}
{', '}
{'B'}
</div>
),
},
});
});

if (ReactFeatureFlags.enableBlocksAPI) {
it('can transfer a Block to the client and render there', () => {
function Query(firstName, lastName) {
return {name: firstName + ' ' + lastName};
}
function User(props, data) {
return (
<span>
{props.greeting}, {data.name}
</span>
);
}
let loadUser = block(Query, User);
let model = {
User: loadUser('Seb', 'Smith'),
};

let transport = ReactNoopFlightServer.render(model);
let root = ReactNoopFlightClient.read(transport);

act(() => {
let UserClient = root.model.User;
ReactNoop.render(<UserClient greeting="Hello" />);
});

expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
});
}
});
Loading

0 comments on commit a56309f

Please sign in to comment.