React 18 Bug: Hydration mismatch error due to plugins generating script tag on top

 

React version: 18.0.0, 18.1.0-next-fc47cb1b6-20220404 (latest version in codesandbox)

Steps To Reproduce

  1. Install a plugin that creates a script tag at the top(ex: Apollo Client Devtools)
  2. Go to the demo in the new SSR suspense guide
  3. Open preview in a new window
  4. UI mismatch error occurs at hydration time

스크린샷 2022-04-24 오전 11 02 34

Link to code example: https://codesandbox.io/s/kind-sammet-j56ro?file=/src/App.js

The current behavior

If a script tag is inserted before the head tag due to the user's browser environment such as a plugin, it is judged as a hydration mismatch and the screen is broken.

 2022-04-24.11.02.00.mov 

The expected behavior

This problem may be a part that each third party needs to solve, but I'm wondering if it's possible to handle an exception in the hydration matching logic of React.

Is the hydrateRoot function expecting the whole html document to be exactly what gets rendered by renderToPipeableStream? The best would be to just try to hydrate the React root element where the app is rendered?

It does seem that to use renderToPipeableStream I need to render the whole HTML document with a React component, but this is not ideal when e.g. using Vite with SSR in development, since it needs to transform the html to inject custom scripts.

rom my understanding, if anything else other than document was passed in into hydrateRoot, it doesn't seem to crash when I have chrome extensions that modify the DOM installed (e.g. Dark Reader / Apollo DevTools).

Here is the code sandbox: https://codesandbox.io/s/react-18-root-div-hydrateroot-1f5d5q?file=/src/Html.js:193-941

In the above example, I changed the following:

Html.js

export default function Html({ assets, children, title }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="shortcut icon" href="favicon.ico" />
        <link rel="stylesheet" href={assets["main.css"]} />
        <title>{title}</title>
      </head>
      <body>
        <noscript
          dangerouslySetInnerHTML={{
            __html: `<b>Enable JavaScript to run this app.</b>`
          }}
        />
+        <div id="root">{children}</div>
-       {children}
        <script
          dangerouslySetInnerHTML={{
            __html: `assetManifest = ${JSON.stringify(assets)};`
          }}
        />
      </body>
    </html>
  );
}

App.js

export default function App({ assets }) {
  return (
    <Html assets={assets} title="Hello">
+         <AppContent />
-         <Suspense fallback={<Spinner />}>
-            <ErrorBoundary FallbackComponent={Error}>
-                <Content />
-            </ErrorBoundary>
=         </Suspense>
    </Html>
  );
}

+ export function AppContent() {
+  return (
+    <Suspense fallback={<Spinner />}>
+      <ErrorBoundary FallbackComponent={Error}>
+        <Content />
+      </ErrorBoundary>
+    </Suspense>
+  );
+ }

index.js:

- hydrateRoot(document <AppContent />);
+ hydrateRoot(document.getElementById("root"), <AppContent />);

I don't know if this crashes with Cypress though. The app doesn't seem to crash under cypress.

Cypress was adding

function $RC(a,b) {...} 

to the document. I'd assume it would crash if I hydrated the document.

 

Something I've noticed is that when React does encounter a hydration mismatch, it attempts to fallback to client side rendering.

Which does show up in the example codesandbox (the one where we are hydrating the document):

Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.
Uncaught Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

However, it results in an application crash because of the next error:

 Failed to execute 'appendChild' on 'Node': Only one element on document allowed.

which is from the call stack:
appendChildToContainer <- insertOrAppendPlacementNodeIntoContainer (3) <- commitPlacement <- commitMutationEffectsOnFiber <- commitMutationEffects_complete <- commitMutationEffects_begin <- commitMutationEffects <- commitRootImpl

Then later another error is thrown:

Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

which is from the call stack:
removeChildFromContainer <- unmountHostComponents <- commitDeletion <- commitMutationEffects_begin <- commitMutationEffects <- commitRootImpl <- commitRoot <- performSyncWorkOnRoot <- flushSyncCallbacks <- commitRootImpl


What I am wondering about is: In the case of falling back to client side rendering, why does React do appendChild instead of replaceChild? If it was replaceChild, there wouldn't be an app crash in this case, but at the cost of needing to fall back to client side rendering.

 

Not an ideal solution but in my use case, I'm only concerned with generating/modifying the head during SSR, and the following hack allows me to work around errors that occur as the result of modifications to the head outside of React by Cypress, various chrome plugins, etc.

const Head: React.FC = () => {

  if (globalThis.document?.head) {
    return (
      <head dangerouslySetInnerHTML={{ __html: document.head.innerHTML }} />
    );
  }

  return (
    <head>
      {/* ... Do important things on the server */}
    </head>
  );
};

This is especially useful for me because even with current fixes added to react-dom@next that allow the client to "recover", doing so wipes out all styles generated by @emotion that have been collected into the head.

I think I have the same error. But mine is from using styled-components. Initially, they put a style tag in the body, but then the style tag gets moved up to the head. You can check this repo here: https://github.com/adbutterfield/fast-refresh-express/tree/react-18

I tried just now using react/react-dom built from main, thinking the change #24523 maybe would fix the issue (I think it's been merged into main?), but I still get the error.

Of course, it's always possible that I'm doing something stupid in my code...