diff --git a/eslint.config.mjs b/eslint.config.mjs index e03a0cc..8ed791a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,14 @@ const config = [ ...nextWebVitals, ...nextTypescript, { - ignores: [".next/**", "node_modules/**", "coverage/**", "next-env.d.ts"], + ignores: [ + ".next/**", + "node_modules/**", + "coverage/**", + "next-env.d.ts", + // Auto-generated by `msw init` — patched on every MSW upgrade. + "public/mockServiceWorker.js", + ], }, ]; diff --git a/src/components/portal/MockWorker.tsx b/src/components/portal/MockWorker.tsx index bad8be2..ed992f0 100644 --- a/src/components/portal/MockWorker.tsx +++ b/src/components/portal/MockWorker.tsx @@ -28,7 +28,6 @@ export function MockWorker() { quiet: true, }); } catch (e) { - // eslint-disable-next-line no-console console.error("[mock-worker] failed to start:", e); } })(); diff --git a/src/components/portal/ThemeToggle.tsx b/src/components/portal/ThemeToggle.tsx index f8c4df4..10535de 100644 --- a/src/components/portal/ThemeToggle.tsx +++ b/src/components/portal/ThemeToggle.tsx @@ -1,24 +1,36 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { Sun, Moon } from "lucide-react"; type Theme = "light" | "dark"; -function readTheme(): Theme { - if (typeof document === "undefined") return "light"; +function getThemeFromDom(): Theme { const attr = document.documentElement.getAttribute("data-theme"); return attr === "dark" ? "dark" : "light"; } -export function ThemeToggle() { - const [theme, setTheme] = useState("light"); - const [mounted, setMounted] = useState(false); +// SSR snapshot — must be a stable reference per React's docs. The root +// layout always renders `data-theme="light"` on the server, then a head +// script overrides to the user's preference before hydration. `` +// has `suppressHydrationWarning` so the mismatch is intentional. +function getServerSnapshot(): Theme { + return "light"; +} - useEffect(() => { - setTheme(readTheme()); - setMounted(true); - }, []); +function subscribe(onChange: () => void): () => void { + const target = document.documentElement; + const observer = new MutationObserver(onChange); + observer.observe(target, { attributes: true, attributeFilter: ["data-theme"] }); + return () => observer.disconnect(); +} + +export function ThemeToggle() { + // useSyncExternalStore is the idiomatic way to read DOM-driven state + // into a React component without tripping the "no setState in effect" + // rule. The MutationObserver in `subscribe` keeps us in sync when any + // other code path (e.g. system preference handler) flips the attribute. + const theme = useSyncExternalStore(subscribe, getThemeFromDom, getServerSnapshot); function toggle() { const next: Theme = theme === "dark" ? "light" : "dark"; @@ -28,7 +40,6 @@ export function ThemeToggle() { } catch { /* no-op */ } - setTheme(next); } return ( @@ -37,9 +48,9 @@ export function ThemeToggle() { className="theme-toggle" onClick={toggle} aria-label="Toggle theme" - title={mounted ? `Switch to ${theme === "dark" ? "light" : "dark"} theme` : "Toggle theme"} + title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`} > - {mounted && theme === "dark" ? : } + {theme === "dark" ? : } ); } diff --git a/src/components/portal/workflows/WorkflowEditor.tsx b/src/components/portal/workflows/WorkflowEditor.tsx index d9186fa..b78a0d8 100644 --- a/src/components/portal/workflows/WorkflowEditor.tsx +++ b/src/components/portal/workflows/WorkflowEditor.tsx @@ -80,8 +80,15 @@ export function WorkflowEditor({ frozen }: { frozen: boolean }) { const wrapRef = useRef(null); const dragRef = useRef(null); + // Latest pan/zoom mirrored into a ref so the global mousemove handler + // (registered once in the effect below) can read the current viewport + // without re-subscribing on every change. The mirror is updated in an + // effect rather than during render to satisfy React's "no ref access + // during render" rule. const stateRef = useRef({ pan, zoom }); - stateRef.current = { pan, zoom }; + useEffect(() => { + stateRef.current = { pan, zoom }; + }, [pan, zoom]); const toWorld = useCallback((cx: number, cy: number) => { const wrap = wrapRef.current;