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. 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..c097c93 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,113 @@ 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` +- `matcher` - custom matcher function for realtime events +- `sorter` - custom sorter for inserting realtime events in correct order + +Pagination logic is configured at the adapter level. See `FeathersAdapter` options for `getNextPageParam` and `getHasNextPage`. + +#### 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. + +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, + 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 `refetch` (default), `merge` or `disabled`. Refetch is recommended for pagination since creates/removes can shift page boundaries. + +#### 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 (-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 (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 + ## 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. @@ -359,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: @@ -379,14 +489,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/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 45b6573..8701aef 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 } @@ -158,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 } /** @@ -177,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, @@ -187,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 @@ -214,6 +259,8 @@ export class FeathersAdapter> implements Adapte }, defaultPageSize, defaultPageSizeWhenFetchingAll, + getNextPageParam = defaultGetNextPageParam, + getHasNextPage = defaultGetHasNextPage, }: FeathersAdapterOptions = {}, ) { this.feathers = feathers @@ -221,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 { @@ -245,8 +294,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, + }, + } } } @@ -277,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 { @@ -384,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/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/index.ts b/lib/index.ts index ccd6b42..bc6bd2a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -43,6 +43,8 @@ export type { // react hooks 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' @@ -57,6 +59,8 @@ 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 { 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 969179a..f94a3f7 100644 --- a/lib/react/createHooks.ts +++ b/lib/react/createHooks.ts @@ -11,7 +11,17 @@ 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 { + createUsePaginatedFind, + type UsePaginatedFindConfig, + type UsePaginatedFindResult, +} from './usePaginatedFind.js' import { useQuery, type QueryResult } from './useQuery.js' /** @@ -51,6 +61,27 @@ type UseMutationForSchema = >( ServicePatch > +type UseInfiniteFindForSchema< + S extends Schema, + TParams = unknown, + TMeta extends Record = Record, +> = >( + serviceName: N, + config?: Omit, ServiceQuery>, 'query'> & { + query?: ServiceQuery + } & Omit, 'query'>, +) => UseInfiniteFindResult, TMeta> + +type UsePaginatedFindForSchema< + S extends Schema, + TParams = unknown, + TMeta extends Record = Record, +> = >( + serviceName: N, + config: UsePaginatedFindConfig> & + Omit, 'query'>, +) => UsePaginatedFindResult, TMeta> + type UseFeathersForSchema = () => TypedFeathersClient // Type helper to extract schema and adapter types from a Figbird instance @@ -85,6 +116,8 @@ export function createHooks>( ): { useGet: UseGetForSchema, InferParams> useFind: UseFindForSchema, InferParams, InferMeta> + useInfiniteFind: UseInfiniteFindForSchema, InferParams, InferMeta> + usePaginatedFind: UsePaginatedFindForSchema, InferParams, InferMeta> useMutation: UseMutationForSchema> useFeathers: UseFeathersForSchema> } { @@ -131,6 +164,36 @@ export function createHooks>( return useQuery[], TMeta, ServiceQuery>(desc, config) } + function useTypedInfiniteFind>( + serviceName: N, + config?: Omit, ServiceQuery>, '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>, + ) + } + + // 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) const actualServiceName = service?.name ?? serviceName @@ -155,6 +218,8 @@ export function createHooks>( return { 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/useInfiniteFind.ts b/lib/react/useInfiniteFind.ts new file mode 100644 index 0000000..f07f4ae --- /dev/null +++ b/lib/react/useInfiniteFind.ts @@ -0,0 +1,157 @@ +import { useCallback, useId, useMemo, useRef, useSyncExternalStore } from 'react' +import type { InfiniteFindQueryConfig, InfiniteQueryState } from '../core/figbird.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' + /** 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 +} + +/** + * 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 +} + +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, + } +} + +/** + * 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, + TMeta extends Record = Record, + TQuery = Record, +>( + serviceName: string, + config: UseInfiniteFindConfig = {}, +): UseInfiniteFindResult { + const figbird = useFigbird() + + const { query, skip = false, realtime = 'merge', limit, matcher, sorter } = config + + // 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() + + // Build the query config + const queryConfig: InfiniteFindQueryConfig = useMemo( + () => ({ + skip, + realtime, + ...(limit !== undefined && { limit }), + ...(matcher !== undefined && { matcher }), + ...(sorter !== undefined && { sorter }), + }), + [skip, realtime, limit, matcher, sorter], + ) + + // 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, + }, + queryConfig as InfiniteFindQueryConfig, + ) + + // Stabilize the query ref by its hash + const q = useMemo(() => _q, [_q.hash()]) + + // Cache empty meta to avoid creating it repeatedly + const emptyMetaRef = useRef(null) + if (emptyMetaRef.current == null) { + emptyMetaRef.current = figbird.adapter.emptyMeta() as TMeta + } + + // Callbacks for useSyncExternalStore + const subscribe = useCallback((onStoreChange: () => void) => q.subscribe(onStoreChange), [q]) + + const getSnapshot = useCallback( + (): InfiniteQueryState => + (q.getSnapshot() as InfiniteQueryState | undefined) ?? + getInitialInfiniteQueryState(emptyMetaRef.current!), + [q], + ) + + // Subscribe to the query state changes + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + // Action callbacks + const loadMore = useCallback(() => q.loadMore(), [q]) + const refetch = useCallback(() => q.refetch(), [q]) + + 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/lib/react/usePaginatedFind.ts b/lib/react/usePaginatedFind.ts new file mode 100644 index 0000000..709a369 --- /dev/null +++ b/lib/react/usePaginatedFind.ts @@ -0,0 +1,309 @@ +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 { + /** 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: 'refetch' (default) refetches current page, 'merge' updates in place, 'disabled' ignores events */ + realtime?: 'merge' | 'refetch' | 'disabled' +} + +/** + * 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 (-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). In cursor mode, silently ignores non-sequential jumps. */ + setPage: (page: number) => void + /** Navigate to the next page */ + nextPage: () => void + /** Navigate to the previous page */ + prevPage: () => void + /** Refetch the current page */ + refetch: () => void +} + +/** + * 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 +} + +/** + * 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). + * + * @param useFind - The base useFind hook (typed or untyped) + * @returns A usePaginatedFind hook + */ +export function createUsePaginatedFind< + TItem, + TMeta extends Record, + TQuery, + TParams extends Record, +>( + useFind: (serviceName: string, params?: TParams) => QueryResult, +): ( + serviceName: string, + 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 + if (pageIndex !== 0) { + setPageIndex(0) + setCursorHistory([null]) + setIsCursorMode(null) + } + } + + // 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) + } + + // 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) + } + } 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 + } + + // Offset mode: clamp and set + const clamped = Math.max(0, Math.min(newIndex, totalPagesRef.current - 1)) + setPageIndex(clamped) + }, + [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/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/helpers-cursor.ts b/test/helpers-cursor.ts new file mode 100644 index 0000000..6683c24 --- /dev/null +++ b/test/helpers-cursor.ts @@ -0,0 +1,244 @@ +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 + $skip?: number + $cursor?: string + $sort?: Record + [key: string]: unknown + } + [key: string]: unknown +} + +type PaginationMode = 'cursor' | 'offset' + +interface CursorFindResult { + data: TestItem[] + hasNextPage?: boolean // Only present in cursor mode + endCursor?: string | null // Only present in cursor mode + total: number + limit: number + skip: number +} + +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 { + name: string + #data: Map + #originalOrder: (string | number)[] + pageSize: number + counts: ServiceCounts + #failNextFind: boolean + #mode: PaginationMode; + [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 + this.#mode = options.mode ?? 'cursor' + } + + 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 + 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 (cursor mode) or $skip (offset mode) + let startIndex = 0 + 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) { + 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 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, + 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..8bdc1d2 --- /dev/null +++ b/test/use-infinite-find.test.tsx @@ -0,0 +1,992 @@ +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, eventBatchProcessingInterval: 0 }) + 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, eventBatchProcessingInterval: 0 }) + 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, eventBatchProcessingInterval: 0 }) + 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, eventBatchProcessingInterval: 0 }) + 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, eventBatchProcessingInterval: 0 }) + 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() +}) + +// 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() +}) diff --git a/test/use-paginated-find.test.tsx b/test/use-paginated-find.test.tsx new file mode 100644 index 0000000..535225f --- /dev/null +++ b/test/use-paginated-find.test.tsx @@ -0,0 +1,1211 @@ +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 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'], + ) + + 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) + // 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() { + // Use realtime: 'merge' to test in-place updates (default is 'refetch') + const { data } = usePaginatedFind('api/documents', { limit: 10, realtime: 'merge' }) + + 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 - 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']) + + 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() +}) + +// 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() +})