cypress icon indicating copy to clipboard operation
cypress copied to clipboard

Hydration errors in Cypress when using React 18, Next, and server components

Open liambutler opened this issue 1 year ago • 30 comments

Current behavior

I’ve got a Next app running React 18. In the next.config.js, it has been configured with appDir to support Server Components. When running Cypress and accessing the page, I’m getting the following React errors:

  • (uncaught exception)Error: Hydration failed because the initial UI does not match what was rendered on the server. Warning: Expected server HTML to contain a matching <script> in <head>. See more info here: https://nextjs.org/docs/messages/react-hydration
  • 
(uncaught exception)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, I do not see these hydration issues when accessing the page via browser, so it’s surprising to see them when running Cypress.

Steps to replicate:

  1. Clone repo and run npm install
  2. Run npm run e2e
  3. Visit http://localhost:3000 in your browser and check for console errors
  4. Run the spec app.cy.ts

Here's what I see in Cypress: Screenshot 2023-07-05 at 16 14 00

It's not there when visiting the app via browser: Screenshot 2023-07-05 at 16 21 08

The error will no longer appear if you remove the

Seeing as this script tag isn't causing errors when accessing via the browser, I can only conclude that it's being caused by how Cypress is loading this page when React Server Components are present

The error occurs both when the app is running in dev mode, and when you visit the application in production mode after running build and start. In production mode, the errors are minified:

  • (uncaught exception)Error: Minified React error #418; visit https://reactjs.org/docs/error-decoder.html?invariant=418
  • (uncaught exception)Error: Minified React error #423; visit https://reactjs.org/docs/error-decoder.html?invariant=423

I’ve seen this issue raised in a few places. The suggestion I usually see is to prevent the tests from failing due to this uncaught exception (eg here), or to catch the issue in the application (Cypress docs)

As far as I have managed to understand, there should never be a hydration mismatch in a server rendered component since there is no client rendering to match it to. And when accessing the app via the browser, there are no mismatch errors. So I can only conclude that the issue stems from how Cypress is loading the page.

Desired behavior

Cypress should not be causing uncaught exceptions. Any exceptions that occur should also be triggered under the same conditions via browser

Test code to reproduce

https://github.com/liambutler/cypress-react-next-hydration-issue

Cypress Version

12.16.0

Node version

18.16.0

Operating System

MacOS 13.4.1

Debug Logs

No response

Other

Edit 8th November 2023- since updating Next.js from 13.4.2 to 13.5.6, we are also seeing this uncaught exception:

  • Minified React error 329; visit https://reactjs.org/docs/error-decoder.html?invariant=329 ("Unknown root exit status.")

This also only appears when Cypress is being injected into the browser

liambutler avatar Jul 05 '23 14:07 liambutler

Hi Liam 👋 - based on the error and the fact that it happens based on simply including a script tag in the server rendered <head>, I have a bit of a guess about what's going on.

The key factor may be that Cypress adds a script tag to the <head> of pages that are visited through Cypress. The Cypress <script> sets up window.Cypress and some other critical stuff, and is injected at the top of the <head> before the page is served. It's important that it be there on initial load since other code further down the page may rely on this global being present.

The <script> tag from your RSC is at the bottom of the head. I suspect that what is happening during hydration is that, since the validator knows to expect a <script> tag, it is parsing the head and finding the one injected by Cypress, and attempting to diff that against the <script></script> from your JSX.

I don't have a workaround at the moment besides ignoring hydration errors, which as you've said it not ideal, they are still there.

I'm assuming the use case here is going to be pretty common - you want to write some inline JS in your <head> so that it is active immediately on page load in your Next app?

marktnoonan avatar Jul 05 '23 17:07 marktnoonan

Hi Mark! Thanks for the reply. Liam and I work together, and I might be able to provide some more context. Your explanation makes total sense. We also observe the problem when adding an empty <style> tag to <head>. From what you've said, it seems like the root cause is the same, i.e., Cypress's looking for its injected <script> tag and finding something of ours instead.

