Skip to content

Commit

Permalink
Encode server rendered host components as array tuples (#18273)
Browse files Browse the repository at this point in the history
This replaces the HTML renderer with instead resolving host elements into
arrays tagged with the react.element symbol. These turn into proper
React Elements on the client.

The symbol is encoded as the magical value "$". This has security implications
so this special value needs to remain escaped for other strings.

We could just encode the element as {$$typeof: "$", key: key props: props}
but that's a lot more bytes. So instead I encode it as:
["$", key, props] and then convert it back.

It would be nicer if React's reconciler could just accept these tuples.
  • Loading branch information
sebmarkbage committed Mar 11, 2020
1 parent bf35108 commit dc7eeda
Show file tree
Hide file tree
Showing 10 changed files with 132 additions and 47 deletions.
6 changes: 2 additions & 4 deletions fixtures/flight-browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@ <h1>Flight Example</h1>

let model = {
title: <Title />,
content: {
__html: <HTML />,
}
content: <HTML />,
};

let stream = ReactFlightDOMServer.renderToReadableStream(model);
Expand Down Expand Up @@ -90,7 +88,7 @@ <h1>Flight Example</h1>
<Suspense fallback="...">
<h1>{model.title}</h1>
</Suspense>
<div dangerouslySetInnerHTML={model.content} />
{model.content}
</div>;
}

Expand Down
4 changes: 1 addition & 3 deletions fixtures/flight/server/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ function HTML() {
module.exports = function(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
let model = {
content: {
__html: <HTML />,
},
content: <HTML />,
};
ReactFlightDOMServer.pipeToNodeWritable(model, res);
};
2 changes: 1 addition & 1 deletion fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, {Suspense} from 'react';

function Content({data}) {
return <p dangerouslySetInnerHTML={data.model.content} />;
return data.model.content;
}

function App({data}) {
Expand Down
89 changes: 73 additions & 16 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @flow
*/

import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';

export type ReactModelRoot<T> = {|
model: T,
|};
Expand All @@ -19,6 +21,8 @@ export type JSONValue =
| {[key: string]: JSONValue}
| Array<JSONValue>;

const isArray = Array.isArray;

const PENDING = 0;
const RESOLVED = 1;
const ERRORED = 2;
Expand Down Expand Up @@ -141,28 +145,81 @@ function definePendingProperty(
});
}

function createElement(type, key, props): React$Element<any> {
const element: any = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,

// Built-in properties that belong on the element
type: type,
key: key,
ref: null,
props: props,

// Record the component responsible for creating this element.
_owner: null,
};
if (__DEV__) {
// We don't really need to add any of these but keeping them for good measure.
// Unfortunately, _store is enumerable in jest matchers so for equality to
// work, I need to keep it or make _store non-enumerable in the other file.
element._store = {};
Object.defineProperty(element._store, 'validated', {
configurable: false,
enumerable: false,
writable: true,
value: true, // This element has already been validated on the server.
});
Object.defineProperty(element, '_self', {
configurable: false,
enumerable: false,
writable: false,
value: null,
});
Object.defineProperty(element, '_source', {
configurable: false,
enumerable: false,
writable: false,
value: null,
});
}
return element;
}

