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:
parent
78433b0e45
commit
3c4d46f3ad
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!toneMenuOpen) return;
|
if (!toneMenuOpen) return;
|
||||||
|
|||||||
@ -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);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user