From e3c6a66959d305c4f59bcfd7d61d77aff3b34034 Mon Sep 17 00:00:00 2001 From: devprom6 Date: Fri, 28 Aug 2026 19:37:49 +0100 Subject: [PATCH 1/2] fix(tasks): revalidate stale MMKV task cache on mount Add tasksLastFetchedAt timestamp to taskStore (persisted to MMKV) and trigger a silent background fetch on mount when the warm cache is older than TASK_FEED_STALE_MS (5 min). The revalidation shows no loading skeleton and never clobbers selectedTask. - taskStore: tasksLastFetchedAt set in setTasks, passed through sanitizePersistedTaskState, cleared on reset - useTaskFeed: silent loadTasks mode + stale-check in mount effect - TASK_FEED_STALE_MS extracted to mockable config module - tests cover within-TTL (no fetch), past-TTL (silent fetch), and selectedTask preservation --- src/__tests__/stores.test.ts | 4 ++ src/__tests__/useTaskFeed.test.ts | 60 +++++++++++++++++++++++ src/__tests__/useTaskFeedRefetch.test.tsx | 54 +++++++++++++++++++- src/config/taskFeedCache.ts | 4 ++ src/hooks/useTaskFeed.ts | 40 +++++++++++---- src/store/taskStore.ts | 33 +++++++++++-- 6 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 src/config/taskFeedCache.ts diff --git a/src/__tests__/stores.test.ts b/src/__tests__/stores.test.ts index d6dae71..39d8080 100644 --- a/src/__tests__/stores.test.ts +++ b/src/__tests__/stores.test.ts @@ -301,6 +301,7 @@ describe('taskStore', () => { selectedAt: '2026-01-01T12:00:00.000Z', page: 2, hasMore: false, + tasksLastFetchedAt: null, }); expect(partial).toEqual({ tasks: [task], @@ -308,6 +309,7 @@ describe('taskStore', () => { selectedAt: '2026-01-01T12:00:00.000Z', page: 2, hasMore: false, + tasksLastFetchedAt: null, }); expect(partial).not.toHaveProperty('isLoading'); expect(partial).not.toHaveProperty('error'); @@ -383,6 +385,7 @@ describe('taskStore', () => { selectedAt: null, page: 1, hasMore: true, + tasksLastFetchedAt: null, }); }); @@ -397,6 +400,7 @@ describe('taskStore', () => { selectedAt: '2026-01-01T12:00:00.000Z', page: 1, hasMore: false, + tasksLastFetchedAt: null, }); expect(partial).not.toHaveProperty('isLoading'); expect(partial).not.toHaveProperty('error'); diff --git a/src/__tests__/useTaskFeed.test.ts b/src/__tests__/useTaskFeed.test.ts index 15bff13..1992492 100644 --- a/src/__tests__/useTaskFeed.test.ts +++ b/src/__tests__/useTaskFeed.test.ts @@ -5,11 +5,16 @@ import { useTaskFeed } from '../hooks/useTaskFeed'; import { useTaskStore } from '../store/taskStore'; import { TaskSortMode } from '../utils/sortTasks'; import { Task } from '../types'; +import { TASK_FEED_STALE_MS } from '../config/taskFeedCache'; jest.mock('../services/api', () => ({ fetchTasks: jest.fn(), })); +jest.mock('../config/taskFeedCache', () => ({ + TASK_FEED_STALE_MS: 5 * 60 * 1000, +})); + import { fetchTasks } from '../services/api'; const mockFetchTasks = fetchTasks as jest.MockedFunction; @@ -195,4 +200,59 @@ describe('useTaskFeed', () => { expect(tasks).toHaveLength(2); expect(tasks.filter(t => t.id === '1')).toHaveLength(1); }); + + it('does not fetch on mount when warm cache is within TTL', async () => { + useTaskStore.setState({ + tasks: [makeTask('cached-1'), makeTask('cached-2')], + tasksLastFetchedAt: new Date().toISOString(), + }); + + renderFeed({}); + await flush(); + + expect(mockFetchTasks).not.toHaveBeenCalled(); + expect(useTaskStore.getState().tasks).toHaveLength(2); + }); + + it('silently revalidates on mount when warm cache is past TTL', async () => { + useTaskStore.setState({ + tasks: [makeTask('cached-1'), makeTask('cached-2')], + tasksLastFetchedAt: new Date( + Date.now() - (TASK_FEED_STALE_MS + 1000), + ).toISOString(), + }); + + renderFeed({}); + await flush(); + + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + // Silent revalidation must not show a loading skeleton. + expect(useTaskStore.getState().isLoading).toBe(false); + // Fresh data should have replaced the stale cache. + expect(useTaskStore.getState().tasks).toHaveLength(2); + expect( + useTaskStore.getState().tasks.every(t => t.id === '1' || t.id === '2'), + ).toBe(true); + }); + + it('preserves selectedTask during silent revalidation', async () => { + const selected = makeTask('selected'); + selected.title = 'Selected Task'; + useTaskStore.setState({ + tasks: [makeTask('cached-1'), selected], + selectedTask: selected, + tasksLastFetchedAt: new Date( + Date.now() - (TASK_FEED_STALE_MS + 1000), + ).toISOString(), + }); + + renderFeed({}); + await flush(); + + expect(mockFetchTasks).toHaveBeenCalledTimes(1); + const after = useTaskStore.getState().selectedTask; + expect(after).not.toBeNull(); + expect(after?.id).toBe('selected'); + expect(after?.title).toBe('Selected Task'); + }); }); diff --git a/src/__tests__/useTaskFeedRefetch.test.tsx b/src/__tests__/useTaskFeedRefetch.test.tsx index 7d574ba..e933f94 100644 --- a/src/__tests__/useTaskFeedRefetch.test.tsx +++ b/src/__tests__/useTaskFeedRefetch.test.tsx @@ -8,6 +8,10 @@ jest.mock('../services/api', () => ({ fetchTasks: jest.fn(), })); +jest.mock('../config/taskFeedCache', () => ({ + TASK_FEED_STALE_MS: 5 * 60 * 1000, +})); + import './__mocks__/setup'; import React from 'react'; @@ -69,10 +73,11 @@ describe('useTaskFeed', () => { (api.fetchTasks as jest.Mock).mockResolvedValue(EMPTY_PAGE); }); - test('does NOT re-fetch on mount when tasks are already in the store', async () => { + test('does NOT re-fetch on mount when warm cache is within TTL', async () => { // Simulate a previous session that already populated the store. useTaskStore.setState({ tasks: [makeTask('existing-1'), makeTask('existing-2')], + tasksLastFetchedAt: new Date().toISOString(), }); let ref!: UseTaskFeedResult; @@ -89,6 +94,53 @@ describe('useTaskFeed', () => { expect(ref.tasks.length).toBe(2); }); + test('silently re-fetches on mount when warm cache is past TTL', async () => { + useTaskStore.setState({ + tasks: [makeTask('existing-1'), makeTask('existing-2')], + tasksLastFetchedAt: new Date( + Date.now() - (5 * 60 * 1000 + 1000), + ).toISOString(), + }); + + let ref!: UseTaskFeedResult; + await act(async () => { + instance = renderer.create( (ref = r)} />); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(api.fetchTasks).toHaveBeenCalledTimes(1); + // No loading skeleton during silent revalidation. + expect(ref.isLoading).toBe(false); + expect(ref.tasks.length).toBe(0); + }); + + test('preserves selectedTask during silent revalidation', async () => { + const selected = makeTask('pinned'); + useTaskStore.setState({ + tasks: [makeTask('existing-1'), selected], + selectedTask: selected, + tasksLastFetchedAt: new Date( + Date.now() - (5 * 60 * 1000 + 1000), + ).toISOString(), + }); + + let ref!: UseTaskFeedResult; + await act(async () => { + instance = renderer.create( (ref = r)} />); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(api.fetchTasks).toHaveBeenCalledTimes(1); + expect(useTaskStore.getState().selectedTask).not.toBeNull(); + expect(useTaskStore.getState().selectedTask?.id).toBe('pinned'); + }); + test('re-fetches exactly once when the filter changes', async () => { await act(async () => { renderer.create( undefined} type="all" />); diff --git a/src/config/taskFeedCache.ts b/src/config/taskFeedCache.ts new file mode 100644 index 0000000..549abff --- /dev/null +++ b/src/config/taskFeedCache.ts @@ -0,0 +1,4 @@ +/** Time window after which a warm MMKV task cache is treated as stale and + * silently revalidated on mount. Exposed as a separate module so tests can + * override it via a Jest module mock. */ +export const TASK_FEED_STALE_MS = 5 * 60 * 1000; diff --git a/src/hooks/useTaskFeed.ts b/src/hooks/useTaskFeed.ts index 8f9479b..9a264b2 100644 --- a/src/hooks/useTaskFeed.ts +++ b/src/hooks/useTaskFeed.ts @@ -5,6 +5,7 @@ import { Task } from '../types'; import { TaskSortMode } from '../utils/sortTasks'; import { enrichTasksWithDistance } from '../utils/geoUtils'; import { normalizeTaskStatus } from '../utils/sortTasks'; +import { TASK_FEED_STALE_MS } from '../config/taskFeedCache'; const LOCATION_DEBOUNCE_MS = 5000; @@ -25,6 +26,7 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { error, page, hasMore, + tasksLastFetchedAt, setTasks, appendTasks, setLoading, @@ -38,6 +40,11 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { const tasksRef = useRef(tasks); tasksRef.current = tasks; + // Keep a ref to the last-fetched timestamp so the mount effect can decide + // whether a warm cache needs silent revalidation without re-running. + const tasksLastFetchedAtRef = useRef(tasksLastFetchedAt); + tasksLastFetchedAtRef.current = tasksLastFetchedAt; + const serverParams = useMemo(() => ({ type, radius }), [type, radius]); const hasLocation = lat !== undefined && lng !== undefined; @@ -48,8 +55,10 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { ); const loadTasks = useCallback( - async (pageNum = 1) => { - setLoading(true); + async (pageNum = 1, silent = false) => { + if (!silent) { + setLoading(true); + } setError(null); try { const params: Record = { @@ -98,7 +107,9 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load tasks'); } finally { - setLoading(false); + if (!silent) { + setLoading(false); + } } }, [ @@ -119,16 +130,27 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { } }, [isLoading, hasMore, page, loadTasks]); - // Initial load: runs exactly once on mount. Only fetch when the persisted - // store has no tasks yet (tasks survive across sessions via MMKV), so we - // don't burn a network request re-fetching data we already hold. + // Initial load: runs exactly once on mount. We fetch when the persisted + // store has no tasks yet (tasks survive across sessions via MMKV), OR when + // the warm cache is stale and should be silently revalidated. A stale cache + // triggers a background fetch that does NOT show a loading skeleton, so the + // user keeps seeing the cached feed until fresh data lands. useEffect(() => { + const lastFetched = tasksLastFetchedAtRef.current; + const isStale = + !lastFetched || + Date.now() - new Date(lastFetched).getTime() > TASK_FEED_STALE_MS; + if (tasksRef.current.length === 0) { void loadTasks(1); + } else if (isStale) { + // Silent background revalidation — keep selectedTask intact and avoid + // the loading skeleton. + void loadTasks(1, true); } else if (hasLocation) { - // Tasks already present: record the current location as "already - // fetched" so the debounced location effect doesn't immediately - // re-fetch on mount. + // Tasks already present and fresh: record the current location as + // "already fetched" so the debounced location effect doesn't + // immediately re-fetch on mount. lastFetchLocationRef.current = { lat: lat as number, lng: lng as number }; } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/store/taskStore.ts b/src/store/taskStore.ts index 52b7019..96419b9 100644 --- a/src/store/taskStore.ts +++ b/src/store/taskStore.ts @@ -19,6 +19,8 @@ export interface PersistedTaskSlice { selectedAt: string | null; page: number; hasMore: boolean; + /** ISO timestamp of the last successful page-1 fetch, used to gate revalidation. */ + tasksLastFetchedAt: string | null; } interface TaskState { @@ -30,6 +32,8 @@ interface TaskState { error: string | null; page: number; hasMore: boolean; + /** ISO timestamp of the last successful page-1 fetch, used to gate revalidation. */ + tasksLastFetchedAt: string | null; setTasks: (tasks: Task[]) => void; appendTasks: (tasks: Task[]) => void; /** @@ -51,7 +55,12 @@ interface TaskState { export function partializeTaskState( state: Pick< TaskState, - 'tasks' | 'selectedTask' | 'selectedAt' | 'page' | 'hasMore' + | 'tasks' + | 'selectedTask' + | 'selectedAt' + | 'page' + | 'hasMore' + | 'tasksLastFetchedAt' >, ): PersistedTaskSlice { return { @@ -60,6 +69,7 @@ export function partializeTaskState( selectedAt: state.selectedAt, page: state.page, hasMore: state.hasMore, + tasksLastFetchedAt: state.tasksLastFetchedAt, }; } @@ -78,6 +88,11 @@ export function sanitizePersistedTaskState( const tasks = Array.isArray(p.tasks) ? p.tasks : []; + const tasksLastFetchedAt = + typeof p.tasksLastFetchedAt === 'string' && p.tasksLastFetchedAt.length > 0 + ? p.tasksLastFetchedAt + : null; + let selectedTask: Task | null = null; let selectedAt: string | null = null; if (p.selectedTask && typeof p.selectedTask === 'object') { @@ -98,6 +113,7 @@ export function sanitizePersistedTaskState( selectedAt: null, page: 1, hasMore: true, + tasksLastFetchedAt: null, }; } @@ -114,7 +130,7 @@ export function sanitizePersistedTaskState( hasMore = false; } - return { tasks, selectedTask, selectedAt, page, hasMore }; + return { tasks, selectedTask, selectedAt, page, hasMore, tasksLastFetchedAt }; } export const useTaskStore = create()( @@ -127,7 +143,9 @@ export const useTaskStore = create()( error: null, page: 1, hasMore: true, - setTasks: tasks => set({ tasks }), + tasksLastFetchedAt: null, + setTasks: tasks => + set({ tasks, tasksLastFetchedAt: new Date().toISOString() }), appendTasks: tasks => set(s => ({ tasks: [ @@ -161,7 +179,14 @@ export const useTaskStore = create()( setError: error => set({ error }), setPage: page => set({ page }), setHasMore: hasMore => set({ hasMore }), - reset: () => set({ tasks: [], page: 1, hasMore: true, error: null }), + reset: () => + set({ + tasks: [], + page: 1, + hasMore: true, + error: null, + tasksLastFetchedAt: null, + }), }), { name: 'task-storage', From 9e1d4d81da7409b965e99da88ad8ae5de25de1b7 Mon Sep 17 00:00:00 2001 From: devprom6 Date: Fri, 28 Aug 2026 19:46:00 +0100 Subject: [PATCH 2/2] test(tasks): remove unused ref in silent revalidation test to fix lint error --- src/__tests__/useTaskFeedRefetch.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/__tests__/useTaskFeedRefetch.test.tsx b/src/__tests__/useTaskFeedRefetch.test.tsx index e933f94..90fe48d 100644 --- a/src/__tests__/useTaskFeedRefetch.test.tsx +++ b/src/__tests__/useTaskFeedRefetch.test.tsx @@ -127,9 +127,8 @@ describe('useTaskFeed', () => { ).toISOString(), }); - let ref!: UseTaskFeedResult; await act(async () => { - instance = renderer.create( (ref = r)} />); + instance = renderer.create( undefined} />); }); await act(async () => {