From 5ef9c8bf08a240ba9c48d10a558d15c8a122165b Mon Sep 17 00:00:00 2001 From: Adam Jolicoeur Date: Mon, 25 May 2026 13:07:03 -0400 Subject: [PATCH] fix: prevent archived day from reappearing after hard refresh beforeunload and handleBackground captured stale closure values. If user did a hard refresh immediately after archiving (before React re-rendered and re-registered the handler), the old handler would overwrite the correctly-cleared localStorage with pre-archive task data. Fix: read latestStateRef.current in both handlers so they always see the current state regardless of React's render cycle. Also update the ref synchronously in archiveDay before any awaits so it's correct the moment state setters fire. Co-Authored-By: Claude Sonnet 4.6 --- src/contexts/TimeTrackingContext.tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 9c88f0a..a876567 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -466,17 +466,21 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ } }, [dataService, loading]); - // Save on window close to prevent data loss + // Save on window close to prevent data loss. + // Uses latestStateRef to avoid stale-closure races (e.g. hard-refresh immediately + // after archiving, before React re-renders and re-registers this handler). useEffect(() => { const handleBeforeUnload = (_event: BeforeUnloadEvent) => { - if (!dataService || (!isDayStarted && tasks.length === 0)) return; + if (!dataService) return; + const state = latestStateRef.current; + if (!state.isDayStarted && state.tasks.length === 0) return; // Async saves cannot be reliably awaited during beforeunload, so write the // current state synchronously to localStorage as a guaranteed crash backup. // Supabase-mode users will have this local copy available for recovery on next load. try { localStorage.setItem( STORAGE_KEYS.CURRENT_DAY, - JSON.stringify({ isDayStarted, dayStartTime, tasks, currentTask, _v: SCHEMA_VERSION }) + JSON.stringify({ ...state, _v: SCHEMA_VERSION }) ); } catch { // localStorage unavailable (quota exceeded, private mode); best effort only. @@ -485,22 +489,24 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [dataService, isDayStarted, tasks, currentTask, dayStartTime]); + }, [dataService]); // On iOS/Capacitor, useAppLifecycle fires at the Swift layer (appStateChange) // before WKWebView is frozen — more reliable than beforeunload or visibilitychange. // On web, it falls back to visibilitychange automatically. + // Uses latestStateRef to avoid the same stale-closure race as beforeunload. const handleBackground = useCallback(() => { - if (!isDayStarted && tasks.length === 0) return; + const state = latestStateRef.current; + if (!state.isDayStarted && state.tasks.length === 0) return; try { localStorage.setItem( STORAGE_KEYS.CURRENT_DAY, - JSON.stringify({ isDayStarted, dayStartTime, tasks, currentTask, _v: SCHEMA_VERSION }) + JSON.stringify({ ...state, _v: SCHEMA_VERSION }) ); } catch { // best effort } - }, [isDayStarted, dayStartTime, tasks, currentTask]); + }, []); useAppLifecycle(handleBackground); @@ -710,6 +716,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setTasks([]); setIsDayStarted(false); + // Immediately sync the ref so beforeunload/handleBackground can't race and + // overwrite localStorage with the old (pre-archive) state before React re-renders. + latestStateRef.current = { isDayStarted: false, dayStartTime: null, currentTask: null, tasks: [] }; + // Save immediately since this is a critical action if (dataService) { try {