We indeed want an inline JS to execute as soon as the page is loaded. For context, that inline JS will test the browser against a series of modern JS commands, immediately redirecting (location.replace) the session to a simple "please upgrade your browser" page if such commands are not supported. That provides the best experience since we don't need to wait for all the JS to load, potentially breaking the session if the user is running on an old browser.

The <style> tag I mentioned is used to globally set the style of some elements. It is needed because the setup we have to produce our CSS design tokens strips away some properties that we then enforce back via <style dangerouslySetInnerHTML={{ __html: minifiedCss }} />

jcmonteiro avatar Jul 06 '23 07:07 jcmonteiro

Thanks @jcmonteiro

Cypress's looking for its injected

It's actually the other way around, Cypress doesn't care about any of this, but my guess is, where React is expecting the innerHtml of <head> to be the same on the client and server, it's not able to ignore the <script> tag added by Cypress. It's not clear to me why Next doesn't throw these errors always, why it only happens when either <style> or <script> is present as you've noticed. But maybe there are some rules/shortcuts for what it checks based on the contents of the JSX.

Cypress adds the script before the HTML is sent over the wire to the browser, in between the application server and the client, so there is no way for the Cypress JS to not be in the HTML sent to the client and evaluated during hydration.

It's similar to the problems that would happen if you are trying to do something like personalize a page by rewriting the HTML on the edge, after it has been rendered on a server. There's a long blog post from Netlify about the hoops that need to be jumped through to make hydration happy in that situation. Probably any tool that modifies the page contents after server-rendering through some middleware is going to have problems like this to solve with RSC.

I can think of a few workarounds, but I'm not too familiar with React and Next so they might not be ideal. Maybe this gives you enough info to work around it on your own, or to feel comfortable ignoring these errors for the time being. I'll flag this for the team as well so we can see if Cypress can avoid triggering this in the first place.

marktnoonan avatar Jul 07 '23 13:07 marktnoonan

Hi Liam and Joāo, Thank you so much for the reproduction case - it was very helpful in troubleshooting. I think I have a workaround for you. Can you try to wrap the style and script elements in a React.Suspense component?

I tested this in the reproduction case along with some small edits to more closely fit your use case, and it seems to no longer throw the hydration error when running in Cypress.

<Suspense>
  <script dangerouslySetInnerHTML={{ __html : `if(window.location.pathname === '/') { window.location.replace('http://localhost:3000/redirected') }` }}></script>
  <style dangerouslySetInnerHTML={{ __html: `body { color: #676 }`}} ></style>
</Suspense>

Please let us know if that helps!

cacieprins avatar Jul 07 '23 14:07 cacieprins

Hi Cacie, you are welcome and thank you for the reply.

Although the <Suspense> can help, I don't see it as a viable option for us. The reason is that our strategy for putting the "browser validation" <script> in the top-level <head> is that it can run before any JS is loaded (including the one that resolves the <Suspense>). As for the <style> element, I fear that wrapping it in a <Suspense> might lead to unforeseen issues in a larger application.

jcmonteiro avatar Jul 10 '23 09:07 jcmonteiro

I was worried about that too, so I did some digging. It turns out that Suspense contents do get rendered by SSR, so that code will execute before your main application code. You can see this come over the wire in network dev tools as a pair of comments.

The only other path forward I can see for this issue is configuring your build system inject the desired <script> and <style> tags outside of the react tree, similar to the other build artifacts. This would prevent hydration from trying to associate a React script component with the scripts that cypress injects. I'm not familiar enough with next.js to aid with this, however.

cacieprins avatar Jul 10 '23 13:07 cacieprins

We will likely continue to catch the warnings and discard them in our Cypress tests, but it's not ideal since we might end up discarding true hydration errors. Could you keep this issue open so the community (ourselves included) can track its progress?

jcmonteiro avatar Jul 10 '23 14:07 jcmonteiro

I'm experiencing the 418 and 423 hydration errors with Cypress tests (currently suspecting usage of the cy.window() API via gatsby-cypress) as well also with Gatsby and a new canary version of React (18.3.0-canary-7118f5dd7-20230705)

Next.js uses canary React internally, which may be one of the reasons that it is visible there.

