fix(web): resolve the 5 actionable React-compiler lint advisories

Web lint warnings reduced from 20 → 15 by fixing the categories that
flag real architectural smells rather than the canonical
fetch-on-mount setState pattern.

Real fixes:

1. web/src/lib/use-theme.ts — replace useEffect + setState mount-sync
   pattern with React.useSyncExternalStore. The hook now subscribes to
   browser storage events, returns a stable snapshot for SSR, and uses
   a manual storage-event dispatch so same-document setters refresh
   correctly. Eliminates the cascading-render advisory and gains free
   cross-tab theme sync.

2. web/src/lib/use-keyboard-shortcuts.ts — move ref assignment from
   render time into a useEffect. Fixes the 'Cannot access refs during
   render' advisory without behavior change.

3. web/src/components/NoteEditor.tsx — move onSaveRef.current = onSave
   from render time into a useEffect for the same reason.

4. web/src/app/(app)/reviews/page.tsx — wrap handleDecision and
   handleBatchDecision in useCallback so the useEffect that depends
   on them no longer re-subscribes the keydown listener on every
   render. Fixes both react-hooks/exhaustive-deps warnings and the
   underlying perf bug they pointed at.

5. web/src/app/(app)/prompts/page.tsx — wrap loadTemplates in
   useCallback declared before the useEffect that calls it. Fixes
   the 'Cannot access variable before it is declared' advisory.

Remaining 15 warnings are React-compiler runtime hints about
fetchData().then(setData) patterns inside useEffect, which is the
canonical fetch-on-mount pattern shown in React's own docs. Resolving
them properly requires Suspense + use() or risky startTransition
wraps; both are out of scope and tracked under future tech debt.

Verified:
- pnpm --filter @notelett/web run typecheck: passes
- pnpm --filter @notelett/web run lint: 0 errors, 15 warnings (down 5)
- pnpm run verify: backend 380/380, web 96/96, mobile 97/97
This commit is contained in:
saravanakumardb1 2026-05-23 00:20:02 -07:00
parent 78433b0e45
commit 3c4d46f3ad
5 changed files with 58 additions and 23 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Sparkles, FileText, Image, Layers, Trash2 } from "lucide-react"; import { Sparkles, FileText, Image, Layers, Trash2 } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Badge, Button, Card } from "@/components/ui/Primitives"; import { Badge, Button, Card } from "@/components/ui/Primitives";
@ -28,11 +28,7 @@ export default function PromptsPage() {
const [activeCategory, setActiveCategory] = useState<PromptCategory | "all">("all"); const [activeCategory, setActiveCategory] = useState<PromptCategory | "all">("all");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const loadTemplates = useCallback(async () => {
void loadTemplates();
}, []);
async function loadTemplates() {
setLoading(true); setLoading(true);
try { try {
setTemplates(await listPromptTemplates()); setTemplates(await listPromptTemplates());
@ -41,7 +37,11 @@ export default function PromptsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, []);
useEffect(() => {
void loadTemplates();
}, [loadTemplates]);
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Delete this custom template?")) return; if (!confirm("Delete this custom template?")) return;

View File

@ -105,7 +105,7 @@ export default function ReviewsPage() {
}, },
] as const; ] as const;
async function handleDecision(decision: "approved" | "rejected") { const handleDecision = useCallback(async (decision: "approved" | "rejected") => {
if (!featuredProposal) { if (!featuredProposal) {
return; return;
} }
@ -146,9 +146,9 @@ export default function ReviewsPage() {
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
} }, [featuredProposal, reviewNote]);
async function handleBatchDecision(decision: "approved" | "rejected") { const handleBatchDecision = useCallback(async (decision: "approved" | "rejected") => {
const batchItems = approvalQueue.filter((item) => selectedBatchIds.has(item.id)); const batchItems = approvalQueue.filter((item) => selectedBatchIds.has(item.id));
if (batchItems.length === 0) return; if (batchItems.length === 0) return;
@ -184,7 +184,7 @@ export default function ReviewsPage() {
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
} }, [approvalQueue, selectedBatchIds, reviewNote]);
useEffect(() => { useEffect(() => {
function selectRelativeQueueItem(offset: number) { function selectRelativeQueueItem(offset: number) {

View File

@ -57,7 +57,14 @@ export function NoteEditor({
const [explainResult, setExplainResult] = useState<string | null>(null); const [explainResult, setExplainResult] = useState<string | null>(null);
const toneMenuRef = useRef<HTMLDivElement>(null); const toneMenuRef = useRef<HTMLDivElement>(null);
const onSaveRef = useRef(onSave); const onSaveRef = useRef(onSave);
// Keep the latest onSave handler in a ref so the debounced auto-save
// effect always invokes the current closure. Assigning inside an effect
// (rather than during render) avoids the React 19 compiler warning
// about mutating refs during render.
useEffect(() => {
onSaveRef.current = onSave; onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => { useEffect(() => {
if (!toneMenuOpen) return; if (!toneMenuOpen) return;

View File

@ -13,7 +13,13 @@ export interface KeyboardShortcut {
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const shortcutsRef = useRef(shortcuts); const shortcutsRef = useRef(shortcuts);
// Keep the ref pointed at the latest shortcuts array. Assigning inside
// an effect (rather than during render) avoids the React 19 compiler
// warning about mutating refs during render.
useEffect(() => {
shortcutsRef.current = shortcuts; shortcutsRef.current = shortcuts;
}, [shortcuts]);
useEffect(() => { useEffect(() => {
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {

View File

@ -1,25 +1,47 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { PRODUCT_ID } from '@/lib/product-config'; import { PRODUCT_ID } from '@/lib/product-config';
type Theme = 'dark' | 'light'; type Theme = 'dark' | 'light';
const STORAGE_KEY = `${PRODUCT_ID}_theme`; const STORAGE_KEY = `${PRODUCT_ID}_theme`;
export function useTheme() { function readTheme(): Theme {
const [theme, setThemeState] = useState<Theme>('dark'); if (typeof window === 'undefined') return 'dark';
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored ?? 'dark';
}
function getServerSnapshot(): Theme {
return 'dark';
}
// Subscribe to storage events so the theme stays in sync across tabs.
// React.useSyncExternalStore handles SSR + hydration correctly and avoids
// the setState-in-effect pattern that the React 19 compiler warns about.
function subscribe(callback: () => void) {
if (typeof window === 'undefined') return () => {};
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
}
export function useTheme() {
const theme = useSyncExternalStore(subscribe, readTheme, getServerSnapshot);
// Reflect the resolved theme on the <html> element after hydration so
// initial mount picks up the value from localStorage. Pure DOM mutation,
// no setState, so the React-compiler advisory does not trigger.
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; document.documentElement.setAttribute('data-theme', theme);
const initial = stored ?? 'dark'; }, [theme]);
setThemeState(initial);
document.documentElement.setAttribute('data-theme', initial);
}, []);
const setTheme = useCallback((t: Theme) => { const setTheme = useCallback((t: Theme) => {
setThemeState(t); if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, t); window.localStorage.setItem(STORAGE_KEY, t);
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', t);
// Manually dispatch a storage event so the current document re-reads
// the snapshot (browsers only fire storage events in OTHER tabs).
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY, newValue: t }));
}, []); }, []);
const toggle = useCallback(() => { const toggle = useCallback(() => {