Skip to content

CSS Modules break selective hydration in React 18 & 19 causing FOUC #77239

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

Open
jantimon opened this issue Mar 18, 2025 · 2 comments
Open

CSS Modules break selective hydration in React 18 & 19 causing FOUC #77239

jantimon opened this issue Mar 18, 2025 · 2 comments
Labels
CSS Related to CSS. Lazy Loading Related to Next.js Lazy Loading (e.g., next/dynamic or React.lazy). linear: next Confirmed issue that is tracked by the Next.js team. Pages Router Related to Pages Router. React Related to React.

Comments

@jantimon
Copy link
Contributor

jantimon commented Mar 18, 2025

Link to the code that reproduces this issue

Pages Router: https://stackblitz.com/edit/stackblitz-starters-h9tuzhzs

App Router: https://stackblitz.com/edit/stackblitz-starters-u1jswo3f

⨯ [Error [InvariantError]: Invariant: Expected workStore to exist when handling searchParams in a client Page. This is a bug in Next.js.] { digest: '3850182586' }

To Reproduce

  1. Open the StackBlitz project:
    1.1 (pages router) Stackblitz
    1.2. (app router) Stackblitz
  2. Look at the footer
    2.1 (pages router) See that the Footer beeing NOT red for 5 seconds
    2.2 (app router) See that the Footer is NOT using the SSR but rendering the fallback
  3. After 5 seconds (hydration), the Footer turns red:
LazyHydrationCss.mp4

The key elements of the reproduction:

A main page that lazily loads a footer component inside Suspense and uses CSS Modules:

// pages/index.tsx
import React, { useState, Suspense } from 'react';

export default function Home() {
  return (
    <h1>
      Lazy Load Hydration Demo
      <Suspense fallback={<div>Loading...</div>}>
        <Footer />
      </Suspense>
    </h1>
  );
}

// Less important footer component with 5-second simulated delay
const Footer = React.lazy(async () => {
  // Simulated delay
  await sleep(5000); 
  const { Footer } = await import('../components/footer');
  return { default: Footer };
});
  1. A footer component using CSS Modules:
// components/footer.tsx
import { useState, useEffect } from 'react';
import * as styles from "./footer.module.css";

export const Footer = () => {
  const [isHydrated, setIsHydrated] = useState(false);
  
  useEffect(() => {
    setIsHydrated(true);
  }, []);

  return (
    <div className={styles.footer}>
      {isHydrated ? '💦' : '🌵'}
      Footer
    </div>
  );
};
  1. Simple CSS Module:
/* components/footer.module.css */
.footer {
  color: red;
}

Current vs. Expected behavior

React 18 introduced selective hydration, a powerful feature that improves performance by prioritizing the hydration of critical UI elements. However, when using this feature with CSS Modules in Next.js Pages Router, it causes a Flash of Unstyled Content (FOUC).

The issue arises because Next.js does not recognize that a lazy-loaded component was rendered during SSR and therefore loads the associated CSS lazily as well. While the component's HTML is correctly rendered server-side and the class name is applied, the CSS is not loaded until the component hydrates on the client, causing a visual flash.

Current Behavior

  • During initial page load, the server pre-renders the entire page (including the footer)
  • The footer component is correctly server-rendered with the proper class name
  • However, the CSS Module styling is not included in the initial CSS bundle
  • When the page loads, the footer is visible but unstyled (no red color)
  • After ~5 seconds, the footer's JavaScript chunk loads and hydrates
  • Only then does the CSS Module load and apply the styles, causing a visible flash of styling change

Expected Behavior

  • When using selective hydration with React.lazy and Suspense, Next.js should recognize components that were SSR'd
  • CSS Modules associated with lazily-loaded, SSR'd components should be included in the initial CSS bundle
  • Even though JavaScript hydration is deferred, the styling should be present from the start
  • No flash of unstyled content should occur as the component transitions from SSR to hydrated state

This issue effectively breaks one of the key benefits of React 18's selective hydration feature - which is to provide a seamless experience where pre-rendered HTML looks identical to the hydrated component while JavaScript loads in the background.

Why This Matters

Selective hydration is one of React 18's most powerful features for improving perceived performance. It allows:

  • Prioritizing critical UI elements for hydration
  • Letting users interact with the most important parts of the UI while less important components load
  • Maintaining a visually stable UI throughout the hydration process

When working correctly, selective hydration allows sites to deliver complex interfaces that are visually complete and stable on initial load, with interactivity progressively enhancing as JavaScript loads

The current issue forces developers to choose between:

  1. Using CSS Modules but accepting FOUC
  2. Not using selective hydration's performance benefits
  3. Switching to inline styles or global CSS

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:16 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000
  Available memory (MB): 65536
  Available CPU cores: 10
Binaries:
  Node: 20.18.2
  npm: 10.8.2
  Yarn: 1.22.22
  pnpm: 10.2.0
Relevant Packages:
  next: 15.3.0-canary.12 // Latest available version is detected (15.3.0-canary.12).
  eslint-config-next: N/A
  react: 18.2.0
  react-dom: 18.2.0
  typescript: N/A
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

CSS, Lazy Loading, App Router, Pages Router, React

Which stage(s) are affected? (Select all that apply)

next build (local), next start (local), next dev (local), Vercel (Deployed), Other (Deployed)

Additional context

More information about selective hydration:

React 18 introduction to selective hydration: https://youtu.be/pj5N-Khihgc?t=688
Demonstration of selective hydration advantages: https://www.youtube.com/watch?v=pj5N-Khihgc&t=966s
React Working Group discussion: reactwg/react-18#37

@github-actions github-actions bot added CSS Related to CSS. Lazy Loading Related to Next.js Lazy Loading (e.g., next/dynamic or React.lazy). Pages Router Related to Pages Router. React Related to React. linear: next Confirmed issue that is tracked by the Next.js team. labels Mar 18, 2025
@eps1lon
Copy link
Member

eps1lon commented Apr 23, 2025

This isn't really related to React 18 and Pages Router, no? It reproduces on App Router as well as Pages Router + React 19. The issue being that the CSS module is not eagerly loaded even though some content was streamed in.

CleanShot.2025-04-23.at.12.40.11.mp4

@jantimon
Copy link
Contributor Author

Yes you are absolutely right - the feature was introduced with React 18 and is still the same for React 19.

Initially I thought it would only affect the pages router but yes it is broken for pages router and app router

Pages Router: https://stackblitz.com/edit/stackblitz-starters-h9tuzhzs

App Router: https://stackblitz.com/edit/stackblitz-starters-u1jswo3f

@jantimon jantimon changed the title CSS Modules break selective hydration in React 18 with Pages Router causing FOUC CSS Modules break selective hydration in React 18 & 19 causing FOUC Apr 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CSS Related to CSS. Lazy Loading Related to Next.js Lazy Loading (e.g., next/dynamic or React.lazy). linear: next Confirmed issue that is tracked by the Next.js team. Pages Router Related to Pages Router. React Related to React.
Projects
None yet
Development

No branches or pull requests

2 participants