Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,50 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// suppressHydrationWarning allows the blocking inline script in <head> to
// mutate the `dark` class on <html> without React logging a hydration
// mismatch warning. See theme-provider.tsx for the full strategy.
return (
<html lang="en" suppressHydrationWarning> {/* TODO: revisit if i18n is introduced */}
<head />
<html lang="en" suppressHydrationWarning>
<head>
{/*
Blocking inline script that reads the user's stored theme preference
from localStorage and applies the `dark` class to <html> BEFORE the
browser paints a single pixel. This prevents the flash-of-wrong-theme
(FART) when the server renders a default theme class that differs from
the user's persisted preference.

The `suppressHydrationWarning` prop on <html> is the companion measure:
after this script mutates the class, React will see a mismatch between
server-rendered <html> (no `dark` class) and the client DOM (possibly
`dark` class) and would normally emit a console warning.
suppressHydrationWarning tells React to skip that check for this
element because the discrepancy is intentional and resolved before
hydration completes.

Flow: inline script runs → class set → browser paints → React hydrates
(suppressHydrationWarning) → next-themes ThemeProvider takes over.
*/}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('flowfi-theme') || 'dark';
if (theme === 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body className={`${sora.variable} ${mono.variable} antialiased`}>
<ThemeProvider
attribute="class"
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/context/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,53 @@
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";

/**
* Thin wrapper around `next-themes`' `ThemeProvider` that centralises theme
* configuration for the FlowFi frontend.
*
* ## SSR / Hydration Strategy
*
* Theme selection is inherently a client-side concern (it depends on
* `localStorage` and/or `matchMedia`). The server cannot know which theme the
* user prefers, so it always renders a default (`dark`) theme class. This
* creates two problems:
*
* 1. **Flash of wrong theme (FART).** If the user chose "light" previously,
* the browser paints the dark theme first, then swaps to light when
* React hydrates — an unpleasant flicker.
*
* 2. **Hydration mismatch warning.** When React hydrates the `<html>` element
* and sees that the client DOM has a `dark` class the server did not
* produce, it logs a console warning.
*
* We solve both problems with a two-part approach inside `layout.tsx`:
*
* - **Blocking inline `<script>` in `<head>`** — reads `flowfi-theme` from
* localStorage and synchronously adds/removes the `dark` class on
* `<html>` **before the first paint**. No flicker occurs because the
* correct class is present from the very first rendered frame.
*
* - **`suppressHydrationWarning` on `<html>`** — tells React to skip the
* hydration-difference check for the `<html>` element because we
* intentionally mutate its class list before hydration.
*
* After hydration, this `ThemeProvider` (via `next-themes`) takes over theme
* management, reacting to `useTheme()` calls and persisting changes to the
* same `flowfi-theme` localStorage key.
*
* ## Configuration
*
* - `attribute="class"` — toggles a `dark` class on `<html>`, which Tailwind
* uses for its `dark:` variant.
* - `defaultTheme="dark"` — what the server (and users without any stored
* preference) will see.
* - `enableSystem={true}` — when theme is set to `"system"`, the provider
* follows the OS-level `prefers-color-scheme` media query.
* - `storageKey="flowfi-theme"` — namespaced localStorage key to avoid
* collisions with other apps on the same origin.
* - `disableTransitionOnChange` — prevents a brief CSS transition when the
* theme class changes, making the switch feel instant.
*/
export function ThemeProvider({
children,
...props
Expand Down