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