diff --git a/apps/mobile-rn/__tests__/history-window-redteam-r2-31.test.tsx b/apps/mobile-rn/__tests__/history-window-redteam-r2-31.test.tsx new file mode 100644 index 0000000..c28f492 --- /dev/null +++ b/apps/mobile-rn/__tests__/history-window-redteam-r2-31.test.tsx @@ -0,0 +1,336 @@ +import React from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { AppState } from 'react-native' +import * as Keychain from 'react-native-keychain' +import type { + HistoryCursor, + HistoryListEntry, + HistoryListOptions, + HistoryPageResult, +} from '../src/features/history/history-service' + +const USER_A = '11111111-1111-4111-8111-111111111111' + +let mockAuthUser: { id: string } | null = null +const mockListHistoryPage = jest.fn, [HistoryListOptions]>() +let mockRemoteChange: (() => void) | null = null + +jest.mock('../src/lib/supabase', () => ({ + supabase: { + channel: jest.fn(), + removeChannel: jest.fn(async () => 'ok'), + }, +})) +jest.mock('../src/lib/auth-context', () => ({ + useAuth: () => ({ user: mockAuthUser }), +})) +jest.mock('../src/features/history/history-service', () => ({ + ...jest.requireActual('../src/features/history/history-service'), + listHistoryPage: (options: HistoryListOptions) => mockListHistoryPage(options), + subscribeToHistory: (_userId: string, onRemoteChange: () => void) => { + mockRemoteChange = onRemoteChange + return { unsubscribe: async () => undefined } + }, +})) + +import { + appendHistoryPage, + applyHeadRefresh, + compareHistoryPosition, + EMPTY_HISTORY_WINDOW, + fetchHistoryHead, + type HistoryPageRequest, +} from '../src/features/history/history-window' +import { useHistorySync, type HistorySyncModel } from '../src/features/history/use-history-sync' + +const keychainMock = Keychain as unknown as typeof Keychain & { __reset: () => void } +const BASE_TIME = Date.UTC(2026, 7, 21) + +function entry(index: number, overrides: Partial = {}): HistoryListEntry { + const id = `e-${String(index).padStart(3, '0')}` + const createdAt = new Date(BASE_TIME - index * 60_000).toISOString() + return { + id, + user_id: USER_A, + title: null, + original_text: `original ${id}`, + polished_text: null, + focused_app: null, + focused_app_name: null, + focused_app_window_title: null, + mode: 'dictation', + status: 'completed', + error_code: null, + audio_storage_key: null, + duration: 2, + detected_language: 'ko', + mic_device: null, + word_count: 2, + stt_model: 'whisper', + llm_model: null, + stt_latency_ms: null, + llm_latency_ms: null, + app_version: '1.0.0', + summary_text: null, + is_favorite: index % 3 === 0, + revision: 1, + created_at: createdAt, + updated_at: createdAt, + activeJob: null, + ...overrides, + } +} + +function cursorOf(value: HistoryListEntry): HistoryCursor { + return { createdAt: value.created_at, id: value.id } +} + +/** In-memory stand-in for the history table with keyset pagination. */ +function createServer(count: number) { + let rows = Array.from({ length: count }, (_, index) => entry(index)) + const page = (options: { + filter?: string + cursor?: HistoryCursor | null + pageSize?: number + }): HistoryPageResult => { + const pageSize = options.pageSize ?? 20 + const cursor = options.cursor ?? null + const matching = rows + .filter((row) => options.filter !== 'favorites' || row.is_favorite) + .filter((row) => cursor === null || compareHistoryPosition(cursorOf(row), cursor) > 0) + .sort((a, b) => compareHistoryPosition(cursorOf(a), cursorOf(b))) + const entries = matching.slice(0, pageSize) + const last = entries.at(-1) + return { + entries, + nextCursor: entries.length === pageSize && last !== undefined ? cursorOf(last) : null, + } + } + return { + page, + remove: (id: string) => { rows = rows.filter((row) => row.id !== id) }, + insert: (row: HistoryListEntry) => { rows = [...rows, row] }, + } +} + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +async function flush(): Promise { + for (let index = 0; index < 20; index += 1) await Promise.resolve() +} + +let model: HistorySyncModel | null = null + +function Harness(): null { + model = useHistorySync() + return null +} + +function current(): HistorySyncModel { + if (model === null) throw new Error('hook not mounted') + return model +} + +async function mount(): Promise { + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create() + await flush() + }) + if (renderer === null) throw new Error('renderer missing') + return renderer +} + +async function triggerRemoteChange(): Promise { + await act(async () => { + mockRemoteChange?.() + jest.advanceTimersByTime(300) + await flush() + }) +} + +describe('history window policy', () => { + test('a head refresh that ends before the loaded window keeps the unverified tail and cursor', () => { + const loaded = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } + const head = { entries: [entry(0), entry(1)], nextCursor: cursorOf(entry(1)) } + const next = applyHeadRefresh(loaded, head) + expect(next.entries.map((row) => row.id)).toEqual(['e-000', 'e-001', 'e-002', 'e-003']) + expect(next.nextCursor).toEqual(cursorOf(entry(3))) + }) + + test('a head refresh drops rows it no longer sees inside its range', () => { + const loaded = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } + const head = { entries: [entry(0), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } + const next = applyHeadRefresh(loaded, head) + expect(next.entries.map((row) => row.id)).toEqual(['e-000', 'e-002', 'e-003']) + expect(next.nextCursor).toEqual(cursorOf(entry(3))) + }) + + test('a head refresh that reaches the end is authoritative', () => { + const loaded = { entries: [entry(0), entry(1), entry(2)], nextCursor: cursorOf(entry(2)) } + const next = applyHeadRefresh(loaded, { entries: [entry(0)], nextCursor: null }) + expect(next).toEqual({ entries: [entry(0)], nextCursor: null }) + }) + + test('an appended page never moves the cursor backwards', () => { + const refreshed = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } + const stalePage = { entries: [entry(2)], nextCursor: cursorOf(entry(2)) } + expect(appendHistoryPage(refreshed, stalePage).nextCursor).toEqual(cursorOf(entry(3))) + const endPage = { entries: [entry(4)], nextCursor: null } + expect(appendHistoryPage(refreshed, endPage)).toEqual({ + entries: [entry(0), entry(1), entry(2), entry(3), entry(4)], + nextCursor: null, + }) + }) + + test('fetchHistoryHead pages until the oldest loaded entry is covered', async () => { + const server = createServer(200) + const requests: HistoryPageRequest[] = [] + const fetchPage = async (request: HistoryPageRequest) => { + requests.push(request) + return server.page(request) + } + const loaded = server.page({ pageSize: 120 }) + const head = await fetchHistoryHead(fetchPage, loaded) + expect(requests.map((request) => request.pageSize)).toEqual([50, 50, 50]) + // Trimmed back to the loaded boundary: the window keeps its size. + expect(head).toEqual(loaded) + + // The last page reaches the end of the results: still trimmed to the + // loaded boundary, with a cursor so the remaining rows stay pageable. + const small = createServer(70) + const loadedSmall = small.page({ pageSize: 60 }) + const smallHead = await fetchHistoryHead(async (request) => small.page(request), loadedSmall) + expect(smallHead).toEqual(loadedSmall) + + requests.length = 0 + const first = await fetchHistoryHead(fetchPage, EMPTY_HISTORY_WINDOW) + expect(requests).toEqual([{ cursor: null, pageSize: 20 }]) + expect(first.entries).toHaveLength(20) + }) +}) + +describe('useHistorySync pagination window', () => { + beforeEach(() => { + jest.useFakeTimers() + keychainMock.__reset() + mockAuthUser = { id: USER_A } + mockRemoteChange = null + model = null + mockListHistoryPage.mockReset() + // The preset mock leaves currentState as a jest.fn; the poll only runs + // while the app is in the foreground. + Object.defineProperty(AppState, 'currentState', { value: 'active', configurable: true }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + test('a background pull keeps pages loaded with loadMore and reconciles deletions across them', async () => { + const server = createServer(100) + mockListHistoryPage.mockImplementation(async (options) => server.page(options)) + const renderer = await mount() + expect(current().entries).toHaveLength(20) + + await act(async () => { + await current().loadMore() + await flush() + }) + await act(async () => { + await current().loadMore() + await flush() + }) + expect(current().entries).toHaveLength(60) + + // Another device deletes a row deep in the loaded window and inserts a new one. + server.remove('e-045') + server.insert(entry(-1)) + await triggerRemoteChange() + + const ids = current().entries.map((row) => row.id) + expect(ids).toHaveLength(60) + expect(ids[0]).toBe('e-0-1') + expect(ids).not.toContain('e-045') + expect(ids).toContain('e-059') + expect(current().hasMore).toBe(true) + + // The 45s deletion-reconciliation poll behaves the same way. + server.remove('e-010') + await act(async () => { + jest.advanceTimersByTime(45_000 + 300) + await flush() + }) + expect(current().entries.map((row) => row.id)).not.toContain('e-010') + expect(current().entries).toHaveLength(59) + + await act(async () => { + renderer.unmount() + await flush() + }) + }) + + test('a loadMore that resolves after a filter change is discarded', async () => { + const server = createServer(100) + const heldPage = deferred() + mockListHistoryPage.mockImplementation(async (options) => { + if (options.filter === 'all' && options.cursor !== null && options.cursor !== undefined) { + return heldPage.promise + } + return server.page(options) + }) + const renderer = await mount() + expect(current().entries).toHaveLength(20) + + let pendingLoadMore: Promise = Promise.resolve() + await act(async () => { + pendingLoadMore = current().loadMore() + await flush() + }) + + await act(async () => { + current().setFilter('favorites') + await flush() + }) + expect(current().filter).toBe('favorites') + expect(current().entries.every((row) => row.is_favorite)).toBe(true) + const favoritesCursorPage = server.page({ filter: 'favorites' }) + + await act(async () => { + heldPage.resolve(server.page({ filter: 'all', cursor: cursorOf(entry(19)) })) + await pendingLoadMore + await flush() + }) + + expect(current().entries.every((row) => row.is_favorite)).toBe(true) + expect(current().entries.map((row) => row.id)).toEqual( + favoritesCursorPage.entries.map((row) => row.id), + ) + + // The next page is requested with the favorites cursor, not the stale 'all' one. + mockListHistoryPage.mockClear() + await act(async () => { + await current().loadMore() + await flush() + }) + expect(mockListHistoryPage).toHaveBeenCalledWith(expect.objectContaining({ + filter: 'favorites', + cursor: favoritesCursorPage.nextCursor, + })) + expect(current().entries.every((row) => row.is_favorite)).toBe(true) + + await act(async () => { + renderer.unmount() + await flush() + }) + }) +}) diff --git a/apps/mobile-rn/src/features/history/history-window.ts b/apps/mobile-rn/src/features/history/history-window.ts new file mode 100644 index 0000000..54e6512 --- /dev/null +++ b/apps/mobile-rn/src/features/history/history-window.ts @@ -0,0 +1,172 @@ +import type { + HistoryCursor, + HistoryListEntry, + HistoryPageResult, +} from './history-service' + +/** + * Pure policy for the paginated history list shown on the History screen. + * + * The list is a contiguous "window" over the server ordering + * (created_at DESC, id DESC) starting at the newest row. `nextCursor` marks + * where the window ends; `null` means the window already reaches the end of + * the result set. Keeping this logic free of React and Supabase lets the hook + * stay a thin adapter and makes the merge rules unit-testable. + */ +export interface HistoryWindow { + entries: HistoryListEntry[] + nextCursor: HistoryCursor | null +} + +export const EMPTY_HISTORY_WINDOW: HistoryWindow = Object.freeze({ + entries: [], + nextCursor: null, +}) as HistoryWindow + +export interface HistoryPageRequest { + cursor: HistoryCursor | null + pageSize: number +} + +/** Port used by {@link fetchHistoryHead}; the hook binds it to listHistoryPage. */ +export type HistoryPageFetcher = (request: HistoryPageRequest) => Promise + +export interface HistoryHeadFetchOptions { + /** Page size used for an empty window (the regular first page). */ + minPageSize: number + /** Largest page the service accepts. */ + maxPageSize: number + /** Upper bound on round trips for one background refresh. */ + maxPages: number +} + +export const DEFAULT_HISTORY_HEAD_FETCH: HistoryHeadFetchOptions = Object.freeze({ + minPageSize: 20, + maxPageSize: 50, + maxPages: 10, +}) + +interface HistoryPosition { + createdAt: string + id: string +} + +function positionOf(entry: HistoryListEntry): HistoryPosition { + return { createdAt: entry.created_at, id: entry.id } +} + +/** + * Orders two positions like the server does. Negative when `a` comes first + * (is newer), positive when `a` comes later (is older), zero when equal. + */ +export function compareHistoryPosition(a: HistoryPosition, b: HistoryPosition): number { + const byDate = b.createdAt.localeCompare(a.createdAt) + return byDate !== 0 ? byDate : b.id.localeCompare(a.id) +} + +function sortAndDedupe(entries: Iterable): HistoryListEntry[] { + const byId = new Map() + for (const entry of entries) byId.set(entry.id, entry) + return [...byId.values()].sort((a, b) => compareHistoryPosition(positionOf(a), positionOf(b))) +} + +/** The oldest loaded position, or null when nothing is loaded. */ +export function historyWindowBoundary(window: HistoryWindow): HistoryCursor | null { + const last = window.entries.at(-1) + return last === undefined ? null : positionOf(last) +} + +/** Picks the cursor that covers more rows; `null` (end of results) covers the most. */ +function furtherCursor(a: HistoryCursor | null, b: HistoryCursor | null): HistoryCursor | null { + if (a === null || b === null) return null + return compareHistoryPosition(a, b) >= 0 ? a : b +} + +/** + * Fetches a fresh head of the list that covers at least everything the + * current window has loaded, so a background refresh can reconcile inserts, + * updates and deletions across the whole loaded window instead of collapsing + * it back to the first page. + */ +export async function fetchHistoryHead( + fetchPage: HistoryPageFetcher, + current: HistoryWindow, + options: HistoryHeadFetchOptions = DEFAULT_HISTORY_HEAD_FETCH, +): Promise { + const boundary = historyWindowBoundary(current) + const pageSize = Math.min( + options.maxPageSize, + Math.max(options.minPageSize, current.entries.length), + ) + const collected: HistoryListEntry[] = [] + let cursor: HistoryCursor | null = null + + for (let pageIndex = 0; pageIndex < Math.max(1, options.maxPages); pageIndex += 1) { + const page: HistoryPageResult = await fetchPage({ cursor, pageSize }) + collected.push(...page.entries) + cursor = page.nextCursor + if (cursor === null || boundary === null) break + // The fetched range now reaches the oldest loaded entry. + if (compareHistoryPosition(cursor, boundary) >= 0) break + } + + const entries = sortAndDedupe(collected) + const coveredBoundary = boundary !== null + && (cursor === null || compareHistoryPosition(cursor, boundary) >= 0) + if (!coveredBoundary) return { entries, nextCursor: cursor } + + // The last page overshot the loaded window. Trim it back to the old + // boundary so a background refresh neither grows nor shrinks the list; a + // keyset cursor is just the position of the last kept row. + const kept = entries.filter((row) => compareHistoryPosition(positionOf(row), boundary) <= 0) + const lastKept = kept.at(-1) + if (lastKept === undefined || kept.length === entries.length) { + return { entries, nextCursor: cursor } + } + return { entries: kept, nextCursor: positionOf(lastKept) } +} + +/** + * Applies a freshly fetched head (which always starts at the newest row) to + * the current window. + * + * - Inside the fetched range the server is authoritative: rows missing from + * `head` were deleted or no longer match the filter and are dropped. + * - Rows older than the fetched range cannot be verified by this refresh and + * are kept, together with the window's cursor, so a page loaded while the + * refresh was in flight is not thrown away. + */ +export function applyHeadRefresh(current: HistoryWindow, head: HistoryWindow): HistoryWindow { + const headEnd = head.nextCursor + if (headEnd === null) return { entries: sortAndDedupe(head.entries), nextCursor: null } + + const tail = current.entries.filter((entry) => ( + compareHistoryPosition(positionOf(entry), headEnd) > 0 + )) + if (tail.length === 0) return { entries: sortAndDedupe(head.entries), nextCursor: headEnd } + + return { + entries: sortAndDedupe([...head.entries, ...tail]), + nextCursor: furtherCursor(current.nextCursor, headEnd), + } +} + +/** + * Appends a page fetched with the window's cursor. The resulting cursor is the + * one that covers more rows, so a page that lands after a larger background + * refresh cannot move the cursor backwards. + */ +export function appendHistoryPage(current: HistoryWindow, page: HistoryPageResult): HistoryWindow { + return { + entries: sortAndDedupe([...current.entries, ...page.entries]), + nextCursor: furtherCursor(current.nextCursor, page.nextCursor), + } +} + +/** Applies an entry-level edit (optimistic update, removal) without touching the cursor. */ +export function mapHistoryWindowEntries( + current: HistoryWindow, + update: (entries: HistoryListEntry[]) => HistoryListEntry[], +): HistoryWindow { + return { entries: update(current.entries), nextCursor: current.nextCursor } +} diff --git a/apps/mobile-rn/src/features/history/use-history-sync.ts b/apps/mobile-rn/src/features/history/use-history-sync.ts index 0e6baf5..5e45de7 100644 --- a/apps/mobile-rn/src/features/history/use-history-sync.ts +++ b/apps/mobile-rn/src/features/history/use-history-sync.ts @@ -8,7 +8,6 @@ import { mergeHistoryEntries, subscribeToHistory, updateHistoryRevisionSafe, - type HistoryCursor, type HistoryFilter, type HistoryListEntry, type HistoryServiceErrorCode, @@ -22,6 +21,14 @@ import { saveHistoryCacheInScope, type HistoryCacheScopeCheck, } from './history-cache-writer' +import { + appendHistoryPage, + applyHeadRefresh, + EMPTY_HISTORY_WINDOW, + fetchHistoryHead, + mapHistoryWindowEntries, + type HistoryWindow, +} from './history-window' export interface HistorySyncError { code: HistoryServiceErrorCode @@ -63,7 +70,12 @@ function syncError(error: unknown): HistorySyncError { export function useHistorySync(): HistorySyncModel { const { user } = useAuth() const userId = user?.id ?? null - const [entries, setEntries] = useState([]) + // Entries and cursor form one contiguous window (see history-window.ts). + // windowRef is the synchronous source of truth so async callbacks never + // merge into a stale render snapshot; state mirrors it for rendering. + const [historyWindow, setHistoryWindow] = useState(EMPTY_HISTORY_WINDOW) + const windowRef = useRef(EMPTY_HISTORY_WINDOW) + const entries = historyWindow.entries const [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') @@ -75,19 +87,31 @@ export function useHistorySync(): HistorySyncModel { const [error, setError] = useState(null) const [cacheWarning, setCacheWarning] = useState(false) const [realtimeDisconnected, setRealtimeDisconnected] = useState(false) - const [nextCursor, setNextCursor] = useState(null) const [mutatingIds, setMutatingIds] = useState>(() => new Set()) const mutatingIdsRef = useRef>(new Set()) const requestId = useRef(0) - const entriesRef = useRef(entries) + // Bumped whenever the list is reset for a new query (filter, search, user). + // A page requested under an older generation must never be merged. + const listGeneration = useRef(0) // Auth scope for cache writes. Invalidated on every user change and on // unmount so a request started for user A can never persist A's history // after the logout privacy purge (see saveHistoryCacheInScope). const [cacheScope] = useState(createHistoryCacheScope) - useEffect(() => { - entriesRef.current = entries - }, [entries]) + const commitWindow = useCallback(( + next: HistoryWindow | ((current: HistoryWindow) => HistoryWindow), + ): HistoryWindow => { + const resolved = typeof next === 'function' ? next(windowRef.current) : next + windowRef.current = resolved + setHistoryWindow(resolved) + return resolved + }, []) + + const updateEntries = useCallback(( + update: (current: HistoryListEntry[]) => HistoryListEntry[], + ): void => { + commitWindow((current) => mapHistoryWindowEntries(current, update)) + }, [commitWindow]) useEffect(() => { const timeout = setTimeout(() => setDebouncedSearch(search), 350) @@ -135,7 +159,7 @@ export function useHistorySync(): HistorySyncModel { const performRefresh = useCallback(async (showRefresh: boolean): Promise => { if (userId === null) { - setEntries([]) + commitWindow(EMPTY_HISTORY_WINDOW) setLoading(false) setRefreshing(false) setError({ code: 'auth', message: 'Login is required' }) @@ -145,32 +169,38 @@ export function useHistorySync(): HistorySyncModel { const currentRequest = ++requestId.current const isCurrentScope = cacheScope.capture() if (showRefresh) setRefreshing(true) - if (entriesRef.current.length === 0) setLoading(true) + if (windowRef.current.entries.length === 0) setLoading(true) try { - const page = await listHistoryPage({ - userId, - filter, - search: debouncedSearch, - }) + // Re-read everything already loaded (not just the first page) so a + // background pull keeps the scrolled window and still reconciles + // deletions across it. + const head = await fetchHistoryHead( + ({ cursor, pageSize }) => listHistoryPage({ + userId, + filter, + search: debouncedSearch, + cursor, + pageSize, + }), + windowRef.current, + ) if (currentRequest !== requestId.current) return - setEntries(page.entries) - setNextCursor(page.nextCursor) + const next = commitWindow((current) => applyHeadRefresh(current, head)) setOffline(false) setStaleAt(null) setError(null) if (filter === 'all' && debouncedSearch.length === 0) { - await writeCache(page.entries, isCurrentScope) + await writeCache(next.entries, isCurrentScope) } } catch (requestError) { if (currentRequest !== requestId.current) return const normalized = syncError(requestError) if (normalized.code === 'auth' || normalized.code === 'validation') { - setEntries([]) - setNextCursor(null) + commitWindow(EMPTY_HISTORY_WINDOW) setOffline(false) setStaleAt(null) setError(normalized) @@ -180,8 +210,10 @@ export function useHistorySync(): HistorySyncModel { try { const cached = await loadHistoryCache(userId) if (cached !== null) { - setEntries(filterCachedHistory(cached.entries, filter, debouncedSearch)) - setNextCursor(null) + commitWindow({ + entries: filterCachedHistory(cached.entries, filter, debouncedSearch), + nextCursor: null, + }) setOffline(true) setStaleAt(cached.savedAt) setError(null) @@ -200,17 +232,17 @@ export function useHistorySync(): HistorySyncModel { setRefreshing(false) } } - }, [cacheScope, debouncedSearch, filter, userId, writeCache]) + }, [cacheScope, commitWindow, debouncedSearch, filter, userId, writeCache]) useEffect(() => { - setEntries([]) - setNextCursor(null) + listGeneration.current += 1 + commitWindow(EMPTY_HISTORY_WINDOW) setStaleAt(null) setError(null) setOffline(false) setLoading(true) void performRefresh(false) - }, [performRefresh]) + }, [commitWindow, performRefresh]) useEffect(() => { if (userId === null) return @@ -271,8 +303,13 @@ export function useHistorySync(): HistorySyncModel { }, [performRefresh]) const loadMore = useCallback(async (): Promise => { - if (userId === null || nextCursor === null || loadingMore || offline) return + const cursor = windowRef.current.nextCursor + if (userId === null || cursor === null || loadingMore || offline) return const isCurrentScope = cacheScope.capture() + const generation = listGeneration.current + const isCurrentQuery = (): boolean => ( + isCurrentScope() && generation === listGeneration.current + ) setLoadingMore(true) try { @@ -280,20 +317,18 @@ export function useHistorySync(): HistorySyncModel { userId, filter, search: debouncedSearch, - cursor: nextCursor, + cursor, }) - if (!isCurrentScope()) return - setEntries((current) => { - const merged = mergeHistoryEntries(current, page.entries) - if (filter === 'all' && debouncedSearch.length === 0) { - void writeCache(merged, isCurrentScope) - } - return merged - }) - setNextCursor(page.nextCursor) + // The filter/search/user changed while this page was in flight: it + // belongs to a different result set and its cursor is meaningless now. + if (!isCurrentQuery()) return + const next = commitWindow((current) => appendHistoryPage(current, page)) setError(null) + if (filter === 'all' && debouncedSearch.length === 0) { + void writeCache(next.entries, isCurrentScope) + } } catch (requestError) { - if (!isCurrentScope()) return + if (!isCurrentQuery()) return const normalized = syncError(requestError) setError(normalized) if (normalized.code === 'network') setOffline(true) @@ -302,10 +337,10 @@ export function useHistorySync(): HistorySyncModel { } }, [ cacheScope, + commitWindow, debouncedSearch, filter, loadingMore, - nextCursor, offline, userId, writeCache, @@ -332,7 +367,7 @@ export function useHistorySync(): HistorySyncModel { const isCurrentScope = cacheScope.capture() const optimisticFavorite = !entry.is_favorite - setEntries((current) => current.map((item) => ( + updateEntries((current) => current.map((item) => ( item.id === entry.id ? { ...item, is_favorite: optimisticFavorite } : item ))) @@ -344,7 +379,7 @@ export function useHistorySync(): HistorySyncModel { { is_favorite: optimisticFavorite }, ) const replacement: HistoryListEntry = { ...entry, ...updated } - setEntries((current) => { + updateEntries((current) => { if (filter === 'favorites' && !updated.is_favorite) { return current.filter((item) => item.id !== entry.id) } @@ -352,31 +387,31 @@ export function useHistorySync(): HistorySyncModel { }) await patchCachedEntry(entry.id, replacement, isCurrentScope) } catch (mutationError) { - setEntries((current) => current.map((item) => ( + updateEntries((current) => current.map((item) => ( item.id === entry.id ? entry : item ))) throw mutationError } finally { endMutation(entry.id) } - }, [beginMutation, cacheScope, endMutation, filter, patchCachedEntry, userId]) + }, [beginMutation, cacheScope, endMutation, filter, patchCachedEntry, updateEntries, userId]) const deleteEntry = useCallback(async (entry: HistoryListEntry): Promise => { if (userId === null) throw new HistoryServiceError('auth', 'Login is required') if (!beginMutation(entry.id)) return const isCurrentScope = cacheScope.capture() - setEntries((current) => current.filter((item) => item.id !== entry.id)) + updateEntries((current) => current.filter((item) => item.id !== entry.id)) try { await deleteHistoryRevisionSafe(userId, entry.id, entry.revision) await patchCachedEntry(entry.id, null, isCurrentScope) } catch (mutationError) { - setEntries((current) => mergeHistoryEntries(current, [entry])) + updateEntries((current) => mergeHistoryEntries(current, [entry])) throw mutationError } finally { endMutation(entry.id) } - }, [beginMutation, cacheScope, endMutation, patchCachedEntry, userId]) + }, [beginMutation, cacheScope, endMutation, patchCachedEntry, updateEntries, userId]) const isMutating = useCallback((entryId: string): boolean => { return mutatingIds.has(entryId) @@ -394,7 +429,7 @@ export function useHistorySync(): HistorySyncModel { error, cacheWarning, realtimeDisconnected, - hasMore: nextCursor !== null, + hasMore: historyWindow.nextCursor !== null, setFilter, setSearch, refresh,