Skip to content

Commit dc7eeda

Browse files
authored
Encode server rendered host components as array tuples (#18273)
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.
1 parent bf35108 commit dc7eeda

File tree

10 files changed

+132
-47
lines changed

10 files changed

+132
-47
lines changed

fixtures/flight-browser/index.html

+2-4
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,7 @@ <h1>Flight Example</h1>
5757

5858
let model = {
5959
title: <Title />,
60-
content: {
61-
__html: <HTML />,
62-
}
60+
content: <HTML />,
6361
};
6462

6563
let stream = ReactFlightDOMServer.renderToReadableStream(model);
@@ -90,7 +88,7 @@ <h1>Flight Example</h1>
9088
<Suspense fallback="...">
9189
<h1>{model.title}</h1>
9290
</Suspense>
93-
<div dangerouslySetInnerHTML={model.content} />
91+
{model.content}
9492
</div>;
9593
}
9694

fixtures/flight/server/handler.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ function HTML() {
2020
module.exports = function(req, res) {
2121
res.setHeader('Access-Control-Allow-Origin', '*');
2222
let model = {
23-
content: {
24-
__html: <HTML />,
25-
},
23+
content: <HTML />,
2624
};
2725
ReactFlightDOMServer.pipeToNodeWritable(model, res);
2826
};

fixtures/flight/src/App.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, {Suspense} from 'react';
22

33
function Content({data}) {
4-
return <p dangerouslySetInnerHTML={data.model.content} />;
4+
return data.model.content;
55
}
66

77
function App({data}) {

packages/react-client/src/ReactFlightClient.js

+73-16
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @flow
88
*/
99

10+
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
11+
1012
export type ReactModelRoot<T> = {|
1113
model: T,
1214
|};
@@ -19,6 +21,8 @@ export type JSONValue =
1921
| {[key: string]: JSONValue}
2022
| Array<JSONValue>;
2123

24+
const isArray = Array.isArray;
25+
2226
const PENDING = 0;
2327
const RESOLVED = 1;
2428
const ERRORED = 2;
@@ -141,28 +145,81 @@ function definePendingProperty(
141145
});
142146
}
143147

