fix(portal): pass Next.js 16's React-strict lint rules in M10.2
CI on PR #13 failed at `pnpm lint --max-warnings 0`. Four findings, all new-in-N16 react-strict checks: * ThemeToggle.tsx — "Calling setState synchronously within an effect" Rewrites the theme reader to use `useSyncExternalStore` with a `MutationObserver` on `<html data-theme>`. SSR snapshot stays "light" (matches the root layout); the head script and the toggle just write the attribute, the observer pushes the change into React. Drops the `mounted` flag because the icon now mirrors the DOM truthfully. * WorkflowEditor.tsx — "Cannot access refs during render" `stateRef.current = { pan, zoom }` was a direct ref-mutation in the component body so the global mousemove handler could read the latest viewport without re-subscribing. Moves the mirror into a `useEffect` keyed on `[pan, zoom]` — same semantics, satisfies the rule. * MockWorker.tsx — drops an unused `eslint-disable-next-line no-console` (the `no-console` rule isn't enabled). * public/mockServiceWorker.js — auto-generated by `msw init`; adds it to the eslint flat-config `ignores` so the lint pass never crosses it. Local: `pnpm lint` + `pnpm typecheck` both clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -6,7 +6,14 @@ const config = [
|
|||||||
...nextWebVitals,
|
...nextWebVitals,
|
||||||
...nextTypescript,
|
...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",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export function MockWorker() {
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("[mock-worker] failed to start:", e);
|
console.error("[mock-worker] failed to start:", e);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { Sun, Moon } from "lucide-react";
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
function readTheme(): Theme {
|
function getThemeFromDom(): Theme {
|
||||||
if (typeof document === "undefined") return "light";
|
|
||||||
const attr = document.documentElement.getAttribute("data-theme");
|
const attr = document.documentElement.getAttribute("data-theme");
|
||||||
return attr === "dark" ? "dark" : "light";
|
return attr === "dark" ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeToggle() {
|
// SSR snapshot — must be a stable reference per React's docs. The root
|
||||||
const [theme, setTheme] = useState<Theme>("light");
|
// layout always renders `data-theme="light"` on the server, then a head
|
||||||
const [mounted, setMounted] = useState(false);
|
// script overrides to the user's preference before hydration. `<html>`
|
||||||
|
// has `suppressHydrationWarning` so the mismatch is intentional.
|
||||||
|
function getServerSnapshot(): Theme {
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function subscribe(onChange: () => void): () => void {
|
||||||
setTheme(readTheme());
|
const target = document.documentElement;
|
||||||
setMounted(true);
|
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() {
|
function toggle() {
|
||||||
const next: Theme = theme === "dark" ? "light" : "dark";
|
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||||
@@ -28,7 +40,6 @@ export function ThemeToggle() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* no-op */
|
/* no-op */
|
||||||
}
|
}
|
||||||
setTheme(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,9 +48,9 @@ export function ThemeToggle() {
|
|||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label="Toggle theme"
|
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" ? <Sun size={15} /> : <Moon size={15} />}
|
{theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,8 +80,15 @@ export function WorkflowEditor({ frozen }: { frozen: boolean }) {
|
|||||||
|
|
||||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragRef = useRef<DragState>(null);
|
const dragRef = useRef<DragState>(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 });
|
const stateRef = useRef({ pan, zoom });
|
||||||
|
useEffect(() => {
|
||||||
stateRef.current = { pan, zoom };
|
stateRef.current = { pan, zoom };
|
||||||
|
}, [pan, zoom]);
|
||||||
|
|
||||||
const toWorld = useCallback((cx: number, cy: number) => {
|
const toWorld = useCallback((cx: number, cy: number) => {
|
||||||
const wrap = wrapRef.current;
|
const wrap = wrapRef.current;
|
||||||
|
|||||||
Reference in New Issue
Block a user