diff --git a/packages/react-dom-bindings/src/client/validateDOMNesting.js b/packages/react-dom-bindings/src/client/validateDOMNesting.js index 2f192cd18bd94..49d8b5c158f62 100644 --- a/packages/react-dom-bindings/src/client/validateDOMNesting.js +++ b/packages/react-dom-bindings/src/client/validateDOMNesting.js @@ -7,7 +7,54 @@ * @flow */ -import {getCurrentParentStackInDev} from 'react-reconciler/src/ReactCurrentFiber'; +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {HydrationDiffNode} from 'react-reconciler/src/ReactFiberHydrationDiffs'; + +import {enableOwnerStacks} from 'shared/ReactFeatureFlags'; + +import { + current, + runWithFiberInDEV, +} from 'react-reconciler/src/ReactCurrentFiber'; +import { + HostComponent, + HostHoistable, + HostSingleton, + HostText, +} from 'react-reconciler/src/ReactWorkTags'; + +import {describeDiff} from 'react-reconciler/src/ReactFiberHydrationDiffs'; + +function describeAncestors( + ancestor: Fiber, + child: Fiber, + props: null | {children: null}, +): string { + let fiber: null | Fiber = child; + let node: null | HydrationDiffNode = null; + let distanceFromLeaf = 0; + while (fiber) { + if (fiber === ancestor) { + distanceFromLeaf = 0; + } + node = { + fiber: fiber, + children: node !== null ? [node] : [], + serverProps: + fiber === child ? props : fiber === ancestor ? null : undefined, + serverTail: [], + distanceFromLeaf: distanceFromLeaf, + }; + distanceFromLeaf++; + fiber = fiber.return; + } + if (node !== null) { + // Describe the node using the hydration diff logic. + // Replace + with - to mark ancestor and child. It's kind of arbitrary. + return describeDiff(node).replaceAll(/^[+-]/gm, '>'); + } + return ''; +} type Info = {tag: string}; export type AncestorInfoDev = { @@ -440,6 +487,21 @@ function findInvalidAncestorForTag( const didWarn: {[string]: boolean} = {}; +function findAncestor(parent: null | Fiber, tagName: string): null | Fiber { + while (parent) { + switch (parent.tag) { + case HostComponent: + case HostHoistable: + case HostSingleton: + if (parent.type === tagName) { + return parent; + } + } + parent = parent.return; + } + return null; +} + function validateDOMNesting( childTag: string, ancestorInfo: AncestorInfoDev, @@ -470,6 +532,14 @@ function validateDOMNesting( } didWarn[warnKey] = true; + const child = current; + const ancestor = child ? findAncestor(child.return, ancestorTag) : null; + + const ancestorDescription = + child !== null && ancestor !== null + ? describeAncestors(ancestor, child, null) + : ''; + const tagDisplayName = '<' + childTag + '>'; if (invalidParent) { let info = ''; @@ -478,33 +548,45 @@ function validateDOMNesting( ' Add a
, or to your code to match the DOM tree generated by ' + 'the browser.'; } - // Don't transform into consoleWithStackDev here because we add a manual stack. - // We use the parent stack here instead of the owner stack because the parent - // stack has more useful context for nesting. - // TODO: Format this as a linkified "diff view" with props instead of - // a stack trace since the stack trace format is now for owner stacks. - console['error']( + console.error( 'In HTML, %s cannot be a child of <%s>.%s\n' + 'This will cause a hydration error.%s', tagDisplayName, ancestorTag, info, - getCurrentParentStackInDev(), + ancestorDescription, ); } else { - // Don't transform into consoleWithStackDev here because we add a manual stack. - // We use the parent stack here instead of the owner stack because the parent - // stack has more useful context for nesting. - // TODO: Format this as a linkified "diff view" with props instead of - // a stack trace since the stack trace format is now for owner stacks. - console['error']( + console.error( 'In HTML, %s cannot be a descendant of <%s>.\n' + 'This will cause a hydration error.%s', tagDisplayName, ancestorTag, - getCurrentParentStackInDev(), + ancestorDescription, ); } + if (enableOwnerStacks && child) { + // For debugging purposes find the nearest ancestor that caused the issue. + // The stack trace of this ancestor can be useful to find the cause. + // If the parent is a direct parent in the same owner, we don't bother. + const parent = child.return; + if ( + ancestor !== null && + parent !== null && + (ancestor !== parent || parent._debugOwner !== child._debugOwner) + ) { + runWithFiberInDEV(ancestor, () => { + console.error( + // We repeat some context because this log might be taken out of context + // such as in React DevTools or grouped server logs. + '<%s> cannot contain a nested %s.\n' + + 'See this log for the ancestor stack trace.', + ancestorTag, + tagDisplayName, + ); + }); + } + } return false; } return true; @@ -522,31 +604,33 @@ function validateTextNesting(childText: string, parentTag: string): boolean { } didWarn[warnKey] = true; + const child = current; + const ancestor = child ? findAncestor(child, parentTag) : null; + + const ancestorDescription = + child !== null && ancestor !== null + ? describeAncestors( + ancestor, + child, + child.tag !== HostText ? {children: null} : null, + ) + : ''; + if (/\S/.test(childText)) { - // Don't transform into consoleWithStackDev here because we add a manual stack. - // We use the parent stack here instead of the owner stack because the parent - // stack has more useful context for nesting. - // TODO: Format this as a linkified "diff view" with props instead of - // a stack trace since the stack trace format is now for owner stacks. - console['error']( + console.error( 'In HTML, text nodes cannot be a child of <%s>.\n' + 'This will cause a hydration error.%s', parentTag, - getCurrentParentStackInDev(), + ancestorDescription, ); } else { - // Don't transform into consoleWithStackDev here because we add a manual stack. - // We use the parent stack here instead of the owner stack because the parent - // stack has more useful context for nesting. - // TODO: Format this as a linkified "diff view" with props instead of - // a stack trace since the stack trace format is now for owner stacks. - console['error']( + console.error( 'In HTML, whitespace text nodes cannot be a child of <%s>. ' + "Make sure you don't have any extra whitespace between tags on " + 'each line of your source code.\n' + 'This will cause a hydration error.%s', parentTag, - getCurrentParentStackInDev(), + ancestorDescription, ); } return false; diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index 6e02e17ece1a7..d37a4ecba6dc6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -2193,13 +2193,18 @@ describe('ReactDOMComponent', () => { , ); }); - }).toErrorDev([ - 'In HTML,cannot be a descendant ' + - 'of
.\n' + + 'In HTML,
cannot be a descendant of
.\n' +
'This will cause a hydration error.' +
// There is no outer `p` here because root container is not part of the stack.
'\n in p (at **)' +
- '\n in span (at **)',
+ (gate(flags => flags.enableOwnerStacks)
+ ? ''
+ : '\n in span (at **)'),
);
});
@@ -2248,29 +2254,90 @@ describe('ReactDOMComponent', () => {
await act(() => {
root.render(