fix(tema): seletor de tema não gera mais hydration mismatch no console

`ThemeProvider` inicializava `theme`/`systemTheme` com
`useState(() => readStoredTheme())` / `useState(() => getSystemTheme())`.
Essas funções checam `typeof window === "undefined"`: no servidor sempre
devolvem "system"/"light"; no navegador leem `localStorage`/`matchMedia` de
verdade.

O problema: o inicializador de `useState` roda de novo na hidratação — que É
a primeira renderização do cliente, comparada literalmente contra o HTML que
o servidor mandou. Um usuário com tema salvo "dark" produzia, nessa
comparação, um servidor dizendo "system" e um cliente dizendo "dark" — e como
`ThemeToggle` deriva o ícone e o `aria-label` de `theme`, a divergência
aparecia no atributo, reproduzindo o "hydration mismatch" relatado (aria-label
"Tema: dark" contra "Tema: system", ícone Moon contra MonitorPlay).

O comentário que o código tinha ("não causa mismatch porque o inline script
já setou o data-theme antes do paint") confundia duas coisas: o script inline
manipula o ATRIBUTO `data-theme` do `<html>` direto no DOM, fora do React —
evita o flash visual de CSS, mas é cego para a árvore React em si.

Trocado por um external store de módulo (`useSyncExternalStore`, mesma
convenção já em uso em `components/branding/CampoDeLogo.tsx` para esta classe
de defeito): `getServerSnapshot` devolve o valor determinístico que o
servidor viu, e só depois do commit o React troca para o valor real — sem
`setState` dentro de `useEffect` (que o `react-hooks/set-state-in-effect`
reprovaria) e sem hydration mismatch, porque a comparação de hidratação nunca
vê o valor real dos dois lados.

Medido: os dois testes de igualdade reprovam voltando ao `useState` antigo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felipe Oliveira
2026-09-09 21:15:57 -03:00
co-authored by Claude Sonnet 5
parent 1c9a46a180
commit fda7133ec3
3 changed files with 255 additions and 31 deletions
@@ -0,0 +1,7 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O seletor de tema não gera mais erro de hidratação no console
---
Quem tinha o tema escuro (ou claro) salvo via, no console do navegador, um aviso de "hydration mismatch" ao abrir qualquer tela — o React reclamando que o HTML do servidor e o do navegador não batiam no ícone e no texto do botão de tema. O visual não quebrava, mas o erro aparecia sempre. Agora a primeira renderização do navegador bate com a do servidor, e o tema salvo é aplicado logo em seguida, sem gerar aviso nenhum.
+156
View File
@@ -0,0 +1,156 @@
/**
* O TEMA DA PRIMEIRA RENDERIZAÇÃO DO CLIENTE É O MESMO QUE O SERVIDOR MANDOU.
*
* ═══ O DEFEITO QUE ESTE ARQUIVO EXISTE PARA IMPEDIR ═══
*
* `ThemeProvider` inicializava `theme`/`systemTheme` com
* `useState(() => readStoredTheme())` / `useState(() => getSystemTheme())`.
* Essas funções checam `typeof window === "undefined"` para decidir a fonte:
* no servidor (sem `window`) sempre devolvem "system"/"light"; no navegador,
* leem `localStorage`/`matchMedia` de verdade.
*
* O problema: o inicializador de `useState` roda de novo na hidratação — que
* É a primeira renderização do cliente, a mesma que o React compara contra o
* HTML que o servidor mandou. Um usuário com tema salvo "dark" produzia,
* nessa comparação, um servidor dizendo "system" e um cliente dizendo "dark"
* — e como `ThemeToggle` deriva o ícone e o `aria-label` de `theme`, a
* divergência aparecia literalmente no atributo, reproduzindo byte a byte o
* "Runtime Error: hydration mismatch" relatado (aria-label "Tema: dark" no
* cliente contra "Tema: system" no servidor, ícone Moon contra MonitorPlay).
*
* O comentário que o código tinha ("não causa hydration mismatch porque o
* inline script no layout já setou o data-theme antes do paint") confundia
* duas coisas: o script inline manipula o ATRIBUTO `data-theme` do `<html>`
* diretamente, fora do React — isso evita o flash visual de CSS, mas é cego
* para a árvore React em si, que continua sendo comparada por conteúdo.
*
* ═══ POR QUE A RECEITA ABAIXO É FIEL, E NÃO UM TRUQUE DE jsdom ═══
*
* `renderToStaticMarkup` nunca roda `useEffect` — é o mesmo motivo que
* `tests/unit/marca-sem-divergencia-de-hidratacao.test.tsx` usa para testar
* hydration mismatch de marca. Isso o torna um substituto fiel tanto da
* saída REAL do servidor quanto da saída da PRIMEIRA renderização do
* cliente (a que a hidratação compara) — porque nos dois casos nenhum
* efeito rodou ainda, só o corpo síncrono do componente.
*
* Em jsdom `window` sempre existe, então "renderizar como servidor" exige
* apagá-lo de propósito do escopo global durante a chamada — `renderToStaticMarkup`
* não toca em nenhuma API de DOM, então isso é seguro. "Renderizar como a
* primeira passada do cliente" é a MESMA chamada com `window` de volta e
* `localStorage` populado como o de um usuário retornando.
*
* ═══ SABOTAGEM QUE ESTE ARQUIVO JÁ REPROVOU ═══
*
* Voltar `useState<Theme>(() => readStoredTheme())` /
* `useState<ResolvedTheme>(() => getSystemTheme())` (o defeito de volta, com
* o conserto no lugar): as duas asserções de igualdade ficam vermelhas —
* `doPrimeiraRenderCliente` passa a dizer "Tema: dark" enquanto `doServidor`
* continua dizendo "Tema: system".
*/
import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { STORAGE_KEY as CHAVE } from "@/lib/theme";
import type { ThemeProvider as ThemeProviderType } from "@/lib/theme";
import type { ThemeToggle as ThemeToggleType } from "@/components/theme/theme-toggle";
vi.mock("react-hotkeys-hook", () => ({ useHotkeys: () => {} }));
function stubMatchMedia(prefersDark: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("dark") && prefersDark,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})) as unknown as typeof window.matchMedia;
}
let ThemeProvider: typeof ThemeProviderType;
let ThemeToggle: typeof ThemeToggleType;
let ARVORE: React.ReactElement;
beforeEach(async () => {
window.localStorage.clear();
stubMatchMedia(false);
// O cache do external store (`temaEmCache`/`sistemaEmCache`) mora no MÓDULO,
// não no componente — de propósito, é o que faz `toggle()` funcionar sem
// reler o storage a cada chamada. Mas isso faz um teste que rodasse antes
// vazar cache para o de depois. `resetModules` + reimportar dá a cada `it`
// um módulo (e um cache) genuinamente zerado, igual a uma aba nova.
vi.resetModules();
({ ThemeProvider } = await import("@/lib/theme"));
({ ThemeToggle } = await import("@/components/theme/theme-toggle"));
ARVORE = (
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
});
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
/** O que o servidor Node (sem `window`) produz — `renderToStaticMarkup` nunca roda efeito. */
function renderizarComoServidor(): string {
const janelaReal = globalThis.window;
// @ts-expect-error — apagar de propósito para `typeof window === "undefined"` ser verdade.
delete globalThis.window;
try {
return renderToStaticMarkup(ARVORE);
} finally {
globalThis.window = janelaReal;
}
}
/** O que a PRIMEIRA renderização do cliente produz — mesma chamada, `window` de verdade. */
function renderizarComoPrimeiraPassadaDoCliente(): string {
return renderToStaticMarkup(ARVORE);
}
describe("o tema não diverge entre o SSR e a primeira renderização do cliente", () => {
it("com tema salvo 'dark', a primeira passada do cliente bate com o servidor", () => {
window.localStorage.setItem(CHAVE, "dark");
const doServidor = renderizarComoServidor();
const doCliente = renderizarComoPrimeiraPassadaDoCliente();
// Os dois precisam dizer "system" — é o valor que `readStoredTheme()`
// devolve sem `window`, e é o que a hidratação tem de bater ANTES do
// efeito que sincroniza com o localStorage rodar.
expect(doServidor).toContain("Tema: system");
expect(
doCliente,
"A primeira renderização do cliente leu o localStorage direto no " +
"inicializador do useState, produzindo 'Tema: dark' — diferente do " +
"que o servidor mandou ('Tema: system'). É o hydration mismatch.",
).toContain("Tema: system");
expect(doCliente).toBe(doServidor);
});
it("com tema salvo 'light', a primeira passada do cliente também bate", () => {
window.localStorage.setItem(CHAVE, "light");
const doServidor = renderizarComoServidor();
const doCliente = renderizarComoPrimeiraPassadaDoCliente();
expect(doCliente).toBe(doServidor);
});
it("GUARDA DE VACUIDADE: depois do efeito, o tema real aparece", () => {
// Sem este caso, os dois de cima passariam num componente que nunca
// reflete o tema salvo — "sempre system" bateria com "sempre system"
// para sempre, e o defeito oposto (a preferência do usuário nunca é
// lida) passaria despercebido.
window.localStorage.setItem(CHAVE, "dark");
const container = document.createElement("div");
document.body.appendChild(container);
act(() => {
createRoot(container).render(ARVORE);
});
expect(container.innerHTML).toContain("Tema: dark");
});
});
+92 -31
View File
@@ -5,7 +5,10 @@ import * as React from "react";
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "deskcomm-theme";
// Exportada para o teste reusar em vez de duplicar o literal — duplicar
// acionaria `tests/unit/branding.test.ts` (a mesma marca hardcoded, fora da
// lista congelada, num segundo arquivo).
export const STORAGE_KEY = "deskcomm-theme";
type ThemeContextValue = {
/** User preference: light, dark, or system. */
@@ -39,52 +42,110 @@ function applyTheme(resolved: ResolvedTheme) {
document.documentElement.setAttribute("data-theme", resolved);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Lê do storage no primeiro render do client (não causa hydration mismatch
// porque o inline script no layout já setou o data-theme antes do paint).
const [theme, setThemeState] = React.useState<Theme>(() => readStoredTheme());
const [systemTheme, setSystemTheme] = React.useState<ResolvedTheme>(() =>
getSystemTheme(),
);
/**
* ═══ POR QUE ISTO É UM EXTERNAL STORE, E NÃO `useState` + `useEffect` ═══
*
* A primeira renderização do CLIENTE é a renderização de hidratação — a
* mesma que o React compara contra o HTML que o servidor mandou. O servidor
* roda com `window === undefined`, então `readStoredTheme()`/`getSystemTheme()`
* sempre devolvem "system"/"light" lá. Um `useState(() => readStoredTheme())`
* reexecuta esse inicializador na hidratação — agora com `window` de verdade
* — e um usuário com tema salvo "dark" produzia, nesse instante, uma
* primeira renderização do cliente dizendo "dark" contra o "system" que o
* servidor mandou. Como `ThemeToggle` deriva o ícone e o `aria-label` de
* `theme`, a divergência aparecia literalmente no atributo: o hydration
* mismatch relatado (aria-label "Tema: dark" batendo contra "Tema: system",
* ícone Moon contra MonitorPlay).
*
* A saída não é "ler depois, num `useEffect`": `setState` dentro de um
* `useEffect` sem dependência externa real é exatamente o padrão que
* `react-hooks/set-state-in-effect` está certo em recusar (cascata de
* renders por engano). A saída certa — e já em uso neste repo para a mesma
* classe de defeito, ver `components/branding/CampoDeLogo.tsx` — é
* `useSyncExternalStore`: `getServerSnapshot` devolve o valor determinístico
* que o servidor viu (idêntico ao que a primeira renderização do cliente
* também usa, ANTES de qualquer inscrição rodar), e só depois do commit o
* React troca para `getSnapshot` (o valor real) — sem cascata, sem aviso, e
* sem hydration mismatch, porque a COMPARAÇÃO de hidratação nunca vê o valor
* real: ela vê `getServerSnapshot` dos dois lados.
*/
type Ouvinte = () => void;
const ouvintesDeTema = new Set<Ouvinte>();
let temaEmCache: Theme | null = null;
// Listener pra mudanças do prefers-color-scheme.
React.useEffect(() => {
function getTemaSnapshot(): Theme {
if (temaEmCache === null) temaEmCache = readStoredTheme();
return temaEmCache;
}
function getTemaSnapshotDoServidor(): Theme {
return "system";
}
function inscreverEmTema(ouvinte: Ouvinte): () => void {
ouvintesDeTema.add(ouvinte);
return () => ouvintesDeTema.delete(ouvinte);
}
function gravarTema(next: Theme) {
temaEmCache = next;
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Persistência opcional — falha silenciosamente.
}
ouvintesDeTema.forEach((ouvinte) => ouvinte());
}
const ouvintesDeSistema = new Set<Ouvinte>();
let sistemaEmCache: ResolvedTheme | null = null;
function getSistemaSnapshot(): ResolvedTheme {
if (sistemaEmCache === null) sistemaEmCache = getSystemTheme();
return sistemaEmCache;
}
function getSistemaSnapshotDoServidor(): ResolvedTheme {
return "light";
}
function inscreverEmSistema(ouvinte: Ouvinte): () => void {
if (ouvintesDeSistema.size === 0 && typeof window !== "undefined") {
// Só liga UM listener nativo, mesmo com N componentes inscritos — o
// fan-out para os `ouvinte()` é responsabilidade deste módulo.
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = (e: MediaQueryListEvent) => {
setSystemTheme(e.matches ? "dark" : "light");
sistemaEmCache = e.matches ? "dark" : "light";
ouvintesDeSistema.forEach((o) => o());
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
}
ouvintesDeSistema.add(ouvinte);
return () => ouvintesDeSistema.delete(ouvinte);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = React.useSyncExternalStore(inscreverEmTema, getTemaSnapshot, getTemaSnapshotDoServidor);
const systemTheme = React.useSyncExternalStore(
inscreverEmSistema,
getSistemaSnapshot,
getSistemaSnapshotDoServidor,
);
const resolvedTheme: ResolvedTheme = theme === "system" ? systemTheme : theme;
// Aplica no DOM sempre que o tema efetivo muda.
// Aplica no DOM sempre que o tema efetivo muda. Isto não é "ler estado
// externo" (o que o external store acima já cobre) — é o único jeito de
// fazer um EFEITO COLATERAL (mutar `data-theme` no `<html>`) a partir de um
// valor computado, e por isso continua em `useEffect`, sem aviso: aqui não
// há `setState`, só uma chamada de DOM.
React.useEffect(() => {
applyTheme(resolvedTheme);
}, [resolvedTheme]);
const setTheme = React.useCallback((next: Theme) => {
setThemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Persistência opcional — falha silenciosamente.
}
gravarTema(next);
}, []);
const toggle = React.useCallback(() => {
setThemeState((current) => {
const currentResolved =
current === "system" ? getSystemTheme() : current;
const next: Theme = currentResolved === "dark" ? "light" : "dark";
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
return next;
});
const atual = getTemaSnapshot();
const resolvidoAtual = atual === "system" ? getSistemaSnapshot() : atual;
gravarTema(resolvidoAtual === "dark" ? "light" : "dark");
}, []);
const value = React.useMemo<ThemeContextValue>(