fix(mobile-history): keep loaded pages on background refresh and drop stale loadMore pages
This commit is contained in:
parent
9a5333c617
commit
ae2d923b9c
3 changed files with 589 additions and 46 deletions
336
apps/mobile-rn/__tests__/history-window-redteam-r2-31.test.tsx
Normal file
336
apps/mobile-rn/__tests__/history-window-redteam-r2-31.test.tsx
Normal file
|
|
@ -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<Promise<HistoryPageResult>, [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> = {}): 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<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve: (value: T) => void = () => undefined
|
||||
const promise = new Promise<T>((settle) => { resolve = settle })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
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<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
renderer = create(<Harness />)
|
||||
await flush()
|
||||
})
|
||||
if (renderer === null) throw new Error('renderer missing')
|
||||
return renderer
|
||||
}
|
||||
|
||||
async function triggerRemoteChange(): Promise<void> {
|
||||
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<HistoryPageResult>()
|
||||
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<void> = 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
172
apps/mobile-rn/src/features/history/history-window.ts
Normal file
172
apps/mobile-rn/src/features/history/history-window.ts
Normal file
|
|
@ -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<HistoryPageResult>
|
||||
|
||||
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>): HistoryListEntry[] {
|
||||
const byId = new Map<string, HistoryListEntry>()
|
||||
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<HistoryWindow> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<HistoryListEntry[]>([])
|
||||
// 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<HistoryWindow>(EMPTY_HISTORY_WINDOW)
|
||||
const windowRef = useRef<HistoryWindow>(EMPTY_HISTORY_WINDOW)
|
||||
const entries = historyWindow.entries
|
||||
const [filter, setFilter] = useState<HistoryFilter>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
|
|
@ -75,19 +87,31 @@ export function useHistorySync(): HistorySyncModel {
|
|||
const [error, setError] = useState<HistorySyncError | null>(null)
|
||||
const [cacheWarning, setCacheWarning] = useState(false)
|
||||
const [realtimeDisconnected, setRealtimeDisconnected] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState<HistoryCursor | null>(null)
|
||||
const [mutatingIds, setMutatingIds] = useState<Set<string>>(() => new Set())
|
||||
const mutatingIdsRef = useRef<Set<string>>(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<void> => {
|
||||
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({
|
||||
// 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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue