diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 7104350fde00e..cca05ad887242 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -4425,4 +4425,195 @@ describe('ReactDOMFizzServer', () => { ); }); }); + + describe('title children', () => { + function prepareJSDOMForTitle() { + // Test Environment + const jsdom = new JSDOM('\u0000', { + runScripts: 'dangerously', + }); + window = jsdom.window; + document = jsdom.window.document; + container = document.getElementsByTagName('head')[0]; + } + + // @gate experimental + it('should accept a single string child', async () => { + // a Single string child + function App() { + return hello; + } + + prepareJSDOMForTitle(); + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual(hello); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual(hello); + }); + + // @gate experimental + it('should accept children array of length 1 containing a string', async () => { + // a Single string child + function App() { + return {['hello']}; + } + + prepareJSDOMForTitle(); + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual(hello); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual(hello); + }); + + // @gate experimental + it('should warn in dev when given an array of length 2 or more', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + if (args.length > 1) { + if (typeof args[1] === 'object') { + mockError(args[0].split('\n')[0]); + return; + } + } + mockError(...args.map(normalizeCodeLocInfo)); + }; + + // a Single string child + function App() { + return {['hello1', 'hello2']}; + } + + try { + prepareJSDOMForTitle(); + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + if (__DEV__) { + expect(mockError).toHaveBeenCalledWith( + 'Warning: A title element received an array with more than 1 element as children. ' + + 'In browsers title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering%s', + '\n' + ' in title (at **)\n' + ' in App (at **)', + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + + expect(getVisibleChildren(container)).toEqual( + {'hello1<!-- -->hello2'}, + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual( + [ + gate(flags => flags.enableClientRenderFallbackOnTextMismatch) + ? 'Text content does not match server-rendered HTML.' + : null, + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ].filter(Boolean), + ); + expect(getVisibleChildren(container)).toEqual( + {['hello1', 'hello2']}, + ); + } finally { + console.error = originalConsoleError; + } + }); + + // @gate experimental + it('should warn in dev if you pass a React Component as a child to ', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + if (args.length > 1) { + if (typeof args[1] === 'object') { + mockError(args[0].split('\n')[0]); + return; + } + } + mockError(...args.map(normalizeCodeLocInfo)); + }; + + function IndirectTitle() { + return 'hello'; + } + + function App() { + return ( + <title> + <IndirectTitle /> + + ); + } + + try { + prepareJSDOMForTitle(); + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + if (__DEV__) { + expect(mockError).toHaveBeenCalledWith( + 'Warning: A title element received a React element for children. ' + + 'In the browser title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering%s', + '\n' + ' in title (at **)\n' + ' in App (at **)', + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + + expect(getVisibleChildren(container)).toEqual(hello); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual(hello); + } finally { + console.error = originalConsoleError; + } + }); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index 95e02a41b4632..36c9469d60818 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -1120,6 +1120,75 @@ function pushStartMenuItem( return null; } +function pushStartTitle( + target: Array, + props: Object, + responseState: ResponseState, +): ReactNodeList { + target.push(startChunkForTag('title')); + + let children = null; + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'children': + children = propValue; + break; + case 'dangerouslySetInnerHTML': + throw new Error( + '`dangerouslySetInnerHTML` does not make sense on .', + ); + // eslint-disable-next-line-no-fallthrough + default: + pushAttribute(target, responseState, propKey, propValue); + break; + } + } + } + target.push(endOfStartTag); + + if (__DEV__) { + const child = + Array.isArray(children) && children.length < 2 + ? children[0] || null + : children; + if (Array.isArray(children) && children.length > 1) { + console.error( + 'A title element received an array with more than 1 element as children. ' + + 'In browsers title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering', + ); + } else if (child != null && child.$$typeof != null) { + console.error( + 'A title element received a React element for children. ' + + 'In the browser title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering', + ); + } else if ( + child != null && + typeof child !== 'string' && + typeof child !== 'number' + ) { + console.error( + 'A title element received a value that was not a string or number for children. ' + + 'In the browser title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering', + ); + } + } + return children; +} + function pushStartGenericElement( target: Array<Chunk | PrecomputedChunk>, props: Object, @@ -1390,6 +1459,8 @@ export function pushStartInstance( return pushInput(target, props, responseState); case 'menuitem': return pushStartMenuItem(target, props, responseState); + case 'title': + return pushStartTitle(target, props, responseState); // Newline eating tags case 'listing': case 'pre': { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 826fe3b5db870..00748befe6d81 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -418,5 +418,6 @@ "430": "ServerContext can only have a value prop and children. Found: %s", "431": "React elements are not allowed in ServerContext", "432": "This Suspense boundary was aborted by the server.", - "433": "useId can only be used while React is rendering" + "433": "useId can only be used while React is rendering", + "434": "`dangerouslySetInnerHTML` does not make sense on <title>." }