Files
react/packages/react-reconciler/src/ReactFiberHydrationDiffs.js
T
Sebastian Markbåge 2d3f81bb6a Format DOM Nesting Warning as Diff View + An Additional Log for Stack Trace (#30302)
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>
2024-07-10 12:17:13 -04:00

650 lines
19 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 './ReactInternalTypes';
import {
HostComponent,
HostHoistable,
HostSingleton,
LazyComponent,
SuspenseComponent,
SuspenseListComponent,
FunctionComponent,
ForwardRef,
SimpleMemoComponent,
ClassComponent,
HostText,
} from './ReactWorkTags';
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import assign from 'shared/assign';
import getComponentNameFromType from 'shared/getComponentNameFromType';
import isArray from 'shared/isArray';
export type HydrationDiffNode = {
fiber: Fiber,
children: Array<HydrationDiffNode>,
serverProps: void | null | $ReadOnly<{[propName: string]: mixed}> | string, // null means no matching server node
serverTail: Array<
| $ReadOnly<{type: string, props: $ReadOnly<{[propName: string]: mixed}>}>
| string,
>,
distanceFromLeaf: number,
};
const maxRowLength = 120;
const idealDepth = 15;
function findNotableNode(
node: HydrationDiffNode,
indent: number,
): HydrationDiffNode {
if (
node.serverProps === undefined &&
node.serverTail.length === 0 &&
node.children.length === 1 &&
node.distanceFromLeaf > 3 &&
node.distanceFromLeaf > idealDepth - indent
) {
// This is not an interesting node for contextual purposes so we can skip it.
const child = node.children[0];
return findNotableNode(child, indent);
}
return node;
}
function indentation(indent: number): string {
return ' ' + ' '.repeat(indent);
}
function added(indent: number): string {
return '+ ' + ' '.repeat(indent);
}
function removed(indent: number): string {
return '- ' + ' '.repeat(indent);
}
function describeFiberType(fiber: Fiber): null | string {
switch (fiber.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
return fiber.type;
case LazyComponent:
return 'Lazy';
case SuspenseComponent:
return 'Suspense';
case SuspenseListComponent:
return 'SuspenseList';
case FunctionComponent:
case SimpleMemoComponent:
const fn = fiber.type;
return fn.displayName || fn.name || null;
case ForwardRef:
const render = fiber.type.render;
return render.displayName || render.name || null;
case ClassComponent:
const ctr = fiber.type;
return ctr.displayName || ctr.name || null;
default:
// Skip
return null;
}
}
const needsEscaping = /["'&<>\n\t]|^\s|\s$/;
function describeTextNode(content: string, maxLength: number): string {
if (needsEscaping.test(content)) {
const encoded = JSON.stringify(content);
if (encoded.length > maxLength - 2) {
if (maxLength < 8) {
return '{"..."}';
}
return '{' + encoded.slice(0, maxLength - 7) + '..."}';
}
return '{' + encoded + '}';
} else {
if (content.length > maxLength) {
if (maxLength < 5) {
return '{"..."}';
}
return content.slice(0, maxLength - 3) + '...';
}
return content;
}
}
function describeTextDiff(
clientText: string,
serverProps: mixed,
indent: number,
): string {
const maxLength = maxRowLength - indent * 2;
if (serverProps === null) {
return added(indent) + describeTextNode(clientText, maxLength) + '\n';
} else if (typeof serverProps === 'string') {
let serverText: string = serverProps;
let firstDiff = 0;
for (
;
firstDiff < serverText.length && firstDiff < clientText.length;
firstDiff++
) {
if (
serverText.charCodeAt(firstDiff) !== clientText.charCodeAt(firstDiff)
) {
break;
}
}
if (firstDiff > maxLength - 8 && firstDiff > 10) {
// The first difference between the two strings would be cut off, so cut off in
// the beginning instead.
clientText = '...' + clientText.slice(firstDiff - 8);
serverText = '...' + serverText.slice(firstDiff - 8);
}
return (
added(indent) +
describeTextNode(clientText, maxLength) +
'\n' +
removed(indent) +
describeTextNode(serverText, maxLength) +
'\n'
);
} else {
return indentation(indent) + describeTextNode(clientText, maxLength) + '\n';
}
}
function objectName(object: mixed): string {
// $FlowFixMe[method-unbinding]
const name = Object.prototype.toString.call(object);
return name.replace(/^\[object (.*)\]$/, function (m, p0) {
return p0;
});
}
function describeValue(value: mixed, maxLength: number): string {
switch (typeof value) {
case 'string': {
const encoded = JSON.stringify(value);
if (encoded.length > maxLength) {
if (maxLength < 5) {
return '"..."';
}
return encoded.slice(0, maxLength - 4) + '..."';
}
return encoded;
}
case 'object': {
if (value === null) {
return 'null';
}
if (isArray(value)) {
return '[...]';
}
if ((value: any).$$typeof === REACT_ELEMENT_TYPE) {
const type = getComponentNameFromType((value: any).type);
return type ? '<' + type + '>' : '<...>';
}
const name = objectName(value);
if (name === 'Object') {
let properties = '';
maxLength -= 2;
for (let propName in value) {
if (!value.hasOwnProperty(propName)) {
continue;
}
const jsonPropName = JSON.stringify(propName);
if (jsonPropName !== '"' + propName + '"') {
propName = jsonPropName;
}
maxLength -= propName.length - 2;
const propValue = describeValue(
value[propName],
maxLength < 15 ? maxLength : 15,
);
maxLength -= propValue.length;
if (maxLength < 0) {
properties += properties === '' ? '...' : ', ...';
break;
}
properties +=
(properties === '' ? '' : ',') + propName + ':' + propValue;
}
return '{' + properties + '}';
}
return name;
}
case 'function': {
const name = (value: any).displayName || value.name;
return name ? 'function ' + name : 'function';
}
default:
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value);
}
}
function describePropValue(value: mixed, maxLength: number): string {
if (typeof value === 'string' && !needsEscaping.test(value)) {
if (value.length > maxLength - 2) {
if (maxLength < 5) {
return '"..."';
}
return '"' + value.slice(0, maxLength - 5) + '..."';
}
return '"' + value + '"';
}
return '{' + describeValue(value, maxLength - 2) + '}';
}
function describeCollapsedElement(
type: string,
props: {[propName: string]: mixed},
indent: number,
): string {
// This function tries to fit the props into a single line for non-essential elements.
// We also ignore children because we're not going deeper.
let maxLength = maxRowLength - indent * 2 - type.length - 2;
let content = '';
for (const propName in props) {
if (!props.hasOwnProperty(propName)) {
continue;
}
if (propName === 'children') {
// Ignored.
continue;
}
const propValue = describePropValue(props[propName], 15);
maxLength -= propName.length + propValue.length + 2;
if (maxLength < 0) {
content += ' ...';
break;
}
content += ' ' + propName + '=' + propValue;
}
return indentation(indent) + '<' + type + content + '>\n';
}
function describeExpandedElement(
type: string,
props: {+[propName: string]: mixed},
rowPrefix: string,
): string {
// This function tries to fit the props into a single line for non-essential elements.
// We also ignore children because we're not going deeper.
let remainingRowLength = maxRowLength - rowPrefix.length - type.length;
// We add the properties to a set so we can choose later whether we'll put it on one
// line or multiple lines.
const properties = [];
for (const propName in props) {
if (!props.hasOwnProperty(propName)) {
continue;
}
if (propName === 'children') {
// Ignored.
continue;
}
const maxLength = maxRowLength - rowPrefix.length - propName.length - 1;
const propValue = describePropValue(props[propName], maxLength);
remainingRowLength -= propName.length + propValue.length + 2;
properties.push(propName + '=' + propValue);
}
if (properties.length === 0) {
return rowPrefix + '<' + type + '>\n';
} else if (remainingRowLength > 0) {
// We can fit all on one row.
return rowPrefix + '<' + type + ' ' + properties.join(' ') + '>\n';
} else {
// Split into one row per property:
return (
rowPrefix +
'<' +
type +
'\n' +
rowPrefix +
' ' +
properties.join('\n' + rowPrefix + ' ') +
'\n' +
rowPrefix +
'>\n'
);
}
}
function describePropertiesDiff(
clientObject: {+[propName: string]: mixed},
serverObject: {+[propName: string]: mixed},
indent: number,
): string {
let properties = '';
const remainingServerProperties = assign({}, serverObject);
for (const propName in clientObject) {
if (!clientObject.hasOwnProperty(propName)) {
continue;
}
delete remainingServerProperties[propName];
const maxLength = maxRowLength - indent * 2 - propName.length - 2;
const clientValue = clientObject[propName];
const clientPropValue = describeValue(clientValue, maxLength);
if (serverObject.hasOwnProperty(propName)) {
const serverValue = serverObject[propName];
const serverPropValue = describeValue(serverValue, maxLength);
properties += added(indent) + propName + ': ' + clientPropValue + '\n';
properties += removed(indent) + propName + ': ' + serverPropValue + '\n';
} else {
properties += added(indent) + propName + ': ' + clientPropValue + '\n';
}
}
for (const propName in remainingServerProperties) {
if (!remainingServerProperties.hasOwnProperty(propName)) {
continue;
}
const maxLength = maxRowLength - indent * 2 - propName.length - 2;
const serverValue = remainingServerProperties[propName];
const serverPropValue = describeValue(serverValue, maxLength);
properties += removed(indent) + propName + ': ' + serverPropValue + '\n';
}
return properties;
}
function describeElementDiff(
type: string,
clientProps: {+[propName: string]: mixed},
serverProps: {+[propName: string]: mixed},
indent: number,
): string {
let content = '';
// Maps any previously unmatched lower case server prop name to its full prop name
const serverPropNames: Map<string, string> = new Map();
for (const propName in serverProps) {
if (!serverProps.hasOwnProperty(propName)) {
continue;
}
serverPropNames.set(propName.toLowerCase(), propName);
}
if (serverPropNames.size === 1 && serverPropNames.has('children')) {
content += describeExpandedElement(type, clientProps, indentation(indent));
} else {
for (const propName in clientProps) {
if (!clientProps.hasOwnProperty(propName)) {
continue;
}
if (propName === 'children') {
// Handled below.
continue;
}
const maxLength = maxRowLength - (indent + 1) * 2 - propName.length - 1;
const serverPropName = serverPropNames.get(propName.toLowerCase());
if (serverPropName !== undefined) {
serverPropNames.delete(propName.toLowerCase());
// There's a diff here.
const clientValue = clientProps[propName];
const serverValue = serverProps[serverPropName];
const clientPropValue = describePropValue(clientValue, maxLength);
const serverPropValue = describePropValue(serverValue, maxLength);
if (
typeof clientValue === 'object' &&
clientValue !== null &&
typeof serverValue === 'object' &&
serverValue !== null &&
objectName(clientValue) === 'Object' &&
objectName(serverValue) === 'Object' &&
// Only do the diff if the object has a lot of keys or was shortened.
(Object.keys(clientValue).length > 2 ||
Object.keys(serverValue).length > 2 ||
clientPropValue.indexOf('...') > -1 ||
serverPropValue.indexOf('...') > -1)
) {
// We're comparing two plain objects. We can diff the nested objects instead.
content +=
indentation(indent + 1) +
propName +
'={{\n' +
describePropertiesDiff(clientValue, serverValue, indent + 2) +
indentation(indent + 1) +
'}}\n';
} else {
content +=
added(indent + 1) + propName + '=' + clientPropValue + '\n';
content +=
removed(indent + 1) + propName + '=' + serverPropValue + '\n';
}
} else {
// Considered equal.
content +=
indentation(indent + 1) +
propName +
'=' +
describePropValue(clientProps[propName], maxLength) +
'\n';
}
}
serverPropNames.forEach(propName => {
if (propName === 'children') {
// Handled below.
return;
}
const maxLength = maxRowLength - (indent + 1) * 2 - propName.length - 1;
content +=
removed(indent + 1) +
propName +
'=' +
describePropValue(serverProps[propName], maxLength) +
'\n';
});
if (content === '') {
// No properties
content = indentation(indent) + '<' + type + '>\n';
} else {
// Had properties
content =
indentation(indent) +
'<' +
type +
'\n' +
content +
indentation(indent) +
'>\n';
}
}
const serverChildren = serverProps.children;
const clientChildren = clientProps.children;
if (
typeof serverChildren === 'string' ||
typeof serverChildren === 'number' ||
typeof serverChildren === 'bigint'
) {
// There's a diff of the children.
// $FlowFixMe[unsafe-addition]
const serverText = '' + serverChildren;
let clientText = '';
if (
typeof clientChildren === 'string' ||
typeof clientChildren === 'number' ||
typeof clientChildren === 'bigint'
) {
// $FlowFixMe[unsafe-addition]
clientText = '' + clientChildren;
}
content += describeTextDiff(clientText, serverText, indent + 1);
} else if (
typeof clientChildren === 'string' ||
typeof clientChildren === 'number' ||
typeof clientChildren === 'bigint'
) {
if (serverChildren == null) {
// This is a new string child.
// $FlowFixMe[unsafe-addition]
content += describeTextDiff('' + clientChildren, null, indent + 1);
} else {
// The client has children but it's not considered a difference from the server.
// $FlowFixMe[unsafe-addition]
content += describeTextDiff('' + clientChildren, undefined, indent + 1);
}
}
return content;
}
function describeSiblingFiber(fiber: Fiber, indent: number): string {
const type = describeFiberType(fiber);
if (type === null) {
// Skip this type of fiber. We currently treat this as a fragment
// so it's just part of the parent's children.
let flatContent = '';
let childFiber = fiber.child;
while (childFiber) {
flatContent += describeSiblingFiber(childFiber, indent);
childFiber = childFiber.sibling;
}
return flatContent;
}
return indentation(indent) + '<' + type + '>' + '\n';
}
function describeNode(node: HydrationDiffNode, indent: number): string {
const skipToNode = findNotableNode(node, indent);
if (
skipToNode !== node &&
(node.children.length !== 1 || node.children[0] !== skipToNode)
) {
return indentation(indent) + '...\n' + describeNode(skipToNode, indent + 1);
}
// Prefix with any server components for context
let parentContent = '';
const debugInfo = node.fiber._debugInfo;
if (debugInfo) {
for (let i = 0; i < debugInfo.length; i++) {
const serverComponentName = debugInfo[i].name;
if (typeof serverComponentName === 'string') {
parentContent +=
indentation(indent) + '<' + serverComponentName + '>' + '\n';
indent++;
}
}
}
// Self
let selfContent = '';
// We use the pending props since we might be generating a diff before the complete phase
// when something throws.
const clientProps = node.fiber.pendingProps;
if (node.fiber.tag === HostText) {
// Text Node
selfContent = describeTextDiff(clientProps, node.serverProps, indent);
indent++;
} else {
const type = describeFiberType(node.fiber);
if (type !== null) {
// Element Node
if (node.serverProps === undefined) {
// Just a reference node for context.
selfContent = describeCollapsedElement(type, clientProps, indent);
indent++;
} else if (node.serverProps === null) {
selfContent = describeExpandedElement(type, clientProps, added(indent));
indent++;
} else if (typeof node.serverProps === 'string') {
if (__DEV__) {
console.error(
'Should not have matched a non HostText fiber to a Text node. This is a bug in React.',
);
}
} else {
selfContent = describeElementDiff(
type,
clientProps,
node.serverProps,
indent,
);
indent++;
}
}
}
// Compute children
let childContent = '';
let childFiber = node.fiber.child;
let diffIdx = 0;
while (childFiber && diffIdx < node.children.length) {
const childNode = node.children[diffIdx];
if (childNode.fiber === childFiber) {
// This was a match in the diff.
childContent += describeNode(childNode, indent);
diffIdx++;
} else {
// This is an unrelated previous sibling.
childContent += describeSiblingFiber(childFiber, indent);
}
childFiber = childFiber.sibling;
}
if (childFiber && node.children.length > 0) {
// If we had any further siblings after the last mismatch, we can't be sure if it's
// actually a valid match since it might not have found a match. So we exclude next
// siblings to avoid confusion.
childContent += indentation(indent) + '...' + '\n';
}
// Deleted tail nodes
const serverTail = node.serverTail;
if (node.serverProps === null) {
indent--;
}
for (let i = 0; i < serverTail.length; i++) {
const tailNode = serverTail[i];
if (typeof tailNode === 'string') {
// Removed text node
childContent +=
removed(indent) +
describeTextNode(tailNode, maxRowLength - indent * 2) +
'\n';
} else {
// Removed element
childContent += describeExpandedElement(
tailNode.type,
tailNode.props,
removed(indent),
);
}
}
return parentContent + selfContent + childContent;
}
export function describeDiff(rootNode: HydrationDiffNode): string {
try {
return '\n\n' + describeNode(rootNode, 0);
} catch (x) {
return '';
}
}