From dc7eedae3c67f1b48db0bb1874633edf4268ac4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 11 Mar 2020 09:48:02 -0700 Subject: [PATCH] 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. --- fixtures/flight-browser/index.html | 6 +- fixtures/flight/server/handler.js | 4 +- fixtures/flight/src/App.js | 2 +- .../react-client/src/ReactFlightClient.js | 89 +++++++++++++++---- .../src/ReactFlightDOMRelayClient.js | 4 +- .../src/__tests__/ReactFlightDOM-test.js | 43 ++++++++- .../__tests__/ReactFlightDOMBrowser-test.js | 7 +- .../src/ReactDOMServerFormatConfig.js | 12 --- .../react-server/src/ReactFlightServer.js | 10 ++- .../forks/ReactServerFormatConfig.custom.js | 2 - 10 files changed, 132 insertions(+), 47 deletions(-) diff --git a/fixtures/flight-browser/index.html b/fixtures/flight-browser/index.html index 1fef79b182bef..e00e78dd48c5f 100644 --- a/fixtures/flight-browser/index.html +++ b/fixtures/flight-browser/index.html @@ -57,9 +57,7 @@

Flight Example

let model = { title: , - content: { - __html: <HTML />, - } + content: <HTML />, }; let stream = ReactFlightDOMServer.renderToReadableStream(model); @@ -90,7 +88,7 @@ <h1>Flight Example</h1> <Suspense fallback="..."> <h1>{model.title}</h1> </Suspense> - <div dangerouslySetInnerHTML={model.content} /> + {model.content} </div>; } diff --git a/fixtures/flight/server/handler.js b/fixtures/flight/server/handler.js index bb82a5e9a41cf..f0558215269c0 100644 --- a/fixtures/flight/server/handler.js +++ b/fixtures/flight/server/handler.js @@ -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); }; diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index acf3af38c2adc..2b177b61c9b4e 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -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}) { diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index b158c0039d1ba..ef236394e0ade 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -7,6 +7,8 @@ * @flow */ +import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; + export type ReactModelRoot<T> = {| model: T, |}; @@ -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; @@ -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; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js index 2a9f7623fe8c9..47bd68c81874a 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js @@ -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, diff --git a/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js index c34e20ec8358b..81011b63b3be8 100644 --- a/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -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> + ), }); }); }); @@ -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 ( @@ -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>', ); }); @@ -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; diff --git a/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 98a0f7f1da646..dd99f31cdb957 100644 --- a/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-flight-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -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> + ), }); }); }); diff --git a/packages/react-server/src/ReactDOMServerFormatConfig.js b/packages/react-server/src/ReactDOMServerFormatConfig.js index 0aeb94cde6ecc..1e36890e995da 100644 --- a/packages/react-server/src/ReactDOMServerFormatConfig.js +++ b/packages/react-server/src/ReactDOMServerFormatConfig.js @@ -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') { @@ -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); -} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 5fba89e63d4ab..4d1ad11a3c8cf 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -19,7 +19,7 @@ import { processModelChunk, processErrorChunk, } from './ReactFlightServerConfig'; -import {renderHostChildrenToString} from './ReactServerFormatConfig'; + import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; type ReactJSONValue = @@ -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.'); } @@ -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; @@ -134,6 +134,10 @@ export function resolveModelToJSON( return escapeStringValue(value); } + if (value === REACT_ELEMENT_TYPE) { + return '$'; + } + while ( typeof value === 'object' && value !== null && diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index a864d5f6beb92..f00ecbf2529aa 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -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;