From 32d6f39eddd6d6b32b6c68068e96c66109ae8425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 27 Mar 2021 13:50:38 -0400 Subject: [PATCH] [Fizz] Support special HTML/SVG/MathML tags to suspend (#21113) * Encode tables as a special insertion mode The table modes are special in that its children can't be created outside a table context so we need the segment container to be wrapped in a table. * Move formatContext from Task to Segment It works the same otherwise. It's just that this context needs to outlive the task so that I can use it when writing the segment. * Use template tag for placeholders and inserted dummy nodes with IDs These can be used in any parent. At least outside IE11. Not sure yet what happens in IE11 to these. Not sure if these are bad for perf since they're special nodes. * Add special wrappers around inserted segments depending on their insertion mode * Allow the root namespace to be configured This allows us to insert the correct wrappers when streaming into an existing non-HTML tree. * Add comment --- .../src/__tests__/ReactDOMFizzServer-test.js | 249 +++++++++++++++++- .../src/server/ReactDOMFizzServerBrowser.js | 3 +- .../src/server/ReactDOMFizzServerNode.js | 3 +- .../src/server/ReactDOMServerFormatConfig.js | 220 +++++++++++++--- .../server/ReactNativeServerFormatConfig.js | 6 +- .../src/ReactNoopServer.js | 3 +- packages/react-server/src/ReactFizzServer.js | 47 ++-- scripts/error-codes/codes.json | 3 +- 8 files changed, 475 insertions(+), 59 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 9021c23c6ce74..31b1f437c9871 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -97,10 +97,22 @@ describe('ReactDOMFizzServer', () => { let node = element.firstChild; while (node) { if (node.nodeType === 1) { - if (node.tagName !== 'SCRIPT' && !node.hasAttribute('hidden')) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { const props = {}; const attributes = node.attributes; for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } props[attributes[i].name] = attributes[i].value; } props.children = getVisibleChildren(node); @@ -112,7 +124,7 @@ describe('ReactDOMFizzServer', () => { node = node.nextSibling; } return children.length === 0 - ? null + ? undefined : children.length === 1 ? children[0] : children; @@ -408,4 +420,237 @@ describe('ReactDOMFizzServer', () => { , ]); }); + + // @gate experimental + it('can resolve async content in esoteric parents', async () => { + function AsyncOption({text}) { + return ; + } + + function AsyncCol({className}) { + return {[]}; + } + + function AsyncPath({id}) { + return {[]}; + } + + function AsyncMi({id}) { + return {[]}; + } + + function App() { + return ( +
+ + + + + + +
+ + + + + + + + +
+
+ ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading... +
, + ); + + await act(async () => { + resolveText('Hello'); + }); + + await act(async () => { + resolveText('World'); + }); + + await act(async () => { + resolveText('my-path'); + resolveText('my-mi'); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ + + + + +
+ + + + + + + + +
, + ); + + expect(container.querySelector('#my-path').namespaceURI).toBe( + 'http://www.w3.org/2000/svg', + ); + expect(container.querySelector('#my-mi').namespaceURI).toBe( + 'http://www.w3.org/1998/Math/MathML', + ); + }); + + // @gate experimental + it('can resolve async content in table parents', async () => { + function AsyncTableBody({className, children}) { + return {children}; + } + + function AsyncTableRow({className, children}) { + return {children}; + } + + function AsyncTableCell({text}) { + return {readText(text)}; + } + + function App() { + return ( + + + + + + + }> + + + + + + +
Loading...
+ ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + + expect(getVisibleChildren(container)).toEqual( + + + + + + +
Loading...
, + ); + + await act(async () => { + resolveText('A'); + }); + + await act(async () => { + resolveText('B'); + }); + + await act(async () => { + resolveText('C'); + }); + + expect(getVisibleChildren(container)).toEqual( + + + + + + +
C
, + ); + }); + + // @gate experimental + it('can stream into an SVG container', async () => { + function AsyncPath({id}) { + return {[]}; + } + + function App() { + return ( + + Loading...}> + + + + ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + { + namespaceURI: 'http://www.w3.org/2000/svg', + onReadyToStream() { + writable.write(''); + startWriting(); + writable.write(''); + }, + }, + ); + }); + + expect(getVisibleChildren(container)).toEqual( + + + Loading... + + , + ); + + await act(async () => { + resolveText('my-path'); + }); + + expect(getVisibleChildren(container)).toEqual( + + + + + , + ); + + expect(container.querySelector('#my-path').namespaceURI).toBe( + 'http://www.w3.org/2000/svg', + ); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 254d2380d5ab2..8d5c59a2d6862 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -23,6 +23,7 @@ import { type Options = { identifierPrefix?: string, + namespaceURI?: string, progressiveChunkSize?: number, signal?: AbortSignal, onReadyToStream?: () => void, @@ -49,7 +50,7 @@ function renderToReadableStream( children, controller, createResponseState(options ? options.identifierPrefix : undefined), - createRootFormatContext(), // We call this here in case we need options to initialize it. + createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, options ? options.onCompleteAll : undefined, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index c9d0cbc303148..1d53e6fda472e 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -28,6 +28,7 @@ function createDrainHandler(destination, request) { type Options = { identifierPrefix?: string, + namespaceURI?: string, progressiveChunkSize?: number, onReadyToStream?: () => void, onCompleteAll?: () => void, @@ -49,7 +50,7 @@ function pipeToNodeWritable( children, destination, createResponseState(options ? options.identifierPrefix : undefined), - createRootFormatContext(), // We call this here in case we need options to initialize it. + createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, options ? options.onCompleteAll : undefined, diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index f7ed7ccf93971..d42e92aa1e2b4 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -50,33 +50,45 @@ export function createResponseState( }; } -// Constants for the namespace we use. We don't actually provide the namespace but conditionally -// use different segment parents based on namespace. Therefore we use constants instead of the string. -const ROOT_NAMESPACE = 0; // At the root we don't need to know which namespace it is. We just need to know that it's already the right one. -const HTML_NAMESPACE = 1; -const SVG_NAMESPACE = 2; -const MATHML_NAMESPACE = 3; - -type NamespaceFlag = 0 | 1 | 2 | 3; +// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion +// modes. We only include the variants as they matter for the sake of our purposes. +// We don't actually provide the namespace therefore we use constants instead of the string. +const HTML_MODE = 0; +const SVG_MODE = 1; +const MATHML_MODE = 2; +const HTML_TABLE_MODE = 4; +const HTML_TABLE_BODY_MODE = 5; +const HTML_TABLE_ROW_MODE = 6; +const HTML_COLGROUP_MODE = 7; +// We have a greater than HTML_TABLE_MODE check elsewhere. If you add more cases here, make sure it +// still makes sense + +type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; // Lets us keep track of contextual state and pick it back up after suspending. export type FormatContext = { - namespace: NamespaceFlag, // root/svg/html/mathml + insertionMode: InsertionMode, // root/svg/html/mathml/table selectedValue: null | string, // the selected value(s) inside a }; function createFormatContext( - namespace: NamespaceFlag, + insertionMode: InsertionMode, selectedValue: null | string, ): FormatContext { return { - namespace, + insertionMode, selectedValue, }; } -export function createRootFormatContext(): FormatContext { - return createFormatContext(ROOT_NAMESPACE, null); +export function createRootFormatContext(namespaceURI?: string): FormatContext { + const insertionMode = + namespaceURI === 'http://www.w3.org/2000/svg' + ? SVG_MODE + : namespaceURI === 'http://www.w3.org/1998/Math/MathML' + ? MATHML_MODE + : HTML_MODE; + return createFormatContext(insertionMode, null); } export function getChildFormatContext( @@ -87,15 +99,32 @@ export function getChildFormatContext( switch (type) { case 'select': return createFormatContext( - parentContext.namespace, + HTML_MODE, props.value != null ? props.value : props.defaultValue, ); case 'svg': - return createFormatContext(SVG_NAMESPACE, null); + return createFormatContext(SVG_MODE, null); case 'math': - return createFormatContext(MATHML_NAMESPACE, null); + return createFormatContext(MATHML_MODE, null); case 'foreignObject': - return createFormatContext(HTML_NAMESPACE, null); + return createFormatContext(HTML_MODE, null); + // Table parents are special in that their children can only be created at all if they're + // wrapped in a table parent. So we need to encode that we're entering this mode. + case 'table': + return createFormatContext(HTML_TABLE_MODE, null); + case 'thead': + case 'tbody': + case 'tfoot': + return createFormatContext(HTML_TABLE_BODY_MODE, null); + case 'colgroup': + return createFormatContext(HTML_COLGROUP_MODE, null); + case 'tr': + return createFormatContext(HTML_TABLE_ROW_MODE, null); + } + if (parentContext.insertionMode >= HTML_TABLE_MODE) { + // Whatever tag this was, it wasn't a table parent or other special parent, so we must have + // entered plain HTML again. + return createFormatContext(HTML_MODE, null); } return parentContext; } @@ -132,8 +161,8 @@ function assignAnID( )); } -const dummyNode1 = stringToPrecomputedChunk(''); +const dummyNode1 = stringToPrecomputedChunk(''); function pushDummyNodeWithID( target: Array, @@ -206,7 +235,20 @@ export function pushStartInstance( startTag2, ); } else { - target.push(startTag1, stringToChunk(type), startTag2); + target.push(startTag1, stringToChunk(type)); + if (props.className) { + target.push( + stringToChunk( + ' class="' + encodeHTMLIDAttribute(props.className) + '"', + ), + ); + } + if (props.id) { + target.push( + stringToChunk(' id="' + encodeHTMLIDAttribute(props.id) + '"'), + ); + } + target.push(startTag2); } } @@ -225,16 +267,15 @@ export function pushEndInstance( // Structural Nodes // A placeholder is a node inside a hidden partial tree that can be filled in later, but before -// display. It's never visible to users. -const placeholder1 = stringToPrecomputedChunk(''); +// display. It's never visible to users. We use the template tag because it can be used in every +// type of parent.