Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
121 changes: 119 additions & 2 deletions docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -293,6 +294,113 @@ const notes = useFind('notes') // QueryResult<Note[], FindMeta>
- `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.
Expand Down Expand Up @@ -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:

Expand All @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions lib/adapters/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 99 additions & 16 deletions lib/adapters/feathers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export interface FeathersParams<TQuery = Record<string, unknown>> {
}

/**
* 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). */
Expand All @@ -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
}
Expand Down Expand Up @@ -158,11 +163,20 @@ export type TypedFeathersClient<S extends Schema> = {
>
}

/** 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
}

/**
Expand All @@ -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<TQuery = Record<string, unknown>> implements Adapter<
FeathersParams<TQuery>,
FeathersFindMeta,
Expand All @@ -187,6 +230,8 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> implements Adapte
#updatedAtField: UpdatedAtFieldType
#defaultPageSize: number | undefined
#defaultPageSizeWhenFetchingAll: number | undefined
#getNextPageParam: GetNextPageParamFn
#getHasNextPage: GetHasNextPageFn

/**
* Helper to merge query parameters while maintaining type safety
Expand Down Expand Up @@ -214,13 +259,17 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> implements Adapte
},
defaultPageSize,
defaultPageSizeWhenFetchingAll,
getNextPageParam = defaultGetNextPageParam,
getHasNextPage = defaultGetHasNextPage,
}: FeathersAdapterOptions = {},
) {
this.feathers = feathers
this.#idField = idField
this.#updatedAtField = updatedAtField
this.#defaultPageSize = defaultPageSize
this.#defaultPageSizeWhenFetchingAll = defaultPageSizeWhenFetchingAll
this.#getNextPageParam = getNextPageParam
this.#getHasNextPage = getHasNextPage
}

#service(serviceName: string): FeathersService {
Expand All @@ -245,8 +294,34 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> 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,
},
}
}
}

Expand Down Expand Up @@ -277,29 +352,29 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> implements Adapte

const result: QueryResponse<unknown[], FeathersFindMeta> = {
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<string, unknown> = {
...(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<unknown> {
Expand Down Expand Up @@ -384,4 +459,12 @@ export class FeathersAdapter<TQuery = Record<string, unknown>> 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)
}
}
Loading