mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Sonnet 5
parent
1c9a46a180
commit
fda7133ec3
@@ -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.
|
||||
@@ -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
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user