fix(mobile-history): keep loaded pages on background refresh and drop stale loadMore pages

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 9a5333c617
commit ae2d923b9c
3 changed files with 589 additions and 46 deletions

View 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 }
}

View file

@ -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({
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<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,