From 125ec3af0c4cf7345bc52070191048cbef2f99ad Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 27 May 2022 11:34:29 -0700 Subject: [PATCH 1/4] [Fizz] Disallow complex children in 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. --- .../src/__tests__/ReactDOMFizzServer-test.js | 187 ++++++++++++++++++ .../src/server/ReactDOMServerFormatConfig.js | 69 +++++++ 2 files changed, 256 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 7104350fde00e..6f186fb4c40c0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -4425,4 +4425,191 @@ 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([ + 'Text content does not match server-rendered HTML.', + '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.', + ]); + 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..a2243ea997931 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -1120,6 +1120,73 @@ function pushStartMenuItem( return null; } +function pushStartTitle( + target: Array, + props: Object, + responseState: ResponseState, +): ReactNodeList { + target.push(startChunkForTag('title')); + + let children = null; + let innerHTML = 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__) { + let child = + Array.isArray(children) && children.length < 2 + ? children[0] || null + : children; + if (Array.isArray(child)) { + // child will only be an Array if it has lenght > 1 based on how it was constructed + 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.$$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 (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 +1457,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': { From 1ea51afe101bf0db02058ca505eeade8f53e32ae Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Mon, 6 Jun 2022 23:58:44 -0700 Subject: [PATCH 2/4] fixup dev warning conditional logic --- .../react-dom/src/server/ReactDOMServerFormatConfig.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index a2243ea997931..6c41df4948ea9 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -1157,8 +1157,7 @@ function pushStartTitle( Array.isArray(children) && children.length < 2 ? children[0] || null : children; - if (Array.isArray(child)) { - // child will only be an Array if it has lenght > 1 based on how it was constructed + 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 ' + @@ -1174,7 +1173,11 @@ function pushStartTitle( 'will display markup and comments as text in the title and hydration will likely fail and ' + 'fall back to client rendering', ); - } else if (typeof child !== 'string' && typeof child !== 'number') { + } 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 ' + From 1810bd5f7573e5e20319844e0262a6bc2e7784d0 Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Tue, 7 Jun 2022 00:01:44 -0700 Subject: [PATCH 3/4] lints --- packages/react-dom/src/server/ReactDOMServerFormatConfig.js | 3 +-- scripts/error-codes/codes.json | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index 6c41df4948ea9..fa7bc88d6166c 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -1128,7 +1128,6 @@ function pushStartTitle( target.push(startChunkForTag('title')); let children = null; - let innerHTML = null; for (const propKey in props) { if (hasOwnProperty.call(props, propKey)) { const propValue = props[propKey]; @@ -1153,7 +1152,7 @@ function pushStartTitle( target.push(endOfStartTag); if (__DEV__) { - let child = + const child = Array.isArray(children) && children.length < 2 ? children[0] || null : children; 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>." } From d97aacc0b62bfd22d1d76d5cd4117f433a47ba63 Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Tue, 7 Jun 2022 00:23:19 -0700 Subject: [PATCH 4/4] fix bugs --- .../src/__tests__/ReactDOMFizzServer-test.js | 14 +++++++++----- .../src/server/ReactDOMServerFormatConfig.js | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 6f186fb4c40c0..cca05ad887242 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -4537,11 +4537,15 @@ describe('ReactDOMFizzServer', () => { }, }); expect(Scheduler).toFlushAndYield([]); - expect(errors).toEqual([ - 'Text content does not match server-rendered HTML.', - '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.', - ]); + 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( <title>{['hello1', 'hello2']}, ); diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index fa7bc88d6166c..36c9469d60818 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -1164,7 +1164,7 @@ function pushStartTitle( 'will display markup and comments as text in the title and hydration will likely fail and ' + 'fall back to client rendering', ); - } else if (child.$$typeof != null) { + } 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 ' +