Search code examples
javascriptreactjsstyled-componentsvercelremix.run

Remix Hydration failed: UI on server and client do not match


It's fine locally (known warning and CSS renders well), but on Vercel my Remix app gets this error:

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

Business logic runs fine but CSS is utterly broken.

Update 26 June 2022, 15:50

I started a new project from scratch and added dependencies one by one and deploying to Vercel at each step. No errors. Styled components render well. So the dependencies are not the problem.

I then started fetching data piece by piece from my database through the loader and rendering them in styled components one by one. The one thing that consistently breaks the CSS and produces the error is the conversion of a datetime object to a string before rendering:

const DateTimeSpan = styled.span`
  font-size: 1rem;
`;

const hr = now.getHours();
const min = now.getMinutes();

<DateTimeSpan>
  {`${hr}:${min}`}
</DateTimeSpan>

Curiously, it's only when I format it to render only time that it breaks. With date, it's fine:

const yr = now.getFullYear();
const mth = now.getMonth();
const dd = now.getDate();

<DateTimeSpan>
  {`${yr}-${mth}-${dd}`}
<DateTimeSpan>

I'm at a loss to explain this.

Update 2 July 2022, 21:55

Using the same simplest project above, friend and I have determined that CSS with styled components breaks when we try to render hours, i.e.:

const hr = now.getHours();

<DateTimeSpan>
  {hr}
</DateTimeSpan>

Our suspicion is styled components breaks because hours is rendered in UTC time on the server but locale time on the client.

I'm not sure if this is a bug or if we're supposed to handle this ourselves. Also not sure if this should be asked on Remix or Styled components GitHub issues. I've opened an issue on Remix as well anyway for a start.

Original post

Not sure but could be related to these issues:

I read through the above and a few other pages and all I could think to do was update some dependencies. Here are the possibly relevant ones:

{
"react": "^18.2.0",
"styled-components": "^5.3.5"
"@remix-run/node": "^1.6.1",
"@remix-run/react": "^1.6.1",
"@remix-run/vercel": "^1.6.1",
"@vercel/node": "^2.2.0",
}

My main suspicion is it has to do with styled-components since I've had similar issues on Nextjs before. But my app/root.tsx and app/entry.server.tsx follow this example for styled-components very closely:

// app/root.tsx

export default function App() {
  const data = useLoaderData();

  return (
    <Html lang="en">
      <head>
        ...
        {typeof document === "undefined" ? "__STYLES__" : null}
      </head>
      <Body>
        ...
      </Body>
    </Html>
  );
}
//app/entry.server.tsx

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const sheet = new ServerStyleSheet();

  let markup = renderToString(
    sheet.collectStyles(
      <RemixServer context={remixContext} url={request.url} />
    )
  );
  const styles = sheet.getStyleTags();
  markup = markup.replace("__STYLES__", styles);
  responseHeaders.set("Content-Type", "text/html");

  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: responseHeaders,
  });
}

The biggest difference with the example seems to be that instead of using hydrate, I use hydrateRoot as we should for React 18. Not sure if it has any bearing on the problem:

// app/entry.client.tsx

import { RemixBrowser } from "@remix-run/react";
import { hydrateRoot } from "react-dom/client";

hydrateRoot(document, <RemixBrowser />);

The Remix docs on CSS-in-JS libraries says: "You may run into hydration warnings when using Styled Components. Hopefully this issue will be fixed soon." The issue hasn't been resolved so maybe this problem doesn't have a solution yet.

But if the example repo works, then maybe I missed something?


Solution

  • Yes, rendering hours in particular is the issue, since server time is in UTC and client time is whatever the locale time is (UTC + X hours). This causes UI to be different on both.

    One quick way to check this out is by setting the timezone for our current CLI instance to UTC before running the app and trying out its pages:

    export TZ=UTC
    
    npm run dev
    

    We'll see that the CSS breaks as described in the problem above.

    There're several ways to fix this, specific to different use cases. One is to not send a datetime object. Instead, send it as a string. For instance:

    const now: Date = new Date()
    
    // Locale time as example only, we need to know client's locale time
    const time: string = now.toLocaleTimeString([], {
                   hour: "2-digit",
                   minute: "2-digit",
                 })
    
    // Send time string to client.
    

    This assumes that we already know the client's time zone so we can use that to set/format time on the server.

    A more flexible way is to set the time only after the page has mounted. For instance:

    const [now, setNow] = useState<Date>();
    const loaderData = useLoaderData<string>();
    
    useEffect(() => {
      if (!loaderData) {
        return;
      }
    
      setNow(JSON.parse(loaderData));
    }, [loaderData]);
    
    return <>{now.toLocaleTimeString([], {
                hour: "2-digit",
                minute: "2-digit",
              })}</>
    

    With this solution, we lose some of the benefit of SSR. This has implications. For instance, we'll need to pay special attention to SEO (view page source, we won't see the date properly rendered). Robots won't be able to index the app properly if we don't.