From d07fefa2ecb683e45b88e539e230e6c934d4f7cf Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 24 Jan 2026 21:23:52 +0000 Subject: [PATCH 1/6] Add useInfiniteFind --- lib/adapters/feathers.ts | 37 +- lib/index.ts | 2 + lib/react/createHooks.ts | 32 ++ lib/react/useInfiniteFind.ts | 493 +++++++++++++++++++++++ test/helpers-cursor.ts | 215 ++++++++++ test/use-infinite-find.test.tsx | 689 ++++++++++++++++++++++++++++++++ 6 files changed, 1465 insertions(+), 3 deletions(-) create mode 100644 lib/react/useInfiniteFind.ts create mode 100644 test/helpers-cursor.ts create mode 100644 test/use-infinite-find.test.tsx diff --git a/lib/adapters/feathers.ts b/lib/adapters/feathers.ts index 45b6573..177d2e2 100644 --- a/lib/adapters/feathers.ts +++ b/lib/adapters/feathers.ts @@ -70,7 +70,8 @@ export interface FeathersParams> { } /** - * Feathers-specific metadata for find operations + * Feathers-specific metadata for find operations. + * Supports both offset pagination (total/limit/skip) and cursor pagination (hasNextPage/endCursor). */ export interface FeathersFindMeta { /** Total number of items matching the query (may be -1 if unknown). */ @@ -79,6 +80,10 @@ export interface FeathersFindMeta { limit: number /** Number of items skipped (offset) for this page. */ skip: number + /** Whether there are more pages available (cursor pagination). */ + hasNextPage?: boolean + /** Cursor to fetch the next page (cursor pagination). */ + endCursor?: string | null /** Additional adapter-specific metadata. */ [key: string]: unknown } @@ -245,8 +250,34 @@ export class FeathersAdapter> implements Adapte if (Array.isArray(res)) { return { data: res, meta: { total: -1, limit: res.length, skip: 0 } } } else { - const { data, total = -1, limit = data.length, skip = 0, ...rest } = res - return { data, meta: { total, limit, skip, ...rest } } + const { + data, + total = -1, + limit = data.length, + skip = 0, + hasNextPage, + endCursor, + ...rest + } = res as { + data: unknown[] + total?: number + limit?: number + skip?: number + hasNextPage?: boolean + endCursor?: string | null + [key: string]: unknown + } + return { + data, + meta: { + total, + limit, + skip, + ...(hasNextPage !== undefined && { hasNextPage }), + ...(endCursor !== undefined && { endCursor }), + ...rest, + }, + } } } diff --git a/lib/index.ts b/lib/index.ts index ccd6b42..adccaab 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -43,6 +43,7 @@ export type { // react hooks export { createHooks } from './react/createHooks.js' export { FigbirdProvider, useFigbird } from './react/react.js' +export { useInfiniteFind } from './react/useInfiniteFind.js' export { useFeathers } from './react/useFeathers.js' export { useMutation } from './react/useMutation.js' export { useFind, useGet } from './react/useQuery.js' @@ -57,6 +58,7 @@ export type { } from './core/figbird.js' // React hook result types (already exported but let's be complete) +export type { UseInfiniteFindConfig, UseInfiniteFindResult } from './react/useInfiniteFind.js' export type { UseMutationResult } from './react/useMutation.js' export type { QueryResult } from './react/useQuery.js' diff --git a/lib/react/createHooks.ts b/lib/react/createHooks.ts index 969179a..49e029a 100644 --- a/lib/react/createHooks.ts +++ b/lib/react/createHooks.ts @@ -11,6 +11,11 @@ import type { ServiceUpdate, } from '../core/schema.js' import { findServiceByName } from '../core/schema.js' +import { + useInfiniteFind as useBaseInfiniteFind, + type UseInfiniteFindConfig, + type UseInfiniteFindResult, +} from './useInfiniteFind.js' import { useMutation as useBaseMutation, type UseMutationResult } from './useMutation.js' import { useQuery, type QueryResult } from './useQuery.js' @@ -51,6 +56,17 @@ type UseMutationForSchema = >( ServicePatch > +type UseInfiniteFindForSchema< + S extends Schema, + TParams = unknown, + TMeta extends Record = Record, +> = >( + serviceName: N, + config?: Omit, ServiceQuery, TMeta>, 'query'> & { + query?: ServiceQuery + } & Omit, 'query'>, +) => UseInfiniteFindResult, TMeta> + type UseFeathersForSchema = () => TypedFeathersClient // Type helper to extract schema and adapter types from a Figbird instance @@ -85,6 +101,7 @@ export function createHooks>( ): { useGet: UseGetForSchema, InferParams> useFind: UseFindForSchema, InferParams, InferMeta> + useInfiniteFind: UseInfiniteFindForSchema, InferParams, InferMeta> useMutation: UseMutationForSchema> useFeathers: UseFeathersForSchema> } { @@ -131,6 +148,20 @@ export function createHooks>( return useQuery[], TMeta, ServiceQuery>(desc, config) } + function useTypedInfiniteFind>( + serviceName: N, + config?: Omit, ServiceQuery, TMeta>, 'query'> & { + query?: ServiceQuery + } & Omit, 'query'>, + ) { + const service = findServiceByName(figbird.schema, serviceName) + const actualServiceName = service?.name ?? serviceName + return useBaseInfiniteFind, TMeta, ServiceQuery>( + actualServiceName, + config as UseInfiniteFindConfig, ServiceQuery, TMeta>, + ) + } + function useTypedMutation>(serviceName: N) { const service = findServiceByName(figbird.schema, serviceName) const actualServiceName = service?.name ?? serviceName @@ -155,6 +186,7 @@ export function createHooks>( return { useGet: useTypedGet as UseGetForSchema, useFind: useTypedFind as UseFindForSchema, + useInfiniteFind: useTypedInfiniteFind as UseInfiniteFindForSchema, useMutation: useTypedMutation as UseMutationForSchema, useFeathers: useTypedFeathers as UseFeathersForSchema, } diff --git a/lib/react/useInfiniteFind.ts b/lib/react/useInfiniteFind.ts new file mode 100644 index 0000000..9f8a329 --- /dev/null +++ b/lib/react/useInfiniteFind.ts @@ -0,0 +1,493 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import type { Adapter, EventHandlers } from '../adapters/adapter.js' +import { useFigbird } from './react.js' + +/** + * Configuration for infinite/cursor-based pagination queries + */ +export interface UseInfiniteFindConfig { + /** Query parameters for filtering/sorting */ + query?: TQuery + /** Skip fetching entirely */ + skip?: boolean + /** Realtime strategy: 'merge' inserts events in sorted order, 'refetch' resets to first page, 'disabled' ignores events */ + realtime?: 'merge' | 'refetch' | 'disabled' + /** Fetch all pages automatically on initial load */ + fetchAllPages?: boolean + /** Page size limit (defaults to adapter's default) */ + limit?: number + /** Custom matcher for realtime events (default: built from query using adapter.matcher/sift) */ + matcher?: (query: TQuery | undefined) => (item: TItem) => boolean + /** Custom sorter for inserting realtime events (default: built from query.$sort) */ + sorter?: (a: TItem, b: TItem) => number + /** Custom cursor extraction (default: meta.endCursor) */ + getCursor?: (meta: TMeta, data: TItem[]) => string | null + /** Custom hasNextPage extraction (default: meta.hasNextPage) */ + getHasNextPage?: (meta: TMeta, data: TItem[]) => boolean +} + +/** + * Result type for infinite/cursor-based pagination queries + */ +export interface UseInfiniteFindResult { + /** Current status of the query */ + status: 'loading' | 'success' | 'error' + /** Accumulated data from all loaded pages */ + data: TItem[] + /** Metadata from the last fetched page */ + meta: TMeta + /** Whether any fetch is in progress (initial or loadMore) */ + isFetching: boolean + /** Whether a loadMore fetch is in progress */ + isLoadingMore: boolean + /** Error from loadMore operation (separate from initial error) */ + loadMoreError: Error | null + /** Error from initial fetch */ + error: Error | null + /** Whether there are more pages available */ + hasNextPage: boolean + /** Load the next page */ + loadMore: () => void + /** Refetch from the beginning */ + refetch: () => void +} + +// State shape for the reducer +interface CursorState { + status: 'loading' | 'success' | 'error' + data: TItem[] + meta: TMeta + isFetching: boolean + isLoadingMore: boolean + loadMoreError: Error | null + error: Error | null + hasNextPage: boolean + cursor: string | null +} + +// Action types +type CursorAction = + | { type: 'FETCH_START' } + | { + type: 'FETCH_SUCCESS' + data: TItem[] + meta: TMeta + hasNextPage: boolean + cursor: string | null + } + | { type: 'FETCH_ERROR'; error: Error } + | { type: 'LOAD_MORE_START' } + | { + type: 'LOAD_MORE_SUCCESS' + data: TItem[] + meta: TMeta + hasNextPage: boolean + cursor: string | null + } + | { type: 'LOAD_MORE_ERROR'; error: Error } + | { type: 'REFETCH' } + | { type: 'REALTIME_UPDATE'; data: TItem[] } + +function createReducer(emptyMeta: TMeta) { + return function reducer( + state: CursorState, + action: CursorAction, + ): CursorState { + switch (action.type) { + case 'FETCH_START': + return { + ...state, + isFetching: true, + error: null, + } + case 'FETCH_SUCCESS': + return { + status: 'success', + data: action.data, + meta: action.meta, + isFetching: false, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: action.hasNextPage, + cursor: action.cursor, + } + case 'FETCH_ERROR': + return { + ...state, + status: 'error', + isFetching: false, + error: action.error, + } + case 'LOAD_MORE_START': + return { + ...state, + isLoadingMore: true, + loadMoreError: null, + } + case 'LOAD_MORE_SUCCESS': + return { + ...state, + data: [...state.data, ...action.data], + meta: action.meta, + isLoadingMore: false, + hasNextPage: action.hasNextPage, + cursor: action.cursor, + } + case 'LOAD_MORE_ERROR': + return { + ...state, + isLoadingMore: false, + loadMoreError: action.error, + } + case 'REFETCH': + return { + status: 'loading', + data: [], + meta: emptyMeta, + isFetching: true, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: false, + cursor: null, + } + case 'REALTIME_UPDATE': + return { + ...state, + data: action.data, + } + default: + return state + } + } +} + +/** + * Build a sorter function from a $sort object + */ +function buildSorter( + sort: Record | undefined, +): (a: TItem, b: TItem) => number { + if (!sort || Object.keys(sort).length === 0) { + // Default: append at end (no sorting) + return () => 0 + } + + const sortEntries = Object.entries(sort) + return (a: TItem, b: TItem) => { + for (const [key, direction] of sortEntries) { + const aVal = (a as Record)[key] + const bVal = (b as Record)[key] + + let cmp = 0 + if (aVal == null && bVal == null) { + cmp = 0 + } else if (aVal == null) { + cmp = 1 + } else if (bVal == null) { + cmp = -1 + } else if (typeof aVal === 'string' && typeof bVal === 'string') { + cmp = aVal.localeCompare(bVal) + } else if (aVal < bVal) { + cmp = -1 + } else if (aVal > bVal) { + cmp = 1 + } + + if (cmp !== 0) { + return cmp * direction + } + } + return 0 + } +} + +/** + * Insert an item into a sorted array at the correct position + */ +function insertSorted( + data: TItem[], + item: TItem, + sorter: (a: TItem, b: TItem) => number, +): TItem[] { + const result = [...data] + let low = 0 + let high = result.length + + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (sorter(item, result[mid]!) <= 0) { + high = mid + } else { + low = mid + 1 + } + } + + result.splice(low, 0, item) + return result +} + +/** + * Hook for infinite/cursor-based pagination with realtime updates. + * Manages accumulated data across pages with loadMore functionality. + */ +export function useInfiniteFind< + TItem, + TMeta extends Record = Record, + TQuery = Record, +>( + serviceName: string, + config: UseInfiniteFindConfig = {}, +): UseInfiniteFindResult { + const figbird = useFigbird() + const adapter = figbird.adapter as Adapter> + + const { + query, + skip = false, + realtime = 'merge', + fetchAllPages = false, + limit, + matcher: customMatcher, + sorter: customSorter, + getCursor = (meta: TMeta) => (meta.endCursor as string | null) ?? null, + getHasNextPage = (meta: TMeta) => (meta.hasNextPage as boolean) ?? false, + } = config + + const emptyMeta = useMemo(() => adapter.emptyMeta(), [adapter]) + const reducer = useMemo(() => createReducer(emptyMeta), [emptyMeta]) + + const [state, dispatch] = useReducer(reducer, { + status: 'loading', + data: [], + meta: emptyMeta, + isFetching: !skip, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: false, + cursor: null, + }) + + // Build matcher for realtime events + const itemMatcher = useMemo(() => { + if (realtime !== 'merge') return () => false + if (customMatcher) return customMatcher(query) + return adapter.matcher(query as (TQuery & Record) | undefined) as ( + item: TItem, + ) => boolean + }, [adapter, query, realtime, customMatcher]) + + // Build sorter for realtime insertions + const itemSorter = useMemo(() => { + if (customSorter) return customSorter + const sort = (query as { $sort?: Record } | undefined)?.$sort + return buildSorter(sort) + }, [query, customSorter]) + + // Refs to access latest values in callbacks + const stateRef = useRef(state) + stateRef.current = state + + const configRef = useRef({ query, limit, getCursor, getHasNextPage }) + configRef.current = { query, limit, getCursor, getHasNextPage } + + // Fetch function + const fetchPage = useCallback( + async (cursor: string | null, isLoadMore: boolean) => { + const { + query: currentQuery, + limit: currentLimit, + getCursor: gc, + getHasNextPage: ghnp, + } = configRef.current + + const params: Record = { + query: { + ...currentQuery, + ...(currentLimit && { $limit: currentLimit }), + ...(cursor && { cursor }), + }, + } + + try { + const result = await adapter.find(serviceName, params) + const data = result.data as TItem[] + const meta = result.meta as TMeta + const nextCursor = gc(meta, data) + const hasMore = ghnp(meta, data) + + if (isLoadMore) { + dispatch({ + type: 'LOAD_MORE_SUCCESS', + data, + meta, + hasNextPage: hasMore, + cursor: nextCursor, + }) + } else { + dispatch({ + type: 'FETCH_SUCCESS', + data, + meta, + hasNextPage: hasMore, + cursor: nextCursor, + }) + } + + return { data, meta, hasNextPage: hasMore, cursor: nextCursor } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + if (isLoadMore) { + dispatch({ type: 'LOAD_MORE_ERROR', error }) + } else { + dispatch({ type: 'FETCH_ERROR', error }) + } + return null + } + }, + [adapter, serviceName], + ) + + // Initial fetch effect + useEffect(() => { + if (skip) return + + let cancelled = false + + async function initialFetch() { + dispatch({ type: 'FETCH_START' }) + + const result = await fetchPage(null, false) + + if (cancelled || !result) return + + // If fetchAllPages is enabled, continue fetching + if (fetchAllPages && result.hasNextPage && result.cursor) { + let currentCursor: string | null = result.cursor + let hasMore: boolean = result.hasNextPage + + while (hasMore && currentCursor && !cancelled) { + dispatch({ type: 'LOAD_MORE_START' }) + const nextResult = await fetchPage(currentCursor, true) + if (!nextResult || cancelled) break + currentCursor = nextResult.cursor + hasMore = nextResult.hasNextPage + } + } + } + + initialFetch() + + return () => { + cancelled = true + } + // Note: We intentionally use JSON.stringify(query) to re-fetch when query changes + }, [skip, fetchPage, fetchAllPages, JSON.stringify(query)]) + + // loadMore function + const loadMore = useCallback(() => { + const currentState = stateRef.current + if (currentState.isLoadingMore || !currentState.hasNextPage || !currentState.cursor) { + return + } + dispatch({ type: 'LOAD_MORE_START' }) + fetchPage(currentState.cursor, true) + }, [fetchPage]) + + // refetch function + const refetch = useCallback(() => { + dispatch({ type: 'REFETCH' }) + fetchPage(null, false) + }, [fetchPage]) + + // Realtime subscription effect + useEffect(() => { + if (skip || realtime === 'disabled' || !adapter.subscribe) { + return + } + + const getId = (item: unknown) => adapter.getId(item) + + const handlers: EventHandlers = { + created: (item: unknown) => { + if (realtime === 'refetch') { + refetch() + return + } + + const typedItem = item as TItem + if (!itemMatcher(typedItem)) return + + const currentData = stateRef.current.data + const newData = insertSorted(currentData, typedItem, itemSorter) + dispatch({ type: 'REALTIME_UPDATE', data: newData }) + }, + updated: (item: unknown) => { + if (realtime === 'refetch') { + refetch() + return + } + + const typedItem = item as TItem + const itemId = getId(typedItem) + const currentData = stateRef.current.data + const existingIndex = currentData.findIndex(d => getId(d) === itemId) + const matches = itemMatcher(typedItem) + + if (existingIndex >= 0 && !matches) { + // Remove: no longer matches + const newData = currentData.filter((_, i) => i !== existingIndex) + dispatch({ type: 'REALTIME_UPDATE', data: newData }) + } else if (existingIndex >= 0 && matches) { + // Update in place + const newData = [...currentData] + newData[existingIndex] = typedItem + dispatch({ type: 'REALTIME_UPDATE', data: newData }) + } else if (existingIndex < 0 && matches) { + // New item that matches - insert sorted + const newData = insertSorted(currentData, typedItem, itemSorter) + dispatch({ type: 'REALTIME_UPDATE', data: newData }) + } + }, + patched: (item: unknown) => { + // Same logic as updated + handlers.updated(item) + }, + removed: (item: unknown) => { + if (realtime === 'refetch') { + refetch() + return + } + + const typedItem = item as TItem + const itemId = getId(typedItem) + const currentData = stateRef.current.data + const newData = currentData.filter(d => getId(d) !== itemId) + if (newData.length !== currentData.length) { + dispatch({ type: 'REALTIME_UPDATE', data: newData }) + } + }, + } + + const unsubscribe = adapter.subscribe(serviceName, handlers) + return unsubscribe + }, [serviceName, skip, realtime, adapter, itemMatcher, itemSorter, refetch]) + + return useMemo( + () => ({ + status: state.status, + data: state.data, + meta: state.meta, + isFetching: state.isFetching, + isLoadingMore: state.isLoadingMore, + loadMoreError: state.loadMoreError, + error: state.error, + hasNextPage: state.hasNextPage, + loadMore, + refetch, + }), + [state, loadMore, refetch], + ) +} diff --git a/test/helpers-cursor.ts b/test/helpers-cursor.ts new file mode 100644 index 0000000..3a983ac --- /dev/null +++ b/test/helpers-cursor.ts @@ -0,0 +1,215 @@ +import EventEmitter from 'events' +import type { FeathersClient } from '../lib/index.js' +import { queueTask } from './helpers.js' + +interface TestItem { + id?: string | number + _id?: string | number + updatedAt?: string | Date | number | null + [key: string]: unknown +} + +interface ServiceCounts { + get: number + find: number + create: number + patch: number + update: number + remove: number +} + +interface FindParams { + query?: { + $limit?: number + cursor?: string + $sort?: Record + [key: string]: unknown + } + [key: string]: unknown +} + +interface CursorFindResult { + data: TestItem[] + hasNextPage: boolean + endCursor: string | null + total?: number + limit: number + skip: number +} + +interface CursorServiceOptions { + data: TestItem[] + pageSize: number + failNextFind?: boolean +} + +class CursorService extends EventEmitter { + name: string + #data: Map + #originalOrder: (string | number)[] + pageSize: number + counts: ServiceCounts + #failNextFind: boolean; + [key: string]: unknown + + constructor(name: string, options: CursorServiceOptions) { + super() + this.name = name + this.#data = new Map() + this.#originalOrder = [] + for (const item of options.data) { + const id = item.id ?? item._id + if (id !== undefined) { + this.#data.set(id, item) + this.#originalOrder.push(id) + } + } + this.pageSize = options.pageSize + this.counts = { + get: 0, + find: 0, + create: 0, + patch: 0, + update: 0, + remove: 0, + } + this.#failNextFind = options.failNextFind ?? false + } + + get(id: string | number): Promise { + this.counts.get++ + const item = this.#data.get(id) + if (!item) { + return Promise.reject(new Error(`Item with id ${id} not found`)) + } + return Promise.resolve(item) + } + + async find(params: FindParams = {}): Promise { + this.counts.find++ + + if (this.#failNextFind) { + this.#failNextFind = false + return Promise.reject(new Error('Simulated fetch error')) + } + + const limit = params.query?.$limit ?? this.pageSize + const cursor = params.query?.cursor + + // Get all items in original order + const allItems = this.#originalOrder + .map(id => this.#data.get(id)) + .filter((item): item is TestItem => item !== undefined) + + // Find starting index based on cursor + let startIndex = 0 + if (cursor) { + const cursorId = parseInt(cursor, 10) + startIndex = this.#originalOrder.findIndex(id => id === cursorId) + if (startIndex === -1) { + startIndex = 0 + } + } + + // Get page of data + const pageData = allItems.slice(startIndex, startIndex + limit) + const hasNextPage = startIndex + limit < allItems.length + const nextCursorId = hasNextPage ? this.#originalOrder[startIndex + limit] : null + + return Promise.resolve({ + data: pageData, + hasNextPage, + endCursor: nextCursorId !== null ? String(nextCursorId) : null, + total: allItems.length, + limit, + skip: startIndex, + }) + } + + create(data: Partial): Promise + create(data: TestItem[]): Promise + create(data: Partial | TestItem[]): Promise { + if (Array.isArray(data)) { + const results: TestItem[] = [] + for (const item of data) { + const id = item.id ?? item._id + if (id !== undefined) { + this.counts.create++ + const newItem = { ...item, updatedAt: item.updatedAt ?? Date.now() } + this.#data.set(id, newItem) + this.#originalOrder.push(id) + results.push(newItem) + queueTask(() => this.emit('created', newItem)) + } + } + return Promise.resolve(results) + } + this.counts.create++ + const id = data.id ?? data._id + if (id === undefined) { + return Promise.reject(new Error('Item must have an id or _id')) + } + const item = { ...data, updatedAt: data.updatedAt ?? Date.now() } + this.#data.set(id, item) + this.#originalOrder.push(id) + const mutatedItem = this.#data.get(id)! + queueTask(() => this.emit('created', mutatedItem)) + return Promise.resolve(mutatedItem) + } + + patch(id: string | number, data: Partial): Promise { + this.counts.patch++ + const existingItem = this.#data.get(id) + if (!existingItem) { + return Promise.reject(new Error(`Item with id ${id} not found`)) + } + const updatedItem = { ...existingItem, ...data, updatedAt: data.updatedAt ?? Date.now() } + this.#data.set(id, updatedItem) + const mutatedItem = this.#data.get(id)! + queueTask(() => this.emit('patched', mutatedItem)) + return Promise.resolve(mutatedItem) + } + + update(id: string | number, data: Partial): Promise { + this.counts.update++ + const updatedItem = { ...data, updatedAt: data.updatedAt ?? Date.now() } + this.#data.set(id, updatedItem) + const mutatedItem = this.#data.get(id)! + queueTask(() => this.emit('updated', mutatedItem)) + return Promise.resolve(mutatedItem) + } + + remove(id: string | number): Promise { + this.counts.remove++ + const item = this.#data.get(id) + if (!item) { + return Promise.reject(new Error(`Item with id ${id} not found`)) + } + this.#data.delete(id) + this.#originalOrder = this.#originalOrder.filter(i => i !== id) + queueTask(() => this.emit('removed', item)) + return Promise.resolve(item) + } +} + +interface MockCursorFeathersServices { + [serviceName: string]: CursorServiceOptions +} + +interface MockCursorFeathers extends FeathersClient { + service(name: string): CursorService +} + +export function mockCursorFeathers(services: MockCursorFeathersServices): MockCursorFeathers { + const processedServices: Record = {} + + for (const [name, options] of Object.entries(services)) { + processedServices[name] = new CursorService(name, options) + } + + return { + service(name: string): CursorService { + return processedServices[name]! + }, + } +} diff --git a/test/use-infinite-find.test.tsx b/test/use-infinite-find.test.tsx new file mode 100644 index 0000000..9162705 --- /dev/null +++ b/test/use-infinite-find.test.tsx @@ -0,0 +1,689 @@ +import test from 'ava' +import { FeathersAdapter } from '../lib/adapters/feathers' +import { Figbird } from '../lib/core/figbird' +import { createSchema, service } from '../lib/core/schema' +import { createHooks } from '../lib/react/createHooks' +import { FigbirdProvider } from '../lib/react/react' +import { dom } from './helpers' +import { mockCursorFeathers } from './helpers-cursor' + +interface Document { + id: number + title: string + createdAt: number + updatedAt?: number +} + +const schema = createSchema({ + services: { + 'api/documents': service<{ + item: Document + query: { personId?: string; $sort?: Record } + }>(), + }, +}) + +test('useInfiniteFind initial fetch returns first page', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { status, data, hasNextPage } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {status} + {data.length} + {String(hasNextPage)} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + + unmount() +}) + +test('useInfiniteFind loadMore fetches next page and accumulates data', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { status, data, hasNextPage, loadMore, isLoadingMore } = useInfiniteFind( + 'api/documents', + { + query: { $sort: { createdAt: -1 } }, + }, + ) + + return ( +
+ {status} + {data.length} + {String(hasNextPage)} + {String(isLoadingMore)} + {data.map(d => ( + + {d.title} + + ))} + {hasNextPage && ( + + )} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + + // Click load more + const loadMoreBtn = $('.load-more') + t.truthy(loadMoreBtn) + click(loadMoreBtn!) + + await flush() + + t.is($('.count')?.textContent, '3') + t.is($('.hasNextPage')?.textContent, 'false') + t.is($all('.doc').length, 3) + + unmount() +}) + +test('useInfiniteFind hasNextPage updates correctly', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, hasNextPage, loadMore } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {data.length} + {String(hasNextPage)} + {hasNextPage && ( + + )} +
+ ) + } + + render( + + + , + ) + + await flush() + + // With 2 items and pageSize 2, hasNextPage should be false + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'false') + t.falsy($('.load-more')) + + unmount() +}) + +test('useInfiniteFind realtime created events insert at sorted position', async t => { + const { $, $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, status } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {status} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($all('.doc').length, 2) + + // Create a new document that should be inserted in the middle + await feathers.service('api/documents').create({ id: 2, title: 'Doc 2', createdAt: 200 }) + await flush() + + const docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 1', 'Doc 2', 'Doc 3']) + + unmount() +}) + +test('useInfiniteFind realtime updated events update in place', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, status } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {status} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($all('.doc').length, 2) + t.is($all('.doc')[0]?.textContent, 'Doc 1') + + // Update the first document + await feathers.service('api/documents').patch(1, { title: 'Doc 1 Updated' }) + await flush() + + const docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 1 Updated', 'Doc 2']) + + unmount() +}) + +test('useInfiniteFind realtime removed events remove from data', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($all('.doc').length, 3) + + // Remove the middle document + await feathers.service('api/documents').remove(2) + await flush() + + const docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 1', 'Doc 3']) + + unmount() +}) + +test('useInfiniteFind skip: true prevents fetch', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [{ id: 1, title: 'Doc 1', createdAt: 300 }] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { status, data, isFetching } = useInfiniteFind('api/documents', { + skip: true, + }) + + return ( +
+ {status} + {data.length} + {String(isFetching)} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'loading') + t.is($('.count')?.textContent, '0') + t.is($('.isFetching')?.textContent, 'false') + t.is(feathers.service('api/documents').counts.find, 0) + + unmount() +}) + +test('useInfiniteFind error handling for failed fetches', async t => { + const { $, flush, render, unmount } = dom() + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: [], + pageSize: 10, + failNextFind: true, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { status, error } = useInfiniteFind('api/documents', {}) + + return ( +
+ {status} + {error?.message ?? 'none'} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'error') + t.is($('.error')?.textContent, 'Simulated fetch error') + + unmount() +}) + +test('useInfiniteFind custom matcher works correctly', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + // Custom matcher that only accepts items with id > 1 + matcher: () => (item: Document) => item.id > 1, + }) + + return ( +
+ {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($all('.doc').length, 2) + + // Create a new document with id 0 - should NOT be added due to custom matcher + await feathers.service('api/documents').create({ id: 0, title: 'Doc 0', createdAt: 400 }) + await flush() + + // Still 2 docs because id 0 doesn't match + t.is($all('.doc').length, 2) + + // Create a new document with id 4 - SHOULD be added + await feathers.service('api/documents').create({ id: 4, title: 'Doc 4', createdAt: 50 }) + await flush() + + t.is($all('.doc').length, 3) + + unmount() +}) + +test('useInfiniteFind custom sorter works correctly', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Banana', createdAt: 300 }, + { id: 2, title: 'Apple', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data } = useInfiniteFind('api/documents', { + // Custom sorter: sort alphabetically by title + sorter: (a: Document, b: Document) => a.title.localeCompare(b.title), + }) + + return ( +
+ {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + // Initial data is in server order (Banana, Apple) + let docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Banana', 'Apple']) + + // Create "Cherry" - should be inserted after Banana alphabetically + await feathers.service('api/documents').create({ id: 3, title: 'Cherry', createdAt: 100 }) + await flush() + + docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Banana', 'Apple', 'Cherry']) + + unmount() +}) + +test('useInfiniteFind refetch resets to first page', async t => { + const { $, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, hasNextPage, loadMore, refetch } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {data.length} + {String(hasNextPage)} + {hasNextPage && ( + + )} + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.count')?.textContent, '2') + + // Load more to get all 3 + click($('.load-more')!) + await flush() + + t.is($('.count')?.textContent, '3') + t.is($('.hasNextPage')?.textContent, 'false') + + // Refetch should reset to first page + click($('.refetch')!) + await flush() + + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + + unmount() +}) + +test('useInfiniteFind realtime disabled ignores events', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [{ id: 1, title: 'Doc 1', createdAt: 300 }] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data } = useInfiniteFind('api/documents', { + realtime: 'disabled', + }) + + return ( +
+ {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($all('.doc').length, 1) + + // Create a new document - should NOT be added because realtime is disabled + await feathers.service('api/documents').create({ id: 2, title: 'Doc 2', createdAt: 200 }) + await flush() + + // Still 1 doc + t.is($all('.doc').length, 1) + + unmount() +}) From 5dedee65f19e721c1379697d3af03d1d04e3010f Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 24 Jan 2026 21:33:40 +0000 Subject: [PATCH 2/6] Also for offsets --- lib/react/useInfiniteFind.ts | 117 ++++++++---- test/helpers-cursor.ts | 41 ++++- test/use-infinite-find.test.tsx | 303 ++++++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+), 43 deletions(-) diff --git a/lib/react/useInfiniteFind.ts b/lib/react/useInfiniteFind.ts index 9f8a329..6454f4a 100644 --- a/lib/react/useInfiniteFind.ts +++ b/lib/react/useInfiniteFind.ts @@ -20,10 +20,13 @@ export interface UseInfiniteFindConfig { matcher?: (query: TQuery | undefined) => (item: TItem) => boolean /** Custom sorter for inserting realtime events (default: built from query.$sort) */ sorter?: (a: TItem, b: TItem) => number - /** Custom cursor extraction (default: meta.endCursor) */ - getCursor?: (meta: TMeta, data: TItem[]) => string | null - /** Custom hasNextPage extraction (default: meta.hasNextPage) */ - getHasNextPage?: (meta: TMeta, data: TItem[]) => boolean + /** + * Extract next page param from response. Returns cursor string, skip number, or null if no more pages. + * Auto-detects mode: uses cursor if meta.endCursor exists, otherwise uses offset (skip). + */ + getPageParam?: (meta: TMeta, data: TItem[], allData: TItem[]) => string | number | null + /** Custom hasNextPage extraction (default: meta.hasNextPage or derived from getPageParam) */ + getHasNextPage?: (meta: TMeta, data: TItem[], allData: TItem[]) => boolean } /** @@ -53,7 +56,7 @@ export interface UseInfiniteFindResult { } // State shape for the reducer -interface CursorState { +interface InfiniteState { status: 'loading' | 'success' | 'error' data: TItem[] meta: TMeta @@ -62,18 +65,18 @@ interface CursorState { loadMoreError: Error | null error: Error | null hasNextPage: boolean - cursor: string | null + pageParam: string | number | null // cursor string OR skip number } // Action types -type CursorAction = +type InfiniteAction = | { type: 'FETCH_START' } | { type: 'FETCH_SUCCESS' data: TItem[] meta: TMeta hasNextPage: boolean - cursor: string | null + pageParam: string | number | null } | { type: 'FETCH_ERROR'; error: Error } | { type: 'LOAD_MORE_START' } @@ -82,7 +85,7 @@ type CursorAction = data: TItem[] meta: TMeta hasNextPage: boolean - cursor: string | null + pageParam: string | number | null } | { type: 'LOAD_MORE_ERROR'; error: Error } | { type: 'REFETCH' } @@ -90,9 +93,9 @@ type CursorAction = function createReducer(emptyMeta: TMeta) { return function reducer( - state: CursorState, - action: CursorAction, - ): CursorState { + state: InfiniteState, + action: InfiniteAction, + ): InfiniteState { switch (action.type) { case 'FETCH_START': return { @@ -110,7 +113,7 @@ function createReducer(emptyMeta: TMeta) { loadMoreError: null, error: null, hasNextPage: action.hasNextPage, - cursor: action.cursor, + pageParam: action.pageParam, } case 'FETCH_ERROR': return { @@ -132,7 +135,7 @@ function createReducer(emptyMeta: TMeta) { meta: action.meta, isLoadingMore: false, hasNextPage: action.hasNextPage, - cursor: action.cursor, + pageParam: action.pageParam, } case 'LOAD_MORE_ERROR': return { @@ -150,7 +153,7 @@ function createReducer(emptyMeta: TMeta) { loadMoreError: null, error: null, hasNextPage: false, - cursor: null, + pageParam: null, } case 'REALTIME_UPDATE': return { @@ -243,6 +246,37 @@ export function useInfiniteFind< const figbird = useFigbird() const adapter = figbird.adapter as Adapter> + // Default getPageParam: auto-detect mode from response + const defaultGetPageParam = (meta: TMeta, data: TItem[], _allData: TItem[]) => { + // Cursor mode: if endCursor exists, use it + if ('endCursor' in meta && meta.endCursor != null) { + return meta.endCursor as string + } + // Offset mode: calculate next skip value + const skip = (meta as Record).skip as number | undefined + const limit = (meta as Record).limit as number | undefined + const total = (meta as Record).total as number | undefined + const currentSkip = skip ?? 0 + const nextSkip = currentSkip + data.length + // No more pages if we've fetched everything + if (total !== undefined && nextSkip >= total) return null + // No more pages if we got fewer items than limit (and limit is known) + if (limit !== undefined && data.length < limit) return null + // If we got no data, no more pages + if (data.length === 0) return null + return nextSkip + } + + // Default getHasNextPage: derive from pageParam or explicit meta.hasNextPage + const defaultGetHasNextPage = (meta: TMeta, data: TItem[], allData: TItem[]) => { + // If meta.hasNextPage is explicitly set, use it + if ('hasNextPage' in meta && typeof meta.hasNextPage === 'boolean') { + return meta.hasNextPage + } + // Otherwise derive from whether getPageParam returns non-null + return defaultGetPageParam(meta, data, allData) !== null + } + const { query, skip = false, @@ -251,8 +285,8 @@ export function useInfiniteFind< limit, matcher: customMatcher, sorter: customSorter, - getCursor = (meta: TMeta) => (meta.endCursor as string | null) ?? null, - getHasNextPage = (meta: TMeta) => (meta.hasNextPage as boolean) ?? false, + getPageParam = defaultGetPageParam, + getHasNextPage = defaultGetHasNextPage, } = config const emptyMeta = useMemo(() => adapter.emptyMeta(), [adapter]) @@ -267,7 +301,7 @@ export function useInfiniteFind< loadMoreError: null, error: null, hasNextPage: false, - cursor: null, + pageParam: null, }) // Build matcher for realtime events @@ -290,16 +324,16 @@ export function useInfiniteFind< const stateRef = useRef(state) stateRef.current = state - const configRef = useRef({ query, limit, getCursor, getHasNextPage }) - configRef.current = { query, limit, getCursor, getHasNextPage } + const configRef = useRef({ query, limit, getPageParam, getHasNextPage }) + configRef.current = { query, limit, getPageParam, getHasNextPage } // Fetch function const fetchPage = useCallback( - async (cursor: string | null, isLoadMore: boolean) => { + async (pageParam: string | number | null, isLoadMore: boolean, allData: TItem[] = []) => { const { query: currentQuery, limit: currentLimit, - getCursor: gc, + getPageParam: gpp, getHasNextPage: ghnp, } = configRef.current @@ -307,7 +341,9 @@ export function useInfiniteFind< query: { ...currentQuery, ...(currentLimit && { $limit: currentLimit }), - ...(cursor && { cursor }), + // Add pagination param based on type + ...(typeof pageParam === 'string' && { cursor: pageParam }), + ...(typeof pageParam === 'number' && { $skip: pageParam }), }, } @@ -315,8 +351,9 @@ export function useInfiniteFind< const result = await adapter.find(serviceName, params) const data = result.data as TItem[] const meta = result.meta as TMeta - const nextCursor = gc(meta, data) - const hasMore = ghnp(meta, data) + const newAllData = isLoadMore ? [...allData, ...data] : data + const nextPageParam = gpp(meta, data, newAllData) + const hasMore = ghnp(meta, data, newAllData) if (isLoadMore) { dispatch({ @@ -324,7 +361,7 @@ export function useInfiniteFind< data, meta, hasNextPage: hasMore, - cursor: nextCursor, + pageParam: nextPageParam, }) } else { dispatch({ @@ -332,11 +369,11 @@ export function useInfiniteFind< data, meta, hasNextPage: hasMore, - cursor: nextCursor, + pageParam: nextPageParam, }) } - return { data, meta, hasNextPage: hasMore, cursor: nextCursor } + return { data, meta, hasNextPage: hasMore, pageParam: nextPageParam, allData: newAllData } } catch (err) { const error = err instanceof Error ? err : new Error(String(err)) if (isLoadMore) { @@ -359,21 +396,23 @@ export function useInfiniteFind< async function initialFetch() { dispatch({ type: 'FETCH_START' }) - const result = await fetchPage(null, false) + const result = await fetchPage(null, false, []) if (cancelled || !result) return // If fetchAllPages is enabled, continue fetching - if (fetchAllPages && result.hasNextPage && result.cursor) { - let currentCursor: string | null = result.cursor + if (fetchAllPages && result.hasNextPage && result.pageParam !== null) { + let currentPageParam: string | number | null = result.pageParam let hasMore: boolean = result.hasNextPage + let allData: TItem[] = result.allData - while (hasMore && currentCursor && !cancelled) { + while (hasMore && currentPageParam !== null && !cancelled) { dispatch({ type: 'LOAD_MORE_START' }) - const nextResult = await fetchPage(currentCursor, true) + const nextResult = await fetchPage(currentPageParam, true, allData) if (!nextResult || cancelled) break - currentCursor = nextResult.cursor + currentPageParam = nextResult.pageParam hasMore = nextResult.hasNextPage + allData = nextResult.allData } } } @@ -389,17 +428,21 @@ export function useInfiniteFind< // loadMore function const loadMore = useCallback(() => { const currentState = stateRef.current - if (currentState.isLoadingMore || !currentState.hasNextPage || !currentState.cursor) { + if ( + currentState.isLoadingMore || + !currentState.hasNextPage || + currentState.pageParam === null + ) { return } dispatch({ type: 'LOAD_MORE_START' }) - fetchPage(currentState.cursor, true) + fetchPage(currentState.pageParam, true, currentState.data) }, [fetchPage]) // refetch function const refetch = useCallback(() => { dispatch({ type: 'REFETCH' }) - fetchPage(null, false) + fetchPage(null, false, []) }, [fetchPage]) // Realtime subscription effect diff --git a/test/helpers-cursor.ts b/test/helpers-cursor.ts index 3a983ac..9e9172a 100644 --- a/test/helpers-cursor.ts +++ b/test/helpers-cursor.ts @@ -21,6 +21,7 @@ interface ServiceCounts { interface FindParams { query?: { $limit?: number + $skip?: number cursor?: string $sort?: Record [key: string]: unknown @@ -28,11 +29,13 @@ interface FindParams { [key: string]: unknown } +type PaginationMode = 'cursor' | 'offset' + interface CursorFindResult { data: TestItem[] - hasNextPage: boolean - endCursor: string | null - total?: number + hasNextPage?: boolean // Only present in cursor mode + endCursor?: string | null // Only present in cursor mode + total: number limit: number skip: number } @@ -41,6 +44,8 @@ interface CursorServiceOptions { data: TestItem[] pageSize: number failNextFind?: boolean + /** Pagination mode: 'cursor' (default) returns endCursor/hasNextPage, 'offset' returns only skip/limit/total */ + mode?: PaginationMode } class CursorService extends EventEmitter { @@ -49,7 +54,8 @@ class CursorService extends EventEmitter { #originalOrder: (string | number)[] pageSize: number counts: ServiceCounts - #failNextFind: boolean; + #failNextFind: boolean + #mode: PaginationMode; [key: string]: unknown constructor(name: string, options: CursorServiceOptions) { @@ -74,6 +80,7 @@ class CursorService extends EventEmitter { remove: 0, } this.#failNextFind = options.failNextFind ?? false + this.#mode = options.mode ?? 'cursor' } get(id: string | number): Promise { @@ -95,15 +102,25 @@ class CursorService extends EventEmitter { const limit = params.query?.$limit ?? this.pageSize const cursor = params.query?.cursor + const skip = params.query?.$skip ?? 0 // Get all items in original order const allItems = this.#originalOrder .map(id => this.#data.get(id)) .filter((item): item is TestItem => item !== undefined) - // Find starting index based on cursor + // Find starting index based on cursor (cursor mode) or $skip (offset mode) let startIndex = 0 - if (cursor) { + if (this.#mode === 'cursor' && cursor) { + const cursorId = parseInt(cursor, 10) + startIndex = this.#originalOrder.findIndex(id => id === cursorId) + if (startIndex === -1) { + startIndex = 0 + } + } else if (this.#mode === 'offset') { + startIndex = skip + } else if (cursor) { + // Cursor mode with cursor param const cursorId = parseInt(cursor, 10) startIndex = this.#originalOrder.findIndex(id => id === cursorId) if (startIndex === -1) { @@ -116,6 +133,18 @@ class CursorService extends EventEmitter { const hasNextPage = startIndex + limit < allItems.length const nextCursorId = hasNextPage ? this.#originalOrder[startIndex + limit] : null + // Return different shape based on mode + if (this.#mode === 'offset') { + // Offset mode: only return skip/limit/total (no endCursor/hasNextPage) + return Promise.resolve({ + data: pageData, + total: allItems.length, + limit, + skip: startIndex, + }) + } + + // Cursor mode: include endCursor and hasNextPage return Promise.resolve({ data: pageData, hasNextPage, diff --git a/test/use-infinite-find.test.tsx b/test/use-infinite-find.test.tsx index 9162705..db1b22e 100644 --- a/test/use-infinite-find.test.tsx +++ b/test/use-infinite-find.test.tsx @@ -687,3 +687,306 @@ test('useInfiniteFind realtime disabled ignores events', async t => { unmount() }) + +// Offset-based pagination tests + +test('useInfiniteFind offset mode: initial fetch returns first page', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { status, data, hasNextPage } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {status} + {data.length} + {String(hasNextPage)} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + + unmount() +}) + +test('useInfiniteFind offset mode: loadMore fetches with correct $skip', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { status, data, hasNextPage, loadMore, isLoadingMore } = useInfiniteFind( + 'api/documents', + { + query: { $sort: { createdAt: -1 } }, + }, + ) + + return ( +
+ {status} + {data.length} + {String(hasNextPage)} + {String(isLoadingMore)} + {data.map(d => ( + + {d.title} + + ))} + {hasNextPage && ( + + )} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + + // Click load more + const loadMoreBtn = $('.load-more') + t.truthy(loadMoreBtn) + click(loadMoreBtn!) + + await flush() + + t.is($('.count')?.textContent, '3') + t.is($('.hasNextPage')?.textContent, 'false') + t.is($all('.doc').length, 3) + + unmount() +}) + +test('useInfiniteFind offset mode: hasNextPage false when no more data', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, hasNextPage, loadMore } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {data.length} + {String(hasNextPage)} + {hasNextPage && ( + + )} +
+ ) + } + + render( + + + , + ) + + await flush() + + // With 2 items and pageSize 2, skip + data.length >= total, so hasNextPage should be false + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'false') + t.falsy($('.load-more')) + + unmount() +}) + +test('useInfiniteFind offset mode: hasNextPage false when data.length < limit', async t => { + const { $, flush, render, unmount } = dom() + + // 3 items with pageSize 5 means we get all items in first page + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 5, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, hasNextPage } = useInfiniteFind('api/documents', {}) + + return ( +
+ {data.length} + {String(hasNextPage)} +
+ ) + } + + render( + + + , + ) + + await flush() + + // Got 3 items but limit was 5, so data.length < limit means no more pages + t.is($('.count')?.textContent, '3') + t.is($('.hasNextPage')?.textContent, 'false') + + unmount() +}) + +test('useInfiniteFind offset mode: multiple loadMore calls accumulate data', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + { id: 5, title: 'Doc 5', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { useInfiniteFind } = createHooks(figbird) + + function App() { + const { data, hasNextPage, loadMore } = useInfiniteFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + }) + + return ( +
+ {data.length} + {String(hasNextPage)} + {data.map(d => ( + + {d.title} + + ))} + {hasNextPage && ( + + )} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.count')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + + // First loadMore: skip=2, get items 3-4 + click($('.load-more')!) + await flush() + + t.is($('.count')?.textContent, '4') + t.is($('.hasNextPage')?.textContent, 'true') + + // Second loadMore: skip=4, get item 5 + click($('.load-more')!) + await flush() + + t.is($('.count')?.textContent, '5') + t.is($('.hasNextPage')?.textContent, 'false') + t.is($all('.doc').length, 5) + + unmount() +}) From 6f3f3bd9858ee8461aa5a140981022c1cba420f3 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 24 Jan 2026 21:47:25 +0000 Subject: [PATCH 3/6] Add usePaginatedFind --- README.md | 1 + docs/content/_index.md | 118 +++- lib/index.ts | 2 + lib/react/createHooks.ts | 32 + lib/react/usePaginatedFind.ts | 437 ++++++++++++++ test/use-paginated-find.test.tsx | 993 +++++++++++++++++++++++++++++++ 6 files changed, 1581 insertions(+), 2 deletions(-) create mode 100644 lib/react/usePaginatedFind.ts create mode 100644 test/use-paginated-find.test.tsx diff --git a/README.md b/README.md index 5727c0c..f9da47d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ function Notes() { - **Live queries** - results update as records are created, modified, or removed - **Shared cache** - same data across components, always consistent - **Realtime built-in** - Feathers websocket events update your UI automatically +- **Pagination hooks** - infinite scroll and page-based navigation with realtime support - **Fetch policies** - `swr`, `cache-first`, or `network-only` per query - **Full TypeScript** - define a schema once, get inference everywhere diff --git a/docs/content/_index.md b/docs/content/_index.md index 8137a3e..c6db0eb 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -39,6 +39,7 @@ Queries are live - if a record is created that matches your query, it appears. I - **Live queries** - results update as records are created, modified, or removed - **Shared cache** - same data across components, always consistent - **Realtime built-in** - Feathers websocket events update your UI automatically +- **Pagination hooks** - infinite scroll and page-based navigation with realtime support - **Fetch policies** - `swr`, `cache-first`, or `network-only` per query - **Full TypeScript** - define a schema once, get inference everywhere - **Framework-agnostic core** - works outside React for SSR, testing, or background sync @@ -293,6 +294,112 @@ const notes = useFind('notes') // QueryResult - `error` - error object if request failed - `refetch` - function to refetch data +## useInfiniteFind + +Fetches resources with infinite scroll / "load more" pagination. Data accumulates across pages as you call `loadMore()`. Supports both cursor-based and offset-based pagination. + +```ts +const { + data, + meta, + status, + isFetching, + isLoadingMore, + hasNextPage, + loadMore, + refetch, + error, + loadMoreError, +} = useInfiniteFind('notes', { + query: { $sort: { createdAt: -1 } }, + limit: 20, +}) +``` + +#### Arguments + +- `serviceName` - the name of Feathers service +- `config` - configuration object + +#### Config options + +- `query` - query parameters for filtering/sorting +- `limit` - page size (uses adapter default if not specified) +- `skip` - setting to true will not fetch the data +- `realtime` - one of `merge` (default), `refetch` or `disabled` +- `fetchAllPages` - fetch all pages automatically on initial load +- `matcher` - custom matcher function for realtime events +- `sorter` - custom sorter for inserting realtime events in correct order +- `getPageParam` - custom function to extract next page param from response +- `getHasNextPage` - custom function to determine if more pages exist + +#### Returns + +- `data` - accumulated data from all loaded pages (array) +- `meta` - metadata from the last fetched page +- `status` - one of `loading`, `success` or `error` +- `isFetching` - `true` if any fetch is in progress +- `isLoadingMore` - `true` if loading more pages +- `hasNextPage` - whether more pages are available +- `loadMore` - function to load the next page +- `refetch` - function to refetch from the beginning +- `error` - error from initial fetch +- `loadMoreError` - error from loadMore operation + +## usePaginatedFind + +Fetches resources with traditional page-based navigation. Shows one page at a time with navigation controls. Previous page data stays visible during transitions for a smooth UX. + +```ts +const { + data, + meta, + status, + page, + totalPages, + hasNextPage, + hasPrevPage, + setPage, + nextPage, + prevPage, + refetch, +} = usePaginatedFind('notes', { + query: { $sort: { createdAt: -1 } }, + limit: 20, +}) +``` + +#### Arguments + +- `serviceName` - the name of Feathers service +- `config` - configuration object (limit is required) + +#### Config options + +- `query` - query parameters for filtering/sorting +- `limit` - page size (required) +- `initialPage` - starting page number, 1-indexed (default: 1) +- `skip` - setting to true will not fetch the data +- `realtime` - one of `merge` (default), `refetch` or `disabled` +- `matcher` - custom matcher function for realtime events +- `getTotal` - custom function to extract total count from meta + +#### Returns + +- `data` - data for the current page (array) +- `meta` - metadata for the current page +- `status` - one of `loading`, `success` or `error` +- `isFetching` - `true` if fetching data +- `error` - error object if request failed +- `page` - current page number (1-indexed) +- `totalPages` - total number of pages +- `hasNextPage` - whether there is a next page +- `hasPrevPage` - whether there is a previous page +- `setPage` - function to navigate to a specific page +- `nextPage` - function to go to next page +- `prevPage` - function to go to previous page +- `refetch` - function to refetch current page + ## useMutation Provides methods to create, update, patch, and remove resources. Mutations automatically update the cache, so all components using related queries re-render with fresh data. @@ -379,14 +486,21 @@ React context provider that makes the Figbird instance available to all hooks in ## createHooks -`createHooks(figbird)` binds a Figbird instance (with its schema and adapter) to typed React hooks. It returns `{ useFind, useGet, useMutation, useFeathers }` with full service- and adapter-aware TypeScript types. +`createHooks(figbird)` binds a Figbird instance (with its schema and adapter) to typed React hooks. It returns `{ useFind, useGet, useInfiniteFind, usePaginatedFind, useMutation, useFeathers }` with full service- and adapter-aware TypeScript types. ```ts import { Figbird, FeathersAdapter, createHooks } from 'figbird' const adapter = new FeathersAdapter(feathers) const figbird = new Figbird({ adapter, schema }) -export const { useFind, useGet, useMutation, useFeathers } = createHooks(figbird) +export const { + useFind, + useGet, + useInfiniteFind, + usePaginatedFind, + useMutation, + useFeathers, +} = createHooks(figbird) // Later in components function People() { diff --git a/lib/index.ts b/lib/index.ts index adccaab..bc6bd2a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -44,6 +44,7 @@ export type { export { createHooks } from './react/createHooks.js' export { FigbirdProvider, useFigbird } from './react/react.js' export { useInfiniteFind } from './react/useInfiniteFind.js' +export { usePaginatedFind } from './react/usePaginatedFind.js' export { useFeathers } from './react/useFeathers.js' export { useMutation } from './react/useMutation.js' export { useFind, useGet } from './react/useQuery.js' @@ -59,6 +60,7 @@ export type { // React hook result types (already exported but let's be complete) export type { UseInfiniteFindConfig, UseInfiniteFindResult } from './react/useInfiniteFind.js' +export type { UsePaginatedFindConfig, UsePaginatedFindResult } from './react/usePaginatedFind.js' export type { UseMutationResult } from './react/useMutation.js' export type { QueryResult } from './react/useQuery.js' diff --git a/lib/react/createHooks.ts b/lib/react/createHooks.ts index 49e029a..8ce7e36 100644 --- a/lib/react/createHooks.ts +++ b/lib/react/createHooks.ts @@ -17,6 +17,11 @@ import { type UseInfiniteFindResult, } from './useInfiniteFind.js' import { useMutation as useBaseMutation, type UseMutationResult } from './useMutation.js' +import { + usePaginatedFind as useBasePaginatedFind, + type UsePaginatedFindConfig, + type UsePaginatedFindResult, +} from './usePaginatedFind.js' import { useQuery, type QueryResult } from './useQuery.js' /** @@ -67,6 +72,17 @@ type UseInfiniteFindForSchema< } & Omit, 'query'>, ) => UseInfiniteFindResult, TMeta> +type UsePaginatedFindForSchema< + S extends Schema, + TParams = unknown, + TMeta extends Record = Record, +> = >( + serviceName: N, + config: Omit, ServiceQuery, TMeta>, 'query'> & { + query?: ServiceQuery + } & Omit, 'query'>, +) => UsePaginatedFindResult, TMeta> + type UseFeathersForSchema = () => TypedFeathersClient // Type helper to extract schema and adapter types from a Figbird instance @@ -102,6 +118,7 @@ export function createHooks>( useGet: UseGetForSchema, InferParams> useFind: UseFindForSchema, InferParams, InferMeta> useInfiniteFind: UseInfiniteFindForSchema, InferParams, InferMeta> + usePaginatedFind: UsePaginatedFindForSchema, InferParams, InferMeta> useMutation: UseMutationForSchema> useFeathers: UseFeathersForSchema> } { @@ -162,6 +179,20 @@ export function createHooks>( ) } + function useTypedPaginatedFind>( + serviceName: N, + config: Omit, ServiceQuery, TMeta>, 'query'> & { + query?: ServiceQuery + } & Omit, 'query'>, + ) { + const service = findServiceByName(figbird.schema, serviceName) + const actualServiceName = service?.name ?? serviceName + return useBasePaginatedFind, TMeta, ServiceQuery>( + actualServiceName, + config as UsePaginatedFindConfig, ServiceQuery, TMeta>, + ) + } + function useTypedMutation>(serviceName: N) { const service = findServiceByName(figbird.schema, serviceName) const actualServiceName = service?.name ?? serviceName @@ -187,6 +218,7 @@ export function createHooks>( useGet: useTypedGet as UseGetForSchema, useFind: useTypedFind as UseFindForSchema, useInfiniteFind: useTypedInfiniteFind as UseInfiniteFindForSchema, + usePaginatedFind: useTypedPaginatedFind as UsePaginatedFindForSchema, useMutation: useTypedMutation as UseMutationForSchema, useFeathers: useTypedFeathers as UseFeathersForSchema, } diff --git a/lib/react/usePaginatedFind.ts b/lib/react/usePaginatedFind.ts new file mode 100644 index 0000000..8800b67 --- /dev/null +++ b/lib/react/usePaginatedFind.ts @@ -0,0 +1,437 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import type { Adapter, EventHandlers } from '../adapters/adapter.js' +import { useFigbird } from './react.js' + +/** + * Configuration for paginated queries (traditional page-based navigation) + */ +export interface UsePaginatedFindConfig { + /** Query parameters for filtering/sorting */ + query?: TQuery + /** Page size (required) */ + limit: number + /** Starting page (1-indexed, default: 1) */ + initialPage?: number + /** Skip fetching entirely */ + skip?: boolean + /** Realtime strategy: 'merge' updates current page, 'refetch' refetches current page, 'disabled' ignores events */ + realtime?: 'merge' | 'refetch' | 'disabled' + /** Custom matcher for realtime events (default: built from query using adapter.matcher/sift) */ + matcher?: (query: TQuery | undefined) => (item: TItem) => boolean + /** Custom function to extract total count from meta */ + getTotal?: (meta: TMeta) => number +} + +/** + * Result type for paginated queries + */ +export interface UsePaginatedFindResult { + /** Current status of the query */ + status: 'loading' | 'success' | 'error' + /** Data for the current page */ + data: TItem[] + /** Metadata from the current page */ + meta: TMeta + /** Whether any fetch is in progress */ + isFetching: boolean + /** Error from fetch */ + error: Error | null + + /** Current page number (1-indexed) */ + page: number + /** Total number of pages */ + totalPages: number + /** Whether there is a next page */ + hasNextPage: boolean + /** Whether there is a previous page */ + hasPrevPage: boolean + + /** Navigate to a specific page (1-indexed) */ + setPage: (page: number) => void + /** Navigate to the next page */ + nextPage: () => void + /** Navigate to the previous page */ + prevPage: () => void + /** Refetch the current page */ + refetch: () => void +} + +// State shape for the reducer +interface PaginatedState { + status: 'loading' | 'success' | 'error' + data: TItem[] + meta: TMeta + isFetching: boolean + error: Error | null + page: number + total: number +} + +// Action types +type PaginatedAction = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS'; data: TItem[]; meta: TMeta; total: number } + | { type: 'FETCH_ERROR'; error: Error } + | { type: 'SET_PAGE'; page: number } + | { type: 'RESET_PAGE' } + | { type: 'REALTIME_UPDATE'; data: TItem[] } + +function createReducer(emptyMeta: TMeta, initialPage: number) { + return function reducer( + state: PaginatedState, + action: PaginatedAction, + ): PaginatedState { + switch (action.type) { + case 'FETCH_START': + return { + ...state, + isFetching: true, + error: null, + } + case 'FETCH_SUCCESS': + return { + status: 'success', + data: action.data, + meta: action.meta, + isFetching: false, + error: null, + page: state.page, + total: action.total, + } + case 'FETCH_ERROR': + return { + ...state, + status: 'error', + isFetching: false, + error: action.error, + } + case 'SET_PAGE': + return { + ...state, + page: action.page, + isFetching: true, + error: null, + } + case 'RESET_PAGE': + return { + status: 'loading', + data: [], + meta: emptyMeta, + isFetching: true, + error: null, + page: initialPage, + total: 0, + } + case 'REALTIME_UPDATE': + return { + ...state, + data: action.data, + } + default: + return state + } + } +} + +/** + * Hook for traditional page-based pagination with caching, smooth transitions, and realtime updates. + * + * Each page is cached separately, making back-navigation instant. + * Previous page data is kept visible during page transitions (keepPreviousData behavior). + * + * @example + * ```tsx + * const { data, page, totalPages, setPage, nextPage, prevPage } = usePaginatedFind( + * 'api/documents', + * { query: { $sort: { createdAt: -1 } }, limit: 20 } + * ) + * ``` + */ +export function usePaginatedFind< + TItem, + TMeta extends Record = Record, + TQuery = Record, +>( + serviceName: string, + config: UsePaginatedFindConfig, +): UsePaginatedFindResult { + const figbird = useFigbird() + const adapter = figbird.adapter as Adapter> + + const { + query, + limit, + initialPage = 1, + skip = false, + realtime = 'merge', + matcher: customMatcher, + getTotal: customGetTotal, + } = config + + // Default getTotal: extract from meta.total + const getTotal = customGetTotal ?? ((meta: TMeta) => (meta as { total?: number }).total ?? 0) + + const emptyMeta = useMemo(() => adapter.emptyMeta(), [adapter]) + const reducer = useMemo( + () => createReducer(emptyMeta, initialPage), + [emptyMeta, initialPage], + ) + + const [state, dispatch] = useReducer(reducer, { + status: 'loading', + data: [], + meta: emptyMeta, + isFetching: !skip, + error: null, + page: initialPage, + total: 0, + }) + + // Build matcher for realtime events + const itemMatcher = useMemo(() => { + if (realtime !== 'merge') return () => false + if (customMatcher) return customMatcher(query) + return adapter.matcher(query as (TQuery & Record) | undefined) as ( + item: TItem, + ) => boolean + }, [adapter, query, realtime, customMatcher]) + + // Ref for keeping previous data during transitions + const prevDataRef = useRef<{ data: TItem[]; meta: TMeta } | null>(null) + + // Track query changes to reset to page 1 + const queryKey = JSON.stringify(query) + const prevQueryKeyRef = useRef(queryKey) + + // Refs to access latest values in callbacks + const stateRef = useRef(state) + stateRef.current = state + + const configRef = useRef({ query, limit, getTotal }) + configRef.current = { query, limit, getTotal } + + // Fetch function + const fetchPage = useCallback( + async (pageNum: number) => { + const { query: currentQuery, limit: currentLimit, getTotal: gt } = configRef.current + + const $skip = (pageNum - 1) * currentLimit + const params: Record = { + query: { + ...currentQuery, + $skip, + $limit: currentLimit, + }, + } + + try { + const result = await adapter.find(serviceName, params) + const data = result.data as TItem[] + const meta = result.meta as TMeta + const total = gt(meta) + + dispatch({ + type: 'FETCH_SUCCESS', + data, + meta, + total, + }) + + return { data, meta, total } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + dispatch({ type: 'FETCH_ERROR', error }) + return null + } + }, + [adapter, serviceName], + ) + + // Reset to page 1 when query changes + useEffect(() => { + if (prevQueryKeyRef.current !== queryKey) { + prevQueryKeyRef.current = queryKey + prevDataRef.current = null + dispatch({ type: 'RESET_PAGE' }) + } + }, [queryKey]) + + // Fetch effect - triggers when page or query changes + useEffect(() => { + if (skip) return + + let cancelled = false + + async function doFetch() { + dispatch({ type: 'FETCH_START' }) + const result = await fetchPage(state.page) + if (cancelled || !result) return + } + + doFetch() + + return () => { + cancelled = true + } + }, [skip, fetchPage, state.page, queryKey]) + + // Store successful results for smooth transitions + useEffect(() => { + if (state.status === 'success' && state.data) { + prevDataRef.current = { data: state.data, meta: state.meta } + } + }, [state.status, state.data, state.meta]) + + // Calculate pagination info + const totalPages = Math.max(1, Math.ceil(state.total / limit)) + const hasNextPage = state.page < totalPages + const hasPrevPage = state.page > 1 + + // Navigation functions + const setPage = useCallback( + (newPage: number) => { + const currentTotal = stateRef.current.total + const currentTotalPages = Math.max(1, Math.ceil(currentTotal / limit)) + const clamped = Math.max(1, Math.min(newPage, currentTotalPages)) + if (clamped !== stateRef.current.page) { + dispatch({ type: 'SET_PAGE', page: clamped }) + } + }, + [limit], + ) + + const nextPage = useCallback(() => { + const currentState = stateRef.current + const currentTotalPages = Math.max(1, Math.ceil(currentState.total / limit)) + if (currentState.page < currentTotalPages) { + dispatch({ type: 'SET_PAGE', page: currentState.page + 1 }) + } + }, [limit]) + + const prevPage = useCallback(() => { + const currentState = stateRef.current + if (currentState.page > 1) { + dispatch({ type: 'SET_PAGE', page: currentState.page - 1 }) + } + }, []) + + const refetch = useCallback(() => { + dispatch({ type: 'FETCH_START' }) + fetchPage(stateRef.current.page) + }, [fetchPage]) + + // Realtime subscription effect + useEffect(() => { + if (skip || realtime === 'disabled' || !adapter.subscribe) { + return + } + + const getId = (item: unknown) => adapter.getId(item) + + const handlers: EventHandlers = { + created: (item: unknown) => { + if (realtime === 'refetch') { + refetch() + return + } + + const typedItem = item as TItem + if (!itemMatcher(typedItem)) return + + // For created events in merge mode, refetch to get accurate page data + // (since insertion position depends on sorting and may affect pagination) + refetch() + }, + updated: (item: unknown) => { + if (realtime === 'refetch') { + refetch() + return + } + + const typedItem = item as TItem + const itemId = getId(typedItem) + const currentData = stateRef.current.data + const existingIndex = currentData.findIndex(d => getId(d) === itemId) + const matches = itemMatcher(typedItem) + + if (existingIndex >= 0 && !matches) { + // Item no longer matches - refetch to get replacement item + refetch() + } else if (existingIndex >= 0 && matches) { + // Update in place + const newData = [...currentData] + newData[existingIndex] = typedItem + dispatch({ type: 'REALTIME_UPDATE', data: newData }) + } + // If item matches but wasn't in our page, we don't add it + // (it may belong on a different page) + }, + patched: (item: unknown) => { + // Same logic as updated + handlers.updated(item) + }, + removed: (item: unknown) => { + if (realtime === 'refetch') { + refetch() + return + } + + const typedItem = item as TItem + const itemId = getId(typedItem) + const currentData = stateRef.current.data + const existingIndex = currentData.findIndex(d => getId(d) === itemId) + + if (existingIndex >= 0) { + // Item was on our page - refetch to get replacement item + refetch() + } + }, + } + + const unsubscribe = adapter.subscribe(serviceName, handlers) + return unsubscribe + }, [serviceName, skip, realtime, adapter, itemMatcher, refetch]) + + // Use previous data during loading for smooth transitions + const displayData = + state.isFetching && state.status !== 'loading' && prevDataRef.current + ? prevDataRef.current.data + : state.data + const displayMeta = + state.isFetching && state.status !== 'loading' && prevDataRef.current + ? prevDataRef.current.meta + : state.meta + + return useMemo( + () => ({ + status: state.status, + data: displayData, + meta: displayMeta, + isFetching: state.isFetching, + error: state.error, + page: state.page, + totalPages, + hasNextPage, + hasPrevPage, + setPage, + nextPage, + prevPage, + refetch, + }), + [ + state.status, + displayData, + displayMeta, + state.isFetching, + state.error, + state.page, + totalPages, + hasNextPage, + hasPrevPage, + setPage, + nextPage, + prevPage, + refetch, + ], + ) +} diff --git a/test/use-paginated-find.test.tsx b/test/use-paginated-find.test.tsx new file mode 100644 index 0000000..00748e4 --- /dev/null +++ b/test/use-paginated-find.test.tsx @@ -0,0 +1,993 @@ +import test from 'ava' +import { useState } from 'react' +import { FeathersAdapter } from '../lib/adapters/feathers' +import { Figbird } from '../lib/core/figbird' +import { createSchema, service } from '../lib/core/schema' +import { createHooks } from '../lib/react/createHooks' +import { FigbirdProvider } from '../lib/react/react' +import { dom } from './helpers' +import { mockCursorFeathers } from './helpers-cursor' + +interface Document { + id: number + title: string + createdAt: number + category?: string + updatedAt?: number +} + +const schema = createSchema({ + services: { + 'api/documents': service<{ + item: Document + query: { category?: string; $sort?: Record } + }>(), + }, +}) + +test('usePaginatedFind initial fetch loads first page', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + { id: 5, title: 'Doc 5', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { status, data, page, totalPages, hasNextPage, hasPrevPage } = usePaginatedFind( + 'api/documents', + { limit: 2 }, + ) + + return ( +
+ {status} + {data.length} + {page} + {totalPages} + {String(hasNextPage)} + {String(hasPrevPage)} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($('.count')?.textContent, '2') + t.is($('.page')?.textContent, '1') + t.is($('.totalPages')?.textContent, '3') + t.is($('.hasNextPage')?.textContent, 'true') + t.is($('.hasPrevPage')?.textContent, 'false') + + unmount() +}) + +test('usePaginatedFind setPage changes page and fetches new data', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + { id: 5, title: 'Doc 5', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data, page, setPage } = usePaginatedFind('api/documents', { limit: 2 }) + + return ( +
+ {page} + {data.map(d => ( + + {d.title} + + ))} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + let docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 1', 'Doc 2']) + + // Go to page 2 + click($('.go-page-2')!) + await flush() + + t.is($('.page')?.textContent, '2') + docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 3', 'Doc 4']) + + // Go to page 3 + click($('.go-page-3')!) + await flush() + + t.is($('.page')?.textContent, '3') + docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 5']) + + unmount() +}) + +test('usePaginatedFind nextPage/prevPage navigation', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data, page, hasNextPage, hasPrevPage, nextPage, prevPage } = usePaginatedFind( + 'api/documents', + { limit: 2 }, + ) + + return ( +
+ {page} + {data.map(d => ( + + {d.title} + + ))} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) + + // Next page + click($('.next')!) + await flush() + + t.is($('.page')?.textContent, '2') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 3', 'Doc 4'], + ) + + // Previous page + click($('.prev')!) + await flush() + + t.is($('.page')?.textContent, '1') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) + + unmount() +}) + +test('usePaginatedFind hasNextPage/hasPrevPage computed correctly', async t => { + const { $, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { page, totalPages, hasNextPage, hasPrevPage, nextPage, prevPage } = usePaginatedFind( + 'api/documents', + { limit: 2 }, + ) + + return ( +
+ {page} + {totalPages} + {String(hasNextPage)} + {String(hasPrevPage)} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + // Page 1 of 2 + t.is($('.page')?.textContent, '1') + t.is($('.totalPages')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'true') + t.is($('.hasPrevPage')?.textContent, 'false') + + // Go to page 2 + click($('.next')!) + await flush() + + // Page 2 of 2 + t.is($('.page')?.textContent, '2') + t.is($('.hasNextPage')?.textContent, 'false') + t.is($('.hasPrevPage')?.textContent, 'true') + + unmount() +}) + +test('usePaginatedFind previous data shown during page transitions', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 400 }, + { id: 2, title: 'Doc 2', createdAt: 300 }, + { id: 3, title: 'Doc 3', createdAt: 200 }, + { id: 4, title: 'Doc 4', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { status, data, isFetching, nextPage } = usePaginatedFind('api/documents', { limit: 2 }) + + return ( +
+ {status} + {String(isFetching)} + {data.length} + {data.map(d => ( + + {d.title} + + ))} + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($('.count')?.textContent, '2') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) + + // Navigate to next page - during transition, old data should still be shown + click($('.next')!) + + // Status should stay success and data should be available during transition + t.is($('.status')?.textContent, 'success') + // Data should still be visible + t.is($('.count')?.textContent, '2') + + await flush() + + // Now should have new data + t.is($('.status')?.textContent, 'success') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 3', 'Doc 4'], + ) + + unmount() +}) + +test('usePaginatedFind realtime updates work for current page', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data } = usePaginatedFind('api/documents', { limit: 10 }) + + return ( +
+ {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($all('.doc').length, 2) + t.is($all('.doc')[0]?.textContent, 'Doc 1') + + // Update an existing document + await feathers.service('api/documents').patch(1, { title: 'Doc 1 Updated' }) + await flush() + + const docs = $all('.doc').map(el => el.textContent) + t.deepEqual(docs, ['Doc 1 Updated', 'Doc 2']) + + unmount() +}) + +test('usePaginatedFind query change resets page to 1', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500, category: 'news' }, + { id: 2, title: 'Doc 2', createdAt: 400, category: 'news' }, + { id: 3, title: 'Doc 3', createdAt: 300, category: 'news' }, + { id: 4, title: 'Doc 4', createdAt: 200, category: 'sports' }, + { id: 5, title: 'Doc 5', createdAt: 100, category: 'sports' }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const [category, setCategory] = useState(undefined) + const { data, page, nextPage } = usePaginatedFind('api/documents', { + query: category ? { category } : {}, + limit: 2, + }) + + return ( +
+ {page} + {data.map(d => ( + + {d.title} + + ))} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) + + // Go to page 2 + click($('.next')!) + await flush() + + t.is($('.page')?.textContent, '2') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 3', 'Doc 4'], + ) + + // Change filter - should reset to page 1 + click($('.filter-sports')!) + await flush() + + t.is($('.page')?.textContent, '1') + // Note: The mock doesn't actually filter, but the page should reset + + unmount() +}) + +test('usePaginatedFind page clamping: page > totalPages', async t => { + const { $, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 200 }, + { id: 2, title: 'Doc 2', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { page, totalPages, setPage } = usePaginatedFind('api/documents', { limit: 2 }) + + return ( +
+ {page} + {totalPages} + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + t.is($('.totalPages')?.textContent, '1') + + // Try to go to page 100 (should clamp to 1) + click($('.go-page-100')!) + await flush() + + t.is($('.page')?.textContent, '1') + + unmount() +}) + +test('usePaginatedFind page clamping: page < 1', async t => { + const { $, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 200 }, + { id: 2, title: 'Doc 2', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { page, setPage } = usePaginatedFind('api/documents', { limit: 2 }) + + return ( +
+ {page} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + + // Try to go to page 0 (should clamp to 1) + click($('.go-page-0')!) + await flush() + + t.is($('.page')?.textContent, '1') + + // Try to go to page -5 (should clamp to 1) + click($('.go-page-neg')!) + await flush() + + t.is($('.page')?.textContent, '1') + + unmount() +}) + +test('usePaginatedFind empty results: totalPages = 1', async t => { + const { $, flush, render, unmount } = dom() + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: [], + pageSize: 10, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data, page, totalPages, hasNextPage, hasPrevPage } = usePaginatedFind('api/documents', { + limit: 10, + }) + + return ( +
+ {data.length} + {page} + {totalPages} + {String(hasNextPage)} + {String(hasPrevPage)} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.count')?.textContent, '0') + t.is($('.page')?.textContent, '1') + t.is($('.totalPages')?.textContent, '1') + t.is($('.hasNextPage')?.textContent, 'false') + t.is($('.hasPrevPage')?.textContent, 'false') + + unmount() +}) + +test('usePaginatedFind skip: true prevents fetch', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [{ id: 1, title: 'Doc 1', createdAt: 100 }] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { status, data, isFetching } = usePaginatedFind('api/documents', { + limit: 10, + skip: true, + }) + + return ( +
+ {status} + {data.length} + {String(isFetching)} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'loading') + t.is($('.count')?.textContent, '0') + t.is($('.isFetching')?.textContent, 'false') + t.is(feathers.service('api/documents').counts.find, 0) + + unmount() +}) + +test('usePaginatedFind error handling', async t => { + const { $, flush, render, unmount } = dom() + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: [], + pageSize: 10, + mode: 'offset', + failNextFind: true, + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { status, error } = usePaginatedFind('api/documents', { limit: 10 }) + + return ( +
+ {status} + {error?.message ?? 'none'} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'error') + t.is($('.error')?.textContent, 'Simulated fetch error') + + unmount() +}) + +test('usePaginatedFind refetch re-fetches current page', async t => { + const { $, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 200 }, + { id: 2, title: 'Doc 2', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { status, data, refetch } = usePaginatedFind('api/documents', { limit: 2 }) + + return ( +
+ {status} + {data.length} + {feathers.service('api/documents').counts.find} + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($('.count')?.textContent, '2') + const initialFindCount = parseInt($('.findCount')?.textContent || '0', 10) + t.true(initialFindCount >= 1) + + // Refetch + click($('.refetch')!) + await flush() + + t.is($('.status')?.textContent, 'success') + const newFindCount = parseInt($('.findCount')?.textContent || '0', 10) + t.true(newFindCount > initialFindCount) + + unmount() +}) + +test('usePaginatedFind initialPage starts at specified page', async t => { + const { $, $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data, page } = usePaginatedFind('api/documents', { + limit: 2, + initialPage: 2, + }) + + return ( +
+ {page} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '2') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 3', 'Doc 4'], + ) + + unmount() +}) + +test('usePaginatedFind realtime disabled ignores events', async t => { + const { $all, flush, render, unmount } = dom() + + const documents = [{ id: 1, title: 'Doc 1', createdAt: 100 }] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 10, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data } = usePaginatedFind('api/documents', { + limit: 10, + realtime: 'disabled', + }) + + return ( +
+ {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($all('.doc').length, 1) + + // Patch document - should NOT update because realtime is disabled + await feathers.service('api/documents').patch(1, { title: 'Doc 1 Updated' }) + await flush() + + // Still original text + t.is($all('.doc')[0]?.textContent, 'Doc 1') + + unmount() +}) + +test('usePaginatedFind with query and sorting', async t => { + const { $, $all, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'offset', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data, page, totalPages } = usePaginatedFind('api/documents', { + query: { $sort: { createdAt: -1 } }, + limit: 2, + }) + + return ( +
+ {page} + {totalPages} + {data.map(d => ( + + {d.title} + + ))} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + t.is($('.totalPages')?.textContent, '2') + t.is($all('.doc').length, 2) + + unmount() +}) From b5a2ee03a1181a890f2ef74c8783f996843769c1 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sun, 25 Jan 2026 17:11:44 +0000 Subject: [PATCH 4/6] Push towards more consistency --- docs/content/_index.md | 19 +- lib/adapters/adapter.ts | 6 + lib/adapters/feathers.ts | 78 +++- lib/react/createHooks.ts | 41 ++- lib/react/useInfiniteFind.ts | 104 +----- lib/react/usePaginatedFind.ts | 606 ++++++++++++------------------- test/helpers-cursor.ts | 8 +- test/use-paginated-find.test.tsx | 234 +++++++++++- 8 files changed, 585 insertions(+), 511 deletions(-) diff --git a/docs/content/_index.md b/docs/content/_index.md index c6db0eb..c097c93 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -327,11 +327,10 @@ const { - `limit` - page size (uses adapter default if not specified) - `skip` - setting to true will not fetch the data - `realtime` - one of `merge` (default), `refetch` or `disabled` -- `fetchAllPages` - fetch all pages automatically on initial load - `matcher` - custom matcher function for realtime events - `sorter` - custom sorter for inserting realtime events in correct order -- `getPageParam` - custom function to extract next page param from response -- `getHasNextPage` - custom function to determine if more pages exist + +Pagination logic is configured at the adapter level. See `FeathersAdapter` options for `getNextPageParam` and `getHasNextPage`. #### Returns @@ -350,6 +349,10 @@ const { Fetches resources with traditional page-based navigation. Shows one page at a time with navigation controls. Previous page data stays visible during transitions for a smooth UX. +Supports both offset pagination (random access to any page) and cursor pagination (sequential navigation only). The mode is auto-detected from the server response: +- **Offset mode**: Server returns `total` - all navigation methods work, `totalPages` is computed +- **Cursor mode**: Server returns `endCursor` or no `total` - `totalPages` is -1, `setPage(n)` silently ignores non-sequential jumps, `nextPage()`/`prevPage()` work using cursor history + ```ts const { data, @@ -380,9 +383,7 @@ const { - `limit` - page size (required) - `initialPage` - starting page number, 1-indexed (default: 1) - `skip` - setting to true will not fetch the data -- `realtime` - one of `merge` (default), `refetch` or `disabled` -- `matcher` - custom matcher function for realtime events -- `getTotal` - custom function to extract total count from meta +- `realtime` - one of `refetch` (default), `merge` or `disabled`. Refetch is recommended for pagination since creates/removes can shift page boundaries. #### Returns @@ -392,10 +393,10 @@ const { - `isFetching` - `true` if fetching data - `error` - error object if request failed - `page` - current page number (1-indexed) -- `totalPages` - total number of pages +- `totalPages` - total number of pages (-1 for cursor mode) - `hasNextPage` - whether there is a next page - `hasPrevPage` - whether there is a previous page -- `setPage` - function to navigate to a specific page +- `setPage` - function to navigate to a specific page (silently ignores non-sequential jumps in cursor mode) - `nextPage` - function to go to next page - `prevPage` - function to go to previous page - `refetch` - function to refetch current page @@ -466,6 +467,8 @@ const adapter = new FeathersAdapter(feathers, options) - `updatedAtField` - string or function, defaults to `item => item.updatedAt || item.updated_at`, used to avoid overwriting newer data in cache with older data when `get` or realtime `patched` requests are racing - `defaultPageSize` - a default page size in `query.$limit` to use when fetching, unset by default so that the server gets to decide - `defaultPageSizeWhenFetchingAll` - a default page size to use in `query.$limit` when fetching using `allPages: true`, unset by default so that the server gets to decide + - `getNextPageParam` - function `(meta, data) => string | number | null` to extract next page param from response. Auto-detects by default: uses `meta.endCursor` for cursor pagination, otherwise calculates next `$skip` for offset pagination + - `getHasNextPage` - function `(meta, data) => boolean` to determine if more pages exist. Uses `meta.hasNextPage` if available, otherwise derives from `getNextPageParam` Meta behavior: diff --git a/lib/adapters/adapter.ts b/lib/adapters/adapter.ts index ea7df88..e72a4dc 100644 --- a/lib/adapters/adapter.ts +++ b/lib/adapters/adapter.ts @@ -57,6 +57,12 @@ export interface Adapter< // Initialize empty meta to avoid unsafe casts emptyMeta(): TMeta + + // Pagination methods for infinite/paginated queries + /** Extract next page param from response. Returns cursor string, skip number, or null if no more pages. */ + getNextPageParam(meta: TMeta, data: unknown[]): string | number | null + /** Determine if there are more pages available */ + getHasNextPage(meta: TMeta, data: unknown[]): boolean } // Helper types to extract adapter properties diff --git a/lib/adapters/feathers.ts b/lib/adapters/feathers.ts index 177d2e2..8701aef 100644 --- a/lib/adapters/feathers.ts +++ b/lib/adapters/feathers.ts @@ -163,11 +163,20 @@ export type TypedFeathersClient = { > } +/** Type for getNextPageParam function */ +type GetNextPageParamFn = (meta: FeathersFindMeta, data: unknown[]) => string | number | null +/** Type for getHasNextPage function */ +type GetHasNextPageFn = (meta: FeathersFindMeta, data: unknown[]) => boolean + interface FeathersAdapterOptions { idField?: IdFieldType updatedAtField?: UpdatedAtFieldType defaultPageSize?: number defaultPageSizeWhenFetchingAll?: number + /** Extract next page param from response. Auto-detects: cursor (endCursor) takes priority, fallback to offset ($skip). */ + getNextPageParam?: GetNextPageParamFn + /** Determine if there are more pages available */ + getHasNextPage?: GetHasNextPageFn } /** @@ -182,6 +191,35 @@ function toEpochMs(ts: Timestamp): number | null { return ts instanceof Date ? ts.getTime() : null } +/** + * Default getNextPageParam implementation. + * Auto-detects pagination mode: cursor (endCursor) takes priority, fallback to offset ($skip). + */ +function defaultGetNextPageParam(meta: FeathersFindMeta, data: unknown[]): string | number | null { + // Cursor mode takes priority + if (meta.endCursor != null) { + return meta.endCursor + } + // Offset mode fallback + const currentSkip = meta.skip ?? 0 + const nextSkip = currentSkip + data.length + if (meta.total != null && meta.total >= 0 && nextSkip >= meta.total) return null + if (meta.limit != null && data.length < meta.limit) return null + if (data.length === 0) return null + return nextSkip +} + +/** + * Default getHasNextPage implementation. + * Uses meta.hasNextPage if available, otherwise derives from getNextPageParam. + */ +function defaultGetHasNextPage(meta: FeathersFindMeta, data: unknown[]): boolean { + if (typeof meta.hasNextPage === 'boolean') { + return meta.hasNextPage + } + return defaultGetNextPageParam(meta, data) !== null +} + export class FeathersAdapter> implements Adapter< FeathersParams, FeathersFindMeta, @@ -192,6 +230,8 @@ export class FeathersAdapter> implements Adapte #updatedAtField: UpdatedAtFieldType #defaultPageSize: number | undefined #defaultPageSizeWhenFetchingAll: number | undefined + #getNextPageParam: GetNextPageParamFn + #getHasNextPage: GetHasNextPageFn /** * Helper to merge query parameters while maintaining type safety @@ -219,6 +259,8 @@ export class FeathersAdapter> implements Adapte }, defaultPageSize, defaultPageSizeWhenFetchingAll, + getNextPageParam = defaultGetNextPageParam, + getHasNextPage = defaultGetHasNextPage, }: FeathersAdapterOptions = {}, ) { this.feathers = feathers @@ -226,6 +268,8 @@ export class FeathersAdapter> implements Adapte this.#updatedAtField = updatedAtField this.#defaultPageSize = defaultPageSize this.#defaultPageSizeWhenFetchingAll = defaultPageSizeWhenFetchingAll + this.#getNextPageParam = getNextPageParam + this.#getHasNextPage = getHasNextPage } #service(serviceName: string): FeathersService { @@ -308,29 +352,29 @@ export class FeathersAdapter> implements Adapte const result: QueryResponse = { data: [], - meta: { total: -1, limit: 0, skip: 0 }, + meta: this.emptyMeta(), } - let $skip = 0 + let pageParam: string | number | null = null while (true) { + const pageQuery: Record = { + ...(typeof pageParam === 'string' && { $cursor: pageParam }), + ...(typeof pageParam === 'number' && { $skip: pageParam }), + } + const { data, meta } = await this.#_find( serviceName, - this.#mergeQueryParams(baseParams, { $skip }), + this.#mergeQueryParams(baseParams, pageQuery), ) - result.meta = { ...result.meta, ...meta } + result.meta = meta result.data.push(...data) - const done = - data.length === 0 || - data.length < meta.limit || - // allow total to be -1 to indicate that total will not be available on this endpoint - (meta.total > 0 && result.data.length >= meta.total) - - if (done) return result - - $skip = result.data.length + pageParam = this.#getNextPageParam(meta, data) + if (pageParam === null) break } + + return result } mutate(serviceName: string, method: string, args: unknown[]): Promise { @@ -415,4 +459,12 @@ export class FeathersAdapter> implements Adapte emptyMeta(): FeathersFindMeta { return { total: -1, limit: 0, skip: 0 } } + + getNextPageParam(meta: FeathersFindMeta, data: unknown[]): string | number | null { + return this.#getNextPageParam(meta, data) + } + + getHasNextPage(meta: FeathersFindMeta, data: unknown[]): boolean { + return this.#getHasNextPage(meta, data) + } } diff --git a/lib/react/createHooks.ts b/lib/react/createHooks.ts index 8ce7e36..f94a3f7 100644 --- a/lib/react/createHooks.ts +++ b/lib/react/createHooks.ts @@ -18,7 +18,7 @@ import { } from './useInfiniteFind.js' import { useMutation as useBaseMutation, type UseMutationResult } from './useMutation.js' import { - usePaginatedFind as useBasePaginatedFind, + createUsePaginatedFind, type UsePaginatedFindConfig, type UsePaginatedFindResult, } from './usePaginatedFind.js' @@ -67,7 +67,7 @@ type UseInfiniteFindForSchema< TMeta extends Record = Record, > = >( serviceName: N, - config?: Omit, ServiceQuery, TMeta>, 'query'> & { + config?: Omit, ServiceQuery>, 'query'> & { query?: ServiceQuery } & Omit, 'query'>, ) => UseInfiniteFindResult, TMeta> @@ -78,9 +78,8 @@ type UsePaginatedFindForSchema< TMeta extends Record = Record, > = >( serviceName: N, - config: Omit, ServiceQuery, TMeta>, 'query'> & { - query?: ServiceQuery - } & Omit, 'query'>, + config: UsePaginatedFindConfig> & + Omit, 'query'>, ) => UsePaginatedFindResult, TMeta> type UseFeathersForSchema = () => TypedFeathersClient @@ -167,7 +166,7 @@ export function createHooks>( function useTypedInfiniteFind>( serviceName: N, - config?: Omit, ServiceQuery, TMeta>, 'query'> & { + config?: Omit, ServiceQuery>, 'query'> & { query?: ServiceQuery } & Omit, 'query'>, ) { @@ -175,23 +174,25 @@ export function createHooks>( const actualServiceName = service?.name ?? serviceName return useBaseInfiniteFind, TMeta, ServiceQuery>( actualServiceName, - config as UseInfiniteFindConfig, ServiceQuery, TMeta>, + config as UseInfiniteFindConfig, ServiceQuery>, ) } - function useTypedPaginatedFind>( - serviceName: N, - config: Omit, ServiceQuery, TMeta>, 'query'> & { - query?: ServiceQuery - } & Omit, 'query'>, - ) { - const service = findServiceByName(figbird.schema, serviceName) - const actualServiceName = service?.name ?? serviceName - return useBasePaginatedFind, TMeta, ServiceQuery>( - actualServiceName, - config as UsePaginatedFindConfig, ServiceQuery, TMeta>, - ) - } + // Create the paginated find hook using the factory with our typed useFind + const useTypedPaginatedFind = createUsePaginatedFind< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + TMeta, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >( + useTypedFind as ( + serviceName: string, + params?: Record, + ) => QueryResult, + ) function useTypedMutation>(serviceName: N) { const service = findServiceByName(figbird.schema, serviceName) diff --git a/lib/react/useInfiniteFind.ts b/lib/react/useInfiniteFind.ts index 6454f4a..3477d19 100644 --- a/lib/react/useInfiniteFind.ts +++ b/lib/react/useInfiniteFind.ts @@ -5,28 +5,19 @@ import { useFigbird } from './react.js' /** * Configuration for infinite/cursor-based pagination queries */ -export interface UseInfiniteFindConfig { +export interface UseInfiniteFindConfig { /** Query parameters for filtering/sorting */ query?: TQuery /** Skip fetching entirely */ skip?: boolean /** Realtime strategy: 'merge' inserts events in sorted order, 'refetch' resets to first page, 'disabled' ignores events */ realtime?: 'merge' | 'refetch' | 'disabled' - /** Fetch all pages automatically on initial load */ - fetchAllPages?: boolean /** Page size limit (defaults to adapter's default) */ limit?: number /** Custom matcher for realtime events (default: built from query using adapter.matcher/sift) */ matcher?: (query: TQuery | undefined) => (item: TItem) => boolean /** Custom sorter for inserting realtime events (default: built from query.$sort) */ sorter?: (a: TItem, b: TItem) => number - /** - * Extract next page param from response. Returns cursor string, skip number, or null if no more pages. - * Auto-detects mode: uses cursor if meta.endCursor exists, otherwise uses offset (skip). - */ - getPageParam?: (meta: TMeta, data: TItem[], allData: TItem[]) => string | number | null - /** Custom hasNextPage extraction (default: meta.hasNextPage or derived from getPageParam) */ - getHasNextPage?: (meta: TMeta, data: TItem[], allData: TItem[]) => boolean } /** @@ -241,52 +232,18 @@ export function useInfiniteFind< TQuery = Record, >( serviceName: string, - config: UseInfiniteFindConfig = {}, + config: UseInfiniteFindConfig = {}, ): UseInfiniteFindResult { const figbird = useFigbird() const adapter = figbird.adapter as Adapter> - // Default getPageParam: auto-detect mode from response - const defaultGetPageParam = (meta: TMeta, data: TItem[], _allData: TItem[]) => { - // Cursor mode: if endCursor exists, use it - if ('endCursor' in meta && meta.endCursor != null) { - return meta.endCursor as string - } - // Offset mode: calculate next skip value - const skip = (meta as Record).skip as number | undefined - const limit = (meta as Record).limit as number | undefined - const total = (meta as Record).total as number | undefined - const currentSkip = skip ?? 0 - const nextSkip = currentSkip + data.length - // No more pages if we've fetched everything - if (total !== undefined && nextSkip >= total) return null - // No more pages if we got fewer items than limit (and limit is known) - if (limit !== undefined && data.length < limit) return null - // If we got no data, no more pages - if (data.length === 0) return null - return nextSkip - } - - // Default getHasNextPage: derive from pageParam or explicit meta.hasNextPage - const defaultGetHasNextPage = (meta: TMeta, data: TItem[], allData: TItem[]) => { - // If meta.hasNextPage is explicitly set, use it - if ('hasNextPage' in meta && typeof meta.hasNextPage === 'boolean') { - return meta.hasNextPage - } - // Otherwise derive from whether getPageParam returns non-null - return defaultGetPageParam(meta, data, allData) !== null - } - const { query, skip = false, realtime = 'merge', - fetchAllPages = false, limit, matcher: customMatcher, sorter: customSorter, - getPageParam = defaultGetPageParam, - getHasNextPage = defaultGetHasNextPage, } = config const emptyMeta = useMemo(() => adapter.emptyMeta(), [adapter]) @@ -324,25 +281,20 @@ export function useInfiniteFind< const stateRef = useRef(state) stateRef.current = state - const configRef = useRef({ query, limit, getPageParam, getHasNextPage }) - configRef.current = { query, limit, getPageParam, getHasNextPage } + const configRef = useRef({ query, limit }) + configRef.current = { query, limit } // Fetch function const fetchPage = useCallback( - async (pageParam: string | number | null, isLoadMore: boolean, allData: TItem[] = []) => { - const { - query: currentQuery, - limit: currentLimit, - getPageParam: gpp, - getHasNextPage: ghnp, - } = configRef.current + async (pageParam: string | number | null, isLoadMore: boolean, _allData: TItem[] = []) => { + const { query: currentQuery, limit: currentLimit } = configRef.current const params: Record = { query: { ...currentQuery, ...(currentLimit && { $limit: currentLimit }), // Add pagination param based on type - ...(typeof pageParam === 'string' && { cursor: pageParam }), + ...(typeof pageParam === 'string' && { $cursor: pageParam }), ...(typeof pageParam === 'number' && { $skip: pageParam }), }, } @@ -351,9 +303,8 @@ export function useInfiniteFind< const result = await adapter.find(serviceName, params) const data = result.data as TItem[] const meta = result.meta as TMeta - const newAllData = isLoadMore ? [...allData, ...data] : data - const nextPageParam = gpp(meta, data, newAllData) - const hasMore = ghnp(meta, data, newAllData) + const nextPageParam = adapter.getNextPageParam(meta, data) + const hasMore = adapter.getHasNextPage(meta, data) if (isLoadMore) { dispatch({ @@ -373,7 +324,7 @@ export function useInfiniteFind< }) } - return { data, meta, hasNextPage: hasMore, pageParam: nextPageParam, allData: newAllData } + return { data, meta, hasNextPage: hasMore, pageParam: nextPageParam } } catch (err) { const error = err instanceof Error ? err : new Error(String(err)) if (isLoadMore) { @@ -391,39 +342,10 @@ export function useInfiniteFind< useEffect(() => { if (skip) return - let cancelled = false - - async function initialFetch() { - dispatch({ type: 'FETCH_START' }) - - const result = await fetchPage(null, false, []) - - if (cancelled || !result) return - - // If fetchAllPages is enabled, continue fetching - if (fetchAllPages && result.hasNextPage && result.pageParam !== null) { - let currentPageParam: string | number | null = result.pageParam - let hasMore: boolean = result.hasNextPage - let allData: TItem[] = result.allData - - while (hasMore && currentPageParam !== null && !cancelled) { - dispatch({ type: 'LOAD_MORE_START' }) - const nextResult = await fetchPage(currentPageParam, true, allData) - if (!nextResult || cancelled) break - currentPageParam = nextResult.pageParam - hasMore = nextResult.hasNextPage - allData = nextResult.allData - } - } - } - - initialFetch() - - return () => { - cancelled = true - } + dispatch({ type: 'FETCH_START' }) + fetchPage(null, false, []) // Note: We intentionally use JSON.stringify(query) to re-fetch when query changes - }, [skip, fetchPage, fetchAllPages, JSON.stringify(query)]) + }, [skip, fetchPage, JSON.stringify(query)]) // loadMore function const loadMore = useCallback(() => { diff --git a/lib/react/usePaginatedFind.ts b/lib/react/usePaginatedFind.ts index 8800b67..709a369 100644 --- a/lib/react/usePaginatedFind.ts +++ b/lib/react/usePaginatedFind.ts @@ -1,11 +1,13 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' -import type { Adapter, EventHandlers } from '../adapters/adapter.js' +import { useCallback, useMemo, useRef, useState } from 'react' +import type { Figbird } from '../core/figbird.js' +import type { AnySchema } from '../core/schema.js' +import type { QueryResult } from './useQuery.js' import { useFigbird } from './react.js' /** * Configuration for paginated queries (traditional page-based navigation) */ -export interface UsePaginatedFindConfig { +export interface UsePaginatedFindConfig { /** Query parameters for filtering/sorting */ query?: TQuery /** Page size (required) */ @@ -14,12 +16,8 @@ export interface UsePaginatedFindConfig { initialPage?: number /** Skip fetching entirely */ skip?: boolean - /** Realtime strategy: 'merge' updates current page, 'refetch' refetches current page, 'disabled' ignores events */ + /** Realtime strategy: 'refetch' (default) refetches current page, 'merge' updates in place, 'disabled' ignores events */ realtime?: 'merge' | 'refetch' | 'disabled' - /** Custom matcher for realtime events (default: built from query using adapter.matcher/sift) */ - matcher?: (query: TQuery | undefined) => (item: TItem) => boolean - /** Custom function to extract total count from meta */ - getTotal?: (meta: TMeta) => number } /** @@ -39,14 +37,14 @@ export interface UsePaginatedFindResult { /** Current page number (1-indexed) */ page: number - /** Total number of pages */ + /** Total number of pages (-1 for cursor mode where total is unknown) */ totalPages: number /** Whether there is a next page */ hasNextPage: boolean /** Whether there is a previous page */ hasPrevPage: boolean - /** Navigate to a specific page (1-indexed) */ + /** Navigate to a specific page (1-indexed). In cursor mode, silently ignores non-sequential jumps. */ setPage: (page: number) => void /** Navigate to the next page */ nextPage: () => void @@ -56,382 +54,256 @@ export interface UsePaginatedFindResult { refetch: () => void } -// State shape for the reducer -interface PaginatedState { - status: 'loading' | 'success' | 'error' - data: TItem[] - meta: TMeta - isFetching: boolean - error: Error | null - page: number - total: number -} - -// Action types -type PaginatedAction = - | { type: 'FETCH_START' } - | { type: 'FETCH_SUCCESS'; data: TItem[]; meta: TMeta; total: number } - | { type: 'FETCH_ERROR'; error: Error } - | { type: 'SET_PAGE'; page: number } - | { type: 'RESET_PAGE' } - | { type: 'REALTIME_UPDATE'; data: TItem[] } - -function createReducer(emptyMeta: TMeta, initialPage: number) { - return function reducer( - state: PaginatedState, - action: PaginatedAction, - ): PaginatedState { - switch (action.type) { - case 'FETCH_START': - return { - ...state, - isFetching: true, - error: null, - } - case 'FETCH_SUCCESS': - return { - status: 'success', - data: action.data, - meta: action.meta, - isFetching: false, - error: null, - page: state.page, - total: action.total, - } - case 'FETCH_ERROR': - return { - ...state, - status: 'error', - isFetching: false, - error: action.error, - } - case 'SET_PAGE': - return { - ...state, - page: action.page, - isFetching: true, - error: null, - } - case 'RESET_PAGE': - return { - status: 'loading', - data: [], - meta: emptyMeta, - isFetching: true, - error: null, - page: initialPage, - total: 0, - } - case 'REALTIME_UPDATE': - return { - ...state, - data: action.data, - } - default: - return state - } +/** + * Keep previous data during loading transitions + */ +function usePreviousData(data: T, keepPrevious: boolean): T { + const ref = useRef(data) + if (!keepPrevious && data != null) { + ref.current = data } + return ref.current } /** - * Hook for traditional page-based pagination with caching, smooth transitions, and realtime updates. + * Create a paginated find hook from a base useFind hook. + * This is a thin wrapper that adds page state management on top of useFind. + * Supports both offset pagination (traditional) and cursor pagination (sequential navigation only). * - * Each page is cached separately, making back-navigation instant. - * Previous page data is kept visible during page transitions (keepPreviousData behavior). - * - * @example - * ```tsx - * const { data, page, totalPages, setPage, nextPage, prevPage } = usePaginatedFind( - * 'api/documents', - * { query: { $sort: { createdAt: -1 } }, limit: 20 } - * ) - * ``` + * @param useFind - The base useFind hook (typed or untyped) + * @returns A usePaginatedFind hook */ -export function usePaginatedFind< +export function createUsePaginatedFind< TItem, - TMeta extends Record = Record, - TQuery = Record, + TMeta extends Record, + TQuery, + TParams extends Record, >( + useFind: (serviceName: string, params?: TParams) => QueryResult, +): ( serviceName: string, - config: UsePaginatedFindConfig, -): UsePaginatedFindResult { - const figbird = useFigbird() - const adapter = figbird.adapter as Adapter> - - const { - query, - limit, - initialPage = 1, - skip = false, - realtime = 'merge', - matcher: customMatcher, - getTotal: customGetTotal, - } = config - - // Default getTotal: extract from meta.total - const getTotal = customGetTotal ?? ((meta: TMeta) => (meta as { total?: number }).total ?? 0) - - const emptyMeta = useMemo(() => adapter.emptyMeta(), [adapter]) - const reducer = useMemo( - () => createReducer(emptyMeta, initialPage), - [emptyMeta, initialPage], - ) - - const [state, dispatch] = useReducer(reducer, { - status: 'loading', - data: [], - meta: emptyMeta, - isFetching: !skip, - error: null, - page: initialPage, - total: 0, - }) - - // Build matcher for realtime events - const itemMatcher = useMemo(() => { - if (realtime !== 'merge') return () => false - if (customMatcher) return customMatcher(query) - return adapter.matcher(query as (TQuery & Record) | undefined) as ( - item: TItem, - ) => boolean - }, [adapter, query, realtime, customMatcher]) - - // Ref for keeping previous data during transitions - const prevDataRef = useRef<{ data: TItem[]; meta: TMeta } | null>(null) - - // Track query changes to reset to page 1 - const queryKey = JSON.stringify(query) - const prevQueryKeyRef = useRef(queryKey) - - // Refs to access latest values in callbacks - const stateRef = useRef(state) - stateRef.current = state - - const configRef = useRef({ query, limit, getTotal }) - configRef.current = { query, limit, getTotal } - - // Fetch function - const fetchPage = useCallback( - async (pageNum: number) => { - const { query: currentQuery, limit: currentLimit, getTotal: gt } = configRef.current - - const $skip = (pageNum - 1) * currentLimit - const params: Record = { - query: { - ...currentQuery, - $skip, - $limit: currentLimit, - }, - } - - try { - const result = await adapter.find(serviceName, params) - const data = result.data as TItem[] - const meta = result.meta as TMeta - const total = gt(meta) - - dispatch({ - type: 'FETCH_SUCCESS', - data, - meta, - total, - }) - - return { data, meta, total } - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)) - dispatch({ type: 'FETCH_ERROR', error }) - return null - } - }, - [adapter, serviceName], - ) - - // Reset to page 1 when query changes - useEffect(() => { + config: UsePaginatedFindConfig & Omit, +) => UsePaginatedFindResult { + return function usePaginatedFind( + serviceName: string, + config: UsePaginatedFindConfig & Omit, + ): UsePaginatedFindResult { + const figbird = useFigbird() as Figbird + const { + query, + limit, + initialPage = 1, + skip = false, + realtime = 'refetch', + ...restParams + } = config + + // Page index for cursor mode (0-indexed internally) + const [pageIndex, setPageIndex] = useState(initialPage - 1) + // Cursor history for navigating backwards in cursor mode + // cursorHistory[0] = null (first page), cursorHistory[1] = cursor for page 2, etc. + const [cursorHistory, setCursorHistory] = useState<(string | null)[]>([null]) + + // Track if we've detected cursor mode + const [isCursorMode, setIsCursorMode] = useState(null) + + // Reset to page 1 when query changes + const queryKey = JSON.stringify(query) + const prevQueryKeyRef = useRef(queryKey) if (prevQueryKeyRef.current !== queryKey) { prevQueryKeyRef.current = queryKey - prevDataRef.current = null - dispatch({ type: 'RESET_PAGE' }) - } - }, [queryKey]) - - // Fetch effect - triggers when page or query changes - useEffect(() => { - if (skip) return - - let cancelled = false - - async function doFetch() { - dispatch({ type: 'FETCH_START' }) - const result = await fetchPage(state.page) - if (cancelled || !result) return - } - - doFetch() - - return () => { - cancelled = true - } - }, [skip, fetchPage, state.page, queryKey]) - - // Store successful results for smooth transitions - useEffect(() => { - if (state.status === 'success' && state.data) { - prevDataRef.current = { data: state.data, meta: state.meta } - } - }, [state.status, state.data, state.meta]) - - // Calculate pagination info - const totalPages = Math.max(1, Math.ceil(state.total / limit)) - const hasNextPage = state.page < totalPages - const hasPrevPage = state.page > 1 - - // Navigation functions - const setPage = useCallback( - (newPage: number) => { - const currentTotal = stateRef.current.total - const currentTotalPages = Math.max(1, Math.ceil(currentTotal / limit)) - const clamped = Math.max(1, Math.min(newPage, currentTotalPages)) - if (clamped !== stateRef.current.page) { - dispatch({ type: 'SET_PAGE', page: clamped }) + if (pageIndex !== 0) { + setPageIndex(0) + setCursorHistory([null]) + setIsCursorMode(null) } - }, - [limit], - ) - - const nextPage = useCallback(() => { - const currentState = stateRef.current - const currentTotalPages = Math.max(1, Math.ceil(currentState.total / limit)) - if (currentState.page < currentTotalPages) { - dispatch({ type: 'SET_PAGE', page: currentState.page + 1 }) } - }, [limit]) - const prevPage = useCallback(() => { - const currentState = stateRef.current - if (currentState.page > 1) { - dispatch({ type: 'SET_PAGE', page: currentState.page - 1 }) + // Create a matcher that uses only the filter query (without $skip/$limit/$cursor) + const matcher = useMemo(() => { + if (realtime !== 'merge') return undefined + return (_queryWithPagination: unknown) => + figbird.adapter.matcher(query as Record | undefined) + }, [figbird.adapter, queryKey, realtime]) + + // Determine what pagination param to use + const currentCursor = cursorHistory[pageIndex] ?? null + const usesCursor = isCursorMode === true || (isCursorMode === null && pageIndex > 0) + + // Build params for useFind with pagination + const params = useMemo( + () => + ({ + ...restParams, + query: { + ...(query as object), + $limit: limit, + // Use cursor if in cursor mode and we have one, otherwise use skip + ...(usesCursor && currentCursor != null + ? { $cursor: currentCursor } + : { $skip: pageIndex * limit }), + }, + skip, + realtime, + ...(matcher && { matcher }), + }) as unknown as TParams, + [ + queryKey, + pageIndex, + limit, + skip, + realtime, + matcher, + currentCursor, + usesCursor, + JSON.stringify(restParams), + ], + ) + + const result = useFind(serviceName, params) + + // Detect pagination mode from meta response + const meta = (result as { meta?: TMeta }).meta + const hasEndCursor = meta && 'endCursor' in meta + const hasTotal = meta && 'total' in meta && typeof meta.total === 'number' && meta.total >= 0 + + // Once we have a response, determine if we're in cursor mode + // Cursor mode: has endCursor OR no valid total + const detectedCursorMode = + result.status === 'success' ? hasEndCursor || !hasTotal : isCursorMode + if (detectedCursorMode !== null && detectedCursorMode !== isCursorMode) { + setIsCursorMode(detectedCursorMode) } - }, []) - - const refetch = useCallback(() => { - dispatch({ type: 'FETCH_START' }) - fetchPage(stateRef.current.page) - }, [fetchPage]) - - // Realtime subscription effect - useEffect(() => { - if (skip || realtime === 'disabled' || !adapter.subscribe) { - return - } - - const getId = (item: unknown) => adapter.getId(item) - - const handlers: EventHandlers = { - created: (item: unknown) => { - if (realtime === 'refetch') { - refetch() - return - } - - const typedItem = item as TItem - if (!itemMatcher(typedItem)) return - - // For created events in merge mode, refetch to get accurate page data - // (since insertion position depends on sorting and may affect pagination) - refetch() - }, - updated: (item: unknown) => { - if (realtime === 'refetch') { - refetch() - return - } - - const typedItem = item as TItem - const itemId = getId(typedItem) - const currentData = stateRef.current.data - const existingIndex = currentData.findIndex(d => getId(d) === itemId) - const matches = itemMatcher(typedItem) - if (existingIndex >= 0 && !matches) { - // Item no longer matches - refetch to get replacement item - refetch() - } else if (existingIndex >= 0 && matches) { - // Update in place - const newData = [...currentData] - newData[existingIndex] = typedItem - dispatch({ type: 'REALTIME_UPDATE', data: newData }) + // Get the next cursor from meta for advancing cursor history + const nextCursor = hasEndCursor ? (meta.endCursor as string | null) : null + + // Calculate pagination values + const total = hasTotal ? (meta.total as number) : 0 + const totalPages = detectedCursorMode ? -1 : Math.max(1, Math.ceil(total / limit)) + const page = pageIndex + 1 // 1-indexed for external API + + // Determine hasNextPage + const hasNextPageFromAdapter = meta + ? figbird.adapter.getHasNextPage(meta, result.data ?? []) + : false + const hasNextPage = detectedCursorMode ? hasNextPageFromAdapter : page < totalPages + const hasPrevPage = pageIndex > 0 + + // Keep previous data during page transitions + const showPrevious = result.isFetching || result.status === 'loading' + const displayData = usePreviousData(result.data ?? [], showPrevious) + const displayMeta = usePreviousData(meta as TMeta, showPrevious) + + // Store refs for callbacks + const totalPagesRef = useRef(totalPages) + totalPagesRef.current = totalPages + const hasNextPageRef = useRef(hasNextPage) + hasNextPageRef.current = hasNextPage + const nextCursorRef = useRef(nextCursor) + nextCursorRef.current = nextCursor + const isCursorModeRef = useRef(detectedCursorMode) + isCursorModeRef.current = detectedCursorMode + + const nextPage = useCallback(() => { + if (isCursorModeRef.current) { + // Cursor mode: advance and store cursor + if (hasNextPageRef.current && nextCursorRef.current !== null) { + setCursorHistory(h => { + const newHistory = [...h] + // Store cursor for the next page index + newHistory[pageIndex + 1] = nextCursorRef.current + return newHistory + }) + setPageIndex(i => i + 1) } - // If item matches but wasn't in our page, we don't add it - // (it may belong on a different page) - }, - patched: (item: unknown) => { - // Same logic as updated - handlers.updated(item) - }, - removed: (item: unknown) => { - if (realtime === 'refetch') { - refetch() + } else { + // Offset mode + setPageIndex(i => Math.min(i + 1, totalPagesRef.current - 1)) + } + }, [pageIndex]) + + const prevPage = useCallback(() => { + setPageIndex(i => Math.max(i - 1, 0)) + }, []) + + const setPage = useCallback( + (newPage: number) => { + const newIndex = newPage - 1 // Convert to 0-indexed + + if (isCursorModeRef.current) { + // Cursor mode: only allow sequential navigation + // Silently ignore non-sequential jumps + if (newIndex === pageIndex + 1) { + nextPage() + } else if (newIndex === pageIndex - 1) { + prevPage() + } + // All other values are silently ignored return } - const typedItem = item as TItem - const itemId = getId(typedItem) - const currentData = stateRef.current.data - const existingIndex = currentData.findIndex(d => getId(d) === itemId) - - if (existingIndex >= 0) { - // Item was on our page - refetch to get replacement item - refetch() - } + // Offset mode: clamp and set + const clamped = Math.max(0, Math.min(newIndex, totalPagesRef.current - 1)) + setPageIndex(clamped) }, - } - - const unsubscribe = adapter.subscribe(serviceName, handlers) - return unsubscribe - }, [serviceName, skip, realtime, adapter, itemMatcher, refetch]) - - // Use previous data during loading for smooth transitions - const displayData = - state.isFetching && state.status !== 'loading' && prevDataRef.current - ? prevDataRef.current.data - : state.data - const displayMeta = - state.isFetching && state.status !== 'loading' && prevDataRef.current - ? prevDataRef.current.meta - : state.meta - - return useMemo( - () => ({ - status: state.status, - data: displayData, - meta: displayMeta, - isFetching: state.isFetching, - error: state.error, - page: state.page, - totalPages, - hasNextPage, - hasPrevPage, - setPage, - nextPage, - prevPage, - refetch, - }), - [ - state.status, - displayData, - displayMeta, - state.isFetching, - state.error, - state.page, - totalPages, - hasNextPage, - hasPrevPage, - setPage, - nextPage, - prevPage, - refetch, - ], - ) + [pageIndex, nextPage, prevPage], + ) + + return useMemo( + () => ({ + status: result.status, + data: displayData, + meta: displayMeta, + isFetching: result.isFetching, + error: result.error, + page, + totalPages, + hasNextPage, + hasPrevPage, + setPage, + nextPage, + prevPage, + refetch: result.refetch, + }), + [ + result.status, + displayData, + displayMeta, + result.isFetching, + result.error, + page, + totalPages, + hasNextPage, + hasPrevPage, + setPage, + nextPage, + prevPage, + result.refetch, + ], + ) + } } + +/** + * Hook for traditional page-based pagination. + * + * This is a thin wrapper around useFind that adds page state management. + * Each page is fetched independently and realtime updates come free from useFind. + * Previous page data is kept visible during page transitions for smooth UX. + * + * Supports both offset pagination (random access) and cursor pagination (sequential only). + * In cursor mode: + * - totalPages is -1 (unknown) + * - setPage(n) silently ignores non-sequential jumps + * - nextPage()/prevPage() work correctly using cursor history + * + * @example + * ```tsx + * const { data, page, totalPages, setPage, nextPage, prevPage } = usePaginatedFind( + * 'api/documents', + * { query: { $sort: { createdAt: -1 } }, limit: 20 } + * ) + * ``` + */ +export { createUsePaginatedFind as usePaginatedFind } diff --git a/test/helpers-cursor.ts b/test/helpers-cursor.ts index 9e9172a..6683c24 100644 --- a/test/helpers-cursor.ts +++ b/test/helpers-cursor.ts @@ -22,7 +22,7 @@ interface FindParams { query?: { $limit?: number $skip?: number - cursor?: string + $cursor?: string $sort?: Record [key: string]: unknown } @@ -101,7 +101,7 @@ class CursorService extends EventEmitter { } const limit = params.query?.$limit ?? this.pageSize - const cursor = params.query?.cursor + const cursor = params.query?.$cursor const skip = params.query?.$skip ?? 0 // Get all items in original order @@ -109,7 +109,7 @@ class CursorService extends EventEmitter { .map(id => this.#data.get(id)) .filter((item): item is TestItem => item !== undefined) - // Find starting index based on cursor (cursor mode) or $skip (offset mode) + // Find starting index based on $cursor (cursor mode) or $skip (offset mode) let startIndex = 0 if (this.#mode === 'cursor' && cursor) { const cursorId = parseInt(cursor, 10) @@ -120,7 +120,7 @@ class CursorService extends EventEmitter { } else if (this.#mode === 'offset') { startIndex = skip } else if (cursor) { - // Cursor mode with cursor param + // Cursor mode with $cursor param const cursorId = parseInt(cursor, 10) startIndex = this.#originalOrder.findIndex(id => id === cursorId) if (startIndex === -1) { diff --git a/test/use-paginated-find.test.tsx b/test/use-paginated-find.test.tsx index 00748e4..535225f 100644 --- a/test/use-paginated-find.test.tsx +++ b/test/use-paginated-find.test.tsx @@ -371,15 +371,21 @@ test('usePaginatedFind previous data shown during page transitions', async t => // Navigate to next page - during transition, old data should still be shown click($('.next')!) - // Status should stay success and data should be available during transition - t.is($('.status')?.textContent, 'success') - // Data should still be visible + // Status shows actual query state (loading for new page), but data is preserved + // This is stale-while-revalidate behavior - show old data while fetching new + t.is($('.isFetching')?.textContent, 'true') + // Data from previous page should still be visible during loading t.is($('.count')?.textContent, '2') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) await flush() // Now should have new data t.is($('.status')?.textContent, 'success') + t.is($('.isFetching')?.textContent, 'false') t.deepEqual( $all('.doc').map(el => el.textContent), ['Doc 3', 'Doc 4'], @@ -404,11 +410,13 @@ test('usePaginatedFind realtime updates work for current page', async t => { }, }) const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) + // Disable event batching for tests so realtime updates are processed immediately + const figbird = new Figbird({ schema, adapter, eventBatchProcessingInterval: 0 }) const { usePaginatedFind } = createHooks(figbird) function App() { - const { data } = usePaginatedFind('api/documents', { limit: 10 }) + // Use realtime: 'merge' to test in-place updates (default is 'refetch') + const { data } = usePaginatedFind('api/documents', { limit: 10, realtime: 'merge' }) return (
@@ -432,9 +440,10 @@ test('usePaginatedFind realtime updates work for current page', async t => { t.is($all('.doc').length, 2) t.is($all('.doc')[0]?.textContent, 'Doc 1') - // Update an existing document - await feathers.service('api/documents').patch(1, { title: 'Doc 1 Updated' }) - await flush() + // Update an existing document - wrap in flush callback to ensure proper event processing + await flush(async () => { + await feathers.service('api/documents').patch(1, { title: 'Doc 1 Updated' }) + }) const docs = $all('.doc').map(el => el.textContent) t.deepEqual(docs, ['Doc 1 Updated', 'Doc 2']) @@ -991,3 +1000,212 @@ test('usePaginatedFind with query and sorting', async t => { unmount() }) + +// Cursor pagination tests + +test('usePaginatedFind cursor mode: totalPages is -1', async t => { + const { $, flush, render, unmount } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'cursor', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { status, data, page, totalPages, hasNextPage, hasPrevPage } = usePaginatedFind( + 'api/documents', + { limit: 2 }, + ) + + return ( +
+ {status} + {data.length} + {page} + {totalPages} + {String(hasNextPage)} + {String(hasPrevPage)} +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.status')?.textContent, 'success') + t.is($('.count')?.textContent, '2') + t.is($('.page')?.textContent, '1') + t.is($('.totalPages')?.textContent, '-1') // Unknown in cursor mode + t.is($('.hasNextPage')?.textContent, 'true') + t.is($('.hasPrevPage')?.textContent, 'false') + + unmount() +}) + +test('usePaginatedFind cursor mode: nextPage/prevPage navigation', async t => { + const { $, $all, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 300 }, + { id: 2, title: 'Doc 2', createdAt: 200 }, + { id: 3, title: 'Doc 3', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'cursor', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { data, page, hasNextPage, hasPrevPage, nextPage, prevPage } = usePaginatedFind( + 'api/documents', + { limit: 2 }, + ) + + return ( +
+ {page} + {String(hasNextPage)} + {String(hasPrevPage)} + {data.map(d => ( + + {d.title} + + ))} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) + t.is($('.hasNextPage')?.textContent, 'true') + t.is($('.hasPrevPage')?.textContent, 'false') + + // Next page + click($('.next')!) + await flush() + + t.is($('.page')?.textContent, '2') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 3'], + ) + t.is($('.hasNextPage')?.textContent, 'false') + t.is($('.hasPrevPage')?.textContent, 'true') + + // Previous page - should navigate back using cursor history + click($('.prev')!) + await flush() + + t.is($('.page')?.textContent, '1') + t.deepEqual( + $all('.doc').map(el => el.textContent), + ['Doc 1', 'Doc 2'], + ) + + unmount() +}) + +test('usePaginatedFind cursor mode: setPage ignores non-sequential jumps', async t => { + const { $, flush, render, unmount, click } = dom() + + const documents = [ + { id: 1, title: 'Doc 1', createdAt: 500 }, + { id: 2, title: 'Doc 2', createdAt: 400 }, + { id: 3, title: 'Doc 3', createdAt: 300 }, + { id: 4, title: 'Doc 4', createdAt: 200 }, + { id: 5, title: 'Doc 5', createdAt: 100 }, + ] + + const feathers = mockCursorFeathers({ + 'api/documents': { + data: documents, + pageSize: 2, + mode: 'cursor', + }, + }) + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ schema, adapter }) + const { usePaginatedFind } = createHooks(figbird) + + function App() { + const { page, setPage } = usePaginatedFind('api/documents', { limit: 2 }) + + return ( +
+ {page} + + +
+ ) + } + + render( + + + , + ) + + await flush() + + t.is($('.page')?.textContent, '1') + + // Try to jump to page 3 (should be silently ignored in cursor mode) + click($('.go-page-3')!) + await flush() + + // Still on page 1 because non-sequential jumps are ignored + t.is($('.page')?.textContent, '1') + + // Try to go to page 1 (same page, should also be ignored) + click($('.go-page-1')!) + await flush() + + t.is($('.page')?.textContent, '1') + + unmount() +}) From da1f3b557e6e6620073a379f73a5a17024801727 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sun, 25 Jan 2026 17:15:35 +0000 Subject: [PATCH 5/6] Update CHANGELOG --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 820ff3b..8058066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Figbird Changelog +## 0.22.0 + +New pagination hooks for infinite scroll and page-based navigation: + +- **`useInfiniteFind`** - Infinite scroll / "load more" pagination hook. Data accumulates across pages as you call `loadMore()`. Supports both cursor-based and offset-based backends with auto-detection. Includes realtime support with `merge`, `refetch`, or `disabled` modes. +- **`usePaginatedFind`** - Traditional page-based navigation hook. Shows one page at a time with `nextPage()`, `prevPage()`, and `setPage(n)` controls. Previous page data stays visible during transitions for smooth UX. Supports both offset pagination (random page access) and cursor pagination (sequential navigation only). +- **Adapter-level pagination config** - `FeathersAdapter` now accepts `getNextPageParam` and `getHasNextPage` options for customizing pagination logic. Default implementation auto-detects: cursor mode (uses `meta.endCursor`) takes priority, offset mode (uses `meta.skip`/`meta.total`) is fallback. +- **Cursor support in `findAll()`** - The `allPages` option now works with cursor-based backends using the `$cursor` query param. + ## 0.21.1 - Remove the `idle` status from `useFind` and `useGet` result to keep consumer code simpler. For cases where `idle` was previously useful look for combination of status `loading` and `isFetching` false. From 2f1eb9128299cdbb3f632277b8a65d5efc548611 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sun, 25 Jan 2026 17:56:19 +0000 Subject: [PATCH 6/6] Rewrite useInfiniteFind into figbird class --- lib/core/figbird.ts | 638 +++++++++++++++++++++++++++++--- lib/react/useInfiniteFind.ts | 433 ++++------------------ lib/react/useQuery.ts | 3 +- test/use-infinite-find.test.tsx | 10 +- 4 files changed, 662 insertions(+), 422 deletions(-) diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index 78523e0..82c3579 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -77,7 +77,9 @@ export interface Query, TQuery = un pending: boolean dirty: boolean filterItem: (item: ElementType) => boolean - state: QueryState + /** Sorter for infinite queries to insert realtime events in order */ + sorter?: (a: ElementType, b: ElementType) => number + state: QueryState | InfiniteQueryState, TMeta> } /** @@ -108,10 +110,21 @@ export interface FindDescriptor { params?: unknown } +/** + * Query descriptor for infinite find operations + */ +export interface InfiniteFindDescriptor { + serviceName: string + method: 'infiniteFind' + params?: unknown + /** Unique ID to make each hook instance a separate query (pagination state is per-instance) */ + instanceId: string +} + /** * Discriminated union of query descriptors */ -export type QueryDescriptor = GetDescriptor | FindDescriptor +export type QueryDescriptor = GetDescriptor | FindDescriptor | InfiniteFindDescriptor /** * Helper type to extract element type from arrays @@ -173,12 +186,70 @@ export interface FindQueryConfig extends Base allPages?: boolean } +/** + * Configuration for infinite find queries + */ +export interface InfiniteFindQueryConfig extends Omit< + BaseQueryConfig, + 'fetchPolicy' +> { + /** + * Page size limit for each fetch + */ + limit?: number + + /** + * Custom sorter for inserting realtime events in sorted order. + * Default: built from query.$sort + */ + sorter?: (a: ElementType, b: ElementType) => number +} + +/** + * Query state for infinite find queries - extended with pagination state + */ +export type InfiniteQueryState> = + | { + status: 'loading' + data: T[] + meta: TMeta + isFetching: boolean + isLoadingMore: boolean + loadMoreError: null + error: null + hasNextPage: boolean + pageParam: string | number | null + } + | { + status: 'success' + data: T[] + meta: TMeta + isFetching: boolean + isLoadingMore: boolean + loadMoreError: Error | null + error: null + hasNextPage: boolean + pageParam: string | number | null + } + | { + status: 'error' + data: T[] + meta: TMeta + isFetching: boolean + isLoadingMore: boolean + loadMoreError: Error | null + error: Error + hasNextPage: boolean + pageParam: string | number | null + } + /** * Discriminated union of query configurations */ export type QueryConfig = | GetQueryConfig | FindQueryConfig + | InfiniteFindQueryConfig /** * Combined config for get operations @@ -199,7 +270,16 @@ export type CombinedFindConfig = FindDescript } /** - * Combined config for internal use + * Combined config for infinite find operations + */ +export type CombinedInfiniteFindConfig = InfiniteFindDescriptor & + InfiniteFindQueryConfig & { + [key: string]: unknown + } + +/** + * Combined config for internal use (used by splitConfig for get/find queries) + * Note: InfiniteFindConfig is not included as infinite queries use a different code path */ export type CombinedConfig = | CombinedGetConfig @@ -405,6 +485,23 @@ export class Figbird< AdapterFindMeta, AdapterQuery > + /** Create a typed `infiniteFind` query reference. */ + query>( + desc: { + serviceName: N + method: 'infiniteFind' + params?: ParamsWithServiceQuery + instanceId: string + }, + config?: InfiniteFindQueryConfig[], ServiceQuery>, + ): QueryRef< + ServiceItem[], + ServiceQuery, + S, + AdapterParams, + AdapterFindMeta, + AdapterQuery + > // Generic fallback overload (for dynamic descriptors) query( desc: D, @@ -421,8 +518,9 @@ export class Figbird< query( desc: { serviceName: string - method: 'find' | 'get' + method: 'find' | 'get' | 'infiniteFind' resourceId?: string | number + instanceId?: string params?: unknown }, config?: QueryConfig, @@ -615,8 +713,11 @@ class QueryRef< } /** Returns the latest known state for this query, if available. */ - getSnapshot(): QueryState | undefined { + getSnapshot(): QueryState | InfiniteQueryState, TMeta> | undefined { this.#queryStore.materialize(this) + if (this.#desc.method === 'infiniteFind') { + return this.#queryStore.getInfiniteQueryState>(this.#queryId) + } return this.#queryStore.getQueryState(this.#queryId) } @@ -625,6 +726,15 @@ class QueryRef< this.#queryStore.materialize(this) return this.#queryStore.refetch(this.#queryId) } + + /** Load the next page for an infinite query. */ + loadMore(): void { + if (this.#desc.method !== 'infiniteFind') { + throw new Error('loadMore is only available for infinite queries') + } + this.#queryStore.materialize(this) + this.#queryStore.loadMore(this.#queryId) + } } /** @@ -676,6 +786,11 @@ class QueryStore< return this.#getQuery(queryId)?.state as QueryState | undefined } + /** Returns the current state for an infinite query by id, if present. */ + getInfiniteQueryState(queryId: string): InfiniteQueryState | undefined { + return this.#getQuery(queryId)?.state as InfiniteQueryState | undefined + } + /** * Ensures that backing state exists for the given QueryRef by creating * service/query structures on first use. @@ -689,30 +804,89 @@ class QueryStore< this.#transactOverService( queryId, service => { - service.queries.set(queryId, { - queryId, - desc, - config: config as QueryConfig, - pending: !config.skip, - dirty: false, - filterItem: this.#createItemFilter( + if (desc.method === 'infiniteFind') { + const infiniteConfig = config as InfiniteFindQueryConfig + service.queries.set(queryId, { + queryId, desc, - config as QueryConfig, - ) as (item: unknown) => boolean, - state: { - status: 'loading' as const, - data: null, - meta: this.#adapter.emptyMeta(), - isFetching: !config.skip, - error: null, - }, - }) + config: config as QueryConfig, + pending: !config.skip, + dirty: false, + filterItem: this.#createItemFilter( + desc, + config as QueryConfig, + ) as (item: unknown) => boolean, + sorter: infiniteConfig.sorter ?? this.#createSorter(desc), + state: { + status: 'loading' as const, + data: [], + meta: this.#adapter.emptyMeta(), + isFetching: !config.skip, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: false, + pageParam: null, + } as InfiniteQueryState, + }) + } else { + service.queries.set(queryId, { + queryId, + desc, + config: config as QueryConfig, + pending: !config.skip, + dirty: false, + filterItem: this.#createItemFilter( + desc, + config as QueryConfig, + ) as (item: unknown) => boolean, + state: { + status: 'loading' as const, + data: null, + meta: this.#adapter.emptyMeta(), + isFetching: !config.skip, + error: null, + }, + }) + } }, { silent: true }, ) } } + #createSorter(desc: QueryDescriptor): (a: unknown, b: unknown) => number { + const sort = (desc.params as { query?: { $sort?: Record } })?.query?.$sort + if (!sort || Object.keys(sort).length === 0) { + return () => 0 + } + const sortEntries = Object.entries(sort) + return (a: unknown, b: unknown) => { + for (const [key, direction] of sortEntries) { + const aVal = (a as Record)[key] + const bVal = (b as Record)[key] + let cmp = 0 + if (aVal == null && bVal == null) { + cmp = 0 + } else if (aVal == null) { + cmp = 1 + } else if (bVal == null) { + cmp = -1 + } else if (typeof aVal === 'string' && typeof bVal === 'string') { + cmp = aVal.localeCompare(bVal) + } else if (aVal < bVal) { + cmp = -1 + } else if (aVal > bVal) { + cmp = 1 + } + if (cmp !== 0) { + return cmp * direction + } + } + return 0 + } + } + #createItemFilter( desc: QueryDescriptor, config: QueryConfig, @@ -785,19 +959,29 @@ class QueryStore< const q = this.#getQuery(queryId) if (!q) return () => {} - if ( - q.pending || - (q.state.status === 'success' && q.config.fetchPolicy === 'swr' && !q.state.isFetching) || - (q.state.status === 'error' && !q.state.isFetching) - ) { - this.#queue(queryId) + // Infinite queries always fetch on first subscribe, but don't have SWR behavior + if (q.desc.method === 'infiniteFind') { + if (q.pending || (q.state.status === 'error' && !q.state.isFetching)) { + this.#queueInfinite(queryId, false) + } + } else { + const fetchPolicy = (q.config as BaseQueryConfig).fetchPolicy + if ( + q.pending || + (q.state.status === 'success' && fetchPolicy === 'swr' && !q.state.isFetching) || + (q.state.status === 'error' && !q.state.isFetching) + ) { + this.#queue(queryId) + } } const removeListener = this.#addListener(queryId, fn) this.#subscribeToRealtime(queryId) - const shouldVacuumByDefault = q.config.fetchPolicy === 'network-only' + // Infinite queries always vacuum (each has unique instanceId) + const fetchPolicy = (q.config as BaseQueryConfig).fetchPolicy + const shouldVacuumByDefault = fetchPolicy === 'network-only' || q.desc.method === 'infiniteFind' return ({ vacuum = shouldVacuumByDefault }: { vacuum?: boolean } = {}) => { removeListener() if (vacuum && this.#listenerCount(queryId) === 0) { @@ -816,6 +1000,11 @@ class QueryStore< const q = this.#getQuery(queryId) if (!q) return + if (q.desc.method === 'infiniteFind') { + this.refetchInfinite(queryId) + return + } + if (!q.state.isFetching) { this.#queue(queryId) } else { @@ -833,6 +1022,284 @@ class QueryStore< } } + /** Refetch an infinite query from the beginning (reset pagination). */ + refetchInfinite(queryId: string): void { + const q = this.#getQuery(queryId) + if (!q || q.desc.method !== 'infiniteFind') return + + const state = q.state as InfiniteQueryState + if (state.isFetching || state.isLoadingMore) { + // Mark as dirty to refetch after current fetch completes + this.#transactOverService( + queryId, + (service, query) => { + service.queries.set(queryId, { + ...query!, + dirty: true, + }) + }, + { silent: true }, + ) + return + } + + // Reset state and refetch from beginning + this.#transactOverService(queryId, (service, query) => { + if (!query) return + service.queries.set(queryId, { + ...query, + state: { + status: 'loading' as const, + data: [], + meta: this.#adapter.emptyMeta(), + isFetching: true, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: false, + pageParam: null, + } as InfiniteQueryState, + }) + }) + + this.#queueInfinite(queryId, false) + } + + /** Load more data for an infinite query. */ + loadMore(queryId: string): void { + const q = this.#getQuery(queryId) + if (!q || q.desc.method !== 'infiniteFind') return + + const state = q.state as InfiniteQueryState + if (state.isLoadingMore || !state.hasNextPage || state.pageParam === null) { + return + } + + this.#transactOverService(queryId, (service, query) => { + if (!query) return + const currentState = query.state as InfiniteQueryState + service.queries.set(queryId, { + ...query, + state: { + ...currentState, + isLoadingMore: true, + loadMoreError: null, + }, + }) + }) + + this.#queueInfinite(queryId, true) + } + + async #queueInfinite(queryId: string, isLoadMore: boolean): Promise { + if (!isLoadMore) { + this.#fetchingInfinite({ queryId }) + } + try { + const result = await this.#fetchInfinite(queryId, isLoadMore) + this.#fetchedInfinite({ queryId, result, isLoadMore }) + } catch (err) { + this.#fetchFailedInfinite({ + queryId, + error: err instanceof Error ? err : new Error(String(err)), + isLoadMore, + }) + } + } + + #fetchInfinite(queryId: string, isLoadMore: boolean): Promise> { + const query = this.#getQuery(queryId) + if (!query || query.desc.method !== 'infiniteFind') { + return Promise.reject(new Error('Infinite query not found')) + } + + const { desc, config } = query + const infiniteConfig = config as InfiniteFindQueryConfig + const state = query.state as InfiniteQueryState + + const baseQuery = (desc.params as { query?: Record })?.query || {} + const pageParam = isLoadMore ? state.pageParam : null + + const params: TParams = { + query: { + ...baseQuery, + ...(infiniteConfig.limit && { $limit: infiniteConfig.limit }), + ...(typeof pageParam === 'string' && { $cursor: pageParam }), + ...(typeof pageParam === 'number' && { $skip: pageParam }), + }, + } as TParams + + return this.#adapter.find(desc.serviceName, params) + } + + #fetchingInfinite({ queryId }: { queryId: string }): void { + this.#transactOverService(queryId, (service, query) => { + if (!query) return + const state = query.state as InfiniteQueryState + + // Preserve the status while updating fetching state + const newState: InfiniteQueryState = + state.status === 'success' + ? { ...state, isFetching: true } + : state.status === 'error' + ? { + status: 'loading' as const, + data: state.data, + meta: state.meta, + isFetching: true, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: state.hasNextPage, + pageParam: state.pageParam, + } + : { ...state, isFetching: true } + + service.queries.set(queryId, { + ...query, + pending: false, + dirty: false, + state: newState, + }) + }) + } + + #fetchedInfinite({ + queryId, + result, + isLoadMore, + }: { + queryId: string + result: QueryResponse + isLoadMore: boolean + }): void { + let shouldRefetch = false + + this.#transactOverService(queryId, (service, query) => { + if (!query) return + + const data = result.data + const meta = result.meta as TMeta + const getId = (item: unknown) => this.#adapter.getId(item) + const nextPageParam = this.#adapter.getNextPageParam(meta, data) + const hasNextPage = this.#adapter.getHasNextPage(meta, data) + + // Store entities in central cache + for (const item of data) { + const itemId = getId(item) + if (itemId !== undefined) { + service.entities.set(itemId, item) + if (!service.itemQueryIndex.has(itemId)) { + service.itemQueryIndex.set(itemId, new Set()) + } + service.itemQueryIndex.get(itemId)!.add(queryId) + } + } + + shouldRefetch = query.dirty + const currentState = query.state as InfiniteQueryState + + if (isLoadMore) { + service.queries.set(queryId, { + ...query, + dirty: false, + state: { + ...currentState, + data: [...(currentState.data as unknown[]), ...data], + meta, + isLoadingMore: false, + hasNextPage, + pageParam: nextPageParam, + }, + }) + } else { + service.queries.set(queryId, { + ...query, + dirty: false, + state: { + status: 'success' as const, + data, + meta, + isFetching: false, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage, + pageParam: nextPageParam, + } as InfiniteQueryState, + }) + } + }) + + if (shouldRefetch && this.#listenerCount(queryId) > 0) { + this.refetchInfinite(queryId) + } + } + + #fetchFailedInfinite({ + queryId, + error, + isLoadMore, + }: { + queryId: string + error: Error + isLoadMore: boolean + }): void { + let shouldRefetch = false + + this.#transactOverService(queryId, (service, query) => { + if (!query) return + + shouldRefetch = query.dirty + const currentState = query.state as InfiniteQueryState + + let newState: InfiniteQueryState + if (isLoadMore) { + // For loadMore failure, keep the current status and update loadMoreError + if (currentState.status === 'success') { + newState = { + ...currentState, + isLoadingMore: false, + loadMoreError: error, + } + } else if (currentState.status === 'error') { + newState = { + ...currentState, + isLoadingMore: false, + loadMoreError: error, + } + } else { + newState = { + ...currentState, + isLoadingMore: false, + } + } + } else { + newState = { + status: 'error' as const, + data: currentState.data, + meta: this.#adapter.emptyMeta(), + isFetching: false, + isLoadingMore: false, + loadMoreError: null, + error, + hasNextPage: false, + pageParam: null, + } + } + + service.queries.set(queryId, { + ...query, + dirty: false, + state: newState, + }) + }) + + if (shouldRefetch && this.#listenerCount(queryId) > 0) { + this.refetchInfinite(queryId) + } + } + async #queue(queryId: string): Promise { this.#fetching({ queryId }) try { @@ -853,11 +1320,13 @@ class QueryStore< if (desc.method === 'get') { return this.#adapter.get(desc.serviceName, desc.resourceId, desc.params as TParams) - } else { + } else if (desc.method === 'find') { const findConfig = config as FindQueryConfig return findConfig.allPages ? this.#adapter.findAll(desc.serviceName, desc.params as TParams) : this.#adapter.find(desc.serviceName, desc.params as TParams) + } else { + return Promise.reject(new Error('Unsupported query method')) } } @@ -1039,7 +1508,8 @@ class QueryStore< continue } - if (query.desc.method === 'find' && query.config.fetchPolicy === 'network-only') { + const fetchPolicy = (query.config as BaseQueryConfig).fetchPolicy + if (query.desc.method === 'find' && fetchPolicy === 'network-only') { continue } @@ -1050,27 +1520,76 @@ class QueryStore< } const hasItem = itemQueryIndex.has(queryId) + + // Handle infiniteFind queries separately + if (query.desc.method === 'infiniteFind') { + const state = query.state as InfiniteQueryState + if (state.status !== 'success') continue + + if (hasItem && !matches) { + // Remove: item no longer matches + const newData = state.data.filter((x: unknown) => getId(x) !== itemId) + service.queries.set(queryId, { + ...query, + state: { + ...state, + meta: itemRemoved(state.meta), + data: newData, + }, + }) + itemQueryIndex.delete(queryId) + touch(queryId) + } else if (hasItem && matches) { + // Update in place + const newData = state.data.map((x: unknown) => (getId(x) === itemId ? item : x)) + service.queries.set(queryId, { + ...query, + state: { + ...state, + data: newData, + }, + }) + touch(queryId) + } else if (matches && state.data) { + // Insert at sorted position + const sorter = query.sorter || (() => 0) + const newData = insertSorted(state.data, item, sorter) + service.queries.set(queryId, { + ...query, + state: { + ...state, + meta: itemAdded(state.meta), + data: newData, + }, + }) + itemQueryIndex.add(queryId) + touch(queryId) + } + continue + } + + // Handle get and find queries (infiniteFind already handled above) + const queryState = query.state as QueryState if (hasItem && !matches) { // remove - const query = service.queries.get(queryId)! const nextState: QueryState = - query.desc.method === 'get' && query.state.status === 'success' + query.desc.method === 'get' && queryState.status === 'success' ? { status: 'loading' as const, data: null, - meta: itemRemoved(query.state.meta), + meta: itemRemoved(queryState.meta), isFetching: false, error: null, } - : query.state.status === 'success' + : queryState.status === 'success' ? { - ...query.state, - meta: itemRemoved(query.state.meta), - data: (query.state.data as unknown[]).filter( + ...queryState, + meta: itemRemoved(queryState.meta), + data: (queryState.data as unknown[]).filter( (x: unknown) => getId(x) !== itemId, ), } - : query.state + : queryState service.queries.set(queryId, { ...query, state: nextState, @@ -1082,30 +1601,30 @@ class QueryStore< service.queries.set(queryId, { ...query, state: - query.state.status === 'success' + queryState.status === 'success' ? { - ...query.state, + ...queryState, data: query.desc.method === 'get' ? item - : (query.state.data as unknown[]).map((x: unknown) => + : (queryState.data as unknown[]).map((x: unknown) => getId(x) === itemId ? item : x, ), } - : query.state, + : queryState, }) touch(queryId) - } else if (matches && query.desc.method === 'find' && query.state.data) { + } else if (matches && query.desc.method === 'find' && queryState.data) { service.queries.set(queryId, { ...query, state: - query.state.status === 'success' + queryState.status === 'success' ? { - ...query.state, - meta: itemAdded(query.state.meta), - data: (query.state.data as unknown[]).concat(item), + ...queryState, + meta: itemAdded(queryState.meta), + data: (queryState.data as unknown[]).concat(item), } - : query.state, + : queryState, }) itemQueryIndex.add(queryId) touch(queryId) @@ -1333,3 +1852,24 @@ function getItems>( ? [query.state.data] : [] } + +/** + * Insert an item into a sorted array at the correct position using binary search. + */ +function insertSorted(data: T[], item: T, sorter: (a: T, b: T) => number): T[] { + const result = [...data] + let low = 0 + let high = result.length + + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (sorter(item, result[mid]!) <= 0) { + high = mid + } else { + low = mid + 1 + } + } + + result.splice(low, 0, item) + return result +} diff --git a/lib/react/useInfiniteFind.ts b/lib/react/useInfiniteFind.ts index 3477d19..f07f4ae 100644 --- a/lib/react/useInfiniteFind.ts +++ b/lib/react/useInfiniteFind.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' -import type { Adapter, EventHandlers } from '../adapters/adapter.js' +import { useCallback, useId, useMemo, useRef, useSyncExternalStore } from 'react' +import type { InfiniteFindQueryConfig, InfiniteQueryState } from '../core/figbird.js' import { useFigbird } from './react.js' /** @@ -46,185 +46,31 @@ export interface UseInfiniteFindResult { refetch: () => void } -// State shape for the reducer -interface InfiniteState { - status: 'loading' | 'success' | 'error' - data: TItem[] - meta: TMeta - isFetching: boolean - isLoadingMore: boolean - loadMoreError: Error | null - error: Error | null - hasNextPage: boolean - pageParam: string | number | null // cursor string OR skip number -} - -// Action types -type InfiniteAction = - | { type: 'FETCH_START' } - | { - type: 'FETCH_SUCCESS' - data: TItem[] - meta: TMeta - hasNextPage: boolean - pageParam: string | number | null - } - | { type: 'FETCH_ERROR'; error: Error } - | { type: 'LOAD_MORE_START' } - | { - type: 'LOAD_MORE_SUCCESS' - data: TItem[] - meta: TMeta - hasNextPage: boolean - pageParam: string | number | null - } - | { type: 'LOAD_MORE_ERROR'; error: Error } - | { type: 'REFETCH' } - | { type: 'REALTIME_UPDATE'; data: TItem[] } - -function createReducer(emptyMeta: TMeta) { - return function reducer( - state: InfiniteState, - action: InfiniteAction, - ): InfiniteState { - switch (action.type) { - case 'FETCH_START': - return { - ...state, - isFetching: true, - error: null, - } - case 'FETCH_SUCCESS': - return { - status: 'success', - data: action.data, - meta: action.meta, - isFetching: false, - isLoadingMore: false, - loadMoreError: null, - error: null, - hasNextPage: action.hasNextPage, - pageParam: action.pageParam, - } - case 'FETCH_ERROR': - return { - ...state, - status: 'error', - isFetching: false, - error: action.error, - } - case 'LOAD_MORE_START': - return { - ...state, - isLoadingMore: true, - loadMoreError: null, - } - case 'LOAD_MORE_SUCCESS': - return { - ...state, - data: [...state.data, ...action.data], - meta: action.meta, - isLoadingMore: false, - hasNextPage: action.hasNextPage, - pageParam: action.pageParam, - } - case 'LOAD_MORE_ERROR': - return { - ...state, - isLoadingMore: false, - loadMoreError: action.error, - } - case 'REFETCH': - return { - status: 'loading', - data: [], - meta: emptyMeta, - isFetching: true, - isLoadingMore: false, - loadMoreError: null, - error: null, - hasNextPage: false, - pageParam: null, - } - case 'REALTIME_UPDATE': - return { - ...state, - data: action.data, - } - default: - return state - } - } -} - -/** - * Build a sorter function from a $sort object - */ -function buildSorter( - sort: Record | undefined, -): (a: TItem, b: TItem) => number { - if (!sort || Object.keys(sort).length === 0) { - // Default: append at end (no sorting) - return () => 0 - } - - const sortEntries = Object.entries(sort) - return (a: TItem, b: TItem) => { - for (const [key, direction] of sortEntries) { - const aVal = (a as Record)[key] - const bVal = (b as Record)[key] - - let cmp = 0 - if (aVal == null && bVal == null) { - cmp = 0 - } else if (aVal == null) { - cmp = 1 - } else if (bVal == null) { - cmp = -1 - } else if (typeof aVal === 'string' && typeof bVal === 'string') { - cmp = aVal.localeCompare(bVal) - } else if (aVal < bVal) { - cmp = -1 - } else if (aVal > bVal) { - cmp = 1 - } - - if (cmp !== 0) { - return cmp * direction - } - } - return 0 - } -} - -/** - * Insert an item into a sorted array at the correct position - */ -function insertSorted( - data: TItem[], - item: TItem, - sorter: (a: TItem, b: TItem) => number, -): TItem[] { - const result = [...data] - let low = 0 - let high = result.length - - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (sorter(item, result[mid]!) <= 0) { - high = mid - } else { - low = mid + 1 - } +function getInitialInfiniteQueryState>( + emptyMeta: TMeta, +): InfiniteQueryState { + return { + status: 'loading' as const, + data: [], + meta: emptyMeta, + isFetching: true, + isLoadingMore: false, + loadMoreError: null, + error: null, + hasNextPage: false, + pageParam: null, } - - result.splice(low, 0, item) - return result } /** * Hook for infinite/cursor-based pagination with realtime updates. * Manages accumulated data across pages with loadMore functionality. + * + * This is a thin wrapper around Figbird's query system using useSyncExternalStore. + * All state is stored in Figbird's central store, enabling: + * - Consistency with mutations (patching an item updates it everywhere) + * - Entity deduplication across queries + * - Proper realtime event batching and handling */ export function useInfiniteFind< TItem, @@ -235,210 +81,63 @@ export function useInfiniteFind< config: UseInfiniteFindConfig = {}, ): UseInfiniteFindResult { const figbird = useFigbird() - const adapter = figbird.adapter as Adapter> - - const { - query, - skip = false, - realtime = 'merge', - limit, - matcher: customMatcher, - sorter: customSorter, - } = config - - const emptyMeta = useMemo(() => adapter.emptyMeta(), [adapter]) - const reducer = useMemo(() => createReducer(emptyMeta), [emptyMeta]) - - const [state, dispatch] = useReducer(reducer, { - status: 'loading', - data: [], - meta: emptyMeta, - isFetching: !skip, - isLoadingMore: false, - loadMoreError: null, - error: null, - hasNextPage: false, - pageParam: null, - }) - - // Build matcher for realtime events - const itemMatcher = useMemo(() => { - if (realtime !== 'merge') return () => false - if (customMatcher) return customMatcher(query) - return adapter.matcher(query as (TQuery & Record) | undefined) as ( - item: TItem, - ) => boolean - }, [adapter, query, realtime, customMatcher]) - - // Build sorter for realtime insertions - const itemSorter = useMemo(() => { - if (customSorter) return customSorter - const sort = (query as { $sort?: Record } | undefined)?.$sort - return buildSorter(sort) - }, [query, customSorter]) - // Refs to access latest values in callbacks - const stateRef = useRef(state) - stateRef.current = state + const { query, skip = false, realtime = 'merge', limit, matcher, sorter } = config - const configRef = useRef({ query, limit }) - configRef.current = { query, limit } + // Each hook instance gets its own unique query via instanceId. + // This ensures pagination state is not shared between components, + // while the underlying entities are still shared in the central cache. + const instanceId = useId() - // Fetch function - const fetchPage = useCallback( - async (pageParam: string | number | null, isLoadMore: boolean, _allData: TItem[] = []) => { - const { query: currentQuery, limit: currentLimit } = configRef.current - - const params: Record = { - query: { - ...currentQuery, - ...(currentLimit && { $limit: currentLimit }), - // Add pagination param based on type - ...(typeof pageParam === 'string' && { $cursor: pageParam }), - ...(typeof pageParam === 'number' && { $skip: pageParam }), - }, - } - - try { - const result = await adapter.find(serviceName, params) - const data = result.data as TItem[] - const meta = result.meta as TMeta - const nextPageParam = adapter.getNextPageParam(meta, data) - const hasMore = adapter.getHasNextPage(meta, data) - - if (isLoadMore) { - dispatch({ - type: 'LOAD_MORE_SUCCESS', - data, - meta, - hasNextPage: hasMore, - pageParam: nextPageParam, - }) - } else { - dispatch({ - type: 'FETCH_SUCCESS', - data, - meta, - hasNextPage: hasMore, - pageParam: nextPageParam, - }) - } + // Build the query config + const queryConfig: InfiniteFindQueryConfig = useMemo( + () => ({ + skip, + realtime, + ...(limit !== undefined && { limit }), + ...(matcher !== undefined && { matcher }), + ...(sorter !== undefined && { sorter }), + }), + [skip, realtime, limit, matcher, sorter], + ) - return { data, meta, hasNextPage: hasMore, pageParam: nextPageParam } - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)) - if (isLoadMore) { - dispatch({ type: 'LOAD_MORE_ERROR', error }) - } else { - dispatch({ type: 'FETCH_ERROR', error }) - } - return null - } + // Create the query reference. + // We create a new one on each render but use useMemo with the hash to stabilize it. + const _q = figbird.query( + { + serviceName, + method: 'infiniteFind' as const, + params: query ? { query } : undefined, + instanceId, }, - [adapter, serviceName], + queryConfig as InfiniteFindQueryConfig, ) - // Initial fetch effect - useEffect(() => { - if (skip) return - - dispatch({ type: 'FETCH_START' }) - fetchPage(null, false, []) - // Note: We intentionally use JSON.stringify(query) to re-fetch when query changes - }, [skip, fetchPage, JSON.stringify(query)]) - - // loadMore function - const loadMore = useCallback(() => { - const currentState = stateRef.current - if ( - currentState.isLoadingMore || - !currentState.hasNextPage || - currentState.pageParam === null - ) { - return - } - dispatch({ type: 'LOAD_MORE_START' }) - fetchPage(currentState.pageParam, true, currentState.data) - }, [fetchPage]) + // Stabilize the query ref by its hash + const q = useMemo(() => _q, [_q.hash()]) - // refetch function - const refetch = useCallback(() => { - dispatch({ type: 'REFETCH' }) - fetchPage(null, false, []) - }, [fetchPage]) - - // Realtime subscription effect - useEffect(() => { - if (skip || realtime === 'disabled' || !adapter.subscribe) { - return - } - - const getId = (item: unknown) => adapter.getId(item) - - const handlers: EventHandlers = { - created: (item: unknown) => { - if (realtime === 'refetch') { - refetch() - return - } - - const typedItem = item as TItem - if (!itemMatcher(typedItem)) return - - const currentData = stateRef.current.data - const newData = insertSorted(currentData, typedItem, itemSorter) - dispatch({ type: 'REALTIME_UPDATE', data: newData }) - }, - updated: (item: unknown) => { - if (realtime === 'refetch') { - refetch() - return - } + // Cache empty meta to avoid creating it repeatedly + const emptyMetaRef = useRef(null) + if (emptyMetaRef.current == null) { + emptyMetaRef.current = figbird.adapter.emptyMeta() as TMeta + } - const typedItem = item as TItem - const itemId = getId(typedItem) - const currentData = stateRef.current.data - const existingIndex = currentData.findIndex(d => getId(d) === itemId) - const matches = itemMatcher(typedItem) + // Callbacks for useSyncExternalStore + const subscribe = useCallback((onStoreChange: () => void) => q.subscribe(onStoreChange), [q]) - if (existingIndex >= 0 && !matches) { - // Remove: no longer matches - const newData = currentData.filter((_, i) => i !== existingIndex) - dispatch({ type: 'REALTIME_UPDATE', data: newData }) - } else if (existingIndex >= 0 && matches) { - // Update in place - const newData = [...currentData] - newData[existingIndex] = typedItem - dispatch({ type: 'REALTIME_UPDATE', data: newData }) - } else if (existingIndex < 0 && matches) { - // New item that matches - insert sorted - const newData = insertSorted(currentData, typedItem, itemSorter) - dispatch({ type: 'REALTIME_UPDATE', data: newData }) - } - }, - patched: (item: unknown) => { - // Same logic as updated - handlers.updated(item) - }, - removed: (item: unknown) => { - if (realtime === 'refetch') { - refetch() - return - } + const getSnapshot = useCallback( + (): InfiniteQueryState => + (q.getSnapshot() as InfiniteQueryState | undefined) ?? + getInitialInfiniteQueryState(emptyMetaRef.current!), + [q], + ) - const typedItem = item as TItem - const itemId = getId(typedItem) - const currentData = stateRef.current.data - const newData = currentData.filter(d => getId(d) !== itemId) - if (newData.length !== currentData.length) { - dispatch({ type: 'REALTIME_UPDATE', data: newData }) - } - }, - } + // Subscribe to the query state changes + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) - const unsubscribe = adapter.subscribe(serviceName, handlers) - return unsubscribe - }, [serviceName, skip, realtime, adapter, itemMatcher, itemSorter, refetch]) + // Action callbacks + const loadMore = useCallback(() => q.loadMore(), [q]) + const refetch = useCallback(() => q.refetch(), [q]) return useMemo( () => ({ diff --git a/lib/react/useQuery.ts b/lib/react/useQuery.ts index 2c2acf7..ca7bb47 100644 --- a/lib/react/useQuery.ts +++ b/lib/react/useQuery.ts @@ -109,9 +109,10 @@ export function useQuery< // the q.subscribe and q.getSnapshot stable and avoid unsubbing and resubbing // you don't need to do this outside React where you can more easily create a // stable reference to a query and use it for as long as you want + const fetchPolicy = (config as { fetchPolicy?: string }).fetchPolicy const _q = figbird.query(desc, { ...config, - ...(config.fetchPolicy === 'network-only' ? { uid: uniqueId } : {}), + ...(fetchPolicy === 'network-only' ? { uid: uniqueId } : {}), } as QueryConfig) // a bit of React foo to create stable fn references diff --git a/test/use-infinite-find.test.tsx b/test/use-infinite-find.test.tsx index db1b22e..8bdc1d2 100644 --- a/test/use-infinite-find.test.tsx +++ b/test/use-infinite-find.test.tsx @@ -215,7 +215,7 @@ test('useInfiniteFind realtime created events insert at sorted position', async }, }) const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) + const figbird = new Figbird({ schema, adapter, eventBatchProcessingInterval: 0 }) const { useInfiniteFind } = createHooks(figbird) function App() { @@ -271,7 +271,7 @@ test('useInfiniteFind realtime updated events update in place', async t => { }, }) const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) + const figbird = new Figbird({ schema, adapter, eventBatchProcessingInterval: 0 }) const { useInfiniteFind } = createHooks(figbird) function App() { @@ -328,7 +328,7 @@ test('useInfiniteFind realtime removed events remove from data', async t => { }, }) const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) + const figbird = new Figbird({ schema, adapter, eventBatchProcessingInterval: 0 }) const { useInfiniteFind } = createHooks(figbird) function App() { @@ -466,7 +466,7 @@ test('useInfiniteFind custom matcher works correctly', async t => { }, }) const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) + const figbird = new Figbird({ schema, adapter, eventBatchProcessingInterval: 0 }) const { useInfiniteFind } = createHooks(figbird) function App() { @@ -528,7 +528,7 @@ test('useInfiniteFind custom sorter works correctly', async t => { }, }) const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) + const figbird = new Figbird({ schema, adapter, eventBatchProcessingInterval: 0 }) const { useInfiniteFind } = createHooks(figbird) function App() {