diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..c481e46e --- /dev/null +++ b/TODO.md @@ -0,0 +1,8 @@ +# Task Tracker + +- [x] Implement scoped 500ms trailing debounce in `src/app/hooks/useDashboardWidgets.tsx` to rate-limit localStorage writes. +- [ ] (Optional) Prevent initial-load persistence writes in `src/app/components/dashboard/DashboardGrid.tsx` (skip first save after mount). +- [ ] Run tests/lint if applicable. +- [ ] Manual verification: Chrome DevTools Performance panel during drag; confirm no long tasks from localStorage I/O. + + diff --git a/src/app/components/dashboard/DashboardGrid.tsx b/src/app/components/dashboard/DashboardGrid.tsx index 1c97fba6..fe9b6924 100644 --- a/src/app/components/dashboard/DashboardGrid.tsx +++ b/src/app/components/dashboard/DashboardGrid.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { DndContext, @@ -104,6 +104,14 @@ export const DashboardGrid: React.FC = ({ const [activeTab, setActiveTab] = useState('overview'); const [dateRange] = useState('Last 30 days'); + const saveTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + }; + }, []); + const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -111,7 +119,7 @@ export const DashboardGrid: React.FC = ({ }), ); - const { saveWidgetLayout, loadWidgetLayout } = useDashboardWidgets(); + const { loadWidgetLayout } = useDashboardWidgets(); // Load saved layout on mount useEffect(() => { @@ -129,18 +137,32 @@ export const DashboardGrid: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Only run on mount - // Save layout when widgets change (but not on initial load) + // Notify parent of widget changes immediately (no I/O) useEffect(() => { - if (widgets.length === 0) return; // Don't save empty state + if (widgets.length === 0) return; + onWidgetChange?.(widgets); + }, [widgets, onWidgetChange]); - try { - saveWidgetLayout(widgets); - onWidgetChange?.(widgets); - } catch (error) { - console.error('Error saving widget layout', error); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [widgets]); // Only depend on widgets, not the functions + // Persist layout to localStorage at most once per 500ms via debounce + useEffect(() => { + if (widgets.length === 0) return; + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + + saveTimerRef.current = setTimeout(() => { + try { + localStorage.setItem('dashboard-widgets', JSON.stringify(widgets)); + } catch (error) { + console.error('Error saving widget layout', error); + } finally { + saveTimerRef.current = null; + } + }, 500); + + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + }; + }, [widgets]); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; diff --git a/src/app/hooks/useDashboardWidgets.tsx b/src/app/hooks/useDashboardWidgets.tsx index c0a37905..c65563b4 100644 --- a/src/app/hooks/useDashboardWidgets.tsx +++ b/src/app/hooks/useDashboardWidgets.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; interface Widget { id: string; @@ -27,12 +27,37 @@ export const useDashboardWidgets = () => { return []; }, []); - // Save widget layout to localStorage + const saveTimerRef = useRef | null>(null); + const pendingWidgetsRef = useRef(null); + + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + }; + }, []); + const saveWidgetLayout = useCallback((widgets: Widget[]) => { + // Trailing debounce to avoid blocking the main thread during drag-and-drop. + // localStorage writes will happen at most once per 500ms, and the latest + // layout will be persisted within ~500ms after the last change. try { - localStorage.setItem('dashboard-widgets', JSON.stringify(widgets)); + pendingWidgetsRef.current = widgets; + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + + saveTimerRef.current = setTimeout(() => { + try { + const pending = pendingWidgetsRef.current; + if (!pending) return; + localStorage.setItem('dashboard-widgets', JSON.stringify(pending)); + } catch (error) { + console.error('Failed to save widget layout:', error); + } finally { + saveTimerRef.current = null; + } + }, 500); } catch (error) { - console.error('Failed to save widget layout:', error); + console.error('Failed to schedule dashboard layout save:', error); } }, []);