Repository: facebook/react
Pull request: Add Diffs to Hydration Warnings

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).

Changed files:
filename: packages/react-dom-bindings/src/client/ReactDOMComponent.js
patch: @@ -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;

filename: packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
patch: @@ -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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main className="child">
+        +       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
+
+          <Mismatch isClient={true}>
+            <div>
+        +     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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main
+                className="child"
+                dangerouslySetInnerHTML={{
+        +         __html: "<span>client</span>"
+        -         __html: "<span>server</span>"
+                }}
+              >
         ",
         ]
       `);
@@ -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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main
+        +       className="child client"
+        -       className="child server"
+        +       dir="ltr"
+        -       dir="rtl"
+              >
         ",
         ]
       `);
@@ -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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main
+                className="child"
+        +       tabIndex={1}
+        -       tabIndex={null}
+        +       dir="ltr"
+        -       dir={null}
+              >
         ",
         ]
       `);
@@ -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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main
+                className="child"
+        +       tabIndex={null}
+        -       tabIndex="1"
+        +       dir={null}
+        -       dir="rtl"
+              >
         ",
         ]
       `);
@@ -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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main
+                className="child"
+        +       tabIndex={1}
+        -       tabIndex={null}
+        +       dir={null}
+        -       dir="rtl"
+              >
         ",
         ]
       `);
@@ -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
+
+          <Mismatch isClient={true}>
+            <div className="parent">
+              <main
+                className="child"
+        +       style={{opacity:1}}
+        -       style={{opacity:"0"}}
+              >
         ",
         ]
       `);
@@ -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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <main className="only">
           ]",
             "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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <header className="1">
+          -     <main className="2">
+                ...
           ]",
             "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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <header>
+          +     <main className="2">
+          -     <footer className="3">
+                ...
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -445,6 +530,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <header>
+                <main>
+          +     <footer className="3">
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -472,6 +563,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     only
+          -     
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -503,6 +599,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <header>
+          +     second
+          -     <footer className="3">
+                ...
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -534,6 +637,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     first
+          -     <main className="2">
+                ...
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -565,6 +674,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <header>
+                <main>
+          +     third
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -598,6 +713,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     <main className="only">
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -629,6 +748,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <main className="2">
+          -     <header className="1">
+                ...
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -660,6 +785,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <header>
+          +     <footer className="3">
+          -     <main className="2">
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -691,6 +822,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     <footer className="3">
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -718,6 +853,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     only
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -749,6 +888,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <main className="2">
+          -     first
+                ...
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -780,6 +925,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <header>
+          +     <footer className="3">
+          -     second
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -811,6 +962,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     third
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -852,6 +1007,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <Suspense fallback={<p>}>
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -885,6 +1044,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     <Suspense>
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -920,6 +1083,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <Suspense fallback={<p>}>
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -959,6 +1126,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     <Suspense>
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -991,6 +1162,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <Suspense fallback={<p>}>
+                  <header>
+          +       <main className="second">
+          -       <footer className="3">
+                  ...
           ]",
             "Caught [There was an error while hydrating this Suspense boundary. Switched to client rendering.]",
           ]
@@ -1023,6 +1202,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+                <Suspense fallback={<p>}>
+                  <header>
+          +       <footer className="3">
+          -       <main className="second">
           ]",
             "Caught [There was an error while hydrating this Suspense boundary. Switched to client rendering.]",
           ]
@@ -1112,6 +1298,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          +     <header className="1">
+                ...
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -1147,6 +1338,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
+
+            <Mismatch isClient={true}>
+              <div className="parent">
+          -     <header className="1">
+          -     <main className="2">
+          -     <footer className="3">
           ]",
             "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
           ]
@@ -1204,6 +1401,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
+
+          <Mismatch isClient={true}>
+            <ProfileSettings>
+              <div className="parent">
+                <input>
+                <Panel type="profile">
+                  <header>
+                  <main>
+        +         <footer className="3">
         ]",
           "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
         ]
@@ -1258,6 +1464,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
+
+          <Mismatch isClient={true}>
+            <ProfileSettings>
+              <div className="parent">
+        -       <footer className="3">
         ]",
           "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
         ]

filename: packages/react-reconciler/src/ReactFiberHydrationContext.js
patch: @@ -82,7 +82,10 @@ let hydrationErrors: Array<CapturedValue<mixed>> | 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;
@@ -182,7 +196,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
@@ -192,13 +209,22 @@ function warnNonHydratedInstance(fiber: Fiber) {
     }
 
     // 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) {
+      const description =
+        describeHydratableInstanceForDevWarnings(rejectedCandidate);
+      diffNode.serverTail.push(description);
+    }
   }
 }
 
