From fda7133ec331dd421dbe9207f9d344e3151eea5d Mon Sep 17 00:00:00 2001 From: Felipe Oliveira Date: Wed, 9 Sep 2026 21:15:57 -0300 Subject: [PATCH] =?UTF-8?q?fix(tema):=20seletor=20de=20tema=20n=C3=A3o=20g?= =?UTF-8?q?era=20mais=20hydration=20mismatch=20no=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `` 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 --- .../tema-nao-diverge-mais-na-hidratacao.md | 7 + lib/theme.test.tsx | 156 ++++++++++++++++++ lib/theme.tsx | 123 ++++++++++---- 3 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 .changes/tema-nao-diverge-mais-na-hidratacao.md create mode 100644 lib/theme.test.tsx diff --git a/.changes/tema-nao-diverge-mais-na-hidratacao.md b/.changes/tema-nao-diverge-mais-na-hidratacao.md new file mode 100644 index 000000000..13f593652 --- /dev/null +++ b/.changes/tema-nao-diverge-mais-na-hidratacao.md @@ -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. diff --git a/lib/theme.test.tsx b/lib/theme.test.tsx new file mode 100644 index 000000000..b72e2cc20 --- /dev/null +++ b/lib/theme.test.tsx @@ -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 `` + * 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(() => readStoredTheme())` / + * `useState(() => 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 = ( + + + + ); +}); + +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"); + }); +}); diff --git a/lib/theme.tsx b/lib/theme.tsx index 8ddb00093..1393d9b0d 100644 --- a/lib/theme.tsx +++ b/lib/theme.tsx @@ -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(() => readStoredTheme()); - const [systemTheme, setSystemTheme] = React.useState(() => - 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(); +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(); +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 ``) 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(