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" 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/__tests__/use-incremental-query.test.tsx b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx new file mode 100644 index 00000000..1da893f3 --- /dev/null +++ b/packages/web/app/hooks/__tests__/use-incremental-query.test.tsx @@ -0,0 +1,343 @@ +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 Promise>>>; + + beforeEach(() => { + mockFetchChunk = vi.fn<(uuids: string[]) => Promise>>(); + }); + + const defaultOptions = (overrides: Record = {}) => ({ + accumulatedKey: ['test', 'accumulated'] as readonly unknown[], + fetchKeyPrefix: ['test', 'fetch'] as readonly unknown[], + 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 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 }: { accKey: readonly unknown[]; fetchPrefix: readonly unknown[] }) => + useIncrementalQuery(['a', 'b'], defaultOptions({ + accumulatedKey: accKey, + fetchKeyPrefix: fetchPrefix, + })), + { + wrapper, + initialProps: { + accKey: ['ctx1', 'accumulated'] as readonly unknown[], + fetchPrefix: ['ctx1', 'fetch'] as readonly unknown[], + }, + }, + ); + + 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 readonly unknown[], + fetchPrefix: ['ctx2', 'fetch'] as readonly unknown[], + }); + + 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(); + + 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 79994e58..acee30ea 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,31 @@ 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]); + +// 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>, +): Map> => new Map([...acc, ...fetched]); + +// 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 hasMapSizeChanged = ( + 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 +68,68 @@ export function useClimbActionsData({ const { showMessage } = useSnackbar(); const queryClient = useQueryClient(); - // Stable sorted UUIDs to prevent unnecessary re-fetches - const sortedClimbUuids = useMemo(() => [...climbUuids].sort(), [climbUuids]); + // === Favorites (incremental) === - // === Favorites === - - 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, + climbUuids: uuids, 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}`); + console.error(`[GraphQL] Favorites query error for ${boardName} (${uuids.length} uuids):`, error); + throw error; } }, - 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, + cancelFetches: cancelFavFetches, + } = useIncrementalQuery>( + climbUuids, + { + accumulatedKey: favAccKey, + fetchKeyPrefix: favFetchKeyPrefix, + enabled: isAuthenticated && !isAuthLoading && !!boardName, + fetchChunk: favFetchChunk, + merge: mergeSetFn, + initialValue: EMPTY_SET, + hasChanged: hasSetSizeChanged, + }, + ); + // 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) => { + // 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); if (next.has(climbUuid)) { next.delete(climbUuid); @@ -109,7 +143,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 +165,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 +181,61 @@ 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 response = await client.request( - GET_PLAYLISTS_FOR_CLIMBS, - { input: { boardType: boardName, layoutId, climbUuids: sortedClimbUuids } }, - ); + const memAccKey = useMemo( + () => ['playlistMemberships', boardName, layoutId, 'accumulated'] as const, + [boardName, layoutId], + ); + const memFetchKeyPrefix = useMemo( + () => ['playlistMemberships', boardName, layoutId, 'fetch'] as const, + [boardName, layoutId], + ); - const memberships = new Map>(); - for (const entry of response.playlistsForClimbs) { - memberships.set(entry.climbUuid, new Set(entry.playlistUuids)); + const memFetchChunk = useCallback( + async (uuids: string[]): Promise>> => { + const client = createGraphQLHttpClient(token); + 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; }, - 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, + cancelFetches: cancelMemFetches, + } = useIncrementalQuery>>( + climbUuids, + { + accumulatedKey: memAccKey, + fetchKeyPrefix: memFetchKeyPrefix, + // MoonBoard doesn't support playlists (no playlist API in Aurora for MoonBoard) + enabled: + isAuthenticated && + !isAuthLoading && + !!boardName && + layoutId > 0 && + boardName !== 'moonboard', + fetchChunk: memFetchChunk, + merge: mergeMapFn, + initialValue: EMPTY_MAP, + hasChanged: hasMapSizeChanged, + }, + ); + // 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 +243,19 @@ 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; - }); + // 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) || []); + 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, cancelMemFetches], ); const removeFromPlaylist = useCallback( @@ -210,22 +265,24 @@ 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; - }); + // 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); + 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, cancelMemFetches], ); const createPlaylist = useCallback( @@ -262,7 +319,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..6cd9126e --- /dev/null +++ b/packages/web/app/hooks/use-incremental-query.ts @@ -0,0 +1,243 @@ +'use client'; + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +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). Values must be primitives. */ + accumulatedKey: readonly unknown[]; + /** 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; + /** 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; + /** + * 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) { + 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, +): UseIncrementalQueryResult { + 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); + + // 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, keyChangeCount], + ); + + // 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, + }); + + // 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 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); + // 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; + + // 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); + lastCacheWriteRef.current = 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. + // 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; + + if (event.type === 'removed') { + // 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); + } + } + }); + 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; + lastCacheWriteRef.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), + cancelFetches, + }; +}