-function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
+function tryHydrateInstance(
+  fiber: Fiber,
+  nextInstance: any,
+  hostContext: HostContext,
+) {
   // fiber is a HostComponent Fiber
   const instance = canHydrateInstance(
     nextInstance,
@@ -208,6 +234,22 @@ function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
   );
   if (instance !== null) {
     fiber.stateNode = (instance: Instance);
+
+    if (__DEV__) {
+      if (!didSuspendOrErrorDEV) {
+        const differences = diffHydratedPropsForDevWarnings(
+          instance,
+          fiber.type,
+          fiber.pendingProps,
+          hostContext,
+        );
+        if (differences !== null) {
+          const diffNode = buildHydrationDiffNode(fiber, 0);
+          diffNode.serverProps = differences;
+        }
+      }
+    }
+
     hydrationParentFiber = fiber;
     nextHydratableInstance = getFirstHydratableChild(instance);
     rootOrSingletonContext = false;
@@ -305,6 +347,22 @@ function claimHydratableSingleton(fiber: Fiber): void {
       currentHostContext,
       false,
     ));
+
+    if (__DEV__) {
+      if (!didSuspendOrErrorDEV) {
+        const differences = diffHydratedPropsForDevWarnings(
+          instance,
+          fiber.type,
+          fiber.pendingProps,
+          currentHostContext,
+        );
+        if (differences !== null) {
+          const diffNode = buildHydrationDiffNode(fiber, 0);
+          diffNode.serverProps = differences;
+        }
+      }
+    }
+
     hydrationParentFiber = fiber;
     rootOrSingletonContext = true;
     nextHydratableInstance = getFirstHydratableChild(instance);
@@ -325,9 +383,12 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
   );
 
   const nextInstance = nextHydratableInstance;
-  if (!nextInstance || !tryHydrateInstance(fiber, nextInstance)) {
+  if (
+    !nextInstance ||
+    !tryHydrateInstance(fiber, nextInstance, currentHostContext)
+  ) {
     if (shouldKeepWarning) {
-      warnNonHydratedInstance(fiber);
+      warnNonHydratedInstance(fiber, nextInstance);
     }
     throwOnHydrationMismatch(fiber);
   }
@@ -347,7 +408,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 +420,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
   }
   const nextInstance = nextHydratableInstance;
   if (!nextInstance || !tryHydrateSuspense(fiber, nextInstance)) {
-    warnNonHydratedInstance(fiber);
+    warnNonHydratedInstance(fiber, nextInstance);
     throwOnHydrationMismatch(fiber);
   }
 }
@@ -404,22 +465,6 @@ function prepareToHydrateHostInstance(
   }
 
   const instance: Instance = fiber.stateNode;
-  if (__DEV__) {
-    const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV;
-    if (shouldWarnIfMismatchDev) {
-      const differences = diffHydratedPropsForDevWarnings(
-        instance,
-        fiber.type,
-        fiber.memoizedProps,
-        hostContext,
-      );
-      if (differences !== null) {
-        const diffNode = buildHydrationDiffNode(fiber);
-        diffNode.serverProps = differences;
-      }
-    }
-  }
-
   const didHydrate = hydrateInstance(
     instance,
     fiber.type,
@@ -458,7 +503,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void {
               parentProps,
             );
             if (difference !== null) {
-              const diffNode = buildHydrationDiffNode(fiber);
+              const diffNode = buildHydrationDiffNode(fiber, 0);
               diffNode.serverProps = difference;
             }
           }
@@ -476,7 +521,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void {
               parentProps,
             );
             if (difference !== null) {
-              const diffNode = buildHydrationDiffNode(fiber);
+              const diffNode = buildHydrationDiffNode(fiber, 0);
               diffNode.serverProps = difference;
             }
           }
@@ -630,11 +675,17 @@ 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);
-      nextInstance = getNextHydratableSibling(nextInstance);
+      if (description.type === 'Suspense') {
+        const suspenseInstance: SuspenseInstance = (nextInstance: any);
+        nextInstance =
+          getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance);
+      } else {
+        nextInstance = getNextHydratableSibling(nextInstance);
+      }
     }
   }
 }

filename: packages/react-reconciler/src/ReactFiberHydrationDiffs.js
patch: @@ -9,6 +9,27 @@
 
 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 assign from 'shared/assign';
+import getComponentNameFromType from 'shared/getComponentNameFromType';
+
+import isArray from 'shared/isArray';
+
 export type HydrationDiffNode = {
   fiber: Fiber,
   children: Array<HydrationDiffNode>,
@@ -17,8 +38,606 @@ export type HydrationDiffNode = {
     | $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 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 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'
+  ) {
+    // 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);
+  } 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 '';
+  }
 }