Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: Hydration mismatch error due to plugins generating script tag on top #24430

Open
yongdamsh opened this issue Apr 24, 2022 · 62 comments
Open

Comments

@yongdamsh
Copy link
Contributor

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.

@yongdamsh yongdamsh added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Apr 24, 2022
@gaearon
Copy link
Collaborator

gaearon commented Apr 24, 2022

Might be related to (or same as) #22833, but let's keep both open for now

@marcusthelin
Copy link

marcusthelin commented Apr 24, 2022

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.

@hrgui
Copy link

hrgui commented Apr 25, 2022

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

@gaearon
Copy link
Collaborator

gaearon commented Apr 25, 2022

I don’t think the “stricter” behavior here is intentional. I’ll be checking with the team but my current understanding is that this is a bug.

@hrgui
Copy link

hrgui commented Apr 30, 2022

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.

@gaearon
Copy link
Collaborator

gaearon commented Apr 30, 2022

Yes, that issue is #22833. I believe the fix we wanted to do is changing which top-level element we modify. (Maybe body instead of html?) It would be nice to not have to introduce a separate “host config” method (which we’d have to do if we added a call to “replace”). So ideally the fix should use the methods we already use.

@rdadoune
Copy link

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.

@adbutterfield
Copy link

adbutterfield commented Jun 3, 2022

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@next/react-dom@next, but I still get the error.

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

@Mordred
Copy link

Mordred commented Jun 15, 2022

I tried React 18.2.0, but it still breaks the page when Apollo Client DevTools extension is used. So my ugly solution for this problem is fix the DOM before hydrateRoot:

document.querySelectorAll('html > script').forEach((s) => {
  s.parentNode.removeChild(s);
});

const root = hydrateRoot(
  document,
  <React.StrictMode>
    <AppWithFullDocument />
  </React.StrictMode>,
);

Note: You can replace the selector more strict query .. e.g. 'html > script[src^=chrome-extension]'

