mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Currently we're printing parent stacks at the end of DOM nesting even with owner stacks enabled. That's because the context of parent tree is relevant for determining why two things are nested. It might not be sufficient to see the owner stack alone. I'm trying to get rid of parent stacks and rely on more of the plain owner stacks or ideally console.createTask. These are generally better anyway since the exact line for creating the JSX is available. It also lets you find a parent stack frame that is most relevant e.g. if it's hidden inside internals. For DOM nesting there's really only two stacks that are relevant. The creation of the parent and the creation of the child. Sometimes they're close enough to be the same thing. Such as for parents that can't have text children or when the ancestor is the direct parent created at the same place (same owner). Sometimes they're far apart. In this case I add a second console.error within the context of the ancestor. That way the second stack trace can be used to read the stack trace for where it was created. To preserve some parent context I now print the parent stack in a diff view format using the logic from hydration diffs. This includes some siblings and props for context. <img width="756" alt="Screenshot 2024-07-10 at 12 21 38 AM" src="https://github.com/facebook/react/assets/63648/0843133d-cc7a-4ecc-91c0-f46ae8e99f20"> Text Nodes: <img width="749" alt="Screenshot 2024-07-10 at 12 37 40 AM" src="https://github.com/facebook/react/assets/63648/ee377d82-54ee-450a-99d1-fcc3ef290d59"> --------- Co-authored-by: tjallingt <tjallingt@gmail.com>
642 lines
16 KiB
JavaScript
642 lines
16 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
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 = {
|
|
current: ?Info,
|
|
|
|
formTag: ?Info,
|
|
aTagInScope: ?Info,
|
|
buttonTagInScope: ?Info,
|
|
nobrTagInScope: ?Info,
|
|
pTagInButtonScope: ?Info,
|
|
|
|
listItemTagAutoclosing: ?Info,
|
|
dlItemTagAutoclosing: ?Info,
|
|
|
|
// <head> or <body>
|
|
containerTagInScope: ?Info,
|
|
};
|
|
|
|
// This validation code was written based on the HTML5 parsing spec:
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope
|
|
//
|
|
// Note: this does not catch all invalid nesting, nor does it try to (as it's
|
|
// not clear what practical benefit doing so provides); instead, we warn only
|
|
// for cases where the parser will give a parse tree differing from what React
|
|
// intended. For example, <b><div></div></b> is invalid but we don't warn
|
|
// because it still parses correctly; we do warn for other cases like nested
|
|
// <p> tags where the beginning of the second element implicitly closes the
|
|
// first, causing a confusing mess.
|
|
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#special
|
|
const specialTags = [
|
|
'address',
|
|
'applet',
|
|
'area',
|
|
'article',
|
|
'aside',
|
|
'base',
|
|
'basefont',
|
|
'bgsound',
|
|
'blockquote',
|
|
'body',
|
|
'br',
|
|
'button',
|
|
'caption',
|
|
'center',
|
|
'col',
|
|
'colgroup',
|
|
'dd',
|
|
'details',
|
|
'dir',
|
|
'div',
|
|
'dl',
|
|
'dt',
|
|
'embed',
|
|
'fieldset',
|
|
'figcaption',
|
|
'figure',
|
|
'footer',
|
|
'form',
|
|
'frame',
|
|
'frameset',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'head',
|
|
'header',
|
|
'hgroup',
|
|
'hr',
|
|
'html',
|
|
'iframe',
|
|
'img',
|
|
'input',
|
|
'isindex',
|
|
'li',
|
|
'link',
|
|
'listing',
|
|
'main',
|
|
'marquee',
|
|
'menu',
|
|
'menuitem',
|
|
'meta',
|
|
'nav',
|
|
'noembed',
|
|
'noframes',
|
|
'noscript',
|
|
'object',
|
|
'ol',
|
|
'p',
|
|
'param',
|
|
'plaintext',
|
|
'pre',
|
|
'script',
|
|
'section',
|
|
'select',
|
|
'source',
|
|
'style',
|
|
'summary',
|
|
'table',
|
|
'tbody',
|
|
'td',
|
|
'template',
|
|
'textarea',
|
|
'tfoot',
|
|
'th',
|
|
'thead',
|
|
'title',
|
|
'tr',
|
|
'track',
|
|
'ul',
|
|
'wbr',
|
|
'xmp',
|
|
];
|
|
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope
|
|
const inScopeTags = [
|
|
'applet',
|
|
'caption',
|
|
'html',
|
|
'table',
|
|
'td',
|
|
'th',
|
|
'marquee',
|
|
'object',
|
|
'template',
|
|
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#html-integration-point
|
|
// TODO: Distinguish by namespace here -- for <title>, including it here
|
|
// errs on the side of fewer warnings
|
|
'foreignObject',
|
|
'desc',
|
|
'title',
|
|
];
|
|
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope
|
|
const buttonScopeTags = __DEV__ ? inScopeTags.concat(['button']) : [];
|
|
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags
|
|
const impliedEndTags = [
|
|
'dd',
|
|
'dt',
|
|
'li',
|
|
'option',
|
|
'optgroup',
|
|
'p',
|
|
'rp',
|
|
'rt',
|
|
];
|
|
|
|
const emptyAncestorInfoDev: AncestorInfoDev = {
|
|
current: null,
|
|
|
|
formTag: null,
|
|
aTagInScope: null,
|
|
buttonTagInScope: null,
|
|
nobrTagInScope: null,
|
|
pTagInButtonScope: null,
|
|
|
|
listItemTagAutoclosing: null,
|
|
dlItemTagAutoclosing: null,
|
|
|
|
containerTagInScope: null,
|
|
};
|
|
|
|
function updatedAncestorInfoDev(
|
|
oldInfo: ?AncestorInfoDev,
|
|
tag: string,
|
|
): AncestorInfoDev {
|
|
if (__DEV__) {
|
|
const ancestorInfo = {...(oldInfo || emptyAncestorInfoDev)};
|
|
const info = {tag};
|
|
|
|
if (inScopeTags.indexOf(tag) !== -1) {
|
|
ancestorInfo.aTagInScope = null;
|
|
ancestorInfo.buttonTagInScope = null;
|
|
ancestorInfo.nobrTagInScope = null;
|
|
}
|
|
if (buttonScopeTags.indexOf(tag) !== -1) {
|
|
ancestorInfo.pTagInButtonScope = null;
|
|
}
|
|
|
|
// See rules for 'li', 'dd', 'dt' start tags in
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
|
|
if (
|
|
specialTags.indexOf(tag) !== -1 &&
|
|
tag !== 'address' &&
|
|
tag !== 'div' &&
|
|
tag !== 'p'
|
|
) {
|
|
ancestorInfo.listItemTagAutoclosing = null;
|
|
ancestorInfo.dlItemTagAutoclosing = null;
|
|
}
|
|
|
|
ancestorInfo.current = info;
|
|
|
|
if (tag === 'form') {
|
|
ancestorInfo.formTag = info;
|
|
}
|
|
if (tag === 'a') {
|
|
ancestorInfo.aTagInScope = info;
|
|
}
|
|
if (tag === 'button') {
|
|
ancestorInfo.buttonTagInScope = info;
|
|
}
|
|
if (tag === 'nobr') {
|
|
ancestorInfo.nobrTagInScope = info;
|
|
}
|
|
if (tag === 'p') {
|
|
ancestorInfo.pTagInButtonScope = info;
|
|
}
|
|
if (tag === 'li') {
|
|
ancestorInfo.listItemTagAutoclosing = info;
|
|
}
|
|
if (tag === 'dd' || tag === 'dt') {
|
|
ancestorInfo.dlItemTagAutoclosing = info;
|
|
}
|
|
if (tag === '#document' || tag === 'html') {
|
|
ancestorInfo.containerTagInScope = null;
|
|
} else if (!ancestorInfo.containerTagInScope) {
|
|
ancestorInfo.containerTagInScope = info;
|
|
}
|
|
|
|
return ancestorInfo;
|
|
} else {
|
|
return (null: any);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether
|
|
*/
|
|
function isTagValidWithParent(tag: string, parentTag: ?string): boolean {
|
|
// First, let's check if we're in an unusual parsing mode...
|
|
switch (parentTag) {
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
|
|
case 'select':
|
|
return (
|
|
tag === 'hr' ||
|
|
tag === 'option' ||
|
|
tag === 'optgroup' ||
|
|
tag === '#text'
|
|
);
|
|
case 'optgroup':
|
|
return tag === 'option' || tag === '#text';
|
|
// Strictly speaking, seeing an <option> doesn't mean we're in a <select>
|
|
// but
|
|
case 'option':
|
|
return tag === '#text';
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption
|
|
// No special behavior since these rules fall back to "in body" mode for
|
|
// all except special table nodes which cause bad parsing behavior anyway.
|
|
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intr
|
|
case 'tr':
|
|
return (
|
|
tag === 'th' ||
|
|
tag === 'td' ||
|
|
tag === 'style' ||
|
|
tag === 'script' ||
|
|
tag === 'template'
|
|
);
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intbody
|
|
case 'tbody':
|
|
case 'thead':
|
|
case 'tfoot':
|
|
return (
|
|
tag === 'tr' ||
|
|
tag === 'style' ||
|
|
tag === 'script' ||
|
|
tag === 'template'
|
|
);
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incolgroup
|
|
case 'colgroup':
|
|
return tag === 'col' || tag === 'template';
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intable
|
|
case 'table':
|
|
return (
|
|
tag === 'caption' ||
|
|
tag === 'colgroup' ||
|
|
tag === 'tbody' ||
|
|
tag === 'tfoot' ||
|
|
tag === 'thead' ||
|
|
tag === 'style' ||
|
|
tag === 'script' ||
|
|
tag === 'template'
|
|
);
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inhead
|
|
case 'head':
|
|
return (
|
|
tag === 'base' ||
|
|
tag === 'basefont' ||
|
|
tag === 'bgsound' ||
|
|
tag === 'link' ||
|
|
tag === 'meta' ||
|
|
tag === 'title' ||
|
|
tag === 'noscript' ||
|
|
tag === 'noframes' ||
|
|
tag === 'style' ||
|
|
tag === 'script' ||
|
|
tag === 'template'
|
|
);
|
|
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
|
|
case 'html':
|
|
return tag === 'head' || tag === 'body' || tag === 'frameset';
|
|
case 'frameset':
|
|
return tag === 'frame';
|
|
case '#document':
|
|
return tag === 'html';
|
|
}
|
|
|
|
// Probably in the "in body" parsing mode, so we outlaw only tag combos
|
|
// where the parsing rules cause implicit opens or closes to be added.
|
|
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
|
|
switch (tag) {
|
|
case 'h1':
|
|
case 'h2':
|
|
case 'h3':
|
|
case 'h4':
|
|
case 'h5':
|
|
case 'h6':
|
|
return (
|
|
parentTag !== 'h1' &&
|
|
parentTag !== 'h2' &&
|
|
parentTag !== 'h3' &&
|
|
parentTag !== 'h4' &&
|
|
parentTag !== 'h5' &&
|
|
parentTag !== 'h6'
|
|
);
|
|
|
|
case 'rp':
|
|
case 'rt':
|
|
return impliedEndTags.indexOf(parentTag) === -1;
|
|
|
|
case 'body':
|
|
case 'caption':
|
|
case 'col':
|
|
case 'colgroup':
|
|
case 'frameset':
|
|
case 'frame':
|
|
case 'head':
|
|
case 'html':
|
|
case 'tbody':
|
|
case 'td':
|
|
case 'tfoot':
|
|
case 'th':
|
|
case 'thead':
|
|
case 'tr':
|
|
// These tags are only valid with a few parents that have special child
|
|
// parsing rules -- if we're down here, then none of those matched and
|
|
// so we allow it only if we don't know what the parent is, as all other
|
|
// cases are invalid.
|
|
return parentTag == null;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Returns whether
|
|
*/
|
|
function findInvalidAncestorForTag(
|
|
tag: string,
|
|
ancestorInfo: AncestorInfoDev,
|
|
): ?Info {
|
|
switch (tag) {
|
|
case 'address':
|
|
case 'article':
|
|
case 'aside':
|
|
case 'blockquote':
|
|
case 'center':
|
|
case 'details':
|
|
case 'dialog':
|
|
case 'dir':
|
|
case 'div':
|
|
case 'dl':
|
|
case 'fieldset':
|
|
case 'figcaption':
|
|
case 'figure':
|
|
case 'footer':
|
|
case 'header':
|
|
case 'hgroup':
|
|
case 'main':
|
|
case 'menu':
|
|
case 'nav':
|
|
case 'ol':
|
|
case 'p':
|
|
case 'section':
|
|
case 'summary':
|
|
case 'ul':
|
|
case 'pre':
|
|
case 'listing':
|
|
case 'table':
|
|
case 'hr':
|
|
case 'xmp':
|
|
case 'h1':
|
|
case 'h2':
|
|
case 'h3':
|
|
case 'h4':
|
|
case 'h5':
|
|
case 'h6':
|
|
return ancestorInfo.pTagInButtonScope;
|
|
|
|
case 'form':
|
|
return ancestorInfo.formTag || ancestorInfo.pTagInButtonScope;
|
|
|
|
case 'li':
|
|
return ancestorInfo.listItemTagAutoclosing;
|
|
|
|
case 'dd':
|
|
case 'dt':
|
|
return ancestorInfo.dlItemTagAutoclosing;
|
|
|
|
case 'button':
|
|
return ancestorInfo.buttonTagInScope;
|
|
|
|
case 'a':
|
|
// Spec says something about storing a list of markers, but it sounds
|
|
// equivalent to this check.
|
|
return ancestorInfo.aTagInScope;
|
|
|
|
case 'nobr':
|
|
return ancestorInfo.nobrTagInScope;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
): boolean {
|
|
if (__DEV__) {
|
|
ancestorInfo = ancestorInfo || emptyAncestorInfoDev;
|
|
const parentInfo = ancestorInfo.current;
|
|
const parentTag = parentInfo && parentInfo.tag;
|
|
|
|
const invalidParent = isTagValidWithParent(childTag, parentTag)
|
|
? null
|
|
: parentInfo;
|
|
const invalidAncestor = invalidParent
|
|
? null
|
|
: findInvalidAncestorForTag(childTag, ancestorInfo);
|
|
const invalidParentOrAncestor = invalidParent || invalidAncestor;
|
|
if (!invalidParentOrAncestor) {
|
|
return true;
|
|
}
|
|
|
|
const ancestorTag = invalidParentOrAncestor.tag;
|
|
|
|
const warnKey =
|
|
// eslint-disable-next-line react-internal/safe-string-coercion
|
|
String(!!invalidParent) + '|' + childTag + '|' + ancestorTag;
|
|
if (didWarn[warnKey]) {
|
|
return false;
|
|
}
|
|
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 = '';
|
|
if (ancestorTag === 'table' && childTag === 'tr') {
|
|
info +=
|
|
' Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated by ' +
|
|
'the browser.';
|
|
}
|
|
console.error(
|
|
'In HTML, %s cannot be a child of <%s>.%s\n' +
|
|
'This will cause a hydration error.%s',
|
|
tagDisplayName,
|
|
ancestorTag,
|
|
info,
|
|
ancestorDescription,
|
|
);
|
|
} else {
|
|
console.error(
|
|
'In HTML, %s cannot be a descendant of <%s>.\n' +
|
|
'This will cause a hydration error.%s',
|
|
tagDisplayName,
|
|
ancestorTag,
|
|
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;
|
|
}
|
|
|
|
function validateTextNesting(childText: string, parentTag: string): boolean {
|
|
if (__DEV__) {
|
|
if (isTagValidWithParent('#text', parentTag)) {
|
|
return true;
|
|
}
|
|
|
|
const warnKey = '#text|' + parentTag;
|
|
if (didWarn[warnKey]) {
|
|
return false;
|
|
}
|
|
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)) {
|
|
console.error(
|
|
'In HTML, text nodes cannot be a child of <%s>.\n' +
|
|
'This will cause a hydration error.%s',
|
|
parentTag,
|
|
ancestorDescription,
|
|
);
|
|
} else {
|
|
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,
|
|
ancestorDescription,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export {updatedAncestorInfoDev, validateDOMNesting, validateTextNesting};
|