export function parseModelFromJSON(
response: Response,
targetObj: Object,
key: string,
value: JSONValue,
): any {
if (typeof value === 'string' && value[0] === '$') {
if (value[1] === '$') {
// This was an escaped string value.
return value.substring(1);
} else {
let id = parseInt(value.substring(1), 16);
let chunks = response.chunks;
let chunk = chunks.get(id);
if (!chunk) {
chunk = createPendingChunk();
chunks.set(id, chunk);
} else if (chunk.status === RESOLVED) {
return chunk.value;
): mixed {
if (typeof value === 'string') {
if (value[0] === '$') {
if (value === '$') {
return REACT_ELEMENT_TYPE;
} else if (value[1] === '$' || value[1] === '@') {
// This was an escaped string value.
return value.substring(1);
} else {
let id = parseInt(value.substring(1), 16);
let chunks = response.chunks;
let chunk = chunks.get(id);
if (!chunk) {
chunk = createPendingChunk();
chunks.set(id, chunk);
} else if (chunk.status === RESOLVED) {
return chunk.value;
}
definePendingProperty(targetObj, key, chunk);
return undefined;
}
definePendingProperty(targetObj, key, chunk);
return undefined;
}
}
if (isArray(value)) {
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]);
}
}
return value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ function parseModel(response, targetObj, key, value) {
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
value[i] = parseModel(response, value, '' + i, value[i]);
(value: any)[i] = parseModel(response, value, '' + i, value[i]);
}
} else {
for (let innerKey in value) {
value[innerKey] = parseModel(
(value: any)[innerKey] = parseModel(
response,
value,
innerKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ describe('ReactFlightDOM', () => {
let result = ReactFlightDOMClient.readFromReadableStream(readable);
await waitForSuspense(() => {
expect(result.model).toEqual({
html: '<div><span>hello</span><span>world</span></div>',
html: (
<div>
<span>hello</span>
<span>world</span>
</div>
),
});
});
});
Expand Down Expand Up @@ -120,7 +125,7 @@ describe('ReactFlightDOM', () => {

// View
function Message({result}) {
return <p dangerouslySetInnerHTML={{__html: result.model.html}} />;
return <section>{result.model.html}</section>;
}
function App({result}) {
return (
Expand All @@ -140,7 +145,7 @@ describe('ReactFlightDOM', () => {
root.render(<App result={result} />);
});
expect(container.innerHTML).toBe(
'<p><div><span>hello</span><span>world</span></div></p>',
'<section><div><span>hello</span><span>world</span></div></section>',
);
});

Expand Down Expand Up @@ -176,6 +181,38 @@ describe('ReactFlightDOM', () => {
expect(container.innerHTML).toBe('<p>$1</p>');
});

it.experimental('should not get confused by @', async () => {
let {Suspense} = React;

// Model
function RootModel() {
return {text: '@div'};
}

// View
function Message({result}) {
return <p>{result.model.text}</p>;
}
function App({result}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Message result={result} />
</Suspense>
);
}

let {writable, readable} = getTestStream();
ReactFlightDOMServer.pipeToNodeWritable(<RootModel />, writable);
let result = ReactFlightDOMClient.readFromReadableStream(readable);

let container = document.createElement('div');
let root = ReactDOM.createRoot(container);
await act(async () => {
root.render(<App result={result} />);
});
expect(container.innerHTML).toBe('<p>@div</p>');
});

it.experimental('should progressively reveal chunks', async () => {
let {Suspense} = React;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ describe('ReactFlightDOMBrowser', () => {
let result = ReactFlightDOMClient.readFromReadableStream(stream);
await waitForSuspense(() => {
expect(result.model).toEqual({
html: '<div><span>hello</span><span>world</span></div>',
html: (
<div>
<span>hello</span>
<span>world</span>
</div>
),
});
});
});
Expand Down
12 changes: 0 additions & 12 deletions packages/react-server/src/ReactDOMServerFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

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

import {renderToStaticMarkup} from 'react-dom/server';

export function formatChunkAsString(type: string, props: Object): string {
let str = '<' + type + '>';
if (typeof props.children === 'string') {
Expand All @@ -23,13 +21,3 @@ 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 renderToStaticMarkup(children);
}
10 changes: 7 additions & 3 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
processModelChunk,
processErrorChunk,
} from './ReactFlightServerConfig';
import {renderHostChildrenToString} from './ReactServerFormatConfig';

import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';

type ReactJSONValue =
Expand Down Expand Up @@ -88,7 +88,7 @@ function attemptResolveModelComponent(element: React$Element<any>): ReactModel {
return type(props);
} else if (typeof type === 'string') {
// This is a host element. E.g. HTML.
return renderHostChildrenToString(element);
return [REACT_ELEMENT_TYPE, type, element.key, element.props];
} else {
throw new Error('Unsupported type.');
}
Expand Down Expand Up @@ -119,7 +119,7 @@ function serializeIDRef(id: number): string {
function escapeStringValue(value: string): string {
if (value[0] === '$') {
// We need to escape $ prefixed strings since we use that to encode
// references to IDs.
// references to IDs and as a special symbol value.
return '$' + value;
} else {
return value;
Expand All @@ -134,6 +134,10 @@ export function resolveModelToJSON(
return escapeStringValue(value);
}

if (value === REACT_ELEMENT_TYPE) {
return '$';
}

while (
typeof value === 'object' &&
value !== null &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,3 @@ 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 dc7eeda

Please sign in to comment.