From cfd423e6c54baf40b545c9a942f0472ffcb97e44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 10:09:19 +0000 Subject: [PATCH 1/6] Optimize playlist & favorites queries with incremental fetching and chunking Replace unbounded single-request pattern with incremental fetch + chunking to fix server validation failures and eliminate wasteful refetching: - Create generic useIncrementalQuery hook that tracks fetched UUIDs, only fetches new ones, chunks requests >500 into parallel batches, and accumulates results in a stable cache key with optimistic update support - Refactor useClimbActionsData to use useIncrementalQuery for both favorites and playlist membership queries - Add server-side validation for favorites climbUuids (max 500, matching existing playlistsForClimbs limit) - Update tests with incremental fetch and reorder-stability scenarios https://claude.ai/code/session_0146aSM1evtmv8pnVAazL6FP --- .../graphql/resolvers/favorites/queries.ts | 3 +- packages/backend/src/validation/schemas.ts | 5 + .../__tests__/use-climb-actions-data.test.tsx | 73 +++++-- .../web/app/hooks/use-climb-actions-data.tsx | 200 ++++++++++-------- .../web/app/hooks/use-incremental-query.ts | 171 +++++++++++++++ 5 files changed, 350 insertions(+), 102 deletions(-) create mode 100644 packages/web/app/hooks/use-incremental-query.ts diff --git a/packages/backend/src/graphql/resolvers/favorites/queries.ts b/packages/backend/src/graphql/resolvers/favorites/queries.ts index 0190860b..18a75744 100644 --- a/packages/backend/src/graphql/resolvers/favorites/queries.ts +++ b/packages/backend/src/graphql/resolvers/favorites/queries.ts @@ -3,7 +3,7 @@ import type { ConnectionContext } from '@boardsesh/shared-schema'; import { db } from '../../../db/client'; import * as dbSchema from '@boardsesh/db/schema'; import { requireAuthenticated, validateInput } from '../shared/helpers'; -import { BoardNameSchema } from '../../../validation/schemas'; +import { BoardNameSchema, FavoritesQueryClimbUuidsSchema } from '../../../validation/schemas'; export const favoriteQueries = { /** @@ -20,6 +20,7 @@ export const favoriteQueries = { } validateInput(BoardNameSchema, boardName, 'boardName'); + validateInput(FavoritesQueryClimbUuidsSchema, climbUuids, 'climbUuids'); const favorites = await db .select({ climbUuid: dbSchema.userFavorites.climbUuid }) diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index 832cda35..54fb1d70 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -294,6 +294,11 @@ export const ToggleFavoriteInputSchema = z.object({ angle: z.number().int(), }); +/** + * Favorites query climbUuids validation schema (matches playlistsForClimbs limit) + */ +export const FavoritesQueryClimbUuidsSchema = z.array(ExternalUUIDSchema).min(1).max(500); + // ============================================ // Ticks Schemas // ============================================ diff --git a/packages/web/app/hooks/__tests__/use-climb-actions-data.test.tsx b/packages/web/app/hooks/__tests__/use-climb-actions-data.test.tsx index 053f0382..30e97fef 100644 --- a/packages/web/app/hooks/__tests__/use-climb-actions-data.test.tsx +++ b/packages/web/app/hooks/__tests__/use-climb-actions-data.test.tsx @@ -259,7 +259,7 @@ describe('useClimbActionsData', () => { }); }); - it('addToPlaylist sends mutation and updates local state', async () => { + it('addToPlaylist sends mutation and updates accumulated cache', async () => { mockRequest.mockResolvedValueOnce({ favorites: [] }); mockRequest.mockResolvedValueOnce({ allUserPlaylists: [{ uuid: 'pl-1', name: 'Test', climbCount: 2 }] }); mockRequest.mockResolvedValueOnce({ playlistsForClimbs: [] }); @@ -278,11 +278,13 @@ describe('useClimbActionsData', () => { await result.current.playlistsProviderProps.addToPlaylist('pl-1', 'climb-1', 40); }); - // Membership should be updated locally - expect(result.current.playlistsProviderProps.playlistMemberships.get('climb-1')?.has('pl-1')).toBe(true); + // Membership should be updated via accumulated cache + await waitFor(() => { + expect(result.current.playlistsProviderProps.playlistMemberships.get('climb-1')?.has('pl-1')).toBe(true); + }); }); - it('removeFromPlaylist sends mutation and updates local state', async () => { + it('removeFromPlaylist sends mutation and updates accumulated cache', async () => { mockRequest.mockResolvedValueOnce({ favorites: [] }); mockRequest.mockResolvedValueOnce({ allUserPlaylists: [{ uuid: 'pl-1', name: 'Test', climbCount: 5 }] }); mockRequest.mockResolvedValueOnce({ playlistsForClimbs: [{ climbUuid: 'climb-1', playlistUuids: ['pl-1'] }] }); @@ -291,13 +293,7 @@ describe('useClimbActionsData', () => { const { result } = renderHook(() => useClimbActionsData(defaultOptions), { wrapper }); await waitFor(() => { - expect(result.current.playlistsProviderProps.isLoading).toBe(false); - }); - - // First add to ensure membership exists in local state - mockRequest.mockResolvedValueOnce({ addClimbToPlaylist: { success: true } }); - await act(async () => { - await result.current.playlistsProviderProps.addToPlaylist('pl-1', 'climb-1', 40); + expect(result.current.playlistsProviderProps.playlistMemberships.get('climb-1')?.has('pl-1')).toBe(true); }); // Now remove @@ -306,9 +302,11 @@ describe('useClimbActionsData', () => { await result.current.playlistsProviderProps.removeFromPlaylist('pl-1', 'climb-1'); }); - // Membership should be removed - const memberships = result.current.playlistsProviderProps.playlistMemberships.get('climb-1'); - expect(memberships?.has('pl-1')).toBe(false); + // Membership should be removed via accumulated cache + await waitFor(() => { + const memberships = result.current.playlistsProviderProps.playlistMemberships.get('climb-1'); + expect(memberships?.has('pl-1')).toBe(false); + }); }); it('createPlaylist sends mutation and updates cache', async () => { @@ -368,7 +366,50 @@ describe('useClimbActionsData', () => { }); }); - it('sorts climbUuids for stable query key', async () => { + it('only fetches new UUIDs incrementally', async () => { + // Initial fetch for climb-1 and climb-2 + mockRequest.mockResolvedValueOnce({ favorites: ['climb-1'] }); + mockRequest.mockResolvedValueOnce({ allUserPlaylists: [] }); + mockRequest.mockResolvedValueOnce({ playlistsForClimbs: [] }); + + const wrapper = createQueryWrapper(); + const { result, rerender } = renderHook( + (props) => useClimbActionsData(props), + { + wrapper, + initialProps: defaultOptions, + }, + ); + + await waitFor(() => { + expect(result.current.favoritesProviderProps.isFavorited('climb-1')).toBe(true); + }); + + const callCountAfterInit = mockRequest.mock.calls.length; + + // Add climb-3 — only climb-3 should be fetched, not climb-1 and climb-2 again + mockRequest.mockResolvedValueOnce({ favorites: ['climb-3'] }); + mockRequest.mockResolvedValueOnce({ playlistsForClimbs: [] }); + + rerender({ ...defaultOptions, climbUuids: ['climb-1', 'climb-2', 'climb-3'] }); + + await waitFor(() => { + expect(result.current.favoritesProviderProps.isFavorited('climb-3')).toBe(true); + }); + + // The favorites fetch should only include climb-3 (not climb-1, climb-2) + const favCalls = mockRequest.mock.calls.filter( + (call: any[]) => call[0] === 'GET_FAVORITES', + ); + // Second favorites call should only contain the new UUID + const lastFavCall = favCalls[favCalls.length - 1]; + expect(lastFavCall[1].climbUuids).toEqual(['climb-3']); + + // Original favorites should still be available + expect(result.current.favoritesProviderProps.isFavorited('climb-1')).toBe(true); + }); + + it('does not refetch when UUIDs are reordered', async () => { mockRequest.mockResolvedValueOnce({ favorites: [] }); mockRequest.mockResolvedValueOnce({ allUserPlaylists: [] }); mockRequest.mockResolvedValueOnce({ playlistsForClimbs: [] }); @@ -395,7 +436,7 @@ describe('useClimbActionsData', () => { // Wait a tick to ensure no new requests await waitFor(() => { - // Should not have made additional favorites request since sorted keys are identical + // Should not have made additional favorites request since UUIDs are already fetched expect(mockRequest.mock.calls.length).toBe(callCount); }); }); diff --git a/packages/web/app/hooks/use-climb-actions-data.tsx b/packages/web/app/hooks/use-climb-actions-data.tsx index 79994e58..2801f79f 100644 --- a/packages/web/app/hooks/use-climb-actions-data.tsx +++ b/packages/web/app/hooks/use-climb-actions-data.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useState, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; import { useSnackbar } from '@/app/components/providers/snackbar-provider'; @@ -24,6 +24,7 @@ import { type CreatePlaylistMutationResponse, type Playlist, } from '@/app/lib/graphql/operations/playlists'; +import { useIncrementalQuery } from '@/app/hooks/use-incremental-query'; interface UseClimbActionsDataOptions { boardName: string; @@ -32,6 +33,26 @@ interface UseClimbActionsDataOptions { climbUuids: string[]; } +// Merge helpers (stable references to avoid re-creating on every render) +const mergeSetFn = (acc: Set, fetched: Set): Set => + new Set([...acc, ...fetched]); + +const mergeMapFn = ( + acc: Map>, + fetched: Map>, +): Map> => new Map([...acc, ...fetched]); + +const hasSetChanged = (prev: Set, next: Set): boolean => + prev.size !== next.size; + +const hasMapChanged = ( + prev: Map>, + next: Map>, +): boolean => prev.size !== next.size; + +const EMPTY_SET = new Set(); +const EMPTY_MAP = new Map>(); + export function useClimbActionsData({ boardName, layoutId, @@ -42,60 +63,57 @@ export function useClimbActionsData({ const { showMessage } = useSnackbar(); const queryClient = useQueryClient(); - // Stable sorted UUIDs to prevent unnecessary re-fetches - const sortedClimbUuids = useMemo(() => [...climbUuids].sort(), [climbUuids]); - - // === Favorites === + // === Favorites (incremental) === - const favoritesQueryKey = useMemo( - () => ['favorites', boardName, angle, sortedClimbUuids.join(',')] as const, - [boardName, angle, sortedClimbUuids], + const favAccKey = useMemo( + () => ['favorites', boardName, angle, 'accumulated'] as const, + [boardName, angle], + ); + const favFetchKeyPrefix = useMemo( + () => ['favorites', boardName, angle, 'fetch'] as const, + [boardName, angle], ); - const { data: favoritesData, isLoading: isLoadingFavorites } = useQuery({ - queryKey: favoritesQueryKey, - queryFn: async (): Promise> => { - if (sortedClimbUuids.length === 0) return new Set(); + const favFetchChunk = useCallback( + async (uuids: string[]): Promise> => { const client = createGraphQLHttpClient(token); - try { - const result = await client.request(GET_FAVORITES, { - boardName, - climbUuids: sortedClimbUuids, - angle, - }); - return new Set(result.favorites); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error(`[GraphQL] Favorites query error for ${boardName}:`, error); - throw new Error(`Failed to fetch favorites: ${errorMessage}`); - } + const result = await client.request(GET_FAVORITES, { + boardName, + climbUuids: uuids, + angle, + }); + return new Set(result.favorites); }, - enabled: isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0 && !!boardName, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); + [token, boardName, angle], + ); - const favorites = favoritesData ?? new Set(); + const { data: favorites, isLoading: isLoadingFavorites } = useIncrementalQuery>( + climbUuids, + { + accumulatedKey: favAccKey, + fetchKeyPrefix: favFetchKeyPrefix, + enabled: isAuthenticated && !isAuthLoading && !!boardName, + fetchChunk: favFetchChunk, + merge: mergeSetFn, + initialValue: EMPTY_SET, + hasChanged: hasSetChanged, + }, + ); + // Toggle favorite mutation — targets the accumulated cache key const toggleFavoriteMutation = useMutation({ mutationKey: ['toggleFavorite', boardName, angle], mutationFn: async (climbUuid: string): Promise<{ uuid: string; favorited: boolean }> => { const client = createGraphQLHttpClient(token); - try { - const result = await client.request(TOGGLE_FAVORITE, { - input: { boardName, climbUuid, angle }, - }); - return { uuid: climbUuid, favorited: result.toggleFavorite.favorited }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error(`[GraphQL] Toggle favorite error for climb ${climbUuid}:`, error); - throw new Error(`Failed to toggle favorite: ${errorMessage}`); - } + const result = await client.request(TOGGLE_FAVORITE, { + input: { boardName, climbUuid, angle }, + }); + return { uuid: climbUuid, favorited: result.toggleFavorite.favorited }; }, onMutate: async (climbUuid: string) => { - await queryClient.cancelQueries({ queryKey: favoritesQueryKey }); - const previousFavorites = queryClient.getQueryData>(favoritesQueryKey); - queryClient.setQueryData>(favoritesQueryKey, (old) => { + await queryClient.cancelQueries({ queryKey: favAccKey }); + const previousFavorites = queryClient.getQueryData>(favAccKey); + queryClient.setQueryData>(favAccKey, (old) => { const next = new Set(old); if (next.has(climbUuid)) { next.delete(climbUuid); @@ -109,7 +127,7 @@ export function useClimbActionsData({ onError: (err, climbUuid, context) => { console.error(`[Favorites] Error toggling favorite for climb ${climbUuid}:`, err); if (context?.previousFavorites) { - queryClient.setQueryData(favoritesQueryKey, context.previousFavorites); + queryClient.setQueryData(favAccKey, context.previousFavorites); } showMessage('Failed to update favorite. Please try again.', 'error'); }, @@ -131,11 +149,7 @@ export function useClimbActionsData({ // === Playlists === - const [playlistMemberships, setPlaylistMemberships] = useState>>( - new Map(), - ); - - // Fetch user's playlists (all boards) + // Fetch user's playlists (all boards) — not incremental, just a simple query const playlistsQueryKey = useMemo(() => ['userPlaylists', token] as const, [token]); const { data: playlists = [], isLoading: playlistsLoading } = useQuery({ @@ -151,37 +165,52 @@ export function useClimbActionsData({ staleTime: 5 * 60 * 1000, }); - // Fetch playlist memberships for visible climbs - const climbUuidsKey = useMemo(() => sortedClimbUuids.join(','), [sortedClimbUuids]); + // === Playlist Memberships (incremental) === - const { data: membershipsData } = useQuery({ - queryKey: ['playlistMemberships', boardName, layoutId, climbUuidsKey], - queryFn: async (): Promise>> => { - if (sortedClimbUuids.length === 0) return new Map(); - const client = createGraphQLHttpClient(token); + const memAccKey = useMemo( + () => ['playlistMemberships', boardName, layoutId, 'accumulated'] as const, + [boardName, layoutId], + ); + const memFetchKeyPrefix = useMemo( + () => ['playlistMemberships', boardName, layoutId, 'fetch'] as const, + [boardName, layoutId], + ); + const memFetchChunk = useCallback( + async (uuids: string[]): Promise>> => { + const client = createGraphQLHttpClient(token); const response = await client.request( GET_PLAYLISTS_FOR_CLIMBS, - { input: { boardType: boardName, layoutId, climbUuids: sortedClimbUuids } }, + { input: { boardType: boardName, layoutId, climbUuids: uuids } }, ); - const memberships = new Map>(); for (const entry of response.playlistsForClimbs) { memberships.set(entry.climbUuid, new Set(entry.playlistUuids)); } return memberships; }, - enabled: - isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0 && !!boardName && layoutId > 0 && boardName !== 'moonboard', - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); + [token, boardName, layoutId], + ); - // Merge query data with local optimistic state - const effectiveMemberships = membershipsData - ? new Map([...membershipsData, ...playlistMemberships]) - : playlistMemberships; + const { data: membershipsData } = useIncrementalQuery>>( + climbUuids, + { + accumulatedKey: memAccKey, + fetchKeyPrefix: memFetchKeyPrefix, + enabled: + isAuthenticated && + !isAuthLoading && + !!boardName && + layoutId > 0 && + boardName !== 'moonboard', + fetchChunk: memFetchChunk, + merge: mergeMapFn, + initialValue: EMPTY_MAP, + hasChanged: hasMapChanged, + }, + ); + // Playlist mutations — update the accumulated membership cache const addToPlaylist = useCallback( async (playlistId: string, climbUuid: string, climbAngle: number) => { if (!token) throw new Error('Not authenticated'); @@ -189,18 +218,18 @@ export function useClimbActionsData({ await client.request(ADD_CLIMB_TO_PLAYLIST, { input: { playlistId, climbUuid, angle: climbAngle }, }); - setPlaylistMemberships((prev) => { - const updated = new Map(prev); - const current = updated.get(climbUuid) || new Set(); - current.add(playlistId); - updated.set(climbUuid, current); - return updated; - }); + // Update accumulated membership cache (triggers cache subscription in useIncrementalQuery) + const prevMem = queryClient.getQueryData>>(memAccKey) ?? new Map(); + const updatedMem = new Map(prevMem); + const currentSet = new Set(updatedMem.get(climbUuid) || []); + currentSet.add(playlistId); + updatedMem.set(climbUuid, currentSet); + queryClient.setQueryData(memAccKey, updatedMem); queryClient.setQueryData(playlistsQueryKey, (prev) => prev?.map((p) => (p.uuid === playlistId ? { ...p, climbCount: p.climbCount + 1 } : p)), ); }, - [token, playlistsQueryKey, queryClient], + [token, memAccKey, playlistsQueryKey, queryClient], ); const removeFromPlaylist = useCallback( @@ -210,22 +239,23 @@ export function useClimbActionsData({ await client.request(REMOVE_CLIMB_FROM_PLAYLIST, { input: { playlistId, climbUuid }, }); - setPlaylistMemberships((prev) => { - const updated = new Map(prev); - const current = updated.get(climbUuid); - if (current) { - current.delete(playlistId); - updated.set(climbUuid, current); - } - return updated; - }); + // Update accumulated membership cache (triggers cache subscription in useIncrementalQuery) + const prevMem = queryClient.getQueryData>>(memAccKey) ?? new Map(); + const updatedMem = new Map(prevMem); + const currentPlaylists = updatedMem.get(climbUuid); + if (currentPlaylists) { + const next = new Set(currentPlaylists); + next.delete(playlistId); + updatedMem.set(climbUuid, next); + } + queryClient.setQueryData(memAccKey, updatedMem); queryClient.setQueryData(playlistsQueryKey, (prev) => prev?.map((p) => p.uuid === playlistId ? { ...p, climbCount: Math.max(0, p.climbCount - 1) } : p, ), ); }, - [token, playlistsQueryKey, queryClient], + [token, memAccKey, playlistsQueryKey, queryClient], ); const createPlaylist = useCallback( @@ -262,7 +292,7 @@ export function useClimbActionsData({ }, playlistsProviderProps: { playlists, - playlistMemberships: effectiveMemberships, + playlistMemberships: membershipsData, addToPlaylist, removeFromPlaylist, createPlaylist, diff --git a/packages/web/app/hooks/use-incremental-query.ts b/packages/web/app/hooks/use-incremental-query.ts new file mode 100644 index 00000000..a22b211b --- /dev/null +++ b/packages/web/app/hooks/use-incremental-query.ts @@ -0,0 +1,171 @@ +'use client'; + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useEffect, useMemo, useState } from 'react'; + +const DEFAULT_CHUNK_SIZE = 500; + +interface UseIncrementalQueryOptions { + /** Stable cache key for accumulated results (no UUIDs in key) */ + accumulatedKey: readonly unknown[]; + /** Prefix for dynamic fetch keys — UUIDs are appended automatically */ + fetchKeyPrefix: readonly unknown[]; + /** Whether the query is enabled (auth check, etc.) */ + enabled: boolean; + /** Max items per request chunk (default 500, matches server validation limit) */ + chunkSize?: number; + /** Fetches data for a chunk of UUIDs, returns the raw result */ + fetchChunk: (uuids: string[]) => Promise; + /** Merges new fetch result into accumulated state, returns new accumulated state */ + merge: (accumulated: T, fetched: T) => T; + /** Empty/initial value for T */ + initialValue: T; + /** Whether accumulated state has changed after merge (to avoid unnecessary re-renders) */ + hasChanged: (prev: T, next: T) => boolean; +} + +function chunkArray(arr: U[], size: number): U[][] { + const chunks: U[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +} + +/** + * Generic incremental fetch hook. + * + * Tracks which UUIDs have already been fetched, only fetches new ones, + * chunks large requests to respect server limits, and accumulates results + * in local state + a stable React Query cache entry. + * + * Follows the proven pattern from useLogbook (packages/web/app/hooks/use-logbook.ts). + * Compatible with optimistic updates via cache subscription. + */ +export function useIncrementalQuery( + uuids: string[], + options: UseIncrementalQueryOptions, +): { data: T; isLoading: boolean } { + const { + accumulatedKey, + fetchKeyPrefix, + enabled, + chunkSize = DEFAULT_CHUNK_SIZE, + fetchChunk, + merge, + initialValue, + hasChanged, + } = options; + + const queryClient = useQueryClient(); + const fetchedUuidsRef = useRef>(new Set()); + const [invalidationCount, setInvalidationCount] = useState(0); + + // Determine which UUIDs haven't been fetched yet. + // invalidationCount forces recomputation after cache invalidation clears + // fetchedUuidsRef, since uuids/enabled may not have changed. + const newUuids = useMemo( + () => (enabled ? uuids.filter((uuid) => !fetchedUuidsRef.current.has(uuid)) : []), + [uuids, enabled, invalidationCount], + ); + + // Dynamic fetch key includes only the new UUIDs + const fetchKey = useMemo( + () => [...fetchKeyPrefix, [...newUuids].sort().join(',')] as const, + [fetchKeyPrefix, newUuids], + ); + + // Fetch only the new UUIDs, chunking if necessary + const fetchQuery = useQuery({ + queryKey: fetchKey, + queryFn: async ({ queryKey }: { queryKey: readonly unknown[] }): Promise => { + // Extract UUIDs from query key to avoid stale closure issues + const uuidsString = queryKey[queryKey.length - 1] as string; + const uuidsToFetch = uuidsString ? uuidsString.split(',') : []; + + if (uuidsToFetch.length === 0) return initialValue; + + const chunks = chunkArray(uuidsToFetch, chunkSize); + + if (chunks.length === 1) { + return fetchChunk(chunks[0]); + } + + // Parallel fetch for multiple chunks, then merge + const results = await Promise.all(chunks.map((chunk) => fetchChunk(chunk))); + return results.reduce((acc, result) => merge(acc, result), initialValue); + }, + enabled: enabled && newUuids.length > 0, + // Each batch is fetched once; accumulation handles deduplication + staleTime: Infinity, + }); + + // Accumulated state — direct useState for guaranteed re-renders + const [accumulated, setAccumulated] = useState(initialValue); + + // When fetch completes, merge new entries into state and cache. + // IMPORTANT: Mark UUIDs as fetched here (not in queryFn) so the query key + // remains stable until the data is consumed. If we mutated the ref inside + // queryFn, useMemo would recompute newUuids on the re-render triggered by + // the resolved query, changing the query key before the data could be read. + const lastMergedRef = useRef(undefined); + useEffect(() => { + if (!fetchQuery.data || fetchQuery.data === lastMergedRef.current) return; + lastMergedRef.current = fetchQuery.data; + + // Mark these UUIDs as fetched (including those with no results) + newUuids.forEach((uuid) => fetchedUuidsRef.current.add(uuid)); + + const merged = merge(accumulated, fetchQuery.data); + if (hasChanged(accumulated, merged)) { + setAccumulated(merged); + queryClient.setQueryData(accumulatedKey, merged); + } + }, [fetchQuery.data, newUuids, accumulated, merge, hasChanged, accumulatedKey, queryClient]); + + // Subscribe to cache changes for the accumulated key only. + // Handles two scenarios: + // 1. Optimistic updates (setQueriesData modifies the cache externally) + // 2. Cache invalidation (removeQueries) — resets fetchedUuidsRef so all + // UUIDs are re-fetched on the next render. + useEffect(() => { + const key = accumulatedKey; + + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + // Only react to events for our accumulated key + const qk = event.query.queryKey; + if (qk.length !== key.length || qk.some((v, i) => v !== key[i])) return; + + if (event.type === 'removed') { + // Cache was cleared — reset tracking so all current UUIDs are re-fetched + fetchedUuidsRef.current = new Set(); + lastMergedRef.current = undefined; + setAccumulated(initialValue); + setInvalidationCount((c) => c + 1); + } else if (event.type === 'updated') { + const cached = queryClient.getQueryData(key); + if (cached !== undefined) { + setAccumulated(cached); + } + } + }); + return unsubscribe; + }, [queryClient, accumulatedKey, initialValue]); + + // Reset when disabled (e.g., user logs out) so a different user logging in + // doesn't see stale data. Uses removeQueries to also clear fetch cache entries. + useEffect(() => { + if (!enabled) { + fetchedUuidsRef.current = new Set(); + lastMergedRef.current = undefined; + setAccumulated(initialValue); + queryClient.removeQueries({ queryKey: fetchKeyPrefix }); + queryClient.removeQueries({ queryKey: accumulatedKey }); + } + }, [enabled, fetchKeyPrefix, accumulatedKey, initialValue, queryClient]); + + return { + data: accumulated, + isLoading: fetchQuery.isLoading && !hasChanged(initialValue, accumulated), + }; +} From 064c0e941faa035c8a4eca711a0c8f6d9c9b987d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 10:09:58 +0000 Subject: [PATCH 2/6] Update package-lock.json after dependency install https://claude.ai/code/session_0146aSM1evtmv8pnVAazL6FP --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc9ac208..771d0b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5178,7 +5178,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.58.2" @@ -8627,7 +8627,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -11748,7 +11748,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.2" @@ -11767,7 +11767,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" From 57fc718952d14f8480a705bb14b33d64a42364e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 10:21:06 +0000 Subject: [PATCH 3/6] Fix redundant cache updates, restore error logging, add useIncrementalQuery tests - Add lastCacheWriteRef guard to skip self-triggered cache subscription events, preventing redundant setAccumulated calls from the merge effect's own write - Restore try-catch error logging in fetchChunk callbacks for both favorites and playlist memberships to aid debugging - Add comprehensive useIncrementalQuery unit tests covering chunking, chunk failure, cache invalidation, optimistic updates, auth reset, and incremental fetch behavior https://claude.ai/code/session_0146aSM1evtmv8pnVAazL6FP --- .../__tests__/use-incremental-query.test.tsx | 301 ++++++++++++++++++ .../web/app/hooks/use-climb-actions-data.tsx | 38 ++- .../web/app/hooks/use-incremental-query.ts | 12 + 3 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 packages/web/app/hooks/__tests__/use-incremental-query.test.tsx diff --git a/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx new file mode 100644 index 00000000..cf6886e2 --- /dev/null +++ b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useIncrementalQuery } from '../use-incremental-query'; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, retryDelay: 0, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }); +} + +function createWrapper(queryClient?: QueryClient) { + const qc = queryClient ?? createTestQueryClient(); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'QueryClientWrapper'; + return { wrapper: Wrapper, queryClient: qc }; +} + +// Set-based helpers for testing +const mergeSet = (a: Set, b: Set) => new Set([...a, ...b]); +const hasSetChanged = (a: Set, b: Set) => a.size !== b.size; +const EMPTY_SET = new Set(); + +describe('useIncrementalQuery', () => { + let mockFetchChunk: ReturnType; + + beforeEach(() => { + mockFetchChunk = vi.fn(); + }); + + const defaultOptions = (overrides = {}) => ({ + accumulatedKey: ['test', 'accumulated'] as const, + fetchKeyPrefix: ['test', 'fetch'] as const, + enabled: true, + fetchChunk: mockFetchChunk, + merge: mergeSet, + initialValue: EMPTY_SET, + hasChanged: hasSetChanged, + ...overrides, + }); + + it('fetches data for provided UUIDs', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper } = createWrapper(); + + const { result } = renderHook( + () => useIncrementalQuery(['a', 'b'], defaultOptions()), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + expect(mockFetchChunk).toHaveBeenCalledTimes(1); + expect(mockFetchChunk).toHaveBeenCalledWith(['a', 'b']); + }); + + it('returns initialValue and isLoading=false when disabled', () => { + const { wrapper } = createWrapper(); + const { result } = renderHook( + () => useIncrementalQuery(['a'], defaultOptions({ enabled: false })), + { wrapper }, + ); + + expect(result.current.data.size).toBe(0); + expect(result.current.isLoading).toBe(false); + expect(mockFetchChunk).not.toHaveBeenCalled(); + }); + + it('does not fetch when UUIDs array is empty', () => { + const { wrapper } = createWrapper(); + renderHook( + () => useIncrementalQuery([], defaultOptions()), + { wrapper }, + ); + + expect(mockFetchChunk).not.toHaveBeenCalled(); + }); + + it('incrementally fetches only new UUIDs', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper } = createWrapper(); + + const { result, rerender } = renderHook( + ({ uuids }) => useIncrementalQuery(uuids, defaultOptions()), + { wrapper, initialProps: { uuids: ['a', 'b'] } }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + expect(mockFetchChunk).toHaveBeenCalledTimes(1); + + // Add a new UUID — only 'c' should be fetched + mockFetchChunk.mockResolvedValueOnce(new Set(['c'])); + rerender({ uuids: ['a', 'b', 'c'] }); + + await waitFor(() => { + expect(result.current.data.has('c')).toBe(true); + }); + expect(mockFetchChunk).toHaveBeenCalledTimes(2); + expect(mockFetchChunk).toHaveBeenLastCalledWith(['c']); + + // Original data still present + expect(result.current.data.has('a')).toBe(true); + }); + + it('does not refetch already-fetched UUIDs when reordered', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper } = createWrapper(); + + const { result, rerender } = renderHook( + ({ uuids }) => useIncrementalQuery(uuids, defaultOptions()), + { wrapper, initialProps: { uuids: ['b', 'a'] } }, + ); + + await waitFor(() => { + expect(result.current.data.size).toBe(1); + }); + const callCount = mockFetchChunk.mock.calls.length; + + // Same UUIDs, different order + rerender({ uuids: ['a', 'b'] }); + + // Should not trigger a new fetch + await waitFor(() => { + expect(mockFetchChunk.mock.calls.length).toBe(callCount); + }); + }); + + it('chunks large UUID arrays into parallel requests', async () => { + // Create 600 UUIDs — should produce 2 chunks (500 + 100) + const uuids = Array.from({ length: 600 }, (_, i) => `uuid-${i}`); + mockFetchChunk + .mockResolvedValueOnce(new Set(['uuid-0'])) + .mockResolvedValueOnce(new Set(['uuid-500'])); + + const { wrapper } = createWrapper(); + const { result } = renderHook( + () => useIncrementalQuery(uuids, defaultOptions({ chunkSize: 500 })), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.data.has('uuid-0')).toBe(true); + expect(result.current.data.has('uuid-500')).toBe(true); + }); + + // Should have been called with two chunks + expect(mockFetchChunk).toHaveBeenCalledTimes(2); + expect(mockFetchChunk.mock.calls[0][0]).toHaveLength(500); + expect(mockFetchChunk.mock.calls[1][0]).toHaveLength(100); + }); + + it('handles chunk failure gracefully (React Query manages error state)', async () => { + mockFetchChunk.mockRejectedValueOnce(new Error('Network error')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { wrapper } = createWrapper(); + const { result } = renderHook( + () => useIncrementalQuery(['a'], defaultOptions()), + { wrapper }, + ); + + // Data stays at initial value, isLoading eventually goes false + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.data.size).toBe(0); + + vi.restoreAllMocks(); + }); + + it('handles partial chunk failure (all-or-nothing per batch)', async () => { + // With 2 chunks, if Promise.all rejects (one chunk fails), the entire batch fails + const uuids = Array.from({ length: 600 }, (_, i) => `uuid-${i}`); + mockFetchChunk + .mockResolvedValueOnce(new Set(['uuid-0'])) + .mockRejectedValueOnce(new Error('Second chunk failed')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { wrapper } = createWrapper(); + const { result } = renderHook( + () => useIncrementalQuery(uuids, defaultOptions({ chunkSize: 500 })), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + // Neither chunk's results should be accumulated since Promise.all failed + expect(result.current.data.size).toBe(0); + + vi.restoreAllMocks(); + }); + + it('resets state when enabled becomes false', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper } = createWrapper(); + + const { result, rerender } = renderHook( + ({ enabled }) => useIncrementalQuery(['a'], defaultOptions({ enabled })), + { wrapper, initialProps: { enabled: true } }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + + // Disable (simulates logout) + rerender({ enabled: false }); + + await waitFor(() => { + expect(result.current.data.size).toBe(0); + }); + }); + + it('re-fetches all UUIDs after re-enable', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper } = createWrapper(); + + const { result, rerender } = renderHook( + ({ enabled }) => useIncrementalQuery(['a', 'b'], defaultOptions({ enabled })), + { wrapper, initialProps: { enabled: true } }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + + // Disable then re-enable — should re-fetch both UUIDs + rerender({ enabled: false }); + await waitFor(() => expect(result.current.data.size).toBe(0)); + + mockFetchChunk.mockResolvedValueOnce(new Set(['a', 'b'])); + rerender({ enabled: true }); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + expect(result.current.data.has('b')).toBe(true); + }); + // Should have been fetched again (not skipped as "already fetched") + expect(mockFetchChunk).toHaveBeenCalledTimes(2); + }); + + it('picks up external cache updates (optimistic updates)', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper, queryClient } = createWrapper(); + + const { result } = renderHook( + () => useIncrementalQuery(['a'], defaultOptions()), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + + // Simulate an external optimistic update + act(() => { + queryClient.setQueryData(['test', 'accumulated'], new Set(['a', 'x'])); + }); + + await waitFor(() => { + expect(result.current.data.has('x')).toBe(true); + }); + }); + + it('resets and re-fetches after cache invalidation (removal)', async () => { + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper, queryClient } = createWrapper(); + + const { result } = renderHook( + () => useIncrementalQuery(['a'], defaultOptions()), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + + // Remove ALL test queries (both accumulated and fetch caches) to simulate + // full invalidation, matching the useInvalidateLogbook pattern. + // This clears the stale fetch cache so re-fetch actually calls fetchChunk again. + mockFetchChunk.mockResolvedValueOnce(new Set(['a', 'refreshed'])); + act(() => { + queryClient.removeQueries({ queryKey: ['test'] }); + }); + + // Should re-fetch all UUIDs after invalidation + await waitFor(() => { + expect(result.current.data.has('refreshed')).toBe(true); + }); + }); +}); diff --git a/packages/web/app/hooks/use-climb-actions-data.tsx b/packages/web/app/hooks/use-climb-actions-data.tsx index 2801f79f..8a33d01a 100644 --- a/packages/web/app/hooks/use-climb-actions-data.tsx +++ b/packages/web/app/hooks/use-climb-actions-data.tsx @@ -77,12 +77,17 @@ export function useClimbActionsData({ const favFetchChunk = useCallback( async (uuids: string[]): Promise> => { const client = createGraphQLHttpClient(token); - const result = await client.request(GET_FAVORITES, { - boardName, - climbUuids: uuids, - angle, - }); - return new Set(result.favorites); + try { + const result = await client.request(GET_FAVORITES, { + boardName, + climbUuids: uuids, + angle, + }); + return new Set(result.favorites); + } catch (error) { + console.error(`[GraphQL] Favorites query error for ${boardName} (${uuids.length} uuids):`, error); + throw error; + } }, [token, boardName, angle], ); @@ -179,15 +184,20 @@ export function useClimbActionsData({ const memFetchChunk = useCallback( async (uuids: string[]): Promise>> => { const client = createGraphQLHttpClient(token); - const response = await client.request( - GET_PLAYLISTS_FOR_CLIMBS, - { input: { boardType: boardName, layoutId, climbUuids: uuids } }, - ); - const memberships = new Map>(); - for (const entry of response.playlistsForClimbs) { - memberships.set(entry.climbUuid, new Set(entry.playlistUuids)); + try { + const response = await client.request( + GET_PLAYLISTS_FOR_CLIMBS, + { input: { boardType: boardName, layoutId, climbUuids: uuids } }, + ); + const memberships = new Map>(); + for (const entry of response.playlistsForClimbs) { + memberships.set(entry.climbUuid, new Set(entry.playlistUuids)); + } + return memberships; + } catch (error) { + console.error(`[GraphQL] Playlist memberships query error for ${boardName} (${uuids.length} uuids):`, error); + throw error; } - return memberships; }, [token, boardName, layoutId], ); diff --git a/packages/web/app/hooks/use-incremental-query.ts b/packages/web/app/hooks/use-incremental-query.ts index a22b211b..94862ea8 100644 --- a/packages/web/app/hooks/use-incremental-query.ts +++ b/packages/web/app/hooks/use-incremental-query.ts @@ -109,6 +109,9 @@ export function useIncrementalQuery( // queryFn, useMemo would recompute newUuids on the re-render triggered by // the resolved query, changing the query key before the data could be read. const lastMergedRef = useRef(undefined); + // Tracks the value we last wrote to the cache ourselves, so the subscription + // can skip the self-triggered 'updated' event (avoids redundant setAccumulated calls). + const lastCacheWriteRef = useRef(undefined); useEffect(() => { if (!fetchQuery.data || fetchQuery.data === lastMergedRef.current) return; lastMergedRef.current = fetchQuery.data; @@ -119,6 +122,7 @@ export function useIncrementalQuery( const merged = merge(accumulated, fetchQuery.data); if (hasChanged(accumulated, merged)) { setAccumulated(merged); + lastCacheWriteRef.current = merged; queryClient.setQueryData(accumulatedKey, merged); } }, [fetchQuery.data, newUuids, accumulated, merge, hasChanged, accumulatedKey, queryClient]); @@ -140,11 +144,18 @@ export function useIncrementalQuery( // Cache was cleared — reset tracking so all current UUIDs are re-fetched fetchedUuidsRef.current = new Set(); lastMergedRef.current = undefined; + lastCacheWriteRef.current = undefined; setAccumulated(initialValue); setInvalidationCount((c) => c + 1); } else if (event.type === 'updated') { const cached = queryClient.getQueryData(key); if (cached !== undefined) { + // Skip the self-triggered event from our own merge effect (same reference + // we just wrote), but clear the ref so subsequent writes are not filtered. + if (cached === lastCacheWriteRef.current) { + lastCacheWriteRef.current = undefined; + return; + } setAccumulated(cached); } } @@ -158,6 +169,7 @@ export function useIncrementalQuery( if (!enabled) { fetchedUuidsRef.current = new Set(); lastMergedRef.current = undefined; + lastCacheWriteRef.current = undefined; setAccumulated(initialValue); queryClient.removeQueries({ queryKey: fetchKeyPrefix }); queryClient.removeQueries({ queryKey: accumulatedKey }); From 2084a05fe25d7e896cd198b42e16011a41c92a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 11:33:40 +0000 Subject: [PATCH 4/6] Fix stale data on context switch and race condition on optimistic toggle P1 fixes: - Reset fetchedUuidsRef when accumulatedKey identity changes (e.g., user switches board or angle). Previously, same UUID list with a different context would reuse stale accumulated results from the old context. - Cancel in-flight fetch queries in onMutate (not just accumulated key) to prevent a completing fetch from overwriting the optimistic update. Expose cancelFetches() from useIncrementalQuery for this purpose. Minor: - Rename hasSetChanged/hasMapChanged to hasSetSizeChanged/hasMapSizeChanged with comments clarifying the size-only comparison is intentional for performance (merge always grows, so size change is a reliable signal). - Add test for context switch reset behavior. https://claude.ai/code/session_0146aSM1evtmv8pnVAazL6FP --- .../__tests__/use-incremental-query.test.tsx | 42 ++++++++++++ .../web/app/hooks/use-climb-actions-data.tsx | 20 ++++-- .../web/app/hooks/use-incremental-query.ts | 68 +++++++++++++++++-- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx index cf6886e2..ab2a16a8 100644 --- a/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx +++ b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx @@ -272,6 +272,48 @@ describe('useIncrementalQuery', () => { }); }); + it('resets fetched tracking when accumulatedKey changes (context switch)', async () => { + // First context: fetch with key ['ctx1', 'accumulated'] + mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); + const { wrapper } = createWrapper(); + + const { result, rerender } = renderHook( + ({ accKey, fetchPrefix }) => + useIncrementalQuery(['a', 'b'], defaultOptions({ + accumulatedKey: accKey, + fetchKeyPrefix: fetchPrefix, + })), + { + wrapper, + initialProps: { + accKey: ['ctx1', 'accumulated'] as const, + fetchPrefix: ['ctx1', 'fetch'] as const, + }, + }, + ); + + await waitFor(() => { + expect(result.current.data.has('a')).toBe(true); + }); + + // Switch context — same UUIDs, different key. Should re-fetch all UUIDs + // because the old fetchedUuidsRef is stale for the new context. + mockFetchChunk.mockResolvedValueOnce(new Set(['a', 'b'])); + rerender({ + accKey: ['ctx2', 'accumulated'] as const, + fetchPrefix: ['ctx2', 'fetch'] as const, + }); + + await waitFor(() => { + expect(result.current.data.has('b')).toBe(true); + }); + // Should have fetched again with all UUIDs (not skipped 'a' and 'b') + expect(mockFetchChunk).toHaveBeenCalledTimes(2); + const lastCall = mockFetchChunk.mock.calls[1][0]; + expect(lastCall).toContain('a'); + expect(lastCall).toContain('b'); + }); + it('resets and re-fetches after cache invalidation (removal)', async () => { mockFetchChunk.mockResolvedValueOnce(new Set(['a'])); const { wrapper, queryClient } = createWrapper(); diff --git a/packages/web/app/hooks/use-climb-actions-data.tsx b/packages/web/app/hooks/use-climb-actions-data.tsx index 8a33d01a..b90ed6dd 100644 --- a/packages/web/app/hooks/use-climb-actions-data.tsx +++ b/packages/web/app/hooks/use-climb-actions-data.tsx @@ -42,10 +42,12 @@ const mergeMapFn = ( fetched: Map>, ): Map> => new Map([...acc, ...fetched]); -const hasSetChanged = (prev: Set, next: Set): boolean => +// Size-only comparison is intentional: merge always grows (union/append), +// so a size change reliably signals new data without expensive deep equality. +const hasSetSizeChanged = (prev: Set, next: Set): boolean => prev.size !== next.size; -const hasMapChanged = ( +const hasMapSizeChanged = ( prev: Map>, next: Map>, ): boolean => prev.size !== next.size; @@ -92,7 +94,11 @@ export function useClimbActionsData({ [token, boardName, angle], ); - const { data: favorites, isLoading: isLoadingFavorites } = useIncrementalQuery>( + const { + data: favorites, + isLoading: isLoadingFavorites, + cancelFetches: cancelFavFetches, + } = useIncrementalQuery>( climbUuids, { accumulatedKey: favAccKey, @@ -101,7 +107,7 @@ export function useClimbActionsData({ fetchChunk: favFetchChunk, merge: mergeSetFn, initialValue: EMPTY_SET, - hasChanged: hasSetChanged, + hasChanged: hasSetSizeChanged, }, ); @@ -116,7 +122,9 @@ export function useClimbActionsData({ return { uuid: climbUuid, favorited: result.toggleFavorite.favorited }; }, onMutate: async (climbUuid: string) => { - await queryClient.cancelQueries({ queryKey: favAccKey }); + // Cancel both the accumulated key AND in-flight fetch queries to prevent + // a stale fetch response from overwriting the optimistic update. + await cancelFavFetches(); const previousFavorites = queryClient.getQueryData>(favAccKey); queryClient.setQueryData>(favAccKey, (old) => { const next = new Set(old); @@ -216,7 +224,7 @@ export function useClimbActionsData({ fetchChunk: memFetchChunk, merge: mergeMapFn, initialValue: EMPTY_MAP, - hasChanged: hasMapChanged, + hasChanged: hasMapSizeChanged, }, ); diff --git a/packages/web/app/hooks/use-incremental-query.ts b/packages/web/app/hooks/use-incremental-query.ts index 94862ea8..dba1374f 100644 --- a/packages/web/app/hooks/use-incremental-query.ts +++ b/packages/web/app/hooks/use-incremental-query.ts @@ -1,7 +1,7 @@ 'use client'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRef, useEffect, useMemo, useState } from 'react'; +import { useRef, useEffect, useMemo, useState, useCallback } from 'react'; const DEFAULT_CHUNK_SIZE = 500; @@ -20,10 +20,25 @@ interface UseIncrementalQueryOptions { merge: (accumulated: T, fetched: T) => T; /** Empty/initial value for T */ initialValue: T; - /** Whether accumulated state has changed after merge (to avoid unnecessary re-renders) */ + /** + * Size-based comparison to detect whether merge produced new data. + * Intentionally compares sizes rather than deep equality for performance — + * merge always grows the collection, so a size change is a reliable signal. + */ hasChanged: (prev: T, next: T) => boolean; } +interface UseIncrementalQueryResult { + data: T; + isLoading: boolean; + /** + * Cancel all in-flight fetch queries for this incremental query. + * Useful for optimistic mutations that need to prevent stale fetch results + * from overwriting the optimistic state. + */ + cancelFetches: () => Promise; +} + function chunkArray(arr: U[], size: number): U[][] { const chunks: U[][] = []; for (let i = 0; i < arr.length; i += size) { @@ -45,7 +60,7 @@ function chunkArray(arr: U[], size: number): U[][] { export function useIncrementalQuery( uuids: string[], options: UseIncrementalQueryOptions, -): { data: T; isLoading: boolean } { +): UseIncrementalQueryResult { const { accumulatedKey, fetchKeyPrefix, @@ -61,12 +76,26 @@ export function useIncrementalQuery( const fetchedUuidsRef = useRef>(new Set()); const [invalidationCount, setInvalidationCount] = useState(0); + // Track the previous key identity so we can reset when context changes + // (e.g., boardName or angle changes but the same UUID list is passed). + const prevKeyRef = useRef(JSON.stringify(accumulatedKey)); + const currentKeyStr = JSON.stringify(accumulatedKey); + if (prevKeyRef.current !== currentKeyStr) { + prevKeyRef.current = currentKeyStr; + // Key identity changed — reset fetched tracking and accumulated state. + // This is synchronous (during render) to ensure the first fetch after the + // change uses a clean fetchedUuidsRef, rather than deferring to an effect + // which would cause one render cycle with stale data. + fetchedUuidsRef.current = new Set(); + } + // Determine which UUIDs haven't been fetched yet. // invalidationCount forces recomputation after cache invalidation clears // fetchedUuidsRef, since uuids/enabled may not have changed. const newUuids = useMemo( () => (enabled ? uuids.filter((uuid) => !fetchedUuidsRef.current.has(uuid)) : []), - [uuids, enabled, invalidationCount], + // eslint-disable-next-line react-hooks/exhaustive-deps + [uuids, enabled, invalidationCount, currentKeyStr], ); // Dynamic fetch key includes only the new UUIDs @@ -100,9 +129,39 @@ export function useIncrementalQuery( staleTime: Infinity, }); + // Cancel all in-flight fetch queries — exposed for optimistic mutations + // to prevent stale fetch responses from overwriting optimistic state. + const cancelFetches = useCallback(async () => { + await queryClient.cancelQueries({ queryKey: fetchKeyPrefix }); + await queryClient.cancelQueries({ queryKey: accumulatedKey }); + }, [queryClient, fetchKeyPrefix, accumulatedKey]); + // Accumulated state — direct useState for guaranteed re-renders const [accumulated, setAccumulated] = useState(initialValue); + // Reset accumulated state when key identity changes (deferred cleanup for + // cache entries; the synchronous reset above handles fetchedUuidsRef). + useEffect(() => { + // On mount this runs once; on key change it clears old cache entries. + return () => { + // Cleanup old cache when key identity changes or component unmounts + queryClient.removeQueries({ queryKey: fetchKeyPrefix }); + queryClient.removeQueries({ queryKey: accumulatedKey }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentKeyStr]); + + // When key identity changes, reset accumulated state + const prevAccKeyRef = useRef(currentKeyStr); + useEffect(() => { + if (prevAccKeyRef.current !== currentKeyStr) { + prevAccKeyRef.current = currentKeyStr; + lastMergedRef.current = undefined; + lastCacheWriteRef.current = undefined; + setAccumulated(initialValue); + } + }, [currentKeyStr, initialValue]); + // When fetch completes, merge new entries into state and cache. // IMPORTANT: Mark UUIDs as fetched here (not in queryFn) so the query key // remains stable until the data is consumed. If we mutated the ref inside @@ -179,5 +238,6 @@ export function useIncrementalQuery( return { data: accumulated, isLoading: fetchQuery.isLoading && !hasChanged(initialValue, accumulated), + cancelFetches, }; } From 59508314d2c174b6b828cf77b1627ddd0f1dbfb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 11:44:12 +0000 Subject: [PATCH 5/6] Fix concurrent mode safety, cancel race conditions, and CI type errors - Replace render-time ref mutation with effect-based keyChangeCount for React concurrent mode safety (discarded renders won't leave stale refs) - Cancel in-flight fetches in addToPlaylist/removeFromPlaylist (consistent with toggleFavorite) to prevent stale responses overwriting cache - Document shallow merge assumption on mergeMapFn - Remove duplicate prevAccKeyRef effect (consolidated into key-change effect) - Fix implicit any parameters in cache subscription - Fix test TypeScript: type mockFetchChunk, widen literal types for context switch test rerender https://claude.ai/code/session_0146aSM1evtmv8pnVAazL6FP --- .../__tests__/use-incremental-query.test.tsx | 20 ++++---- .../web/app/hooks/use-climb-actions-data.tsx | 18 +++++-- .../web/app/hooks/use-incremental-query.ts | 49 +++++++++---------- 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx index ab2a16a8..1da893f3 100644 --- a/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx +++ b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx @@ -28,15 +28,15 @@ const hasSetChanged = (a: Set, b: Set) => a.size !== b.size; const EMPTY_SET = new Set(); describe('useIncrementalQuery', () => { - let mockFetchChunk: ReturnType; + let mockFetchChunk: ReturnType Promise>>>; beforeEach(() => { - mockFetchChunk = vi.fn(); + mockFetchChunk = vi.fn<(uuids: string[]) => Promise>>(); }); - const defaultOptions = (overrides = {}) => ({ - accumulatedKey: ['test', 'accumulated'] as const, - fetchKeyPrefix: ['test', 'fetch'] as const, + const defaultOptions = (overrides: Record = {}) => ({ + accumulatedKey: ['test', 'accumulated'] as readonly unknown[], + fetchKeyPrefix: ['test', 'fetch'] as readonly unknown[], enabled: true, fetchChunk: mockFetchChunk, merge: mergeSet, @@ -278,7 +278,7 @@ describe('useIncrementalQuery', () => { const { wrapper } = createWrapper(); const { result, rerender } = renderHook( - ({ accKey, fetchPrefix }) => + ({ accKey, fetchPrefix }: { accKey: readonly unknown[]; fetchPrefix: readonly unknown[] }) => useIncrementalQuery(['a', 'b'], defaultOptions({ accumulatedKey: accKey, fetchKeyPrefix: fetchPrefix, @@ -286,8 +286,8 @@ describe('useIncrementalQuery', () => { { wrapper, initialProps: { - accKey: ['ctx1', 'accumulated'] as const, - fetchPrefix: ['ctx1', 'fetch'] as const, + accKey: ['ctx1', 'accumulated'] as readonly unknown[], + fetchPrefix: ['ctx1', 'fetch'] as readonly unknown[], }, }, ); @@ -300,8 +300,8 @@ describe('useIncrementalQuery', () => { // because the old fetchedUuidsRef is stale for the new context. mockFetchChunk.mockResolvedValueOnce(new Set(['a', 'b'])); rerender({ - accKey: ['ctx2', 'accumulated'] as const, - fetchPrefix: ['ctx2', 'fetch'] as const, + accKey: ['ctx2', 'accumulated'] as readonly unknown[], + fetchPrefix: ['ctx2', 'fetch'] as readonly unknown[], }); await waitFor(() => { diff --git a/packages/web/app/hooks/use-climb-actions-data.tsx b/packages/web/app/hooks/use-climb-actions-data.tsx index b90ed6dd..dea87f1e 100644 --- a/packages/web/app/hooks/use-climb-actions-data.tsx +++ b/packages/web/app/hooks/use-climb-actions-data.tsx @@ -37,6 +37,9 @@ interface UseClimbActionsDataOptions { const mergeSetFn = (acc: Set, fetched: Set): Set => new Set([...acc, ...fetched]); +// Shallow merge (overwrites per-key) is safe here because the incremental fetch +// pattern guarantees no key overlap between accumulated and fetched batches — +// each UUID is only fetched once and never re-fetched unless the cache is reset. const mergeMapFn = ( acc: Map>, fetched: Map>, @@ -210,7 +213,10 @@ export function useClimbActionsData({ [token, boardName, layoutId], ); - const { data: membershipsData } = useIncrementalQuery>>( + const { + data: membershipsData, + cancelFetches: cancelMemFetches, + } = useIncrementalQuery>>( climbUuids, { accumulatedKey: memAccKey, @@ -236,7 +242,8 @@ export function useClimbActionsData({ await client.request(ADD_CLIMB_TO_PLAYLIST, { input: { playlistId, climbUuid, angle: climbAngle }, }); - // Update accumulated membership cache (triggers cache subscription in useIncrementalQuery) + // Cancel in-flight fetches and update accumulated membership cache + await cancelMemFetches(); const prevMem = queryClient.getQueryData>>(memAccKey) ?? new Map(); const updatedMem = new Map(prevMem); const currentSet = new Set(updatedMem.get(climbUuid) || []); @@ -247,7 +254,7 @@ export function useClimbActionsData({ prev?.map((p) => (p.uuid === playlistId ? { ...p, climbCount: p.climbCount + 1 } : p)), ); }, - [token, memAccKey, playlistsQueryKey, queryClient], + [token, memAccKey, playlistsQueryKey, queryClient, cancelMemFetches], ); const removeFromPlaylist = useCallback( @@ -257,7 +264,8 @@ export function useClimbActionsData({ await client.request(REMOVE_CLIMB_FROM_PLAYLIST, { input: { playlistId, climbUuid }, }); - // Update accumulated membership cache (triggers cache subscription in useIncrementalQuery) + // Cancel in-flight fetches and update accumulated membership cache + await cancelMemFetches(); const prevMem = queryClient.getQueryData>>(memAccKey) ?? new Map(); const updatedMem = new Map(prevMem); const currentPlaylists = updatedMem.get(climbUuid); @@ -273,7 +281,7 @@ export function useClimbActionsData({ ), ); }, - [token, memAccKey, playlistsQueryKey, queryClient], + [token, memAccKey, playlistsQueryKey, queryClient, cancelMemFetches], ); const createPlaylist = useCallback( diff --git a/packages/web/app/hooks/use-incremental-query.ts b/packages/web/app/hooks/use-incremental-query.ts index dba1374f..7841d54e 100644 --- a/packages/web/app/hooks/use-incremental-query.ts +++ b/packages/web/app/hooks/use-incremental-query.ts @@ -76,26 +76,36 @@ export function useIncrementalQuery( const fetchedUuidsRef = useRef>(new Set()); const [invalidationCount, setInvalidationCount] = useState(0); - // Track the previous key identity so we can reset when context changes - // (e.g., boardName or angle changes but the same UUID list is passed). - const prevKeyRef = useRef(JSON.stringify(accumulatedKey)); - const currentKeyStr = JSON.stringify(accumulatedKey); - if (prevKeyRef.current !== currentKeyStr) { - prevKeyRef.current = currentKeyStr; - // Key identity changed — reset fetched tracking and accumulated state. - // This is synchronous (during render) to ensure the first fetch after the - // change uses a clean fetchedUuidsRef, rather than deferring to an effect - // which would cause one render cycle with stale data. - fetchedUuidsRef.current = new Set(); - } + // Track key identity changes so we can reset fetched UUIDs when context + // changes (e.g., boardName or angle changes but the same UUID list is passed). + // Uses a state counter instead of render-time ref mutation to be safe under + // React concurrent mode (discarded renders won't leave stale refs). + const currentKeyStr = useMemo(() => JSON.stringify(accumulatedKey), [accumulatedKey]); + const prevKeyRef = useRef(currentKeyStr); + const [keyChangeCount, setKeyChangeCount] = useState(0); + + useEffect(() => { + if (prevKeyRef.current !== currentKeyStr) { + prevKeyRef.current = currentKeyStr; + // Key identity changed — reset all tracking so UUIDs are re-fetched + // for the new context. Bumping keyChangeCount triggers newUuids + // recomputation via useMemo dependency. + fetchedUuidsRef.current = new Set(); + lastMergedRef.current = undefined; + lastCacheWriteRef.current = undefined; + setAccumulated(initialValue); + setKeyChangeCount((c) => c + 1); + } + }, [currentKeyStr, initialValue]); // Determine which UUIDs haven't been fetched yet. // invalidationCount forces recomputation after cache invalidation clears // fetchedUuidsRef, since uuids/enabled may not have changed. + // keyChangeCount forces recomputation after context key changes. const newUuids = useMemo( () => (enabled ? uuids.filter((uuid) => !fetchedUuidsRef.current.has(uuid)) : []), // eslint-disable-next-line react-hooks/exhaustive-deps - [uuids, enabled, invalidationCount, currentKeyStr], + [uuids, enabled, invalidationCount, keyChangeCount], ); // Dynamic fetch key includes only the new UUIDs @@ -151,17 +161,6 @@ export function useIncrementalQuery( // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentKeyStr]); - // When key identity changes, reset accumulated state - const prevAccKeyRef = useRef(currentKeyStr); - useEffect(() => { - if (prevAccKeyRef.current !== currentKeyStr) { - prevAccKeyRef.current = currentKeyStr; - lastMergedRef.current = undefined; - lastCacheWriteRef.current = undefined; - setAccumulated(initialValue); - } - }, [currentKeyStr, initialValue]); - // When fetch completes, merge new entries into state and cache. // IMPORTANT: Mark UUIDs as fetched here (not in queryFn) so the query key // remains stable until the data is consumed. If we mutated the ref inside @@ -197,7 +196,7 @@ export function useIncrementalQuery( const unsubscribe = queryClient.getQueryCache().subscribe((event) => { // Only react to events for our accumulated key const qk = event.query.queryKey; - if (qk.length !== key.length || qk.some((v, i) => v !== key[i])) return; + if (qk.length !== key.length || qk.some((v: unknown, i: number) => v !== key[i])) return; if (event.type === 'removed') { // Cache was cleared — reset tracking so all current UUIDs are re-fetched From faf71e3b803e395f5fe6c018d0bfd5ba881de5af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 12:04:24 +0000 Subject: [PATCH 6/6] Add documentation comments for key constraints and MoonBoard exclusion - Document primitive-only constraint on accumulatedKey/fetchKeyPrefix - Note reference equality usage in cache subscription key comparison - Explain why MoonBoard is excluded from playlist memberships query https://claude.ai/code/session_0146aSM1evtmv8pnVAazL6FP --- packages/web/app/hooks/use-climb-actions-data.tsx | 1 + packages/web/app/hooks/use-incremental-query.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/web/app/hooks/use-climb-actions-data.tsx b/packages/web/app/hooks/use-climb-actions-data.tsx index dea87f1e..acee30ea 100644 --- a/packages/web/app/hooks/use-climb-actions-data.tsx +++ b/packages/web/app/hooks/use-climb-actions-data.tsx @@ -221,6 +221,7 @@ export function useClimbActionsData({ { accumulatedKey: memAccKey, fetchKeyPrefix: memFetchKeyPrefix, + // MoonBoard doesn't support playlists (no playlist API in Aurora for MoonBoard) enabled: isAuthenticated && !isAuthLoading && diff --git a/packages/web/app/hooks/use-incremental-query.ts b/packages/web/app/hooks/use-incremental-query.ts index 7841d54e..6cd9126e 100644 --- a/packages/web/app/hooks/use-incremental-query.ts +++ b/packages/web/app/hooks/use-incremental-query.ts @@ -6,9 +6,9 @@ import { useRef, useEffect, useMemo, useState, useCallback } from 'react'; const DEFAULT_CHUNK_SIZE = 500; interface UseIncrementalQueryOptions { - /** Stable cache key for accumulated results (no UUIDs in key) */ + /** Stable cache key for accumulated results (no UUIDs in key). Values must be primitives. */ accumulatedKey: readonly unknown[]; - /** Prefix for dynamic fetch keys — UUIDs are appended automatically */ + /** Prefix for dynamic fetch keys — UUIDs are appended automatically. Values must be primitives. */ fetchKeyPrefix: readonly unknown[]; /** Whether the query is enabled (auth check, etc.) */ enabled: boolean; @@ -194,7 +194,8 @@ export function useIncrementalQuery( const key = accumulatedKey; const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - // Only react to events for our accumulated key + // Only react to events for our accumulated key. + // Uses reference equality (===) — keys must contain only primitives (strings, numbers). const qk = event.query.queryKey; if (qk.length !== key.length || qk.some((v: unknown, i: number) => v !== key[i])) return;