diff --git a/README.md b/README.md index f9da47d..d0895f1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ function Notes() { - **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 +- **Automatic retry** - exponential backoff on failures with configurable stale time - **Full TypeScript** - define a schema once, get inference everywhere ## Documentation diff --git a/docs/content/_index.md b/docs/content/_index.md index c097c93..d773ebf 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -41,6 +41,7 @@ Queries are live - if a record is created that matches your query, it appears. I - **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 +- **Automatic retry** - exponential backoff on failures with configurable stale time - **Full TypeScript** - define a schema once, get inference everywhere - **Framework-agnostic core** - works outside React for SSR, testing, or background sync @@ -246,6 +247,10 @@ const note = useGet('notes', '1') // note: QueryResult - `skip` - setting to true will not fetch the data - `realtime` - one of `merge` (default), `refetch` or `disabled` - `fetchPolicy` - one of `swr` (default), `cache-first` or `network-only` +- `retry` - number of retry attempts on failure, or `false` to disable (default: 3) +- `retryDelay` - delay between retries in ms, or function `(attempt) => ms` (default: exponential backoff) +- `staleTime` - time in ms that data is considered fresh (default: 30000) +- `refetchOnWindowFocus` - refetch stale data when window regains focus (default: true) #### Returns @@ -284,6 +289,10 @@ const notes = useFind('notes') // QueryResult - `parallel` - when used in combination with `allPages` will fetch all pages in parallel - `parallelLimit` - when used in combination with `parallel` limits how many parallel requests to make at once (default: 4) - `matcher` - custom matcher function of signature `(query) => (item) => bool`, used when merging realtime events into local query cache +- `retry` - number of retry attempts on failure, or `false` to disable (default: 3) +- `retryDelay` - delay between retries in ms, or function `(attempt) => ms` (default: exponential backoff) +- `staleTime` - time in ms that data is considered fresh (default: 30000) +- `refetchOnWindowFocus` - refetch stale data when window regains focus (default: true) #### Returns @@ -450,6 +459,22 @@ const figbird = new Figbird({ adapter, schema }) - `adapter` - an instance of a data fetching adapter - `schema` - optional schema to enable full TypeScript inference +- `defaultQueryConfig` - optional global defaults for query options + +```ts +const figbird = new Figbird({ + adapter, + schema, + defaultQueryConfig: { + retry: 3, + retryDelay: 1000, + staleTime: 60_000, + refetchOnWindowFocus: true, + }, +}) +``` + +These defaults apply to all queries and can be overridden per-query. ## FeathersAdapter @@ -566,6 +591,51 @@ With this policy, Figbird will show cached data if possible upon mounting the co With this policy, Figbird will never show cached data on mount and will always fetch on component mount. +## Retry and data freshness + +Figbird provides built-in retry logic and freshness control to handle network failures and keep data up to date. + +### Automatic retry + +Failed fetches automatically retry with exponential backoff. By default, queries retry 3 times with delays of 1s, 2s, 4s (capped at 30s). + +```ts +// Disable retry for a specific query +useFind('notes', { retry: false }) + +// Custom retry count +useFind('notes', { retry: 5 }) + +// Fixed delay between retries +useFind('notes', { retryDelay: 2000 }) + +// Custom delay function +useFind('notes', { retryDelay: (attempt) => attempt * 1000 }) +``` + +### Stale time + +The `staleTime` option controls how long data is considered "fresh" before Figbird will refetch in the background. This prevents refetch storms from rapid tab switching while still catching missed realtime events. + +```ts +// Data is fresh for 60 seconds +useFind('notes', { staleTime: 60_000 }) + +// Always refetch (staleTime of 0) +useFind('notes', { staleTime: 0 }) +``` + +Default is 30 seconds - a balance between responsiveness and efficiency. + +### Refetch on window focus + +When enabled (default), Figbird refetches stale queries when the browser window regains focus. This catches any realtime events that may have been missed while the tab was in the background. + +```ts +// Disable for a specific query +useFind('notes', { refetchOnWindowFocus: false }) +``` + ## Inspect cache contents If you want to have a look at the cache contents for debugging reasons, you can do so as shown below. diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index fa6591b..c18981f 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -122,6 +122,10 @@ export interface Query, TQuery = un /** Sorter for infinite queries to insert realtime events in order */ sorter?: (a: ElementType, b: ElementType) => number state: QueryState | InfiniteQueryState, TMeta> + /** Number of retry attempts made for the current fetch */ + retryCount: number + /** Timestamp when data was last successfully fetched */ + fetchedAt: number | null } /** @@ -177,6 +181,12 @@ export type QueryDescriptor = GetDescriptor | FindDescriptor | InfiniteFindDescr */ type ElementType = T extends (infer E)[] ? E : T +/** + * Retry delay configuration - either a number (ms) or a function that receives + * the attempt number (0-indexed) and returns the delay in milliseconds. + */ +export type RetryDelayFn = (attempt: number) => number + /** * Base query configuration shared by all query types. * Add these alongside adapter params when calling useFind/useGet. @@ -211,6 +221,32 @@ interface BaseQueryConfig { * Note: For find queries, the matcher works with individual items, not arrays. */ matcher?: (query: TQuery | undefined) => (item: ElementType) => boolean + + /** + * Number of times to retry failed fetches. Set to `false` to disable retries. + * Default: 3 + */ + retry?: number | false + + /** + * Delay between retries. Can be a number (ms) or a function receiving attempt number. + * Default: exponential backoff (1s, 2s, 4s... capped at 30s) + */ + retryDelay?: number | RetryDelayFn + + /** + * Time in milliseconds that cached data is considered "fresh". + * During this window, cache-hit queries won't trigger background refetches. + * Default: 30000 (30 seconds) - prevents refetch storms from rapid tab switching + * while still catching missed realtime events reasonably quickly. + */ + staleTime?: number + + /** + * Refetch stale queries when the browser window regains focus. + * Default: true + */ + refetchOnWindowFocus?: boolean } /** @@ -428,6 +464,16 @@ type ParamsWithServiceQuery, A exten // Multiple queries can safely reference the same cached state. unsub() */ +/** + * Default query configuration options that can be set globally + */ +export interface DefaultQueryConfig { + retry?: number | false + retryDelay?: number | RetryDelayFn + staleTime?: number + refetchOnWindowFocus?: boolean +} + /** * Figbird core instance holding the adapter and shared query state. * Prefer `createHooks(figbird)` in React apps to get strongly-typed hooks. @@ -446,21 +492,25 @@ export class Figbird< * @param adapter Data adapter (e.g. FeathersAdapter) * @param eventBatchProcessingInterval Optional interval (ms) for batching realtime events * @param schema Optional schema to enable full TypeScript inference + * @param defaultQueryConfig Global defaults for query options (retry, staleTime, etc.) */ constructor({ adapter, eventBatchProcessingInterval, schema, + defaultQueryConfig, }: { adapter: A eventBatchProcessingInterval?: number schema?: S + defaultQueryConfig?: DefaultQueryConfig }) { this.adapter = adapter this.schema = schema this.queryStore = new QueryStore, AdapterFindMeta, AdapterQuery>({ adapter, - eventBatchProcessingInterval: eventBatchProcessingInterval, + eventBatchProcessingInterval, + ...(defaultQueryConfig && { defaultQueryConfig }), }) } @@ -627,6 +677,10 @@ export function splitConfig( realtime = 'merge', fetchPolicy = 'swr', matcher, + retry, + retryDelay, + staleTime, + refetchOnWindowFocus, ...rest } = combinedConfig @@ -645,6 +699,10 @@ export function splitConfig( realtime, fetchPolicy, ...(matcher !== undefined && { matcher }), + ...(retry !== undefined && { retry }), + ...(retryDelay !== undefined && { retryDelay }), + ...(staleTime !== undefined && { staleTime }), + ...(refetchOnWindowFocus !== undefined && { refetchOnWindowFocus }), } return { desc, config } @@ -663,6 +721,10 @@ export function splitConfig( fetchPolicy, ...(matcher !== undefined && { matcher }), ...(allPages !== undefined && { allPages }), + ...(retry !== undefined && { retry }), + ...(retryDelay !== undefined && { retryDelay }), + ...(staleTime !== undefined && { staleTime }), + ...(refetchOnWindowFocus !== undefined && { refetchOnWindowFocus }), } return { desc, config } @@ -757,6 +819,26 @@ class QueryRef< // ==================== QUERY STORE CLASS ==================== +/** + * Default exponential backoff: 1s, 2s, 4s, 8s... capped at 30s + */ +function defaultRetryDelay(attempt: number): number { + return Math.min(1000 * Math.pow(2, attempt), 30000) +} + +/** + * Compute retry delay from config + */ +function computeRetryDelay(attempt: number, retryDelay: number | RetryDelayFn | undefined): number { + if (typeof retryDelay === 'function') { + return retryDelay(attempt) + } + if (typeof retryDelay === 'number') { + return retryDelay + } + return defaultRetryDelay(attempt) +} + /** * Internal query store managing entities, queries, and subscriptions. */ @@ -769,6 +851,7 @@ class QueryStore< // ==================== SHARED STATE ==================== #adapter: Adapter + #defaultQueryConfig: DefaultQueryConfig #realtime: Set = new Set() #listeners: Map) => void>> = new Map() @@ -784,12 +867,62 @@ class QueryStore< constructor({ adapter, eventBatchProcessingInterval = 100, + defaultQueryConfig = {}, }: { adapter: Adapter eventBatchProcessingInterval?: number | undefined + defaultQueryConfig?: DefaultQueryConfig }) { this.#adapter = adapter this.#eventBatchProcessingInterval = eventBatchProcessingInterval + this.#defaultQueryConfig = defaultQueryConfig + this.#setupWindowFocusListener() + } + + #setupWindowFocusListener(): void { + // SSR guard - both window and document must exist + if (typeof window === 'undefined' || typeof document === 'undefined') return + + const onFocus = () => this.#onWindowFocus() + + window.addEventListener('focus', onFocus) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') onFocus() + }) + } + + #onWindowFocus(): void { + for (const [, service] of this.#state) { + for (const [queryId, query] of service.queries) { + if (this.#listenerCount(queryId) === 0) continue + if (!this.#getEffectiveConfig(query.config).refetchOnWindowFocus) continue + if (!this.#isStale(query)) continue + if (query.state.isFetching) continue + this.#queue(queryId) + } + } + } + + #getEffectiveConfig(config: QueryConfig): { + retry: number | false + retryDelay: number | RetryDelayFn | undefined + staleTime: number + refetchOnWindowFocus: boolean + } { + return { + retry: config.retry ?? this.#defaultQueryConfig.retry ?? 3, + retryDelay: config.retryDelay ?? this.#defaultQueryConfig.retryDelay, + staleTime: config.staleTime ?? this.#defaultQueryConfig.staleTime ?? 30_000, + refetchOnWindowFocus: + config.refetchOnWindowFocus ?? this.#defaultQueryConfig.refetchOnWindowFocus ?? true, + } + } + + #isStale(query: Query): boolean { + if (query.state.status !== 'success') return true + if (query.fetchedAt === null) return true + const { staleTime } = this.#getEffectiveConfig(query.config) + return staleTime === 0 || Date.now() - query.fetchedAt > staleTime } // ==================== PUBLIC API ==================== @@ -846,6 +979,8 @@ class QueryStore< hasNextPage: false, pageParam: null, } as InfiniteQueryState, + retryCount: 0, + fetchedAt: null, }) } else { service.queries.set(queryId, { @@ -865,6 +1000,8 @@ class QueryStore< isFetching: !config.skip, error: null, }, + retryCount: 0, + fetchedAt: null, }) } }, @@ -888,11 +1025,15 @@ class QueryStore< } } else { const fetchPolicy = (q.config as BaseQueryConfig).fetchPolicy - if ( + const shouldFetch = q.pending || - (q.state.status === 'success' && fetchPolicy === 'swr' && !q.state.isFetching) || + (q.state.status === 'success' && + fetchPolicy === 'swr' && + !q.state.isFetching && + this.#isStale(q)) || (q.state.status === 'error' && !q.state.isFetching) - ) { + + if (shouldFetch) { this.#queue(queryId) } } @@ -1040,8 +1181,45 @@ class QueryStore< const result = await this.#fetch(queryId) this.#fetched({ queryId, result }) } catch (err) { - this.#fetchFailed({ queryId, error: err instanceof Error ? err : new Error(String(err)) }) + await this.#handleFetchError({ + queryId, + error: err instanceof Error ? err : new Error(String(err)), + }) + } + } + + async #handleFetchError({ queryId, error }: { queryId: string; error: Error }): Promise { + const query = this.#getQuery(queryId) + if (!query) { + this.#fetchFailed({ queryId, error }) + return } + + const { retry: maxRetries, retryDelay } = this.#getEffectiveConfig(query.config) + + if (maxRetries !== false && query.retryCount < maxRetries) { + const delay = computeRetryDelay(query.retryCount, retryDelay) + + // Increment retryCount + this.#transactOverService( + queryId, + (service, q) => { + if (q) { + service.queries.set(queryId, { ...q, retryCount: q.retryCount + 1 }) + } + }, + { silent: true }, + ) + + await new Promise(r => setTimeout(r, delay)) + + // Only retry if still has listeners + if (this.#listenerCount(queryId) > 0) { + return this.#queue(queryId) + } + } + + this.#fetchFailed({ queryId, error }) } #fetch(queryId: string): Promise> { @@ -1126,6 +1304,8 @@ class QueryStore< service.queries.set(queryId, { ...query, + retryCount: 0, + fetchedAt: Date.now(), state: { status: 'success' as const, data, @@ -1151,6 +1331,7 @@ class QueryStore< service.queries.set(queryId, { ...query!, + retryCount: 0, // Reset for next attempt state: { status: 'error' as const, data: null, diff --git a/test/figbird.test.tsx b/test/figbird.test.tsx index 6a275bf..2e57537 100644 --- a/test/figbird.test.tsx +++ b/test/figbird.test.tsx @@ -712,7 +712,8 @@ test('useFind error', async t => { const { App, useFind, feathers } = app() function Note() { - const notes = useFind('notes') + // Disable retry to get immediate error + const notes = useFind('notes', { retry: false }) return } @@ -895,7 +896,8 @@ test('refetch only works with active listeners', async t => { let calls = 0 function Note() { - const notes = useFind('notes') + // staleTime: 0 to test immediate refetch on remount + const notes = useFind('notes', { staleTime: 0 }) refetch = notes.refetch return
{notes.status === 'success' ? notes.data[0]?.id : null}
} @@ -1163,7 +1165,8 @@ test('useFind - realtime refetch only with active listeners', async t => { let findCallCount = 0 function Note() { - const notes = useFind('notes', { query: { tag: 'idea' }, realtime: 'refetch' }) + // staleTime: 0 to test immediate refetch on remount + const notes = useFind('notes', { query: { tag: 'idea' }, realtime: 'refetch', staleTime: 0 }) return } const originalFind = feathers.service('notes').find.bind(feathers.service('notes')) @@ -1297,7 +1300,8 @@ test('useFind - fetchPolicy swr', async t => { } function Note() { - const notes = useFind('notes', { query: { tag: 'idea' }, fetchPolicy: 'swr' }) + // staleTime: 0 to test immediate refetch behavior of SWR + const notes = useFind('notes', { query: { tag: 'idea' }, fetchPolicy: 'swr', staleTime: 0 }) return } @@ -1918,12 +1922,14 @@ test('subscribeToStateChanges', async t => { ) } } - // Remove updatedAt fields from all query data + // Remove updatedAt fields from all query data and fetchedAt from queries if (state?.notes && typeof state.notes === 'object' && state.notes !== null) { const notesState = state.notes as Record if (notesState?.queries && typeof notesState.queries === 'object') { Object.values(notesState.queries as Record>).forEach( query => { + // Remove dynamic fetchedAt timestamp + delete query.fetchedAt const data = query?.state && typeof query.state === 'object' ? (query.state as Record).data @@ -2166,7 +2172,8 @@ test('useFind recovers gracefully from errors on refetch', async t => { let refetch: () => void function Notes() { - const notes = useFind('notes') + // Disable retry to control error/success flow manually + const notes = useFind('notes', { retry: false }) refetch = notes.refetch return ( @@ -2360,6 +2367,7 @@ test('allPages handles errors gracefully during pagination', async t => { const notes = useFind('notes', { query: { $limit: 2 }, allPages: true, + retry: false, // Disable retry to get immediate error }) return ( @@ -2646,3 +2654,504 @@ test('realtime events are batched to reduce re-renders', async t => { unmount() }) + +// ============================================================================ +// Retry, staleTime, and refetchOnWindowFocus tests +// ============================================================================ + +test('retry - retries failed requests with exponential backoff', async t => { + const { render, flush, unmount, $ } = dom() + const { App, useFind, feathers } = app() + + let callCount = 0 + const callTimes: number[] = [] + + function Notes() { + const notes = useFind('notes', { + retry: 3, + retryDelay: 50, // Fixed 50ms delay for faster testing + }) + + return ( +
+
{notes.status}
+ {notes.error &&
{notes.error.message}
} + +
+ ) + } + + // Make the service fail first 3 times, then succeed on 4th + const originalFind = feathers.service('notes').find.bind(feathers.service('notes')) + feathers.service('notes').find = async (params?: Record) => { + callTimes.push(Date.now()) + callCount++ + if (callCount <= 3) { + throw new Error('Network error') + } + return originalFind(params) + } + + render( + + + , + ) + + // Wait for retries to complete (3 retries * 50ms delay + some buffer) + await flush(async () => { + await new Promise(resolve => setTimeout(resolve, 300)) + }) + + t.is(callCount, 4, 'Should have made 4 attempts (1 initial + 3 retries)') + t.is($('.status')!.innerHTML, 'success', 'Should succeed after retries') + t.is($('.note')!.innerHTML, 'hello') + + unmount() +}) + +test('retry - gives up after max retries', async t => { + const { render, flush, unmount, $ } = dom() + const { App, useFind, feathers } = app() + + let callCount = 0 + + function Notes() { + const notes = useFind('notes', { + retry: 2, + retryDelay: 20, + }) + + return ( +
+
{notes.status}
+ {notes.error &&
{notes.error.message}
} +
+ ) + } + + // Always fail + feathers.service('notes').find = async () => { + callCount++ + throw new Error('Persistent failure') + } + + render( + + + , + ) + + // Wait for retries to exhaust + await flush(async () => { + await new Promise(resolve => setTimeout(resolve, 150)) + }) + + t.is(callCount, 3, 'Should have made 3 attempts (1 initial + 2 retries)') + t.is($('.status')!.innerHTML, 'error', 'Should show error after exhausting retries') + t.is($('.error')!.innerHTML, 'Persistent failure') + + unmount() +}) + +test('retry - can be disabled with retry: false', async t => { + const { render, flush, unmount, $ } = dom() + const { App, useFind, feathers } = app() + + let callCount = 0 + + function Notes() { + const notes = useFind('notes', { retry: false }) + + return ( +
+
{notes.status}
+ {notes.error &&
{notes.error.message}
} +
+ ) + } + + feathers.service('notes').find = async () => { + callCount++ + throw new Error('Immediate failure') + } + + render( + + + , + ) + + await flush() + + t.is(callCount, 1, 'Should have made only 1 attempt') + t.is($('.status')!.innerHTML, 'error', 'Should show error immediately') + + unmount() +}) + +test('retry - custom retryDelay function receives attempt number', async t => { + const { render, flush, unmount } = dom() + const { App, useFind, feathers } = app() + + const attemptsSeen: number[] = [] + + function Notes() { + const notes = useFind('notes', { + retry: 2, + retryDelay: (attempt: number) => { + attemptsSeen.push(attempt) + return 10 // Fast delay for testing + }, + }) + + return ( +
+
{notes.status}
+
+ ) + } + + feathers.service('notes').find = async () => { + throw new Error('Always fails') + } + + render( + + + , + ) + + await flush(async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + }) + + t.deepEqual(attemptsSeen, [0, 1], 'retryDelay should receive 0-indexed attempt numbers') + + unmount() +}) + +test('retry - stops retrying when component unmounts', async t => { + const { render, flush, unmount } = dom() + const { App, useFind, feathers } = app() + + let callCount = 0 + + function Notes() { + const notes = useFind('notes', { + retry: 5, + retryDelay: 50, + }) + return
{notes.status}
+ } + + feathers.service('notes').find = async () => { + callCount++ + throw new Error('Always fails') + } + + render( + + + , + ) + + // Wait for first call + await flush() + + // Unmount after first retry starts + await new Promise(resolve => setTimeout(resolve, 30)) + unmount() + + const callsAtUnmount = callCount + + // Wait to ensure no more retries happen + await new Promise(resolve => setTimeout(resolve, 200)) + + t.is(callCount, callsAtUnmount, 'Should stop retrying after unmount') +}) + +test('staleTime - fresh data is not refetched on remount', async t => { + const { App, useFind, feathers } = app() + let calls = 0 + + function Note() { + const notes = useFind('notes', { staleTime: 60_000 }) // 1 minute + return
{notes.status === 'success' ? notes.data[0]?.id : null}
+ } + + feathers.service('notes').find = async () => { + calls++ + return { data: [{ id: calls }], limit: 100, skip: 0, total: 1 } + } + + // First mount + const dom1 = dom() + dom1.render( + + + , + ) + + await dom1.flush() + t.is(calls, 1, 'Should fetch on first mount') + t.is(dom1.$('.data')!.innerHTML, '1') + + // Unmount + dom1.unmount() + + // Immediately remount - data should still be fresh + const dom2 = dom() + dom2.render( + + + , + ) + + await dom2.flush() + t.is(calls, 1, 'Should NOT refetch because data is still fresh') + t.is(dom2.$('.data')!.innerHTML, '1', 'Should show cached data') + + dom2.unmount() +}) + +test('staleTime - stale data is refetched on remount', async t => { + const { App, useFind, feathers } = app() + let calls = 0 + + function Note() { + const notes = useFind('notes', { staleTime: 50 }) // 50ms + return
{notes.status === 'success' ? notes.data[0]?.id : null}
+ } + + feathers.service('notes').find = async () => { + calls++ + return { data: [{ id: calls }], limit: 100, skip: 0, total: 1 } + } + + // First mount + const dom1 = dom() + dom1.render( + + + , + ) + + await dom1.flush() + t.is(calls, 1, 'Should fetch on first mount') + + // Unmount + dom1.unmount() + + // Wait for data to become stale + await new Promise(resolve => setTimeout(resolve, 100)) + + // Remount - data should now be stale + const dom2 = dom() + dom2.render( + + + , + ) + + await dom2.flush() + t.is(calls, 2, 'Should refetch because data is stale') + t.is(dom2.$('.data')!.innerHTML, '2', 'Should show new data') + + dom2.unmount() +}) + +test('staleTime: 0 - always refetches on remount (legacy SWR behavior)', async t => { + const { App, useFind, feathers } = app() + let calls = 0 + + function Note() { + const notes = useFind('notes', { staleTime: 0 }) + return
{notes.status === 'success' ? notes.data[0]?.id : null}
+ } + + feathers.service('notes').find = async () => { + calls++ + return { data: [{ id: calls }], limit: 100, skip: 0, total: 1 } + } + + // First mount + const dom1 = dom() + dom1.render( + + + , + ) + + await dom1.flush() + t.is(calls, 1) + + // Unmount + dom1.unmount() + + // Immediate remount + const dom2 = dom() + dom2.render( + + + , + ) + + await dom2.flush() + t.is(calls, 2, 'Should always refetch with staleTime: 0') + + dom2.unmount() +}) + +test('defaultQueryConfig - applies global defaults', async t => { + const { render, flush, unmount, $ } = dom() + + const feathers = createFeathers() + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ + schema, + adapter, + defaultQueryConfig: { + retry: 1, // Global default: 1 retry + retryDelay: 20, // Fast retry for testing + }, + }) + + const { App, useFind } = app({ figbird }) + + let calls = 0 + + function Notes() { + // No retry/staleTime specified - should use global defaults + const notes = useFind('notes') + return ( +
+
{notes.status}
+
{calls}
+
+ ) + } + + // Make service fail first time, succeed second time + const originalFind = feathers.service('notes').find.bind(feathers.service('notes')) + feathers.service('notes').find = async (params?: Record) => { + calls++ + if (calls === 1) { + throw new Error('First failure') + } + return originalFind(params) + } + + render( + + + , + ) + + // Wait for retry + await flush(async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + }) + + t.is(calls, 2, 'Should have retried once (global default retry: 1)') + t.is($('.status')!.innerHTML, 'success', 'Should succeed after retry') + + unmount() +}) + +test('per-query config overrides global defaults', async t => { + const { render, flush, unmount, $ } = dom() + + const feathers = createFeathers() + const adapter = new FeathersAdapter(feathers) + const figbird = new Figbird({ + schema, + adapter, + defaultQueryConfig: { + retry: 5, // Global default: 5 retries + }, + }) + + const { App, useFind } = app({ figbird }) + + let calls = 0 + + function Notes() { + // Override: no retries + const notes = useFind('notes', { retry: false }) + return ( +
+
{notes.status}
+ {notes.error &&
{notes.error.message}
} +
+ ) + } + + feathers.service('notes').find = async () => { + calls++ + throw new Error('Failure') + } + + render( + + + , + ) + + await flush() + + t.is(calls, 1, 'Should not retry because per-query retry: false overrides global') + t.is($('.status')!.innerHTML, 'error') + + unmount() +}) + +// Note: Window focus refetch tests require a real browser environment. +// The refetchOnWindowFocus feature works by listening to window 'focus' and +// document 'visibilitychange' events. In jsdom, these events don't work the +// same way as in a real browser. The feature is tested via: +// - staleTime tests (which test the isStale logic used by focus handler) +// - Integration/E2E tests in a real browser environment + +test('staleTime - default is 30 seconds (prevents rapid refetch)', async t => { + const { App, useFind, feathers } = app() + let calls = 0 + + function Note() { + // No staleTime specified - should use 30s default + const notes = useFind('notes') + return
{notes.status === 'success' ? notes.data[0]?.id : null}
+ } + + feathers.service('notes').find = async () => { + calls++ + return { data: [{ id: calls }], limit: 100, skip: 0, total: 1 } + } + + // First mount + const dom1 = dom() + dom1.render( + + + , + ) + + await dom1.flush() + t.is(calls, 1, 'Should fetch on first mount') + + // Unmount + dom1.unmount() + + // Immediate remount - with 30s default staleTime, should NOT refetch + const dom2 = dom() + dom2.render( + + + , + ) + + await dom2.flush() + t.is(calls, 1, 'Should NOT refetch with default 30s staleTime') + + dom2.unmount() +}) diff --git a/test/snapshots/figbird.test.tsx.md b/test/snapshots/figbird.test.tsx.md index 5d27fdd..61876fe 100644 --- a/test/snapshots/figbird.test.tsx.md +++ b/test/snapshots/figbird.test.tsx.md @@ -47,6 +47,7 @@ Generated by [AVA](https://avajs.dev). dirty: false, pending: false, queryId: 'q/MaxUYg==', + retryCount: 0, state: { data: [ { diff --git a/test/snapshots/figbird.test.tsx.snap b/test/snapshots/figbird.test.tsx.snap index 577e216..ace6de1 100644 Binary files a/test/snapshots/figbird.test.tsx.snap and b/test/snapshots/figbird.test.tsx.snap differ diff --git a/test/use-paginated-find.test.tsx b/test/use-paginated-find.test.tsx index 535225f..863fb17 100644 --- a/test/use-paginated-find.test.tsx +++ b/test/use-paginated-find.test.tsx @@ -755,7 +755,7 @@ test('usePaginatedFind error handling', async t => { const { usePaginatedFind } = createHooks(figbird) function App() { - const { status, error } = usePaginatedFind('api/documents', { limit: 10 }) + const { status, error } = usePaginatedFind('api/documents', { limit: 10, retry: false }) return (