From bcbeb52bf36c6f5ecdad46a48e87cf4354c5a64f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 7 Jun 2022 03:33:36 -0400 Subject: [PATCH] [Fizz] Disallow complex children in elements (#24679) * [Fizz] Disallow complex children in <title> elements <title> Elements in the DOM can only have Text content. In Fizz if more than one text node is emitted an HTML comment node is used as a text separator. Unfortunately because of the content restriction of the DOM representation of the title element this separator is displayed as escaped text which is not what the component author intended. This commit special cases title handling, primarily to issue warnings if you pass complex children to <title>. At the moment title expects to receive a single child or an array of length 1. In both cases the type of that child must be string or number. If anything more complex is provided a warning will be logged to the console explaining why this is problematic. There is no runtime behavior change so broken things are still broken (e.g. returning two text nodes which will cause a separator or using Suspense inside title children) but they should at least be accompanied by warnings that are useful. One edge case that will now warn but won't technically break an application is if you use a Component that returns a single string as a child of title. This is a form of indirection that works but becasue we cannot discriminate between a Component that will follow the rules and one that violates them the warning is issued regardless. * fixup dev warning conditional logic * lints * fix bugs --- .../src/__tests__/ReactDOMFizzServer-test.js | 191 ++++++++++++++++++ .../src/server/ReactDOMServerFormatConfig.js | 71 +++++++ scripts/error-codes/codes.json | 3 +- 3 files changed, 264 insertions(+), 1 deletion(-) 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('<!DOCTYPE html><html><head>\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 <title>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>." }