Skip to content

Commit

Permalink
[Fizz] Disallow complex children in <title> elements (#24679)
Browse files Browse the repository at this point in the history
* [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
  • Loading branch information
gnoff authored Jun 7, 2022
1 parent 4f29ba1 commit bcbeb52
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 1 deletion.
191 changes: 191 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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</title>;
}

prepareJSDOMForTitle();
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
});

// @gate experimental
it('should accept children array of length 1 containing a string', async () => {
// a Single string child
function App() {
return <title>{['hello']}</title>;
}

prepareJSDOMForTitle();
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
});

// @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 <title>{['hello1', 'hello2']}</title>;
}

try {
prepareJSDOMForTitle();

await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
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(
<title>{'hello1<!-- -->hello2'}</title>,
);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
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(
<title>{['hello1', 'hello2']}</title>,
);
} finally {
console.error = originalConsoleError;
}
});

// @gate experimental
it('should warn in dev if you pass a React Component as a child to <title>', 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 />
</title>
);
}

try {
prepareJSDOMForTitle();

await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
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(<title>hello</title>);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
} finally {
console.error = originalConsoleError;
}
});
});
});
71 changes: 71 additions & 0 deletions packages/react-dom/src/server/ReactDOMServerFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,75 @@ function pushStartMenuItem(
return null;
}

function pushStartTitle(
target: Array<Chunk | PrecomputedChunk>,
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 <title>.',
);
// 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,
Expand Down Expand Up @@ -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': {
Expand Down
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>."
}

0 comments on commit bcbeb52

Please sign in to comment.