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;