diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index a61d9de..4d68ba1 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -8,6 +8,7 @@ import { useColorPalette } from "@/context/ColorPaletteContext"; import { useFilters } from "@/context/FilterContext"; import { useMutation } from "@/context/MutationContext"; import { useSettings } from "@/context/SettingsContext"; +import { useMutationVersionEffect } from "@/hooks/useMutationVersionEffect"; import { TodoEditingProvider } from "@/hooks/useTodoEditing"; import { AgendaEntry, @@ -171,7 +172,7 @@ export default function AgendaScreen() { const { groupByCategory, multiDayRangeLength, multiDayPastDays } = useSettings(); const { getCategoryColor } = useColorPalette(); - const isInitialMount = useRef(true); + const requestIdRef = useRef(0); const { width } = useWindowDimensions(); const useCompactDate = width < 400; @@ -569,6 +570,7 @@ export default function AgendaScreen() { if (!api) { return; } + const requestId = ++requestIdRef.current; try { const dateString = formatDateForApi(date); @@ -589,6 +591,7 @@ export default function AgendaScreen() { api.getTodoStates().catch(() => null), api.getAllHabitStatuses(14, 14).catch(() => null), ]); + if (requestId !== requestIdRef.current) return; // Convert multi-day response to single-day format const entries = multiDayData.days[dateString] || []; const agendaData: SingleDayAgendaResponse = { @@ -615,6 +618,7 @@ export default function AgendaScreen() { } setError(null); } catch (err) { + if (requestId !== requestIdRef.current) return; console.error("Failed to load agenda:", err); setError("Failed to load agenda"); } @@ -627,6 +631,7 @@ export default function AgendaScreen() { if (!api) { return; } + const requestId = ++requestIdRef.current; try { const startDateString = formatDateForApi(startDate); @@ -647,6 +652,7 @@ export default function AgendaScreen() { api.getTodoStates().catch(() => null), api.getAllHabitStatuses(14, 14).catch(() => null), ]); + if (requestId !== requestIdRef.current) return; setMultiDayData(multiDayAgendaData); if (statesData) { setTodoStates(statesData); @@ -666,6 +672,7 @@ export default function AgendaScreen() { } setError(null); } catch (err) { + if (requestId !== requestIdRef.current) return; console.error("Failed to load multi-day agenda:", err); setError("Failed to load multi-day agenda"); } @@ -699,18 +706,17 @@ export default function AgendaScreen() { ]); // Refetch when mutations happen elsewhere - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - if (viewMode === "multiday") { - fetchMultiDayAgenda(selectedDate, multiDayRangeLength, showCompleted); - } else { - fetchAgenda(selectedDate, showCompleted); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mutationVersion]); + useMutationVersionEffect( + mutationVersion, + () => { + if (viewMode === "multiday") { + fetchMultiDayAgenda(selectedDate, multiDayRangeLength, showCompleted); + } else { + fetchAgenda(selectedDate, showCompleted); + } + }, + { skipInitial: true }, + ); const goToPrevious = useCallback(() => { const newDate = new Date(selectedDate); diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index 555ad40..6e0c903 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -4,6 +4,7 @@ import { getTodoKey, TodoItem } from "@/components/TodoItem"; import { useApi } from "@/context/ApiContext"; import { useFilters } from "@/context/FilterContext"; import { useMutation } from "@/context/MutationContext"; +import { useMutationVersionEffect } from "@/hooks/useMutationVersionEffect"; import { TodoEditingProvider } from "@/hooks/useTodoEditing"; import { Todo, TodoStatesResponse } from "@/services/api"; import { filterTodos } from "@/utils/filterTodos"; @@ -36,7 +37,7 @@ export default function SearchScreen() { const theme = useTheme(); const { mutationVersion } = useMutation(); const { filters } = useFilters(); - const isInitialMount = useRef(true); + const requestIdRef = useRef(0); const handleTodoUpdated = useCallback( (todo: Todo, updates: Partial) => { @@ -50,12 +51,14 @@ export default function SearchScreen() { const fetchTodos = useCallback(async () => { if (!api) return; + const requestId = ++requestIdRef.current; try { const [todosResponse, statesResponse] = await Promise.all([ api.getAllTodos(), api.getTodoStates().catch(() => null), ]); + if (requestId !== requestIdRef.current) return; setTodos(todosResponse.todos); setFilteredTodos(todosResponse.todos); if (statesResponse) { @@ -63,6 +66,7 @@ export default function SearchScreen() { } setError(null); } catch (err) { + if (requestId !== requestIdRef.current) return; setError("Failed to load todos"); console.error(err); } @@ -73,14 +77,13 @@ export default function SearchScreen() { }, [fetchTodos]); // Refetch when mutations happen elsewhere - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - fetchTodos(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mutationVersion]); + useMutationVersionEffect( + mutationVersion, + () => { + fetchTodos(); + }, + { skipInitial: true }, + ); useEffect(() => { // First apply context filters diff --git a/app/(tabs)/views.tsx b/app/(tabs)/views.tsx index 2624ce8..a704083 100644 --- a/app/(tabs)/views.tsx +++ b/app/(tabs)/views.tsx @@ -4,6 +4,7 @@ import { getTodoKey, TodoItem } from "@/components/TodoItem"; import { useApi } from "@/context/ApiContext"; import { useFilters } from "@/context/FilterContext"; import { useMutation } from "@/context/MutationContext"; +import { useMutationVersionEffect } from "@/hooks/useMutationVersionEffect"; import { TodoEditingProvider } from "@/hooks/useTodoEditing"; import { CustomView, @@ -41,7 +42,7 @@ export default function ViewsScreen() { const theme = useTheme(); const { mutationVersion } = useMutation(); const { filters } = useFilters(); - const isInitialMount = useRef(true); + const requestIdRef = useRef(0); // Apply filters to view entries const filteredEntries = selectedView @@ -67,18 +68,21 @@ export default function ViewsScreen() { const fetchViews = useCallback(async () => { if (!api) return; + const requestId = ++requestIdRef.current; try { const [viewsData, statesData] = await Promise.all([ api.getCustomViews(), api.getTodoStates().catch(() => null), ]); + if (requestId !== requestIdRef.current) return; setViews(viewsData.views); if (statesData) { setTodoStates(statesData); } setError(null); } catch (err) { + if (requestId !== requestIdRef.current) return; setError("Failed to load views"); console.error(err); } @@ -87,17 +91,22 @@ export default function ViewsScreen() { const fetchViewEntries = useCallback( async (key: string) => { if (!api) return; + const requestId = ++requestIdRef.current; setLoading(true); try { const viewData = await api.getCustomView(key); + if (requestId !== requestIdRef.current) return; setSelectedView(viewData); setError(null); } catch (err) { + if (requestId !== requestIdRef.current) return; setError("Failed to load view entries"); console.error(err); } finally { - setLoading(false); + if (requestId === requestIdRef.current) { + setLoading(false); + } } }, [api], @@ -108,18 +117,17 @@ export default function ViewsScreen() { }, [fetchViews]); // Refetch when mutations happen elsewhere - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - if (selectedView) { - fetchViewEntries(selectedView.key); - } else { - fetchViews(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mutationVersion]); + useMutationVersionEffect( + mutationVersion, + () => { + if (selectedView) { + fetchViewEntries(selectedView.key); + } else { + fetchViews(); + } + }, + { skipInitial: true }, + ); const onRefresh = useCallback(async () => { setRefreshing(true); diff --git a/context/TemplatesContext.tsx b/context/TemplatesContext.tsx index 0dc51a2..22c88fb 100644 --- a/context/TemplatesContext.tsx +++ b/context/TemplatesContext.tsx @@ -15,6 +15,7 @@ import React, { useCallback, useContext, useEffect, + useRef, useState, } from "react"; @@ -57,18 +58,21 @@ export function TemplatesProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(null); const { isAuthenticated } = useAuth(); const api = useApi(); + const requestIdRef = useRef(0); const reloadTemplates = useCallback(async () => { if (!api) { return; } + const requestId = ++requestIdRef.current; setIsLoading(true); setError(null); try { // Use the unified /metadata endpoint for all app metadata const metadata = await api.getMetadata(); + if (requestId !== requestIdRef.current) return; // Log any errors from the backend if (metadata.errors && metadata.errors.length > 0) { @@ -102,6 +106,7 @@ export function TemplatesProvider({ children }: { children: ReactNode }) { api.getCategoryTypes(), api.getHabitConfig(), ]); + if (requestId !== requestIdRef.current) return; const [ templatesResult, @@ -153,8 +158,10 @@ export function TemplatesProvider({ children }: { children: ReactNode }) { // exposedFunctions not available in fallback mode (no individual endpoint) setExposedFunctions(null); } finally { - setIsLoading(false); - setHasLoadedOnce(true); + if (requestId === requestIdRef.current) { + setIsLoading(false); + setHasLoadedOnce(true); + } } }, [api]); @@ -171,6 +178,7 @@ export function TemplatesProvider({ children }: { children: ReactNode }) { setHabitConfig(null); setExposedFunctions(null); setHasLoadedOnce(false); + setIsLoading(false); } }, [isAuthenticated, reloadTemplates]); diff --git a/hooks/useMutationVersionEffect.ts b/hooks/useMutationVersionEffect.ts new file mode 100644 index 0000000..1133001 --- /dev/null +++ b/hooks/useMutationVersionEffect.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; + +/** + * Run an effect when mutationVersion changes, without pulling the callback into + * the effect dependency array (avoids "exhaustive-deps" disables). + */ +export function useMutationVersionEffect( + mutationVersion: number, + effect: () => void, + options: { skipInitial?: boolean } = {}, +) { + const effectRef = useRef(effect); + const isInitial = useRef(true); + + useEffect(() => { + effectRef.current = effect; + }, [effect]); + + useEffect(() => { + if (options.skipInitial !== false && isInitial.current) { + isInitial.current = false; + return; + } + effectRef.current(); + }, [mutationVersion, options.skipInitial]); +}