diff --git a/web/src/app/(app)/prompts/page.tsx b/web/src/app/(app)/prompts/page.tsx index 5664c31..a3eb51d 100644 --- a/web/src/app/(app)/prompts/page.tsx +++ b/web/src/app/(app)/prompts/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Sparkles, FileText, Image, Layers, Trash2 } from "lucide-react"; import { AppShell } from "@/components/AppShell"; import { Badge, Button, Card } from "@/components/ui/Primitives"; @@ -28,11 +28,7 @@ export default function PromptsPage() { const [activeCategory, setActiveCategory] = useState("all"); const [loading, setLoading] = useState(true); - useEffect(() => { - void loadTemplates(); - }, []); - - async function loadTemplates() { + const loadTemplates = useCallback(async () => { setLoading(true); try { setTemplates(await listPromptTemplates()); @@ -41,7 +37,11 @@ export default function PromptsPage() { } finally { setLoading(false); } - } + }, []); + + useEffect(() => { + void loadTemplates(); + }, [loadTemplates]); async function handleDelete(id: string) { if (!confirm("Delete this custom template?")) return; diff --git a/web/src/app/(app)/reviews/page.tsx b/web/src/app/(app)/reviews/page.tsx index 21b6995..e08bc71 100644 --- a/web/src/app/(app)/reviews/page.tsx +++ b/web/src/app/(app)/reviews/page.tsx @@ -105,7 +105,7 @@ export default function ReviewsPage() { }, ] as const; - async function handleDecision(decision: "approved" | "rejected") { + const handleDecision = useCallback(async (decision: "approved" | "rejected") => { if (!featuredProposal) { return; } @@ -146,9 +146,9 @@ export default function ReviewsPage() { } finally { 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)); if (batchItems.length === 0) return; @@ -184,7 +184,7 @@ export default function ReviewsPage() { } finally { setIsSubmitting(false); } - } + }, [approvalQueue, selectedBatchIds, reviewNote]); useEffect(() => { function selectRelativeQueueItem(offset: number) { diff --git a/web/src/components/NoteEditor.tsx b/web/src/components/NoteEditor.tsx index 79bbae3..2e7e840 100644 --- a/web/src/components/NoteEditor.tsx +++ b/web/src/components/NoteEditor.tsx @@ -57,7 +57,14 @@ export function NoteEditor({ const [explainResult, setExplainResult] = useState(null); const toneMenuRef = useRef(null); const onSaveRef = useRef(onSave); - onSaveRef.current = 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; + }, [onSave]); useEffect(() => { if (!toneMenuOpen) return; diff --git a/web/src/lib/use-keyboard-shortcuts.ts b/web/src/lib/use-keyboard-shortcuts.ts index 319665e..b7b70d5 100644 --- a/web/src/lib/use-keyboard-shortcuts.ts +++ b/web/src/lib/use-keyboard-shortcuts.ts @@ -13,7 +13,13 @@ export interface KeyboardShortcut { export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { const shortcutsRef = useRef(shortcuts); - shortcutsRef.current = 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; + }, [shortcuts]); useEffect(() => { function handleKeyDown(event: KeyboardEvent) { diff --git a/web/src/lib/use-theme.ts b/web/src/lib/use-theme.ts index de93574..1292b3c 100644 --- a/web/src/lib/use-theme.ts +++ b/web/src/lib/use-theme.ts @@ -1,25 +1,47 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useSyncExternalStore } from 'react'; import { PRODUCT_ID } from '@/lib/product-config'; type Theme = 'dark' | 'light'; const STORAGE_KEY = `${PRODUCT_ID}_theme`; -export function useTheme() { - const [theme, setThemeState] = useState('dark'); +function readTheme(): Theme { + 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 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(() => { - const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; - const initial = stored ?? 'dark'; - setThemeState(initial); - document.documentElement.setAttribute('data-theme', initial); - }, []); + document.documentElement.setAttribute('data-theme', theme); + }, [theme]); const setTheme = useCallback((t: Theme) => { - setThemeState(t); - localStorage.setItem(STORAGE_KEY, t); + if (typeof window === 'undefined') return; + window.localStorage.setItem(STORAGE_KEY, 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(() => {