shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
73.49k stars 4.52k forks source link

[bug]: Theme Provider creates hydration error in Next.js 15.0.1 #5552

Open TheOrcDev opened 1 week ago

TheOrcDev commented 1 week ago

Describe the bug

Implementing dark mode, and putting ThemeProvider into the layout is making hydration error in newest version of Next.js (15.0.1).

Screenshot 2024-10-24 at 12 05 15

Affected component/components

ThemeProvider

How to reproduce

  1. Do npm install next-themes
  2. Create ThemeProvider component.
  3. Wrap children in layout file

Codesandbox/StackBlitz link

https://ui.shadcn.com/docs/dark-mode/next

Logs

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used

-className="dark"
-style={{color-scheme:"dark"}}

### System Info

```bash
Next.js 15.0.1

MacOS, Google Chrome

Before submitting

Medaillek commented 1 week ago

Hey, as it is marked on the doc, you should add suppressHydrationWarning on your html tag :

import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="en" suppressHydrationWarning>
        <head />
        <body>
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}
TheOrcDev commented 1 week ago

Hey, as it is marked on the doc, you should add suppressHydrationWarning on your html tag :

import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="en" suppressHydrationWarning>
        <head />
        <body>
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}

Thank you!

So this is not connected to Next.js 15? It's still that old problem we had with ThemeProvider?

Medaillek commented 1 week ago

Hey, as it is marked on the doc, you should add suppressHydrationWarning on your html tag :

import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="en" suppressHydrationWarning>
        <head />
        <body>
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}

Thank you!

So this is not connected to Next.js 15? It's still that old problem we had with ThemeProvider?

Here is the real fix :

'use client'

import * as React from 'react'
const NextThemesProvider = dynamic(
    () => import('next-themes').then((e) => e.ThemeProvider),
    {
        ssr: false,
    }
)

import { type ThemeProviderProps } from 'next-themes/dist/types'
import dynamic from 'next/dynamic'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
    return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

You have to dynamically import the theme provider from next-theme and you can now remove the suppressHydrationWarning from your <html> tag

TheOrcDev commented 1 week ago

Hey, as it is marked on the doc, you should add suppressHydrationWarning on your html tag :

import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="en" suppressHydrationWarning>
        <head />
        <body>
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}

Thank you! So this is not connected to Next.js 15? It's still that old problem we had with ThemeProvider?

Here is the real fix :

'use client'

import * as React from 'react'
const NextThemesProvider = dynamic(
  () => import('next-themes').then((e) => e.ThemeProvider),
  {
      ssr: false,
  }
)

import { type ThemeProviderProps } from 'next-themes/dist/types'
import dynamic from 'next/dynamic'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

You have to dynamically import the theme provider from next-theme and you can now remove the suppressHydrationWarning from your <html> tag

Amazing! That's exactly what I needed!

Thank you! You're a true web dev warrior!

muhammetakalan commented 1 week ago

This solution is effective in preventing errors during SSR, but it isn’t an "elegant" approach. Dynamically loading the next-themes library can lead to undesired outcomes, such as a "snapshot of the wrong theme" at the start.

Medaillek commented 1 week ago

This solution is effective in preventing errors during SSR, but it isn’t an "elegant" approach. Dynamically loading the next-themes library can lead to undesired outcomes, such as a "snapshot of the wrong theme" at the start.

That means that there is somewhere an issue, either with next-theme or with NextJs.

Now I think, I preffer having a "snapshot of the wrong theme" instead of suppressing all my hydration warning and troubleshouting during hours because nobody told me that i have put a <div> into an <p> in development mode.

But for the build, you're right, you could just suppress hydration warnings by using env like :

<html suppressHydrationWarning={process.env.NODE_ENV === 'production'}>
<>{children}</>
</html>

And then in your theme-provider.tsx :

'use client'

import * as React from 'react'
import dynamic from 'next/dynamic'

import { type ThemeProviderProps } from 'next-themes/dist/types'
import { ThemeProvider as StaticProvider } from 'next-themes'
const DynProvider = dynamic(
    () => import('next-themes').then((e) => e.ThemeProvider),
    {
        ssr: false,
    }
)

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
    const NextThemeProvider =
        process.env.NODE_ENV === 'production' ? StaticProvider : DynProvider
    return <NextThemeProvider {...props}>{children}</NextThemeProvider>
}
riberojuanca commented 1 week ago
'use client'

import * as React from 'react'
const NextThemesProvider = dynamic(
  () => import('next-themes').then((e) => e.ThemeProvider),
  {
      ssr: false,
  }
)

import { type ThemeProviderProps } from 'next-themes/dist/types'
import dynamic from 'next/dynamic'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

Thanks! It seems to be the correct solution in general, although I must clarify that something different has worked better for me, understanding that it slows down rendering, in small projects I have not noticed any difference. On the other hand, using dynamic with ssr: false has generated a small flash at the start that I prefer to avoid. I am quite new to this, I am probably making mistakes, so I would like to read them.

"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
import { useEffect, useState } from "react";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    setIsLoaded(true);
  }, []);

  if (!isLoaded) {
    return null;
  }

  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
abdelhamied403 commented 3 days ago
'use client'

import * as React from 'react'
const NextThemesProvider = dynamic(
    () => import('next-themes').then((e) => e.ThemeProvider),
    {
        ssr: false,
    }
)

import { type ThemeProviderProps } from 'next-themes/dist/types'
import dynamic from 'next/dynamic'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
    return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

i tried that it works fine, but when i am using next intl, with this fix, i got non sense bug. assume i have 2 languages (en, de) if i have "de" selected and refresh the page it's fine, but if i switch to "en" and switch back to "de" i only see blank white screen. this not happening if i refresh the page with the default language in my case "en". my best guess next intl is changing the html props to add language like this

const { locale } = await params;

  return (
    <html lang={locale}>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <MainProvider>{children}</MainProvider>
      </body>
    </html>
  );

and next themes is changing over it, or vise versa. that introduce this conflict, i checked the terminal and the pages was requested exactly as it supposed to no errors on console, no errors on terminal

kamyarkaviani commented 13 hours ago
if (!isLoaded) {
    return null;
  }

that's not a good solution either. That useEffect will not run until after the component is mounted and painted, meaning you will initially see a blank screen for hundred or so milliseconds before NextThemesProvider can run. It's not an ideal. The whole point of SSR is to avoid this type of rendering delay. According to next-theme's readme the suppressHydrationWarning prop on the html tag is mostly innocuous because

"This property [suppressHydrationWarning] only applies one level deep, so it won't block hydration warnings on other elements."