But if this indicates a general hydration problem with React 18.3.0, then Cypress may have problems with many more applications when a non-canary version of React 18.3.0 is released.

References:

  • Other software like browser extensions are also having issues over here https://github.com/facebook/react/issues/24430

karlhorky avatar Jul 21 '23 16:07 karlhorky

Workaround

In case you're wondering how exactly to disable the uncaught exception errors in Cypress as Liam and João mentioned:

@liambutler in PR description: prevent the tests from failing due to this uncaught exception (eg here),

@jcmonteiro in comment 1629028523: We will likely continue to catch the warnings and discard them in our Cypress tests

...here's an example which was working for us (add block 1 before the test code that causes the failure, add block 2 after the test code that causes the failure):

// 1. Disable Cypress uncaught exception failures from React hydration errors
Cypress.on('uncaught:exception', (err) => {
  if (
    err.message.includes('Minified React error #418') ||
    err.message.includes('Error: Minified React error #423')
  ) {
    return false;
  }
  // Enable uncaught exception failures for other errors
});

// 2. Re-enable Cypress uncaught exception failures from React hydration errors
Cypress.on('uncaught:exception', () => {});

karlhorky avatar Jul 21 '23 17:07 karlhorky

Happends also if you put in the <head> tag a stylesheet link <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap" rel="stylesheet"/>

minimit avatar Aug 07 '23 08:08 minimit

