fix(portal): pass Next.js 16's React-strict lint rules in M10.2
ci / shared (pull_request) Successful in 13s
ci / test (pull_request) Failing after 5m3s
ci / e2e (pull_request) Has been skipped
ci / image (pull_request) Has been skipped

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:
Sharang Parnerkar
2026-06-04 17:30:24 +02:00
parent 0797f8f99c
commit 582355a1f2
4 changed files with 40 additions and 16 deletions
+8 -1
View File
@@ -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",
],
}, },
]; ];
-1
View File
@@ -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);
} }
})(); })();
+24 -13
View File
@@ -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 });
stateRef.current = { pan, zoom }; useEffect(() => {
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;