148+
function createElement(type, key, props): React$Element<any> {
149+
const element: any = {
150+
// This tag allows us to uniquely identify this as a React Element
151+
$$typeof: REACT_ELEMENT_TYPE,
152+
153+
// Built-in properties that belong on the element
154+
type: type,
155+
key: key,
156+
ref: null,
157+
props: props,
158+
159+
// Record the component responsible for creating this element.
160+
_owner: null,
161+
};
162+
if (__DEV__) {
163+
// We don't really need to add any of these but keeping them for good measure.
164+
// Unfortunately, _store is enumerable in jest matchers so for equality to
165+
// work, I need to keep it or make _store non-enumerable in the other file.
166+
element._store = {};
167+
Object.defineProperty(element._store, 'validated', {
168+
configurable: false,
169+
enumerable: false,
170+
writable: true,
171+
value: true, // This element has already been validated on the server.
172+
});
173+
Object.defineProperty(element, '_self', {
174+
configurable: false,
175+
enumerable: false,
176+
writable: false,
177+
value: null,
178+
});
179+
Object.defineProperty(element, '_source', {
180+
configurable: false,
181+
enumerable: false,
182+
writable: false,
183+
value: null,
184+
});
185+
}
186+
return element;
187+
}
188+
144189
export function parseModelFromJSON(
145190
response: Response,
146191
targetObj: Object,
147192
key: string,
148193
value: JSONValue,
149-
): any {
150-
if (typeof value === 'string' && value[0] === '$') {
151-
if (value[1] === '$') {
152-
// This was an escaped string value.
153-
return value.substring(1);
154-
} else {
155-
let id = parseInt(value.substring(1), 16);
156-
let chunks = response.chunks;
157-
let chunk = chunks.get(id);
158-
if (!chunk) {
159-
chunk = createPendingChunk();
160-
chunks.set(id, chunk);
161-
} else if (chunk.status === RESOLVED) {
162-
return chunk.value;
194+
): mixed {
195+
if (typeof value === 'string') {
196+
if (value[0] === '$') {
197+
if (value === '$') {
198+
return REACT_ELEMENT_TYPE;
199+
} else if (value[1] === '$' || value[1] === '@') {
200+
// This was an escaped string value.
201+
return value.substring(1);
202+
} else {
203+
let id = parseInt(value.substring(1), 16);
204+
let chunks = response.chunks;
205+
let chunk = chunks.get(id);
206+
if (!chunk) {
207+
chunk = createPendingChunk();
208+
chunks.set(id, chunk);
209+
} else if (chunk.status === RESOLVED) {
210+
return chunk.value;
211+
}
212+
definePendingProperty(targetObj, key, chunk);
213+
return undefined;
163214
}
164-
definePendingProperty(targetObj, key, chunk);
165-
return undefined;
215+
}
216+
}
217+
if (isArray(value)) {
218+
let tuple: [mixed, mixed, mixed, mixed] = (value: any);
219+
if (tuple[0] === REACT_ELEMENT_TYPE) {
220+
// TODO: Consider having React just directly accept these arrays as elements.
221+
// Or even change the ReactElement type to be an array.
222+
return createElement(tuple[1], tuple[2], tuple[3]);
166223
}
167224
}
168225
return value;

packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ function parseModel(response, targetObj, key, value) {
2222
if (typeof value === 'object' && value !== null) {
2323
if (Array.isArray(value)) {
2424
for (let i = 0; i < value.length; i++) {
25-
value[i] = parseModel(response, value, '' + i, value[i]);
25+
(value: any)[i] = parseModel(response, value, '' + i, value[i]);
2626
}
2727
} else {
2828
for (let innerKey in value) {
29-
value[innerKey] = parseModel(
29+
(value: any)[innerKey] = parseModel(
3030
response,
3131
value,
3232
innerKey,

packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js

+40-3
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ describe('ReactFlightDOM', () => {
9292
let result = ReactFlightDOMClient.readFromReadableStream(readable);
9393
await waitForSuspense(() => {
9494
expect(result.model).toEqual({
95-
html: '<div><span>hello</span><span>world</span></div>',
95+
html: (
96+
<div>
97+
<span>hello</span>
98+
<span>world</span>
99+
</div>
100+
),
96101
});
97102
});
98103
});
@@ -120,7 +125,7 @@ describe('ReactFlightDOM', () => {
120125

121126
// View
122127
function Message({result}) {
123-
return <p dangerouslySetInnerHTML={{__html: result.model.html}} />;
128+
return <section>{result.model.html}</section>;
124129
}
125130
function App({result}) {
126131
return (
@@ -140,7 +145,7 @@ describe('ReactFlightDOM', () => {
140145
root.render(<App result={result} />);
141146
});
142147
expect(container.innerHTML).toBe(
143-
'<p><div><span>hello</span><span>world</span></div></p>',
148+
'<section><div><span>hello</span><span>world</span></div></section>',
144149
);
145150
});
146151

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

184+
it.experimental('should not get confused by @', async () => {
185+
let {Suspense} = React;
186+
187+
// Model
188+
function RootModel() {
189+
return {text: '@div'};
190+
}
191+
192+
// View
193+
function Message({result}) {
194+
return <p>{result.model.text}</p>;
195+
}
196+
function App({result}) {
197+
return (
198+
<Suspense fallback={<h1>Loading...</h1>}>
199+
<Message result={result} />
200+
</Suspense>
201+
);
202+
}
203+
204+
let {writable, readable} = getTestStream();
205+
ReactFlightDOMServer.pipeToNodeWritable(<RootModel />, writable);
206+
let result = ReactFlightDOMClient.readFromReadableStream(readable);
207+
208+
let container = document.createElement('div');
209+
let root = ReactDOM.createRoot(container);
210+
await act(async () => {
211+
root.render(<App result={result} />);
212+
});
213+
expect(container.innerHTML).toBe('<p>@div</p>');
214+
});
215+
179216
it.experimental('should progressively reveal chunks', async () => {
180217
let {Suspense} = React;
181218

packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ describe('ReactFlightDOMBrowser', () => {
6565
let result = ReactFlightDOMClient.readFromReadableStream(stream);
6666
await waitForSuspense(() => {
6767
expect(result.model).toEqual({
68-
html: '<div><span>hello</span><span>world</span></div>',
68+
html: (
69+
<div>
70+
<span>hello</span>
71+
<span>world</span>
72+
</div>
73+
),
6974
});
7075
});
7176
});

packages/react-server/src/ReactDOMServerFormatConfig.js

-12
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

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

12-
import {renderToStaticMarkup} from 'react-dom/server';
13-
1412
export function formatChunkAsString(type: string, props: Object): string {
1513
let str = '<' + type + '>';
1614
if (typeof props.children === 'string') {
@@ -23,13 +21,3 @@ export function formatChunkAsString(type: string, props: Object): string {
2321
export function formatChunk(type: string, props: Object): Uint8Array {
2422
return convertStringToBuffer(formatChunkAsString(type, props));
2523
}
26-
27-
export function renderHostChildrenToString(
28-
children: React$Element<any>,
29-
): string {
30-
// TODO: This file is used to actually implement a server renderer
31-
// so we can't actually reference the renderer here. Instead, we
32-
// should replace this method with a reference to Fizz which
33-
// then uses this file to implement the server renderer.
34-
return renderToStaticMarkup(children);
35-
}

packages/react-server/src/ReactFlightServer.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
processModelChunk,
2020
processErrorChunk,
2121
} from './ReactFlightServerConfig';
22-
import {renderHostChildrenToString} from './ReactServerFormatConfig';
22+
2323
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
2424

2525
type ReactJSONValue =
@@ -88,7 +88,7 @@ function attemptResolveModelComponent(element: React$Element<any>): ReactModel {
8888
return type(props);
8989
} else if (typeof type === 'string') {
9090
// This is a host element. E.g. HTML.
91-
return renderHostChildrenToString(element);
91+
return [REACT_ELEMENT_TYPE, type, element.key, element.props];
9292
} else {
9393
throw new Error('Unsupported type.');
9494
}
@@ -119,7 +119,7 @@ function serializeIDRef(id: number): string {
119119
function escapeStringValue(value: string): string {
120120
if (value[0] === '$') {
121121
// We need to escape $ prefixed strings since we use that to encode
122-
// references to IDs.
122+
// references to IDs and as a special symbol value.
123123
return '$' + value;
124124
} else {
125125
return value;
@@ -134,6 +134,10 @@ export function resolveModelToJSON(
134134
return escapeStringValue(value);
135135
}
136136

137+
if (value === REACT_ELEMENT_TYPE) {
138+
return '$';
139+
}
140+
137141
while (
138142
typeof value === 'object' &&
139143
value !== null &&

packages/react-server/src/forks/ReactServerFormatConfig.custom.js

-2
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,3 @@ export opaque type Destination = mixed; // eslint-disable-line no-undef
2828

2929
export const formatChunkAsString = $$$hostConfig.formatChunkAsString;
3030
export const formatChunk = $$$hostConfig.formatChunk;
31-
export const renderHostChildrenToString =
32-
$$$hostConfig.renderHostChildrenToString;

0 commit comments

Comments
 (0)