@cacieprins would it be possible to remove the 'existing workaround' tag from this ticket? I believe that the exceptions are causing issues for my tests despite the Cypress.on('uncaught:exception' catch detailed above

liambutler avatar Aug 21 '23 15:08 liambutler

I will also add that the workaround interferes with the tests in a way that errors might go unnoticed. Thus, I also advocate for removing the existing workaround tag since it undermines the purpose of using Cypress.

jcmonteiro avatar Aug 22 '23 14:08 jcmonteiro

I've been interpreting the "existing workaround" tag as referring to the use of <Suspense> mentioned here and other options around building mentioned here.

I don't see squashing the errors as a workaround - that's just ignoring errors - but I do see structuring your application in a way that it won't yell about injecting JS needed for testing as a workaround, which while inconvenient might not be too painful.

I'm interested in the reasons people might not find those options acceptable/feasible - and if you have tried any of them before choosing to ignore the error. Eg if somebody has tried the <Suspense> solution, did you find it didn't solve the problem, or it solved the problem but introduced unwanted side effects?

marktnoonan avatar Aug 24 '23 20:08 marktnoonan

In our use case, the issue with the <Suspense> is the one I mentioned previously.

The reason is that our strategy for putting the "browser validation"

This is just our use case. In any case, adding the <Suspense> changes the execution flow substantially, so I would recommend against it, given the wide variety of use cases that are difficult to foresee.

jcmonteiro avatar Aug 27 '23 10:08 jcmonteiro

Thanks @jcmonteiro - for clarity, what I am interested in is: have you verified that it does or would in fact cause a problem, given our understanding the <Suspense> is resolved during SSR and comes over the wire with the initial payload HTML, causing no need to wait for JS to load on the client to deliver the contents?

I'm not saying that you should accept/implement the workarounds, it's valid to be cautious. But at the moment I can't tell for sure which of these we are dealing with:

Option 1: <Suspense> works, but is a workaround that you find unacceptable due to an abundance of caution around possible unexpected side effects later (I think this is where we are)

Option 2: <Suspense> doesn't solve the problem because the information in this comment is incorrect and SSR is not resolving the contents on the server in your case

@cacieprins also mentioned "configuring your build system inject the desired <script> and <style> tags outside of the react tree, similar to the other build artifacts" so I'd have a similar question about that option: is this something that you tried and it didn't work, or didn't try (for whatever reason)?

marktnoonan avatar Aug 29 '23 02:08 marktnoonan

Hi @marktnoonan

You're right in that we're at option 1. I've wrapped the scripts in <Suspense> and no longer see the exceptions. I've also tested the application on older browsers to ensure that the compatibility check script still works (see this earlier comment from @jcmonteiro ), and all still works as expected.

However, I'm getting a lot of pushback from the team around making a change like this to our layout.tsx, and I can see their point. Should we be making fundamental changes to our application in order to have a workaround for an issue caused by our choice of e2e tool? It's not an easy sell. (I haven't tried @cacieprins' suggestion, but the pushback would be much the same)

liambutler avatar Sep 06 '23 09:09 liambutler

Should we be making fundamental changes to our application in order to have a workaround for an issue caused by our choice of e2e tool? It's not an easy sell.

Yeah, I would guess this is enough for many teams to switch from Cypress to Playwright. I know that I would, if I was in that situation.

karlhorky avatar Sep 06 '23 11:09 karlhorky

Hey @liambutler ... @marktnoonan brought this to my attention and I am recommending and testing out an approach where we automatically cleanup our injections before any of your react code runs, which may actually fix this issue and not require any changes on your side. We're going to put together a POC and may want ya'll to try out a development version of this fix to confirm it works.

This is a very simple example:

<html>
  <head>
    <script type="text/javascript">window.foo = {}; document.currentScript.remove()</script>

    <script type="text/javascript">console.log(window.foo)</script>
  </head>
</html>

brian-mann avatar Sep 08 '23 14:09 brian-mann

Hi @brian-mann, thanks for looking into this!

Happy to help test any fix builds you want to try out. Let me know

liambutler avatar Sep 11 '23 10:09 liambutler

Happends also if you put in the <head> tag a stylesheet link <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap" rel="stylesheet"/>

Hi @minimit In my case, I had the same problem when using the following.

<head>
  <link
    rel="stylesheet"
    as="style"
    crossOrigin="anonymous"
    href="https://cdnjs.cloudflare.com/ajax/libs/pretendard/1.3.8/static/pretendard.css"
  />
</head>

Changing the rel attribute to preload solved the problem.

<head>
  <link
    rel="preload"
    as="style"
    crossOrigin="anonymous"
    href="https://cdnjs.cloudflare.com/ajax/libs/pretendard/1.3.8/static/pretendard.css"
  />
</head>

Hope this works for you !

0420syj avatar Sep 12 '23 00:09 0420syj

Hi folks, I've confirmed that the modification suggested by @brian-mann to have Cypress clean up the script element as the last step in its execution does not solve this issue, though I think it will be part of the final solution.

Reading through the thread in the React repo that @karlhorky linked to (https://github.com/facebook/react/issues/24430) was interesting, in that people reported hydration errors with the likes of Loom and other browser extensions that modify the DOM, not just when testing with Cypress.

This makes sense based on the the nature of the problem. And it seems like the React team is aware of these consequences of stricter hydration rules and has worked to mitigate them in previous releases. I'm curious what will happen when 18.3.0 is stable. One thing we can test is a recent canary build that seems to fix a similar hydration problem for one person here: https://github.com/remix-run/remix/issues/4822#issuecomment-1699577771

To summarize: We will look into the long term way for Cypress to handle this kind of error, or to at least confirm that it is handled upstream in React.

The "existing workaround" label doesn't mean that we won't work on it, just that there are ways to become unblocked in the meantime and allow your tests to run.

marktnoonan avatar Sep 12 '23 14:09 marktnoonan

Tested example repo with latest 18.3.0-canary-9ba1bbd65-20230922, hydration error still present.

dkasper-was-taken avatar Sep 25 '23 17:09 dkasper-was-taken

Hitting this problem sometimes. React should respect an element's attribute something like react-ignore-when-hydrating=true in this case.

bahmutov avatar Dec 04 '23 20:12 bahmutov

Workaround

Cypress.on("uncaught:exception", (err) => {
  // Cypress and React Hydrating the document don't get along
  // for some unknown reason. Hopefully, we figure out why eventually
  // so we can remove this.
  if (
    /hydrat/i.test(err.message) ||
    /Minified React error #418/.test(err.message) ||
    /Minified React error #423/.test(err.message)
  ) {
    return false;
  }
});

One important note regarding this, it should be in the e2e.ts in the cypress/support directory. Otherwise it won't work

vahidvdn avatar Jan 16 '24 17:01 vahidvdn

Hi folks, I've confirmed that the modification suggested by @brian-mann to have Cypress clean up the script element as the last step in its execution does not solve this issue, though I think it will be part of the final solution.

Reading through the thread in the React repo that @karlhorky linked to (facebook/react#24430) was interesting, in that people reported hydration errors with the likes of Loom and other browser extensions that modify the DOM, not just when testing with Cypress. . . .

In that thread they also discuss the workaround of modifying the code so that the <head> is not hydrated, only the <body>. This works rather well for me. See the example in this comment: https://github.com/facebook/react/issues/24430#issuecomment-1108058368

n-l-i avatar Jan 24 '24 21:01 n-l-i

Workaround

Cypress.on("uncaught:exception", (err) => {
  // Cypress and React Hydrating the document don't get along
  // for some unknown reason. Hopefully, we figure out why eventually
  // so we can remove this.
  if (
    /hydrat/i.test(err.message) ||
    /Minified React error #418/.test(err.message) ||
    /Minified React error #423/.test(err.message)
  ) {
    return false;
  }
});

One important note regarding this, it should be in the e2e.ts in the cypress/support directory. Otherwise it won't work

This was the simplest solution for me (removing any stylesheet etc... in the head didn't fix the error). (Using remix with tailwind). One thing to note is that all my tests were failing due to this error, it wasn't just an error that could be ignored (which may not be obvious given the above). I'll use the workaround until the react team have a more permanent solution. Thx

BKG01 avatar Feb 05 '24 14:02 BKG01

Hi Liam 👋 - based on the error and the fact that it happens based on simply including a script tag in the server rendered <head>, I have a bit of a guess about what's going on.

The key factor may be that Cypress adds a script tag to the <head> of pages that are visited through Cypress. The Cypress <script> sets up window.Cypress and some other critical stuff, and is injected at the top of the <head> before the page is served. It's important that it be there on initial load since other code further down the page may rely on this global being present.

The <script> tag from your RSC is at the bottom of the head. I suspect that what is happening during hydration is that, since the validator knows to expect a <script> tag, it is parsing the head and finding the one injected by Cypress, and attempting to diff that against the <script></script> from your JSX.

I don't have a workaround at the moment besides ignoring hydration errors, which as you've said it not ideal, they are still there.

I'm assuming the use case here is going to be pretty common - you want to write some inline JS in your <head> so that it is active immediately on page load in your Next app?

I have to remember this one, when somthing is only happenning on certain modern browser, I just diabled the extensions, then it worked!

RandallRock123 avatar Apr 03 '24 02:04 RandallRock123

Hello everyone, I had the same problem and I solved it with the following example:

Next JS version: 14.2.3

/context/StyledComponentsRegistry/index.tsx

"use client";

import { useServerInsertedHTML } from "next/navigation";

export function StyledComponentsRegistry({
  children,
  dynamicStyle,
}: {
  children: React.ReactNode;
  dynamicStyle: string;
}) {
  useServerInsertedHTML(() => {
    return <style>{dynamicStyle}</style>;
  });

  if (typeof window !== "undefined") return <>{children}</>;

  return <>{children}</>;
}

layout.tsx

import { StyledComponentsRegistry } from "@/context/StyledComponentsRegistry";


export default function RootLayout({ children }: Props) {
  const dynamicStyle = `:root {--bg-brand-sel: #fff;`;

  return (
    <html >
      <body>
        <StyledComponentsRegistry dynamicStyle={dynamicStyle}>
            {children}
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

nelsondev19 avatar Apr 28 '24 23:04 nelsondev19

In my case, using experimentalStudio: true in my e2e config was the issue.

defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    experimentalStudio: true,  // <--- Here
  }
 })

Removing it solved my hydration issues.

yukulelix avatar May 07 '24 02:05 yukulelix

Even applying the solutions mentioned here didn't solve it. When running Cypress, the website doesn't renerise everything and the Login option doesn't appear.

douglasgf avatar May 08 '24 11:05 douglasgf