(remix-run/remix#2947)

@dbashford
Copy link

Getting the issue with Cypress tests, as suggested in Remix Discord, my workaround for those coming here:

if (process.env.NODE_ENV === 'test') {
  require('react-dom').hydrate(<RemixBrowser />, document);
} else {
  hydrateRoot(document, <RemixBrowser />);
}

🙏 for a fix soon, thx all!

@gaearon
Copy link
Collaborator

gaearon commented Jun 22, 2022

@dbashford Which in particular issue are you hitting? We’ve released a short-term fix for the most urgent part of the problem in 18.2 (document fallback didn’t work at all). Now it should work by doing a full client render but this will lose the third party things. There is a possible fix to do something smarter but it’s a bigger project and a bit further on the timeline. So it would help to know what exactly broke for you. Is there a repro? Particular scenario? Thanks.

@dbashford
Copy link

Mine is the Cypress case. Everything works fine in dev and in prod, but Cypress when it kicks up fails (418), and only after making the hydrateRoot switch. I haven't dug into it too far to really understand the problem, but I've read in this thread and in the Remix discord that the problem may be that Cypress monkey's with the head causing the hydration issue. I admit that SSR/hydration are still voodoo/witchcraft to me, so I'm struggling a bit to work my way through it. Baby steps.

@CanRau
Copy link

CanRau commented Jun 22, 2022

Hey @Mordred thanks a lot for this quick hack, I modified it slightly to also get rid of inputs within html as the Yoroi extensions injects a hidden input directly into the <html/> (not <body/>)

document.querySelectorAll("html > script, html > input").forEach((s) => {
  s.parentNode?.removeChild(s);
});

Using this now in my Remix Deno Stack with Streaming 🥳

@dbashford
Copy link

dbashford commented Jun 22, 2022

Nevermind this, was local NODE_ENV issue (cypress issue above is still a problem, though)

Also having issues with google analytics writing script tags to the head

image

In this case /blog/gtag pulls in the tag manager snippet...

Google tag manager starts adding scripts to the head, like google-analytics, and things go sideways from there. I've got microsite where this seems to be working, and some where it isn't.

@gaearon
Copy link
Collaborator

gaearon commented Jun 22, 2022

@dbashford Can you share a minimal project? It always helps to have concrete examples to check fixes against. This goes for everyone else too.

@dbashford
Copy link

dbashford commented Jun 22, 2022

@gaearon Can call off the dogs on that latest comment, was something I introduced myself while trying to debug a real hydration issue with dates.

The Cypress issue is still a problem, that's all local and consistent. I'll work to get a repro up over the next few weeks when I get a second.

@adbutterfield
Copy link

I just updated my repo with some instructions to more easily reproduce the issue, and test that it works when not using styled components.

See here: https://github.com/adbutterfield/fast-refresh-express/tree/react-18

@camjackson
Copy link

Hi @gaearon I have a repro repo for the hydration errors with cypress. It's not exactly "minimal", it's a freshly-created Remix grunge stack app, which has quite a bit of stuff in it. Here it is: https://github.com/camjackson/remix-cypress-error.

Here are the steps to repro the error locally:

  1. git clone git@github.com:camjackson/remix-cypress-error.git
  2. cd remix-cypress-error
  3. yarn
  4. yarn test:e2e:run

The test will fail with a hydration error. To show that it's related to react 18, you can open up app/entry.client.tsx and comment/un-comment a few lines to switch it from hydrateRoot back to the old hydrate. Then run the e2es again and it will pass.

To debug it further, instead of yarn test:e2e:run you can do yarn test:e2e:dev to fire up cypress in interactive mode and see the test fail in a real browser.

Oh and one final note, if you just start the app normally with yarn dev you should see that there are no errors. It only happens with cypress, presumably because cypress injects something extra into the document.

For completeness, here's how I created this repo from scratch:
  1. yarn create remix --template remix-run/grunge-stack
  2. Go through the prompts, give it a name and cd into the newly created project
  3. yarn add react react-dom
  4. yarn add -D @types/react @types/react-dom
  5. Open up app/entry.client.tsx and migrate from react-dom's hydrate to react-dom/client's hydrateRoot

@BleedingDev
Copy link

I have replicated same behaviour - it can be replicated anywhere with hydrateRoot with any Chrome extension that injects the script. It then switches to client-side rendering.

Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.

@0Lucifer0
Copy link

0Lucifer0 commented Jun 27, 2022

it's also preventing loom (extension) to work as the extension add a to the dom. Seems like it just break any extension that update the dom and having a workaround every potential extension is going to be a nightmare

fix for loom

document.querySelectorAll('loom-container').forEach((s) => {
    s.parentNode.removeChild(s);
});

additionally this also cause warning if the extension is adding attributes like this one with colorzilla
image

@19Qingfeng

This comment was marked as off-topic.

@roggc
Copy link

roggc commented Oct 7, 2023

Hey @Mordred thanks a lot for this quick hack, I modified it slightly to also get rid of inputs within html as the Yoroi extensions injects a hidden input directly into the <html/> (not <body/>)

document.querySelectorAll("html > script, html > input").forEach((s) => {
  s.parentNode?.removeChild(s);
});

Using this now in my Remix Deno Stack with Streaming 🥳

What worked for me was:

[...document.querySelectorAll("html>*")]
  .filter((elem) => !elem.matches("head,body"))
  .forEach((s) => {
    s.parentNode?.removeChild(s);
  });

This removes anything added to html from extensions like grammarly. Also add suppressHydrationWarning={true} in the html and body tags in the case of grammarly. I faced this situation in production in an app created with npx create-rsc-app@latest my-app --ssr (it's a setup with RSC and SSR I've made myself).

@Mordred
Copy link

Mordred commented Oct 12, 2023

Today I learned

Not every script tags and style tags comes from browser extensions. Kaspersky Internet Security -> Anti-Banner feature is injecting HTML tags on the network layer (with MiTM, but that is not relevant now :D). This effectively triggers hydration errors.

For now: I'm extending my querySelectorAll to

  document
    .querySelectorAll(
      'html > *:not(body, head), script[src*="extension://"], link[href*="extension://"], script[src*="scr.kaspersky-labs.com"], link[href*="scr.kaspersky-labs.com"]',
    )
    .forEach((s) => {
      s.parentNode.removeChild(s);
    });

If this will not work I swear I will add data-my-tag to every script, link, and style tag which were generated by my server 😂

There is also another problem. React is trying to recover but it replace all HTML elements with new ones. This breaks CSS-in-JS solutions e.g. Emotion, because they already have references to old elements where they are still injecting their styles.

I'm using the MUI library in one of my projects and after the hydration error, all styles are gone.

There is an onRecoverableError but it is called after the DOM was changed by React.

Can we have a callback that will be called before React tries to recover from the hydration mismatch? (@gnoff @gaearon)

I would like to make a snapshot of DOM for better debugging of what people have injected in their browsers. Also Emotion (or another) library can be reset (clear their elements refs) before react switch to client-side rendering (An error occurred during hydration. The server HTML was replaced with client content in <#document>.).

@Danones
Copy link

Danones commented Jan 8, 2024

Hey everyone 👋🏽

I am currently facing the same issue on my project. I have created the following reproduction repo , in my case I use Remix + Vite + Tailwind.

But the hydration issues have been around long before the Vite integration. It was only with the introduction of Vite, that this was most noticeable because, on Save, and after the HMR kicks in you will see the styles being removed from the DOM.

The following solves my issue:

  • updating react and react-dom following versions:
    "react": "^18.3.0-next-fccf3a9fb-20230213",
    "react-dom": "^18.3.0-next-fccf3a9fb-20230213",

And this fixes my styling issues despite continuing to get hydration erros in the console.

  • Disabling all extensions, or even opening a page in incognito mode ( the important stuff here is having all extensions disabled). This also solves the issue but I do not think this is a feasible option.

Knowing now what I know, the root cause might be react related but I still left an issue at vite's repo and I am wondering if remix staff can do something about this in the meantime.

@giankotarola
Copy link

my hydration error got fixed with:

"react": "^18.3.0-canary-3d9b20132-20240124",
"react-dom": "^18.3.0-canary-3d9b20132-20240124",

For testing i added back the cloudflare email obfuscated script injection + enable back the chrome extensions that triggers the hydration issue for me, Apollo client dev tools / Requestly and have not seen the issue 🙌

@rcfrias
Copy link

rcfrias commented Mar 1, 2024

I cant believe this is still a problem for the past 2 years. I have LastPass installed, and even a Hello World app with ONE input tag type "text" and a placeholder "email" is causing the hydration issue. and no, suppressHydrationWarning does not fix the problem. (placed it everywhere without luck) :(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests