From 2ce25358c8124ddd7f366a2090b361a5ee58129f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 5 Mar 2024 22:31:52 -0500 Subject: [PATCH 1/8] Track a mismatched instance on the server tail --- .../src/ReactFiberHydrationContext.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index a25dee709a6de..7b26c2d25b4b3 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -182,7 +182,10 @@ export function errorHydratingContainer(parentContainer: Container): void { } } -function warnNonHydratedInstance(fiber: Fiber) { +function warnNonHydratedInstance( + fiber: Fiber, + rejectedCandidate: null | HydratableInstance, +) { if (__DEV__) { if (didSuspendOrErrorDEV) { // Inside a boundary that already suspended. We're currently rendering the @@ -195,6 +198,11 @@ function warnNonHydratedInstance(fiber: Fiber) { const diffNode = buildHydrationDiffNode(fiber); // We use null as a signal that there was no node to match. diffNode.serverProps = null; + if (rejectedCandidate !== null) { + const description = + describeHydratableInstanceForDevWarnings(rejectedCandidate); + diffNode.serverTail.push(description); + } } } @@ -327,7 +335,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { const nextInstance = nextHydratableInstance; if (!nextInstance || !tryHydrateInstance(fiber, nextInstance)) { if (shouldKeepWarning) { - warnNonHydratedInstance(fiber); + warnNonHydratedInstance(fiber, nextInstance); } throwOnHydrationMismatch(fiber); } @@ -347,7 +355,7 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void { const nextInstance = nextHydratableInstance; if (!nextInstance || !tryHydrateText(fiber, nextInstance)) { if (shouldKeepWarning) { - warnNonHydratedInstance(fiber); + warnNonHydratedInstance(fiber, nextInstance); } throwOnHydrationMismatch(fiber); } @@ -359,7 +367,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void { } const nextInstance = nextHydratableInstance; if (!nextInstance || !tryHydrateSuspense(fiber, nextInstance)) { - warnNonHydratedInstance(fiber); + warnNonHydratedInstance(fiber, nextInstance); throwOnHydrationMismatch(fiber); } } From a03f107480e0eafe49fc056a84a3a1ba0204f6d5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2024 18:45:18 -0500 Subject: [PATCH 2/8] Alias class/for attribute to className/htmlFor when it doesn't exist in client props --- .../src/client/ReactDOMComponent.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 622b8e044951d..80ce758959927 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -251,7 +251,7 @@ function warnForExtraAttributes( ) { if (__DEV__) { attributeNames.forEach(function (attributeName) { - serverDifferences[attributeName] = + serverDifferences[getPropNameFromAttributeName(attributeName)] = attributeName === 'style' ? getStylesObjectFromElement(domElement) : domElement.getAttribute(attributeName); @@ -1829,12 +1829,24 @@ function getPossibleStandardName(propName: string): string | null { return null; } +function getPropNameFromAttributeName(attrName: string): string { + switch (attrName) { + case 'class': + return 'className'; + case 'for': + return 'htmlFor'; + // TODO: The rest of the aliases. + default: + return attrName; + } +} + export function getPropsFromElement(domElement: Element): Object { const serverDifferences: {[propName: string]: mixed} = {}; const attributes = domElement.attributes; for (let i = 0; i < attributes.length; i++) { const attr = attributes[i]; - serverDifferences[attr.name] = + serverDifferences[getPropNameFromAttributeName(attr.name)] = attr.name.toLowerCase() === 'style' ? getStylesObjectFromElement(domElement) : attr.value; From 4ffc842836ccfe59f7316ac656401310e8f12469 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 5 Mar 2024 23:47:32 -0500 Subject: [PATCH 3/8] Format a hydration diff view --- .../src/ReactFiberHydrationDiffs.js | 521 +++++++++++++++++- 1 file changed, 520 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js index f6b6bee117be1..ea73ecadedd11 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js +++ b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js @@ -9,6 +9,26 @@ import type {Fiber} from './ReactInternalTypes'; +import { + HostComponent, + HostHoistable, + HostSingleton, + LazyComponent, + SuspenseComponent, + SuspenseListComponent, + FunctionComponent, + IndeterminateComponent, + ForwardRef, + SimpleMemoComponent, + ClassComponent, + HostText, +} from './ReactWorkTags'; + +import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; +import getComponentNameFromType from 'shared/getComponentNameFromType'; + +import isArray from 'shared/isArray'; + export type HydrationDiffNode = { fiber: Fiber, children: Array, @@ -19,6 +39,505 @@ export type HydrationDiffNode = { >, }; +const maxRowLength = 120; + +function shouldCollapse(node: HydrationDiffNode): null | HydrationDiffNode { + return null; +} + +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 IndeterminateComponent: + 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]/; + +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 describePropValue(value: mixed, maxLength: number): string { + switch (typeof value) { + case 'string': { + if (needsEscaping.test(value)) { + const encoded = JSON.stringify(value); + if (encoded.length > maxLength - 2) { + if (maxLength < 8) { + return '{"..."}'; + } + return '{' + encoded.slice(0, maxLength - 7) + '..."}'; + } + return '{' + encoded + '}'; + } else { + if (value.length > maxLength - 2) { + if (maxLength < 5) { + return '"..."'; + } + return '"' + value.slice(0, maxLength - 3) + '..."'; + } + return '"' + value + '"'; + } + } + 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') { + return '{{...}}'; + } + 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 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; + } + + // No properties + 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 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 = 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. + // TODO: Handle nested diffs. + const clientPropValue = describePropValue( + clientProps[propName], + maxLength, + ); + content += added(indent + 1) + propName + '=' + clientPropValue + '\n'; + const serverPropValue = describePropValue( + serverProps[serverPropName], + maxLength, + ); + 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' + ) { + // 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 = shouldCollapse(node); + if (skipToNode !== null) { + 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); + } 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)); + // If this was an insertion we won't step down further. Any tail + // are considered siblings so we don't indent. + // TODO: Model this a little better. + } 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; + 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 { - return '\n'; + try { + return '\n\n' + describeNode(rootNode, 0); + } catch (x) { + return ''; + } } From e86513d3df9ba834a0da1655e8112d064afb5aa1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2024 20:47:38 -0500 Subject: [PATCH 4/8] Skip past suspense instances when collecting tail nodes This assumes that the description returns the string "Suspense" and that any other hydratable node won't do that. --- .../react-reconciler/src/ReactFiberHydrationContext.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 7b26c2d25b4b3..54f007e15ab2d 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -642,7 +642,12 @@ function warnIfUnhydratedTailNodes(fiber: Fiber) { const description = describeHydratableInstanceForDevWarnings(nextInstance); diffNode.serverTail.push(description); - nextInstance = getNextHydratableSibling(nextInstance); + if (description.type === 'Suspense') { + nextInstance = + getNextHydratableInstanceAfterSuspenseInstance(nextInstance); + } else { + nextInstance = getNextHydratableSibling(nextInstance); + } } } } From f77b7b566106eda4188f652c3f0e28d75457f136 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2024 20:48:03 -0500 Subject: [PATCH 5/8] Describe objects --- .../src/ReactFiberHydrationDiffs.js | 154 ++++++++++++++---- 1 file changed, 118 insertions(+), 36 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js index ea73ecadedd11..c9d8b86027134 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js +++ b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js @@ -25,6 +25,7 @@ import { } from './ReactWorkTags'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; +import assign from 'shared/assign'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import isArray from 'shared/isArray'; @@ -158,53 +159,79 @@ function objectName(object: mixed): string { }); } -function describePropValue(value: mixed, maxLength: number): string { +function describeValue(value: mixed, maxLength: number): string { switch (typeof value) { case 'string': { - if (needsEscaping.test(value)) { - const encoded = JSON.stringify(value); - if (encoded.length > maxLength - 2) { - if (maxLength < 8) { - return '{"..."}'; - } - return '{' + encoded.slice(0, maxLength - 7) + '..."}'; - } - return '{' + encoded + '}'; - } else { - if (value.length > maxLength - 2) { - if (maxLength < 5) { - return '"..."'; - } - return '"' + value.slice(0, maxLength - 3) + '..."'; + const encoded = JSON.stringify(value); + if (encoded.length > maxLength) { + if (maxLength < 5) { + return '"..."'; } - return '"' + value + '"'; + return encoded.slice(0, maxLength - 4) + '..."'; } + return encoded; } case 'object': { if (value === null) { - return '{null}'; + return 'null'; } if (isArray(value)) { - return '{[...]}'; + return '[...]'; } if ((value: any).$$typeof === REACT_ELEMENT_TYPE) { const type = getComponentNameFromType((value: any).type); - return type ? '{<' + type + '>}' : '{<...>}'; + return type ? '<' + type + '>' : '<...>'; } const name = objectName(value); if (name === 'Object') { - return '{{...}}'; + 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 + '}'; + return name; } case 'function': { const name = (value: any).displayName || value.name; - return name ? '{function ' + name + '}' : '{function}'; + return name ? 'function ' + name : 'function'; } default: // eslint-disable-next-line react-internal/safe-string-coercion - return '{' + String(value) + '}'; + 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( @@ -291,6 +318,42 @@ function describeExpandedElement( } } +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}, @@ -325,18 +388,37 @@ function describeElementDiff( if (serverPropName !== undefined) { serverPropNames.delete(propName.toLowerCase()); // There's a diff here. - // TODO: Handle nested diffs. - const clientPropValue = describePropValue( - clientProps[propName], - maxLength, - ); - content += added(indent + 1) + propName + '=' + clientPropValue + '\n'; - const serverPropValue = describePropValue( - serverProps[serverPropName], - maxLength, - ); - content += - removed(indent + 1) + propName + '=' + serverPropValue + '\n'; + 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 += From af753bdd0fe07f46e823897c8f3ab708ec6f8696 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2024 22:25:47 -0500 Subject: [PATCH 6/8] Collapse parent nodes unless they're close to the edge This collapses parent nodes of larger trees unless they're a fork, has diff information or is close to a leaf of the tree that has diff info. --- .../src/ReactFiberHydrationContext.js | 33 ++++++++++++++----- .../src/ReactFiberHydrationDiffs.js | 27 ++++++++++++--- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 54f007e15ab2d..c7c3d9c8746a2 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -82,7 +82,10 @@ let hydrationErrors: Array> | null = null; let rootOrSingletonContext = false; // Builds a common ancestor tree from the root down for collecting diffs. -function buildHydrationDiffNode(fiber: Fiber): HydrationDiffNode { +function buildHydrationDiffNode( + fiber: Fiber, + distanceFromLeaf: number, +): HydrationDiffNode { if (fiber.return === null) { // We're at the root. if (hydrationDiffRootDEV === null) { @@ -91,27 +94,38 @@ function buildHydrationDiffNode(fiber: Fiber): HydrationDiffNode { children: [], serverProps: undefined, serverTail: [], + distanceFromLeaf: distanceFromLeaf, }; } else if (hydrationDiffRootDEV.fiber !== fiber) { throw new Error( 'Saw multiple hydration diff roots in a pass. This is a bug in React.', ); + } else if (hydrationDiffRootDEV.distanceFromLeaf > distanceFromLeaf) { + hydrationDiffRootDEV.distanceFromLeaf = distanceFromLeaf; } return hydrationDiffRootDEV; } - const siblings = buildHydrationDiffNode(fiber.return).children; + const siblings = buildHydrationDiffNode( + fiber.return, + distanceFromLeaf + 1, + ).children; // The same node may already exist in the parent. Since we currently always render depth first // and rerender if we suspend or terminate early, if a shared ancestor was added we should still // be inside of that shared ancestor which means it was the last one to be added. If this changes // we may have to scan the whole set. if (siblings.length > 0 && siblings[siblings.length - 1].fiber === fiber) { - return siblings[siblings.length - 1]; + const existing = siblings[siblings.length - 1]; + if (existing.distanceFromLeaf > distanceFromLeaf) { + existing.distanceFromLeaf = distanceFromLeaf; + } + return existing; } const newNode: HydrationDiffNode = { fiber: fiber, children: [], serverProps: undefined, serverTail: [], + distanceFromLeaf: distanceFromLeaf, }; siblings.push(newNode); return newNode; @@ -195,7 +209,7 @@ function warnNonHydratedInstance( } // Add this fiber to the diff tree. - const diffNode = buildHydrationDiffNode(fiber); + const diffNode = buildHydrationDiffNode(fiber, 0); // We use null as a signal that there was no node to match. diffNode.serverProps = null; if (rejectedCandidate !== null) { @@ -422,7 +436,7 @@ function prepareToHydrateHostInstance( hostContext, ); if (differences !== null) { - const diffNode = buildHydrationDiffNode(fiber); + const diffNode = buildHydrationDiffNode(fiber, 0); diffNode.serverProps = differences; } } @@ -466,7 +480,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void { parentProps, ); if (difference !== null) { - const diffNode = buildHydrationDiffNode(fiber); + const diffNode = buildHydrationDiffNode(fiber, 0); diffNode.serverProps = difference; } } @@ -484,7 +498,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void { parentProps, ); if (difference !== null) { - const diffNode = buildHydrationDiffNode(fiber); + const diffNode = buildHydrationDiffNode(fiber, 0); diffNode.serverProps = difference; } } @@ -638,13 +652,14 @@ function warnIfUnhydratedTailNodes(fiber: Fiber) { if (__DEV__) { let nextInstance = nextHydratableInstance; while (nextInstance) { - const diffNode = buildHydrationDiffNode(fiber); + const diffNode = buildHydrationDiffNode(fiber, 0); const description = describeHydratableInstanceForDevWarnings(nextInstance); diffNode.serverTail.push(description); if (description.type === 'Suspense') { + const suspenseInstance: SuspenseInstance = (nextInstance: any); nextInstance = - getNextHydratableInstanceAfterSuspenseInstance(nextInstance); + getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance); } else { nextInstance = getNextHydratableSibling(nextInstance); } diff --git a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js index c9d8b86027134..85e225dc28d92 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js +++ b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js @@ -38,12 +38,28 @@ export type HydrationDiffNode = { | $ReadOnly<{type: string, props: $ReadOnly<{[propName: string]: mixed}>}> | string, >, + distanceFromLeaf: number, }; const maxRowLength = 120; +const idealDepth = 15; -function shouldCollapse(node: HydrationDiffNode): null | HydrationDiffNode { - return null; +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 { @@ -509,8 +525,11 @@ function describeSiblingFiber(fiber: Fiber, indent: number): string { } function describeNode(node: HydrationDiffNode, indent: number): string { - const skipToNode = shouldCollapse(node); - if (skipToNode !== null) { + const skipToNode = findNotableNode(node, indent); + if ( + skipToNode !== node && + (node.children.length !== 1 || node.children[0] !== skipToNode) + ) { return indentation(indent) + '...\n' + describeNode(skipToNode, indent + 1); } From a26423619a4b7cd4bb310c5ba8186ca8441fa435 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Mar 2024 22:26:13 -0500 Subject: [PATCH 7/8] Update snapshots --- .../__tests__/ReactDOMHydrationDiff-test.js | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js index c134ab973d58c..a52bb65181c90 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js @@ -94,6 +94,12 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
+ + client + - server ]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -128,6 +134,11 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+ + This markup contains an nbsp entity:   client text + - This markup contains an nbsp entity:   server text ]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -164,6 +175,16 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
client" + - __html: "server" + }} + > ", ] `); @@ -196,6 +217,15 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
", ] `); @@ -227,6 +257,16 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
", ] `); @@ -258,6 +298,16 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
", ] `); @@ -289,6 +339,16 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
", ] `); @@ -321,6 +381,14 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
", ] `); @@ -352,6 +420,10 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+ +
]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -383,6 +455,12 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+ +
+ -
+ ... ]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -414,6 +492,13 @@ describe('ReactDOMServerHydration', () => { It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. https://react.dev/link/hydration-mismatch + + +
+
+ +
+ -