@samitouri / QOS-React-2 / commits / dc7eedae3c

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.

Sebastian Markbåge committed Mar 11, 2020 at 09:48 UTC dc7eedae3c67f1b48db0bb1874633edf4268ac4e
10 files changed +132 -47
fixtures/flight-browser/index.html
+2 -4
@@ -57,9 +57,7 @@
57
58 let model = {
59 title: <Title />,
60 - content: {
61 - __html: <HTML />,
62 - }
60 + content: <HTML />,
61 };
62
63 let stream = ReactFlightDOMServer.renderToReadableStream(model);
@@ -90,7 +88,7 @@
88 <Suspense fallback="...">
89 <h1>{model.title}</h1>
90 </Suspense>
93 - <div dangerouslySetInnerHTML={model.content} />
91 + {model.content}
92 </div>;
93 }
94
fixtures/flight/server/handler.js
+1 -3
@@ -20,9 +20,7 @@ function HTML() {
20 module.exports = function(req, res) {
21 res.setHeader('Access-Control-Allow-Origin', '*');
22 let model = {
23 - content: {
24 - __html: <HTML />,
25 - },
23 + content: <HTML />,
24 };
25 ReactFlightDOMServer.pipeToNodeWritable(model, res);
26 };
fixtures/flight/src/App.js
+1 -1
@@ -1,7 +1,7 @@
1 import React, {Suspense} from 'react';
2
3 function Content({data}) {
4 - return <p dangerouslySetInnerHTML={data.model.content} />;
4 + return data.model.content;
5 }
6
7 function App({data}) {
packages/react-client/src/ReactFlightClient.js
+73 -16
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
11 +
12 export type ReactModelRoot<T> = {|
13 model: T,
14 |};
@@ -19,6 +21,8 @@ export type JSONValue =
21 | {[key: string]: JSONValue}
22 | Array<JSONValue>;
23
24 +const isArray = Array.isArray;
25 +
26 const PENDING = 0;
27 const RESOLVED = 1;
28 const ERRORED = 2;
@@ -141,28 +145,81 @@ function definePendingProperty(
145 });
146 }
147
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 +
189 export function parseModelFromJSON(
190 response: Response,
191 targetObj: Object,
192 key: string,
193 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;
214 }
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]);
223 }
224 }
225 return value;
packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js
+2 -2
@@ -22,11 +22,11 @@ function parseModel(response, targetObj, key, value) {
22 if (typeof value === 'object' && value !== null) {
23 if (Array.isArray(value)) {
24 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]);
26 }
27 } else {
28 for (let innerKey in value) {
29 - value[innerKey] = parseModel(
29 + (value: any)[innerKey] = parseModel(
30 response,
31 value,
32 innerKey,
packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+40 -3
@@ -92,7 +92,12 @@ describe('ReactFlightDOM', () => {
92 let result = ReactFlightDOMClient.readFromReadableStream(readable);
93 await waitForSuspense(() => {
94 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 + ),
101 });
102 });
103 });
@@ -120,7 +125,7 @@ describe('ReactFlightDOM', () => {
125
126 // View
127 function Message({result}) {
123 - return <p dangerouslySetInnerHTML={{__html: result.model.html}} />;
128 + return <section>{result.model.html}</section>;
129 }
130 function App({result}) {
131 return (
@@ -140,7 +145,7 @@ describe('ReactFlightDOM', () => {
145 root.render(<App result={result} />);
146 });
147 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>',
149 );
150 });
151
@@ -176,6 +181,38 @@ describe('ReactFlightDOM', () => {
181 expect(container.innerHTML).toBe('<p>$1</p>');
182 });
183
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 +
216 it.experimental('should progressively reveal chunks', async () => {
217 let {Suspense} = React;
218
packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+6 -1
@@ -65,7 +65,12 @@ describe('ReactFlightDOMBrowser', () => {
65 let result = ReactFlightDOMClient.readFromReadableStream(stream);
66 await waitForSuspense(() => {
67 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 + ),
74 });
75 });
76 });
packages/react-server/src/ReactDOMServerFormatConfig.js
-12
@@ -9,8 +9,6 @@
9
10 import {convertStringToBuffer} from 'react-server/src/ReactServerStreamConfig';
11
12 -import {renderToStaticMarkup} from 'react-dom/server';
13 -
12 export function formatChunkAsString(type: string, props: Object): string {
13 let str = '<' + type + '>';
14 if (typeof props.children === 'string') {
@@ -23,13 +21,3 @@ export function formatChunkAsString(type: string, props: Object): string {
21 export function formatChunk(type: string, props: Object): Uint8Array {
22 return convertStringToBuffer(formatChunkAsString(type, props));
23 }
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
@@ -19,7 +19,7 @@ import {
19 processModelChunk,
20 processErrorChunk,
21 } from './ReactFlightServerConfig';
22 -import {renderHostChildrenToString} from './ReactServerFormatConfig';
22 +
23 import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
24
25 type ReactJSONValue =
@@ -88,7 +88,7 @@ function attemptResolveModelComponent(element: React$Element<any>): ReactModel {
88 return type(props);
89 } else if (typeof type === 'string') {
90 // This is a host element. E.g. HTML.
91 - return renderHostChildrenToString(element);
91 + return [REACT_ELEMENT_TYPE, type, element.key, element.props];
92 } else {
93 throw new Error('Unsupported type.');
94 }
@@ -119,7 +119,7 @@ function serializeIDRef(id: number): string {
119 function escapeStringValue(value: string): string {
120 if (value[0] === '$') {
121 // 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.
123 return '$' + value;
124 } else {
125 return value;
@@ -134,6 +134,10 @@ export function resolveModelToJSON(
134 return escapeStringValue(value);
135 }
136
137 + if (value === REACT_ELEMENT_TYPE) {
138 + return '$';
139 + }
140 +
141 while (
142 typeof value === 'object' &&
143 value !== null &&
packages/react-server/src/forks/ReactServerFormatConfig.custom.js
-2
@@ -28,5 +28,3 @@ export opaque type Destination = mixed; // eslint-disable-line no-undef
28
29 export const formatChunkAsString = $$$hostConfig.formatChunkAsString;
30 export const formatChunk = $$$hostConfig.formatChunk;
31 -export const renderHostChildrenToString =
32 - $$$hostConfig.renderHostChildrenToString;