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.