@samitouri / QOS-React-1 / commits / 2ec2aaea98

Add Diffs to Hydration Warnings (#28512)

Stacked on #28502. This builds on the mechanism in #28502 by adding a diff of everything we've collected so far to the thrown error or logged error. This isn't actually a longest common subsequence diff. This means that there are certain cases that can appear confusing such as a node being added/removed when it really would've appeared later in the list. In fact once a node mismatches, we abort rendering so we don't have the context of what would've been rendered. It's not quite right to use the result of the recovery render because it can use client-only code paths using useSyncExternalStore which would yield false differences. That's why diffing the HTML isn't quite right. I also present abstract components in the stack, these are presented with the client props and no diff since we don't have the props that were on the server. The lack of difference might be confusing but it's useful for context. The main thing that's data new here is that we're adding some siblings and props for context. Examples in the [snapshot commit](https://github.com/facebook/react/pull/28512/commits/e14532fd8df858a319d3eca687d83227209a498c).

Sebastian Markbåge committed Mar 26, 2024 at 17:02 UTC 2ec2aaea98588178525f83495669e11e96815a00
4 files changed +926 -33
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+14 -2
@@ -251,7 +251,7 @@ function warnForExtraAttributes(
251 ) {
252 if (__DEV__) {
253 attributeNames.forEach(function (attributeName) {
254 - serverDifferences[attributeName] =
254 + serverDifferences[getPropNameFromAttributeName(attributeName)] =
255 attributeName === 'style'
256 ? getStylesObjectFromElement(domElement)
257 : domElement.getAttribute(attributeName);
@@ -1829,12 +1829,24 @@ function getPossibleStandardName(propName: string): string | null {
1829 return null;
1830 }
1831
1832 +function getPropNameFromAttributeName(attrName: string): string {
1833 + switch (attrName) {
1834 + case 'class':
1835 + return 'className';
1836 + case 'for':
1837 + return 'htmlFor';
1838 + // TODO: The rest of the aliases.
1839 + default:
1840 + return attrName;
1841 + }
1842 +}
1843 +
1844 export function getPropsFromElement(domElement: Element): Object {
1845 const serverDifferences: {[propName: string]: mixed} = {};
1846 const attributes = domElement.attributes;
1847 for (let i = 0; i < attributes.length; i++) {
1848 const attr = attributes[i];
1837 - serverDifferences[attr.name] =
1849 + serverDifferences[getPropNameFromAttributeName(attr.name)] =
1850 attr.name.toLowerCase() === 'style'
1851 ? getStylesObjectFromElement(domElement)
1852 : attr.value;
packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
+211
@@ -94,6 +94,12 @@ describe('ReactDOMServerHydration', () => {
94 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
95
96 https://react.dev/link/hydration-mismatch
97 +
98 + <Mismatch isClient={true}>
99 + <div className="parent">
100 + <main className="child">
101 + + client
102 + - server
103 ]",
104 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
105 ]
@@ -128,6 +134,11 @@ describe('ReactDOMServerHydration', () => {
134 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
135
136 https://react.dev/link/hydration-mismatch
137 +
138 + <Mismatch isClient={true}>
139 + <div>
140 + + This markup contains an nbsp entity:   client text
141 + - This markup contains an nbsp entity:   server text
142 ]",
143 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
144 ]
@@ -164,6 +175,16 @@ describe('ReactDOMServerHydration', () => {
175 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
176
177 https://react.dev/link/hydration-mismatch
178 +
179 + <Mismatch isClient={true}>
180 + <div className="parent">
181 + <main
182 + className="child"
183 + dangerouslySetInnerHTML={{
184 + + __html: "<span>client</span>"
185 + - __html: "<span>server</span>"
186 + }}
187 + >
188 ",
189 ]
190 `);
@@ -196,6 +217,15 @@ describe('ReactDOMServerHydration', () => {
217 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
218
219 https://react.dev/link/hydration-mismatch
220 +
221 + <Mismatch isClient={true}>
222 + <div className="parent">
223 + <main
224 + + className="child client"
225 + - className="child server"
226 + + dir="ltr"
227 + - dir="rtl"
228 + >
229 ",
230 ]
231 `);
@@ -227,6 +257,16 @@ describe('ReactDOMServerHydration', () => {
257 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
258
259 https://react.dev/link/hydration-mismatch
260 +
261 + <Mismatch isClient={true}>
262 + <div className="parent">
263 + <main
264 + className="child"
265 + + tabIndex={1}
266 + - tabIndex={null}
267 + + dir="ltr"
268 + - dir={null}
269 + >
270 ",
271 ]
272 `);
@@ -258,6 +298,16 @@ describe('ReactDOMServerHydration', () => {
298 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
299
300 https://react.dev/link/hydration-mismatch
301 +
302 + <Mismatch isClient={true}>
303 + <div className="parent">
304 + <main
305 + className="child"
306 + + tabIndex={null}
307 + - tabIndex="1"
308 + + dir={null}
309 + - dir="rtl"
310 + >
311 ",
312 ]
313 `);
@@ -289,6 +339,16 @@ describe('ReactDOMServerHydration', () => {
339 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
340
341 https://react.dev/link/hydration-mismatch
342 +
343 + <Mismatch isClient={true}>
344 + <div className="parent">
345 + <main
346 + className="child"
347 + + tabIndex={1}
348 + - tabIndex={null}
349 + + dir={null}
350 + - dir="rtl"
351 + >
352 ",
353 ]
354 `);
@@ -321,6 +381,14 @@ describe('ReactDOMServerHydration', () => {
381 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
382
383 https://react.dev/link/hydration-mismatch
384 +
385 + <Mismatch isClient={true}>
386 + <div className="parent">
387 + <main
388 + className="child"
389 + + style={{opacity:1}}
390 + - style={{opacity:"0"}}
391 + >
392 ",
393 ]
394 `);
@@ -352,6 +420,10 @@ describe('ReactDOMServerHydration', () => {
420 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
421
422 https://react.dev/link/hydration-mismatch
423 +
424 + <Mismatch isClient={true}>
425 + <div className="parent">
426 + + <main className="only">
427 ]",
428 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
429 ]
@@ -383,6 +455,12 @@ describe('ReactDOMServerHydration', () => {
455 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
456
457 https://react.dev/link/hydration-mismatch
458 +
459 + <Mismatch isClient={true}>
460 + <div className="parent">
461 + + <header className="1">
462 + - <main className="2">
463 + ...
464 ]",
465 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
466 ]
@@ -414,6 +492,13 @@ describe('ReactDOMServerHydration', () => {
492 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
493
494 https://react.dev/link/hydration-mismatch
495 +
496 + <Mismatch isClient={true}>
497 + <div className="parent">
498 + <header>
499 + + <main className="2">
500 + - <footer className="3">
501 + ...
502 ]",
503 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
504 ]
@@ -445,6 +530,12 @@ describe('ReactDOMServerHydration', () => {
530 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
531
532 https://react.dev/link/hydration-mismatch
533 +
534 + <Mismatch isClient={true}>
535 + <div className="parent">
536 + <header>
537 + <main>
538 + + <footer className="3">
539 ]",
540 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
541 ]
@@ -472,6 +563,11 @@ describe('ReactDOMServerHydration', () => {
563 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
564
565 https://react.dev/link/hydration-mismatch
566 +
567 + <Mismatch isClient={true}>
568 + <div className="parent">
569 + + only
570 + -
571 ]",
572 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
573 ]
@@ -503,6 +599,13 @@ describe('ReactDOMServerHydration', () => {
599 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
600
601 https://react.dev/link/hydration-mismatch
602 +
603 + <Mismatch isClient={true}>
604 + <div className="parent">
605 + <header>
606 + + second
607 + - <footer className="3">
608 + ...
609 ]",
610 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
611 ]
@@ -534,6 +637,12 @@ describe('ReactDOMServerHydration', () => {
637 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
638
639 https://react.dev/link/hydration-mismatch
640 +
641 + <Mismatch isClient={true}>
642 + <div className="parent">
643 + + first
644 + - <main className="2">
645 + ...
646 ]",
647 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
648 ]
@@ -565,6 +674,12 @@ describe('ReactDOMServerHydration', () => {
674 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
675
676 https://react.dev/link/hydration-mismatch
677 +
678 + <Mismatch isClient={true}>
679 + <div className="parent">
680 + <header>
681 + <main>
682 + + third
683 ]",
684 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
685 ]
@@ -598,6 +713,10 @@ describe('ReactDOMServerHydration', () => {
713 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
714
715 https://react.dev/link/hydration-mismatch
716 +
717 + <Mismatch isClient={true}>
718 + <div className="parent">
719 + - <main className="only">
720 ]",
721 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
722 ]
@@ -629,6 +748,12 @@ describe('ReactDOMServerHydration', () => {
748 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
749
750 https://react.dev/link/hydration-mismatch
751 +
752 + <Mismatch isClient={true}>
753 + <div className="parent">
754 + + <main className="2">
755 + - <header className="1">
756 + ...
757 ]",
758 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
759 ]
@@ -660,6 +785,12 @@ describe('ReactDOMServerHydration', () => {
785 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
786
787 https://react.dev/link/hydration-mismatch
788 +
789 + <Mismatch isClient={true}>
790 + <div className="parent">
791 + <header>
792 + + <footer className="3">
793 + - <main className="2">
794 ]",
795 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
796 ]
@@ -691,6 +822,10 @@ describe('ReactDOMServerHydration', () => {
822 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
823
824 https://react.dev/link/hydration-mismatch
825 +
826 + <Mismatch isClient={true}>
827 + <div className="parent">
828 + - <footer className="3">
829 ]",
830 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
831 ]
@@ -718,6 +853,10 @@ describe('ReactDOMServerHydration', () => {
853 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
854
855 https://react.dev/link/hydration-mismatch
856 +
857 + <Mismatch isClient={true}>
858 + <div className="parent">
859 + - only
860 ]",
861 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
862 ]
@@ -749,6 +888,12 @@ describe('ReactDOMServerHydration', () => {
888 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
889
890 https://react.dev/link/hydration-mismatch
891 +
892 + <Mismatch isClient={true}>
893 + <div className="parent">
894 + + <main className="2">
895 + - first
896 + ...
897 ]",
898 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
899 ]
@@ -780,6 +925,12 @@ describe('ReactDOMServerHydration', () => {
925 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
926
927 https://react.dev/link/hydration-mismatch
928 +
929 + <Mismatch isClient={true}>
930 + <div className="parent">
931 + <header>
932 + + <footer className="3">
933 + - second
934 ]",
935 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
936 ]
@@ -811,6 +962,10 @@ describe('ReactDOMServerHydration', () => {
962 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
963
964 https://react.dev/link/hydration-mismatch
965 +
966 + <Mismatch isClient={true}>
967 + <div className="parent">
968 + - third
969 ]",
970 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
971 ]
@@ -852,6 +1007,10 @@ describe('ReactDOMServerHydration', () => {
1007 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1008
1009 https://react.dev/link/hydration-mismatch
1010 +
1011 + <Mismatch isClient={true}>
1012 + <div className="parent">
1013 + + <Suspense fallback={<p>}>
1014 ]",
1015 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1016 ]
@@ -885,6 +1044,10 @@ describe('ReactDOMServerHydration', () => {
1044 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1045
1046 https://react.dev/link/hydration-mismatch
1047 +
1048 + <Mismatch isClient={true}>
1049 + <div className="parent">
1050 + - <Suspense>
1051 ]",
1052 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1053 ]
@@ -920,6 +1083,10 @@ describe('ReactDOMServerHydration', () => {
1083 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1084
1085 https://react.dev/link/hydration-mismatch
1086 +
1087 + <Mismatch isClient={true}>
1088 + <div className="parent">
1089 + + <Suspense fallback={<p>}>
1090 ]",
1091 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1092 ]
@@ -959,6 +1126,10 @@ describe('ReactDOMServerHydration', () => {
1126 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1127
1128 https://react.dev/link/hydration-mismatch
1129 +
1130 + <Mismatch isClient={true}>
1131 + <div className="parent">
1132 + - <Suspense>
1133 ]",
1134 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1135 ]
@@ -991,6 +1162,14 @@ describe('ReactDOMServerHydration', () => {
1162 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1163
1164 https://react.dev/link/hydration-mismatch
1165 +
1166 + <Mismatch isClient={true}>
1167 + <div className="parent">
1168 + <Suspense fallback={<p>}>
1169 + <header>
1170 + + <main className="second">
1171 + - <footer className="3">
1172 + ...
1173 ]",
1174 "Caught [There was an error while hydrating this Suspense boundary. Switched to client rendering.]",
1175 ]
@@ -1023,6 +1202,13 @@ describe('ReactDOMServerHydration', () => {
1202 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1203
1204 https://react.dev/link/hydration-mismatch
1205 +
1206 + <Mismatch isClient={true}>
1207 + <div className="parent">
1208 + <Suspense fallback={<p>}>
1209 + <header>
1210 + + <footer className="3">
1211 + - <main className="second">
1212 ]",
1213 "Caught [There was an error while hydrating this Suspense boundary. Switched to client rendering.]",
1214 ]
@@ -1112,6 +1298,11 @@ describe('ReactDOMServerHydration', () => {
1298 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1299
1300 https://react.dev/link/hydration-mismatch
1301 +
1302 + <Mismatch isClient={true}>
1303 + <div className="parent">
1304 + + <header className="1">
1305 + ...
1306 ]",
1307 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1308 ]
@@ -1147,6 +1338,12 @@ describe('ReactDOMServerHydration', () => {
1338 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1339
1340 https://react.dev/link/hydration-mismatch
1341 +
1342 + <Mismatch isClient={true}>
1343 + <div className="parent">
1344 + - <header className="1">
1345 + - <main className="2">
1346 + - <footer className="3">
1347 ]",
1348 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1349 ]
@@ -1204,6 +1401,15 @@ describe('ReactDOMServerHydration', () => {
1401 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1402
1403 https://react.dev/link/hydration-mismatch
1404 +
1405 + <Mismatch isClient={true}>
1406 + <ProfileSettings>
1407 + <div className="parent">
1408 + <input>
1409 + <Panel type="profile">
1410 + <header>
1411 + <main>
1412 + + <footer className="3">
1413 ]",
1414 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1415 ]
@@ -1258,6 +1464,11 @@ describe('ReactDOMServerHydration', () => {
1464 It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
1465
1466 https://react.dev/link/hydration-mismatch
1467 +
1468 + <Mismatch isClient={true}>
1469 + <ProfileSettings>
1470 + <div className="parent">
1471 + - <footer className="3">
1472 ]",
1473 "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
1474 ]
packages/react-reconciler/src/ReactFiberHydrationContext.js
+81 -30
@@ -82,7 +82,10 @@ let hydrationErrors: Array<CapturedValue<mixed>> | null = null;
82 let rootOrSingletonContext = false;
83
84 // Builds a common ancestor tree from the root down for collecting diffs.
85 -function buildHydrationDiffNode(fiber: Fiber): HydrationDiffNode {
85 +function buildHydrationDiffNode(
86 + fiber: Fiber,
87 + distanceFromLeaf: number,
88 +): HydrationDiffNode {
89 if (fiber.return === null) {
90 // We're at the root.
91 if (hydrationDiffRootDEV === null) {
@@ -91,27 +94,38 @@ function buildHydrationDiffNode(fiber: Fiber): HydrationDiffNode {
94 children: [],
95 serverProps: undefined,
96 serverTail: [],
97 + distanceFromLeaf: distanceFromLeaf,
98 };
99 } else if (hydrationDiffRootDEV.fiber !== fiber) {
100 throw new Error(
101 'Saw multiple hydration diff roots in a pass. This is a bug in React.',
102 );
103 + } else if (hydrationDiffRootDEV.distanceFromLeaf > distanceFromLeaf) {
104 + hydrationDiffRootDEV.distanceFromLeaf = distanceFromLeaf;
105 }
106 return hydrationDiffRootDEV;
107 }
102 - const siblings = buildHydrationDiffNode(fiber.return).children;
108 + const siblings = buildHydrationDiffNode(
109 + fiber.return,
110 + distanceFromLeaf + 1,
111 + ).children;
112 // The same node may already exist in the parent. Since we currently always render depth first
113 // and rerender if we suspend or terminate early, if a shared ancestor was added we should still
114 // be inside of that shared ancestor which means it was the last one to be added. If this changes
115 // we may have to scan the whole set.
116 if (siblings.length > 0 && siblings[siblings.length - 1].fiber === fiber) {
108 - return siblings[siblings.length - 1];
117 + const existing = siblings[siblings.length - 1];
118 + if (existing.distanceFromLeaf > distanceFromLeaf) {
119 + existing.distanceFromLeaf = distanceFromLeaf;
120 + }
121 + return existing;
122 }
123 const newNode: HydrationDiffNode = {
124 fiber: fiber,
125 children: [],
126 serverProps: undefined,
127 serverTail: [],
128 + distanceFromLeaf: distanceFromLeaf,
129 };
130 siblings.push(newNode);
131 return newNode;
@@ -182,7 +196,10 @@ export function errorHydratingContainer(parentContainer: Container): void {
196 }
197 }
198
185 -function warnNonHydratedInstance(fiber: Fiber) {
199 +function warnNonHydratedInstance(
200 + fiber: Fiber,
201 + rejectedCandidate: null | HydratableInstance,
202 +) {
203 if (__DEV__) {
204 if (didSuspendOrErrorDEV) {
205 // Inside a boundary that already suspended. We're currently rendering the
@@ -192,13 +209,22 @@ function warnNonHydratedInstance(fiber: Fiber) {
209 }
210
211 // Add this fiber to the diff tree.
195 - const diffNode = buildHydrationDiffNode(fiber);
212 + const diffNode = buildHydrationDiffNode(fiber, 0);
213 // We use null as a signal that there was no node to match.
214 diffNode.serverProps = null;
215 + if (rejectedCandidate !== null) {
216 + const description =
217 + describeHydratableInstanceForDevWarnings(rejectedCandidate);
218 + diffNode.serverTail.push(description);
219 + }
220 }
221 }
222
201 -function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
223 +function tryHydrateInstance(
224 + fiber: Fiber,
225 + nextInstance: any,
226 + hostContext: HostContext,
227 +) {
228 // fiber is a HostComponent Fiber
229 const instance = canHydrateInstance(
230 nextInstance,
@@ -208,6 +234,22 @@ function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
234 );
235 if (instance !== null) {
236 fiber.stateNode = (instance: Instance);
237 +
238 + if (__DEV__) {
239 + if (!didSuspendOrErrorDEV) {
240 + const differences = diffHydratedPropsForDevWarnings(
241 + instance,
242 + fiber.type,
243 + fiber.pendingProps,
244 + hostContext,
245 + );
246 + if (differences !== null) {
247 + const diffNode = buildHydrationDiffNode(fiber, 0);
248 + diffNode.serverProps = differences;
249 + }
250 + }
251 + }
252 +
253 hydrationParentFiber = fiber;
254 nextHydratableInstance = getFirstHydratableChild(instance);
255 rootOrSingletonContext = false;
@@ -305,6 +347,22 @@ function claimHydratableSingleton(fiber: Fiber): void {
347 currentHostContext,
348 false,
349 ));
350 +
351 + if (__DEV__) {
352 + if (!didSuspendOrErrorDEV) {
353 + const differences = diffHydratedPropsForDevWarnings(
354 + instance,
355 + fiber.type,
356 + fiber.pendingProps,
357 + currentHostContext,
358 + );
359 + if (differences !== null) {
360 + const diffNode = buildHydrationDiffNode(fiber, 0);
361 + diffNode.serverProps = differences;
362 + }
363 + }
364 + }
365 +
366 hydrationParentFiber = fiber;
367 rootOrSingletonContext = true;
368 nextHydratableInstance = getFirstHydratableChild(instance);
@@ -325,9 +383,12 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
383 );
384
385 const nextInstance = nextHydratableInstance;
328 - if (!nextInstance || !tryHydrateInstance(fiber, nextInstance)) {
386 + if (
387 + !nextInstance ||
388 + !tryHydrateInstance(fiber, nextInstance, currentHostContext)
389 + ) {
390 if (shouldKeepWarning) {
330 - warnNonHydratedInstance(fiber);
391 + warnNonHydratedInstance(fiber, nextInstance);
392 }
393 throwOnHydrationMismatch(fiber);
394 }
@@ -347,7 +408,7 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void {
408 const nextInstance = nextHydratableInstance;
409 if (!nextInstance || !tryHydrateText(fiber, nextInstance)) {
410 if (shouldKeepWarning) {
350 - warnNonHydratedInstance(fiber);
411 + warnNonHydratedInstance(fiber, nextInstance);
412 }
413 throwOnHydrationMismatch(fiber);
414 }
@@ -359,7 +420,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
420 }
421 const nextInstance = nextHydratableInstance;
422 if (!nextInstance || !tryHydrateSuspense(fiber, nextInstance)) {
362 - warnNonHydratedInstance(fiber);
423 + warnNonHydratedInstance(fiber, nextInstance);
424 throwOnHydrationMismatch(fiber);
425 }
426 }
@@ -404,22 +465,6 @@ function prepareToHydrateHostInstance(
465 }
466
467 const instance: Instance = fiber.stateNode;
407 - if (__DEV__) {
408 - const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV;
409 - if (shouldWarnIfMismatchDev) {
410 - const differences = diffHydratedPropsForDevWarnings(
411 - instance,
412 - fiber.type,
413 - fiber.memoizedProps,
414 - hostContext,
415 - );
416 - if (differences !== null) {
417 - const diffNode = buildHydrationDiffNode(fiber);
418 - diffNode.serverProps = differences;
419 - }
420 - }
421 - }
422 -
468 const didHydrate = hydrateInstance(
469 instance,
470 fiber.type,
@@ -458,7 +503,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void {
503 parentProps,
504 );
505 if (difference !== null) {
461 - const diffNode = buildHydrationDiffNode(fiber);
506 + const diffNode = buildHydrationDiffNode(fiber, 0);
507 diffNode.serverProps = difference;
508 }
509 }
@@ -476,7 +521,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void {
521 parentProps,
522 );
523 if (difference !== null) {
479 - const diffNode = buildHydrationDiffNode(fiber);
524 + const diffNode = buildHydrationDiffNode(fiber, 0);
525 diffNode.serverProps = difference;
526 }
527 }
@@ -630,11 +675,17 @@ function warnIfUnhydratedTailNodes(fiber: Fiber) {
675 if (__DEV__) {
676 let nextInstance = nextHydratableInstance;
677 while (nextInstance) {
633 - const diffNode = buildHydrationDiffNode(fiber);
678 + const diffNode = buildHydrationDiffNode(fiber, 0);
679 const description =
680 describeHydratableInstanceForDevWarnings(nextInstance);
681 diffNode.serverTail.push(description);
637 - nextInstance = getNextHydratableSibling(nextInstance);
682 + if (description.type === 'Suspense') {
683 + const suspenseInstance: SuspenseInstance = (nextInstance: any);
684 + nextInstance =
685 + getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance);
686 + } else {
687 + nextInstance = getNextHydratableSibling(nextInstance);
688 + }
689 }
690 }
691 }
packages/react-reconciler/src/ReactFiberHydrationDiffs.js
+620 -1
@@ -9,6 +9,27 @@
9
10 import type {Fiber} from './ReactInternalTypes';
11
12 +import {
13 + HostComponent,
14 + HostHoistable,
15 + HostSingleton,
16 + LazyComponent,
17 + SuspenseComponent,
18 + SuspenseListComponent,
19 + FunctionComponent,
20 + IndeterminateComponent,
21 + ForwardRef,
22 + SimpleMemoComponent,
23 + ClassComponent,
24 + HostText,
25 +} from './ReactWorkTags';
26 +
27 +import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
28 +import assign from 'shared/assign';
29 +import getComponentNameFromType from 'shared/getComponentNameFromType';
30 +
31 +import isArray from 'shared/isArray';
32 +
33 export type HydrationDiffNode = {
34 fiber: Fiber,
35 children: Array<HydrationDiffNode>,
@@ -17,8 +38,606 @@ export type HydrationDiffNode = {
38 | $ReadOnly<{type: string, props: $ReadOnly<{[propName: string]: mixed}>}>
39 | string,
40 >,
41 + distanceFromLeaf: number,
42 };
43
44 +const maxRowLength = 120;
45 +const idealDepth = 15;
46 +
47 +function findNotableNode(
48 + node: HydrationDiffNode,
49 + indent: number,
50 +): HydrationDiffNode {
51 + if (
52 + node.serverProps === undefined &&
53 + node.serverTail.length === 0 &&
54 + node.children.length === 1 &&
55 + node.distanceFromLeaf > 3 &&
56 + node.distanceFromLeaf > idealDepth - indent
57 + ) {
58 + // This is not an interesting node for contextual purposes so we can skip it.
59 + const child = node.children[0];
60 + return findNotableNode(child, indent);
61 + }
62 + return node;
63 +}
64 +
65 +function indentation(indent: number): string {
66 + return ' ' + ' '.repeat(indent);
67 +}
68 +
69 +function added(indent: number): string {
70 + return '+ ' + ' '.repeat(indent);
71 +}
72 +
73 +function removed(indent: number): string {
74 + return '- ' + ' '.repeat(indent);
75 +}
76 +
77 +function describeFiberType(fiber: Fiber): null | string {
78 + switch (fiber.tag) {
79 + case HostHoistable:
80 + case HostSingleton:
81 + case HostComponent:
82 + return fiber.type;
83 + case LazyComponent:
84 + return 'Lazy';
85 + case SuspenseComponent:
86 + return 'Suspense';
87 + case SuspenseListComponent:
88 + return 'SuspenseList';
89 + case FunctionComponent:
90 + case IndeterminateComponent:
91 + case SimpleMemoComponent:
92 + const fn = fiber.type;
93 + return fn.displayName || fn.name || null;
94 + case ForwardRef:
95 + const render = fiber.type.render;
96 + return render.displayName || render.name || null;
97 + case ClassComponent:
98 + const ctr = fiber.type;
99 + return ctr.displayName || ctr.name || null;
100 + default:
101 + // Skip
102 + return null;
103 + }
104 +}
105 +
106 +const needsEscaping = /["'&<>\n\t]/;
107 +
108 +function describeTextNode(content: string, maxLength: number): string {
109 + if (needsEscaping.test(content)) {
110 + const encoded = JSON.stringify(content);
111 + if (encoded.length > maxLength - 2) {
112 + if (maxLength < 8) {
113 + return '{"..."}';
114 + }
115 + return '{' + encoded.slice(0, maxLength - 7) + '..."}';
116 + }
117 + return '{' + encoded + '}';
118 + } else {
119 + if (content.length > maxLength) {
120 + if (maxLength < 5) {
121 + return '{"..."}';
122 + }
123 + return content.slice(0, maxLength - 3) + '...';
124 + }
125 + return content;
126 + }
127 +}
128 +
129 +function describeTextDiff(
130 + clientText: string,
131 + serverProps: mixed,
132 + indent: number,
133 +): string {
134 + const maxLength = maxRowLength - indent * 2;
135 + if (serverProps === null) {
136 + return added(indent) + describeTextNode(clientText, maxLength) + '\n';
137 + } else if (typeof serverProps === 'string') {
138 + let serverText: string = serverProps;
139 + let firstDiff = 0;
140 + for (
141 + ;
142 + firstDiff < serverText.length && firstDiff < clientText.length;
143 + firstDiff++
144 + ) {
145 + if (
146 + serverText.charCodeAt(firstDiff) !== clientText.charCodeAt(firstDiff)
147 + ) {
148 + break;
149 + }
150 + }
151 + if (firstDiff > maxLength - 8 && firstDiff > 10) {
152 + // The first difference between the two strings would be cut off, so cut off in
153 + // the beginning instead.
154 + clientText = '...' + clientText.slice(firstDiff - 8);
155 + serverText = '...' + serverText.slice(firstDiff - 8);
156 + }
157 + return (
158 + added(indent) +
159 + describeTextNode(clientText, maxLength) +
160 + '\n' +
161 + removed(indent) +
162 + describeTextNode(serverText, maxLength) +
163 + '\n'
164 + );
165 + } else {
166 + return indentation(indent) + describeTextNode(clientText, maxLength) + '\n';
167 + }
168 +}
169 +
170 +function objectName(object: mixed): string {
171 + // $FlowFixMe[method-unbinding]
172 + const name = Object.prototype.toString.call(object);
173 + return name.replace(/^\[object (.*)\]$/, function (m, p0) {
174 + return p0;
175 + });
176 +}
177 +
178 +function describeValue(value: mixed, maxLength: number): string {
179 + switch (typeof value) {
180 + case 'string': {
181 + const encoded = JSON.stringify(value);
182 + if (encoded.length > maxLength) {
183 + if (maxLength < 5) {
184 + return '"..."';
185 + }
186 + return encoded.slice(0, maxLength - 4) + '..."';
187 + }
188 + return encoded;
189 + }
190 + case 'object': {
191 + if (value === null) {
192 + return 'null';
193 + }
194 + if (isArray(value)) {
195 + return '[...]';
196 + }
197 + if ((value: any).$$typeof === REACT_ELEMENT_TYPE) {
198 + const type = getComponentNameFromType((value: any).type);
199 + return type ? '<' + type + '>' : '<...>';
200 + }
201 + const name = objectName(value);
202 + if (name === 'Object') {
203 + let properties = '';
204 + maxLength -= 2;
205 + for (let propName in value) {
206 + if (!value.hasOwnProperty(propName)) {
207 + continue;
208 + }
209 + const jsonPropName = JSON.stringify(propName);
210 + if (jsonPropName !== '"' + propName + '"') {
211 + propName = jsonPropName;
212 + }
213 + maxLength -= propName.length - 2;
214 + const propValue = describeValue(
215 + value[propName],
216 + maxLength < 15 ? maxLength : 15,
217 + );
218 + maxLength -= propValue.length;
219 + if (maxLength < 0) {
220 + properties += properties === '' ? '...' : ', ...';
221 + break;
222 + }
223 + properties +=
224 + (properties === '' ? '' : ',') + propName + ':' + propValue;
225 + }
226 + return '{' + properties + '}';
227 + }
228 + return name;
229 + }
230 + case 'function': {
231 + const name = (value: any).displayName || value.name;
232 + return name ? 'function ' + name : 'function';
233 + }
234 + default:
235 + // eslint-disable-next-line react-internal/safe-string-coercion
236 + return String(value);
237 + }
238 +}
239 +
240 +function describePropValue(value: mixed, maxLength: number): string {
241 + if (typeof value === 'string' && !needsEscaping.test(value)) {
242 + if (value.length > maxLength - 2) {
243 + if (maxLength < 5) {
244 + return '"..."';
245 + }
246 + return '"' + value.slice(0, maxLength - 5) + '..."';
247 + }
248 + return '"' + value + '"';
249 + }
250 + return '{' + describeValue(value, maxLength - 2) + '}';
251 +}
252 +
253 +function describeCollapsedElement(
254 + type: string,
255 + props: {[propName: string]: mixed},
256 + indent: number,
257 +): string {
258 + // This function tries to fit the props into a single line for non-essential elements.
259 + // We also ignore children because we're not going deeper.
260 +
261 + let maxLength = maxRowLength - indent * 2 - type.length - 2;
262 +
263 + let content = '';
264 +
265 + for (const propName in props) {
266 + if (!props.hasOwnProperty(propName)) {
267 + continue;
268 + }
269 + if (propName === 'children') {
270 + // Ignored.
271 + continue;
272 + }
273 + const propValue = describePropValue(props[propName], 15);
274 + maxLength -= propName.length + propValue.length + 2;
275 + if (maxLength < 0) {
276 + content += ' ...';
277 + break;
278 + }
279 + content += ' ' + propName + '=' + propValue;
280 + }
281 +
282 + return indentation(indent) + '<' + type + content + '>\n';
283 +}
284 +
285 +function describeExpandedElement(
286 + type: string,
287 + props: {+[propName: string]: mixed},
288 + rowPrefix: string,
289 +): string {
290 + // This function tries to fit the props into a single line for non-essential elements.
291 + // We also ignore children because we're not going deeper.
292 +
293 + let remainingRowLength = maxRowLength - rowPrefix.length - type.length;
294 +
295 + // We add the properties to a set so we can choose later whether we'll put it on one
296 + // line or multiple lines.
297 +
298 + const properties = [];
299 +
300 + for (const propName in props) {
301 + if (!props.hasOwnProperty(propName)) {
302 + continue;
303 + }
304 + if (propName === 'children') {
305 + // Ignored.
306 + continue;
307 + }
308 + const maxLength = maxRowLength - rowPrefix.length - propName.length - 1;
309 + const propValue = describePropValue(props[propName], maxLength);
310 + remainingRowLength -= propName.length + propValue.length + 2;
311 + properties.push(propName + '=' + propValue);
312 + }
313 +
314 + if (properties.length === 0) {
315 + return rowPrefix + '<' + type + '>\n';
316 + } else if (remainingRowLength > 0) {
317 + // We can fit all on one row.
318 + return rowPrefix + '<' + type + ' ' + properties.join(' ') + '>\n';
319 + } else {
320 + // Split into one row per property:
321 + return (
322 + rowPrefix +
323 + '<' +
324 + type +
325 + '\n' +
326 + rowPrefix +
327 + ' ' +
328 + properties.join('\n' + rowPrefix + ' ') +
329 + '\n' +
330 + rowPrefix +
331 + '>\n'
332 + );
333 + }
334 +}
335 +
336 +function describePropertiesDiff(
337 + clientObject: {+[propName: string]: mixed},
338 + serverObject: {+[propName: string]: mixed},
339 + indent: number,
340 +): string {
341 + let properties = '';
342 + const remainingServerProperties = assign({}, serverObject);
343 + for (const propName in clientObject) {
344 + if (!clientObject.hasOwnProperty(propName)) {
345 + continue;
346 + }
347 + delete remainingServerProperties[propName];
348 + const maxLength = maxRowLength - indent * 2 - propName.length - 2;
349 + const clientValue = clientObject[propName];
350 + const clientPropValue = describeValue(clientValue, maxLength);
351 + if (serverObject.hasOwnProperty(propName)) {
352 + const serverValue = serverObject[propName];
353 + const serverPropValue = describeValue(serverValue, maxLength);
354 + properties += added(indent) + propName + ': ' + clientPropValue + '\n';
355 + properties += removed(indent) + propName + ': ' + serverPropValue + '\n';
356 + } else {
357 + properties += added(indent) + propName + ': ' + clientPropValue + '\n';
358 + }
359 + }
360 + for (const propName in remainingServerProperties) {
361 + if (!remainingServerProperties.hasOwnProperty(propName)) {
362 + continue;
363 + }
364 + const maxLength = maxRowLength - indent * 2 - propName.length - 2;
365 + const serverValue = remainingServerProperties[propName];
366 + const serverPropValue = describeValue(serverValue, maxLength);
367 + properties += removed(indent) + propName + ': ' + serverPropValue + '\n';
368 + }
369 + return properties;
370 +}
371 +
372 +function describeElementDiff(
373 + type: string,
374 + clientProps: {+[propName: string]: mixed},
375 + serverProps: {+[propName: string]: mixed},
376 + indent: number,
377 +): string {
378 + let content = '';
379 +
380 + // Maps any previously unmatched lower case server prop name to its full prop name
381 + const serverPropNames: Map<string, string> = new Map();
382 +
383 + for (const propName in serverProps) {
384 + if (!serverProps.hasOwnProperty(propName)) {
385 + continue;
386 + }
387 + serverPropNames.set(propName.toLowerCase(), propName);
388 + }
389 +
390 + if (serverPropNames.size === 1 && serverPropNames.has('children')) {
391 + content += describeExpandedElement(type, clientProps, indentation(indent));
392 + } else {
393 + for (const propName in clientProps) {
394 + if (!clientProps.hasOwnProperty(propName)) {
395 + continue;
396 + }
397 + if (propName === 'children') {
398 + // Handled below.
399 + continue;
400 + }
401 + const maxLength = maxRowLength - (indent + 1) * 2 - propName.length - 1;
402 + const serverPropName = serverPropNames.get(propName.toLowerCase());
403 + if (serverPropName !== undefined) {
404 + serverPropNames.delete(propName.toLowerCase());
405 + // There's a diff here.
406 + const clientValue = clientProps[propName];
407 + const serverValue = serverProps[serverPropName];
408 + const clientPropValue = describePropValue(clientValue, maxLength);
409 + const serverPropValue = describePropValue(serverValue, maxLength);
410 + if (
411 + typeof clientValue === 'object' &&
412 + clientValue !== null &&
413 + typeof serverValue === 'object' &&
414 + serverValue !== null &&
415 + objectName(clientValue) === 'Object' &&
416 + objectName(serverValue) === 'Object' &&
417 + // Only do the diff if the object has a lot of keys or was shortened.
418 + (Object.keys(clientValue).length > 2 ||
419 + Object.keys(serverValue).length > 2 ||
420 + clientPropValue.indexOf('...') > -1 ||
421 + serverPropValue.indexOf('...') > -1)
422 + ) {
423 + // We're comparing two plain objects. We can diff the nested objects instead.
424 + content +=
425 + indentation(indent + 1) +
426 + propName +
427 + '={{\n' +
428 + describePropertiesDiff(clientValue, serverValue, indent + 2) +
429 + indentation(indent + 1) +
430 + '}}\n';
431 + } else {
432 + content +=
433 + added(indent + 1) + propName + '=' + clientPropValue + '\n';
434 + content +=
435 + removed(indent + 1) + propName + '=' + serverPropValue + '\n';
436 + }
437 + } else {
438 + // Considered equal.
439 + content +=
440 + indentation(indent + 1) +
441 + propName +
442 + '=' +
443 + describePropValue(clientProps[propName], maxLength) +
444 + '\n';
445 + }
446 + }
447 +
448 + serverPropNames.forEach(propName => {
449 + if (propName === 'children') {
450 + // Handled below.
451 + return;
452 + }
453 + const maxLength = maxRowLength - (indent + 1) * 2 - propName.length - 1;
454 + content +=
455 + removed(indent + 1) +
456 + propName +
457 + '=' +
458 + describePropValue(serverProps[propName], maxLength) +
459 + '\n';
460 + });
461 +
462 + if (content === '') {
463 + // No properties
464 + content = indentation(indent) + '<' + type + '>\n';
465 + } else {
466 + // Had properties
467 + content =
468 + indentation(indent) +
469 + '<' +
470 + type +
471 + '\n' +
472 + content +
473 + indentation(indent) +
474 + '>\n';
475 + }
476 + }
477 +
478 + const serverChildren = serverProps.children;
479 + const clientChildren = clientProps.children;
480 + if (
481 + typeof serverChildren === 'string' ||
482 + typeof serverChildren === 'number' ||
483 + typeof serverChildren === 'bigint'
484 + ) {
485 + // There's a diff of the children.
486 + // $FlowFixMe[unsafe-addition]
487 + const serverText = '' + serverChildren;
488 + let clientText = '';
489 + if (
490 + typeof clientChildren === 'string' ||
491 + typeof clientChildren === 'number' ||
492 + typeof clientChildren === 'bigint'
493 + ) {
494 + // $FlowFixMe[unsafe-addition]
495 + clientText = '' + clientChildren;
496 + }
497 + content += describeTextDiff(clientText, serverText, indent + 1);
498 + } else if (
499 + typeof clientChildren === 'string' ||
500 + typeof clientChildren === 'number' ||
501 + typeof clientChildren === 'bigint'
502 + ) {
503 + // The client has children but it's not considered a difference from the server.
504 + // $FlowFixMe[unsafe-addition]
505 + content += describeTextDiff('' + clientChildren, undefined, indent + 1);
506 + }
507 + return content;
508 +}
509 +
510 +function describeSiblingFiber(fiber: Fiber, indent: number): string {
511 + const type = describeFiberType(fiber);
512 + if (type === null) {
513 + // Skip this type of fiber. We currently treat this as a fragment
514 + // so it's just part of the parent's children.
515 + let flatContent = '';
516 + let childFiber = fiber.child;
517 + while (childFiber) {
518 + flatContent += describeSiblingFiber(childFiber, indent);
519 + childFiber = childFiber.sibling;
520 + }
521 + return flatContent;
522 + }
523 + return indentation(indent) + '<' + type + '>' + '\n';
524 +}
525 +
526 +function describeNode(node: HydrationDiffNode, indent: number): string {
527 + const skipToNode = findNotableNode(node, indent);
528 + if (
529 + skipToNode !== node &&
530 + (node.children.length !== 1 || node.children[0] !== skipToNode)
531 + ) {
532 + return indentation(indent) + '...\n' + describeNode(skipToNode, indent + 1);
533 + }
534 +
535 + // Prefix with any server components for context
536 + let parentContent = '';
537 + const debugInfo = node.fiber._debugInfo;
538 + if (debugInfo) {
539 + for (let i = 0; i < debugInfo.length; i++) {
540 + const serverComponentName = debugInfo[i].name;
541 + if (typeof serverComponentName === 'string') {
542 + parentContent +=
543 + indentation(indent) + '<' + serverComponentName + '>' + '\n';
544 + indent++;
545 + }
546 + }
547 + }
548 +
549 + // Self
550 + let selfContent = '';
551 +
552 + // We use the pending props since we might be generating a diff before the complete phase
553 + // when something throws.
554 + const clientProps = node.fiber.pendingProps;
555 +
556 + if (node.fiber.tag === HostText) {
557 + // Text Node
558 + selfContent = describeTextDiff(clientProps, node.serverProps, indent);
559 + } else {
560 + const type = describeFiberType(node.fiber);
561 + if (type !== null) {
562 + // Element Node
563 + if (node.serverProps === undefined) {
564 + // Just a reference node for context.
565 + selfContent = describeCollapsedElement(type, clientProps, indent);
566 + indent++;
567 + } else if (node.serverProps === null) {
568 + selfContent = describeExpandedElement(type, clientProps, added(indent));
569 + // If this was an insertion we won't step down further. Any tail
570 + // are considered siblings so we don't indent.
571 + // TODO: Model this a little better.
572 + } else if (typeof node.serverProps === 'string') {
573 + if (__DEV__) {
574 + console.error(
575 + 'Should not have matched a non HostText fiber to a Text node. This is a bug in React.',
576 + );
577 + }
578 + } else {
579 + selfContent = describeElementDiff(
580 + type,
581 + clientProps,
582 + node.serverProps,
583 + indent,
584 + );
585 + indent++;
586 + }
587 + }
588 + }
589 +
590 + // Compute children
591 + let childContent = '';
592 + let childFiber = node.fiber.child;
593 + let diffIdx = 0;
594 + while (childFiber && diffIdx < node.children.length) {
595 + const childNode = node.children[diffIdx];
596 + if (childNode.fiber === childFiber) {
597 + // This was a match in the diff.
598 + childContent += describeNode(childNode, indent);
599 + diffIdx++;
600 + } else {
601 + // This is an unrelated previous sibling.
602 + childContent += describeSiblingFiber(childFiber, indent);
603 + }
604 + childFiber = childFiber.sibling;
605 + }
606 +
607 + if (childFiber && node.children.length > 0) {
608 + // If we had any further siblings after the last mismatch, we can't be sure if it's
609 + // actually a valid match since it might not have found a match. So we exclude next
610 + // siblings to avoid confusion.
611 + childContent += indentation(indent) + '...' + '\n';
612 + }
613 +
614 + // Deleted tail nodes
615 + const serverTail = node.serverTail;
616 + for (let i = 0; i < serverTail.length; i++) {
617 + const tailNode = serverTail[i];
618 + if (typeof tailNode === 'string') {
619 + // Removed text node
620 + childContent +=
621 + removed(indent) +
622 + describeTextNode(tailNode, maxRowLength - indent * 2) +
623 + '\n';
624 + } else {
625 + // Removed element
626 + childContent += describeExpandedElement(
627 + tailNode.type,
628 + tailNode.props,
629 + removed(indent),
630 + );
631 + }
632 + }
633 +
634 + return parentContent + selfContent + childContent;
635 +}
636 +
637 export function describeDiff(rootNode: HydrationDiffNode): string {
23 - return '\n';
638 + try {
639 + return '\n\n' + describeNode(rootNode, 0);
640 + } catch (x) {
641 + return '';
642 + }
643 }