diff --git a/.changeset/fast-delivery-prefetch-retain.md b/.changeset/fast-delivery-prefetch-retain.md new file mode 100644 index 0000000..7a5a507 --- /dev/null +++ b/.changeset/fast-delivery-prefetch-retain.md @@ -0,0 +1,10 @@ +--- +'fetchium': minor +--- + +New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before any component reads them. A component that mounts while one is held reuses its request and renders the data on its first render if it has arrived. Both return a `release` function; `prefetch` lasts 10 seconds by default and `retain` until released. + +New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no data yet, and sends a failed first fetch to the error boundary. + +- A refetch without `debounce` starts on a microtask instead of after `setTimeout(0)`. +- Reading `.value` on a pending or failed query doesn't suspend or throw. The docs said it did. diff --git a/.changeset/should-retry-hook.md b/.changeset/should-retry-hook.md new file mode 100644 index 0000000..be5b0ab --- /dev/null +++ b/.changeset/should-retry-hook.md @@ -0,0 +1,7 @@ +--- +'fetchium': minor +--- + +New optional `shouldRetry(error, attempt, status)` on `QueryClientConfig` and on a query's or mutation's `retry` config. Return `false` to stop retrying an error you know is permanent, such as a 404. + +`status` is the HTTP status when it is known, and `getErrorStatus` is exported. Without the hook, retries work as before. `retry.retries` is now optional. diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index b02089a..98f2c51 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -8,7 +8,7 @@ description: API reference for the fetchium React integration. React hooks for using Fetchium queries in React components. Built on top of Signalium's `useReactive` hook. ```ts -import { useQuery } from 'fetchium/react'; +import { useQuery, useSuspenseQuery } from 'fetchium/react'; ``` --- @@ -39,14 +39,14 @@ React hook for fetching a query. Subscribes the component to the query's reactiv The returned promise object has the following properties: -| Property | Type | Description | -| ------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `QueryResult` | The resolved query result. Reading this while pending triggers React Suspense. Returns a deep clone to avoid accidental mutation of cached entities. | -| `isReady` | `boolean` | `true` once the query has loaded a value at least once. Use for type narrowing on `value`. | -| `isPending` | `boolean` | `true` while the query is loading. Also true during refetches, even if a value already exists. | -| `isResolved` | `boolean` | `true` when the most recent execution resolved successfully. | -| `isRejected` | `boolean` | `true` when the most recent execution failed. | -| `error` | `unknown` | The error if `isRejected` is `true`. | +| Property | Type | Description | +| ------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `QueryResult` | The resolved query result, or `undefined` until the first value arrives. Returns a deep clone to avoid accidental mutation of cached entities. | +| `isReady` | `boolean` | `true` once the query has loaded a value at least once. Use for type narrowing on `value`. | +| `isPending` | `boolean` | `true` while the query is loading. Also true during refetches, even if a value already exists. | +| `isResolved` | `boolean` | `true` when the most recent execution resolved successfully. | +| `isRejected` | `boolean` | `true` when the most recent execution failed. | +| `error` | `unknown` | The error if `isRejected` is `true`. | The resolved `QueryResult` includes pagination helpers: @@ -133,4 +133,39 @@ const SearchResults = component(() => { - `useQuery` is a thin wrapper around Signalium v3's `useReactive`, which is deep-by-default. It returns a **structurally-shared snapshot** of the query result, so memoized children that receive subtrees as props keep stable references when the underlying data is unchanged. - Fetchium registers a custom snapshot for entity proxies so the snapshot walks into entities (instead of returning them by reference), giving you correct re-rendering on entity field changes. - The snapshot is a plain-object copy of the query result to prevent accidental mutation of the entity cache. Use `draft()` from `fetchium` if you need a mutable copy for mutations. -- When used with React Suspense, reading `.value` on a pending query will suspend the component. +- `useQuery` never suspends. Reading `.value` while pending returns `undefined`. Use [`useSuspenseQuery`](#usesuspensequery) to suspend on a cold miss. + +--- + +### `useSuspenseQuery` + +```ts +function useSuspenseQuery( + QueryClass: new () => T, + params?: ExtractQueryParams, +): ReadyReactivePromise>; +``` + +`useQuery` for components under a React `` boundary. It suspends only on a **cold miss**: the query has never produced a value, neither in memory nor, with a synchronous store, in the persisted cache. Anything else renders at once, including a stale value whose refetch is in flight. Refetches never suspend. Because the hook only returns once a value exists, `value` is always defined. + +A cold fetch that fails is thrown to the nearest error boundary. Resetting the boundary (remounting the component) tries the fetch again. + +Changing params to ones with no value yet is a cold miss, so the component suspends. Wrap the change in `startTransition` to keep showing the previous result. + +Call it from a plain function component. Like `useQuery`, it cannot run inside a Signalium `component()`, which is a reactive context. + +```tsx +import { Suspense } from 'react'; +import { useSuspenseQuery } from 'fetchium/react'; + +function UserName({ id }: { id: number }) { + const user = useSuspenseQuery(GetUser, { id }); + return {user.value.name}; +} + +}> + +; +``` + +While suspended, the client keeps the query active, since React discards a suspended render without subscribing to anything. The component's commit takes over. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 4ef56cb..bcf3f80 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,18 +225,19 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| ----------------------- | ----------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | -| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | -| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | -| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | -| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | +| Field | Type | Default | Description | +| ----------------------- | ------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | +| `shouldRetry` | `(error, attempt, status) => boolean` | — | Optional. Whether a failed attempt is retried. Without it, every failed attempt is retried, whatever its status. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). Return `false` to stop retrying errors you know are permanent, such as a 4xx. Per-query override: `retry.shouldRetry`. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | #### Methods @@ -245,7 +246,37 @@ new QueryClient(config: QueryClientConfig) | `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | | `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | | `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | -| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches. | +| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query now and keeps it active for `ttl` ms (default `DEFAULT_PREFETCH_TTL`, 10 s). See below. | +| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until the returned `release` is called, or for `ttl` ms. See below. | +| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches, and releases outstanding leases. | + +#### `prefetch` and `retain` + +Both take a lease: the queries stay active (fetched, subscribed, exempt from GC) without a component reading them. A reader that mounts while the lease is held joins the active query, so it starts no request of its own and renders the data on its first render if it has arrived. Both return an idempotent `release` function. Releasing never interrupts other readers; once the last one goes, the query deactivates and its `gcTime` starts as usual. Calling `release` while a fetch is in flight, with no reader, aborts that fetch. A `ttl` is an upper bound: the lease goes when it runs out even if the fetch is still in flight (offline, or a topic never fulfilled), which aborts the fetch unless a reader has joined it. Call `retain` and `prefetch` from event handlers or effects, not from inside a reactive computation; called from one, they warn in development and keep only the leases from the computation's latest run. + +`prefetch` is for the moment a user commits to a navigation, such as a tap. Call it in the handler, before navigating: + +```ts +function onPressToken(id: string) { + queryClient.prefetch(GetTokenDetail, { id }, { ttl: 5_000 }); + router.push(`/token/${id}`); +} +``` + +With a synchronous store, a cached result is applied inside the call. A fresh one makes no request, and a stale one is refetched, as on any activation. + +`retain` keeps a set of queries warm, for example the queries of a screen that is hidden but likely to come back. `fn` runs immediately, with the client as `QueryClientContext`, and again when what it reads changes. It holds a query when it reads one of the query's fields, or when it returns the query's promise or an array of them: + +```ts +const release = queryClient.retain(() => [ + fetchQuery(GetPortfolio), + fetchQuery(GetPrices, { ids }), +]); +// when the surface is gone for good: +release(); +``` + +Without a `ttl`, a `retain` lease lasts until `release` is called. --- @@ -625,7 +656,7 @@ The `t` object provides a declarative type definition DSL for describing query p | ------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | | `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | -| `debounce` | `number` | `0` | Milliseconds to debounce param-change refetches. | +| `debounce` | `number` | `0` | Milliseconds to debounce refetches. At `0`, a refetch starts on a microtask (invalidation: on the next task). | | `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | | `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | | `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | @@ -660,9 +691,9 @@ The `t` object provides a declarative type definition DSL for describing query p ### Retry types -| Type | Definition | Description | -| ------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `RetryConfig` | `{ retries: number; retryDelay?: (attemptIndex: number) => number }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). | +| Type | Definition | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's `shouldRetry`. | ### Utility types diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index 03b3605..c44362b 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -147,6 +147,22 @@ class GetUser extends RESTQuery { The default retry delay uses exponential backoff --- each successive attempt waits longer than the last (roughly 1s, 2s, 4s, ...). This avoids hammering a struggling server with rapid retries. +Every failed attempt is retried, whatever its status, including `4xx` responses. When you know an error is permanent, opt out with `shouldRetry`, either per query in `retry` or for every query on the `QueryClient`. It receives the error, the attempt index and the attempt's HTTP status when known: the response the adapter received (a REST error response whose body doesn't match `result`), or `status`, `statusCode` or `response.status` on the thrown error. A query's `shouldRetry` overrides the client's. + +```ts +class GetUser extends RESTQuery { + // ... + config = { + retry: { + // A 404 won't change on retry; status is undefined for network errors + shouldRetry: (error, attempt, status) => status !== 404, + }, + }; +} +``` + +`getErrorStatus(error)` reads the status from an error, if you need it elsewhere. + To disable retries entirely: ```ts @@ -295,7 +311,7 @@ if (result.isRejected) { ### Opting into throw behavior -If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- you can read `.value` directly. Reading `.value` on a rejected `ReactivePromise` throws the error, which will propagate up to the nearest error boundary. +If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- throw the error yourself. Reading `.value` does not throw: on a rejected query it holds the last successful value, or `undefined` if there never was one. ```tsx import { ErrorBoundary } from 'react-error-boundary'; @@ -303,13 +319,13 @@ import { ErrorBoundary } from 'react-error-boundary'; function UserProfile({ userId }: { userId: number }) { const result = useQuery(GetUser, { id: userId }); - // This throws if the query is rejected, - // which the ErrorBoundary above will catch - const user = result.value; + // Hand a failure with nothing to show to the ErrorBoundary above + if (result.isRejected && !result.isReady) throw result.error; + if (!result.isReady) return ; return (
-

{user.name}

+

{result.value.name}

); } @@ -323,6 +339,8 @@ function App() { } ``` +`useSuspenseQuery` from `fetchium/react` does this for you: it suspends while a query with no value loads, and throws a failed cold fetch to the error boundary. + This is a conscious opt-in. The default explicit-checking pattern (`isRejected` + `error`) is recommended for most use cases because it gives you the most flexibility. Error boundaries are best reserved for catching truly unexpected failures that shouldn't be handled inline. --- diff --git a/packages/fetchium/plugin/agents/fetchium.md b/packages/fetchium/plugin/agents/fetchium.md index b873c30..2d8189a 100644 --- a/packages/fetchium/plugin/agents/fetchium.md +++ b/packages/fetchium/plugin/agents/fetchium.md @@ -137,7 +137,9 @@ function UserProfile() { } ``` -`useQuery` returns `ReactivePromise` with: `value`, `isReady`, `isPending`, `isResolved`, `isRejected`, `error`. Pass `{ suspended: true/false }` to control whether the query is active. +`useQuery` returns `ReactivePromise` with: `value`, `isReady`, `isPending`, `isResolved`, `isRejected`, `error`. It never suspends; `value` is `undefined` until the first value arrives. `useSuspenseQuery` (same arguments) suspends on a cold miss only, never for a refetch, and throws a failed cold fetch to the error boundary. + +To start a destination's query when the user taps, call `queryClient.prefetch(QueryClass, params, { ttl })` before navigating; the screen's `useQuery` then joins the running query. `queryClient.retain(() => [fetchQuery(A), fetchQuery(B)], { ttl })` keeps a set of queries active until its returned `release()` is called. ## React + Signalium Usage diff --git a/packages/fetchium/src/GcManager.ts b/packages/fetchium/src/GcManager.ts index c3793b7..30196d2 100644 --- a/packages/fetchium/src/GcManager.ts +++ b/packages/fetchium/src/GcManager.ts @@ -62,6 +62,7 @@ export class GcManager { private _nextTickScheduled = false; private _onEvict: (key: number, type: GcKeyType) => void; private _multiplier: number; + private _destroyed = false; constructor(onEvict: (key: number, type: GcKeyType) => void, multiplier: number = 1) { this._onEvict = onEvict; @@ -69,7 +70,7 @@ export class GcManager { } schedule(key: number, gcTime: number, type: GcKeyType): void { - if (gcTime === Infinity) return; + if (gcTime === Infinity || this._destroyed) return; if (gcTime === 0) { const { _nextTickEntries } = this; @@ -111,6 +112,7 @@ export class GcManager { }; destroy(): void { + this._destroyed = true; const { _buckets, _nextTickEntries } = this; for (const bucket of _buckets.values()) { bucket.destroy(); diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index b266ed6..36bd814 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -449,6 +449,7 @@ export class LiveValueInstance { this._value = this._onDelete(this._value, entity ?? entityData); break; } + // Notify even for the same reference: the reducer may mutate in place. this._notifier.notify(); } diff --git a/packages/fetchium/src/MutationResult.ts b/packages/fetchium/src/MutationResult.ts index 234c706..78041fa 100644 --- a/packages/fetchium/src/MutationResult.ts +++ b/packages/fetchium/src/MutationResult.ts @@ -2,12 +2,12 @@ import { task, type ReactiveTask } from 'signalium'; import { ComplexTypeDef, MutationEffects, EntityClassOrTypename } from './types.js'; import { ValidatorDef, getEntityDef } from './typeDefs.js'; import { type QueryClient } from './QueryClient.js'; -import { MutationDefinition } from './mutation.js'; +import { MutationDefinition, type Mutation } from './mutation.js'; import { resolveRetryConfig } from './query.js'; import { parseEntities, ParseContext } from './parseEntities.js'; import { createExecutionContext, reifyValue } from './fieldRef.js'; import { Entity } from './proxy.js'; -import { withRetry } from './retry.js'; +import { getFailedResponseStatus, withRetry } from './retry.js'; /** * Internal mutation manager. Consumers interact with the public `task` property, @@ -112,16 +112,28 @@ export class MutationResultImpl { ); } - return withRetry(async () => { - const abortController = new AbortController(); - const ctx = createExecutionContext( - this.def.captured, - (request ?? {}) as Record, - this.queryClient.getContext(), - ); - - return (await adapter.sendMutation!(ctx, abortController.signal)) as Result; - }, retryConfig); + let attemptCtx: Mutation | undefined; + return withRetry( + async () => { + attemptCtx = undefined; + const abortController = new AbortController(); + const ctx = createExecutionContext( + this.def.captured, + (request ?? {}) as Record, + this.queryClient.getContext(), + ); + attemptCtx = ctx; + + return (await adapter.sendMutation!(ctx, abortController.signal)) as Result; + }, + retryConfig, + undefined, + { + shouldRetry: this.queryClient.shouldRetry, + // Each attempt has a fresh ctx, so a response here is this attempt's. + getAttemptStatus: () => getFailedResponseStatus((attemptCtx as { response?: unknown } | undefined)?.response), + }, + ); } } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index bb2f37b..56c2571 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1,4 +1,4 @@ -import { context, watcher, ReactiveTask, type Context } from 'signalium'; +import { context, signal, watcher, withContexts, ReactivePromise, ReactiveTask, type Context } from 'signalium'; import { hashValue } from 'signalium/utils'; import { EntityDef, @@ -26,6 +26,7 @@ import { ValidatorDef } from './typeDefs.js'; import { ConstraintMatcher, EVENT_SOURCE_FIELD } from './ConstraintMatcher.js'; import { LiveCollectionBinding } from './LiveCollection.js'; import { QueryAdapter, type QueryAdapterClass } from './QueryAdapter.js'; +import type { ShouldRetry } from './retry.js'; import { type QueryContext, type QueryStore, @@ -35,6 +36,8 @@ import { queryKeyFor, } from './query-types.js'; import { SyncQueryStore, MemoryPersistentStore } from './stores/sync.js'; +import type { ExtractType } from './types.js'; +import type { Optionalize, Signalize } from './type-utils.js'; export interface QueryClientConfig { store?: QueryStore; @@ -54,7 +57,19 @@ export interface QueryClientConfig { * Reconnects, `refetch()`, invalidation and a failed last fetch still refetch. Default: 0. */ reactivationGraceMs?: number; - /** Ms. Reactivation refetches started in the same task are spread evenly across this window. Default: 0. */ + /** + * Decides whether a failed query or mutation attempt is retried, for example + * to stop on a 4xx. A query's or mutation's own `retry.shouldRetry` overrides + * it. Default: every failed attempt is retried. + */ + shouldRetry?: ShouldRetry; + /** + * Milliseconds. Reactivation refetches that start in the same task (for + * example every query on a screen that just resumed) are spread evenly + * across this window, in activation order, instead of all starting at once. + * Queries of an adapter that `coalescesRequests` are not spread. Default: 0 + * (all start together). + */ reactivationStaggerMs?: number; /** Foreground/background source. `poll()` stops its timers while the app is inactive. */ activity?: ActivitySource; @@ -62,6 +77,34 @@ export interface QueryClientConfig { pollResumeJitterMs?: number; } +export interface RetainOptions { + /** Milliseconds until the lease releases itself. Omit to hold until `release` is called. */ + ttl?: number; +} + +export interface PrefetchOptions { + /** Milliseconds to keep the query active. Default: `DEFAULT_PREFETCH_TTL` (10 s). */ + ttl?: number; +} + +export const DEFAULT_PREFETCH_TTL = 10_000; + +/** How long a hold outlives its fetch when no reader commits (abandoned tree). */ +const SUSPENSE_HOLD_TTL = 10_000; + +/** React retries a suspended render within a task or two. An error unclaimed by then is dropped. */ +const UNCLAIMED_FAILURE_TTL = 50; + +interface SuspenseHold { + release: () => void; + /** Never rejects. */ + settled: Promise | undefined; + done: boolean; + /** The error has been handed to a render. */ + failed: boolean; + timer: ReturnType | undefined; +} + export { type QueryContext, type ActivitySource, @@ -93,10 +136,21 @@ export class QueryClient { readonly reactivationGraceMs: number; readonly reactivationStaggerMs: number; + /** See `QueryClientConfig.shouldRetry`. */ + readonly shouldRetry: ShouldRetry | undefined; private staggerQueue = new Set>(); private staggerTimer: ReturnType | undefined = undefined; + private leases = new Set<() => void>(); + /** During a lease's first run: queries with a start queued on a microtask, for the lease to run now. */ + private leaseStarts: Set> | undefined = undefined; + /** Leases `retain()` took inside a reactive computation, by computation and run. */ + private reactiveLeases = new WeakMap void> }>(); + private warnedReactiveRetain = false; + /** `useSuspenseQuery` cold-miss leases, by query key. */ + private suspenseHolds = new Map(); + private context!: QueryContext; private typenameRegistry = new Map[]>(); private constraintRegistry = new Map(); @@ -112,6 +166,7 @@ export class QueryClient { evictionMultiplier, reactivationGraceMs, reactivationStaggerMs, + shouldRetry, adapters: _c, networkManager: _n, gcManager: _g, @@ -121,7 +176,8 @@ export class QueryClient { this.store = store; this.reactivationGraceMs = nonNegative(reactivationGraceMs); this.reactivationStaggerMs = nonNegative(reactivationStaggerMs); - // poll() reads `activity` and `pollResumeJitterMs` from the context, via `rest`. + this.shouldRetry = shouldRetry; + // `activity` and `pollResumeJitterMs` ride along in `rest`: poll() reads them from the context. this.context = { ...rest, log: log ?? console, evictionMultiplier }; this.gcManager = config.gcManager ?? @@ -300,9 +356,211 @@ export class QueryClient { this.queryInstances.set(queryKey, queryInstance as QueryInstance); } + // An earlier reader may have activated it with its start still queued. + this.leaseStarts?.add(queryInstance); + return queryInstance.relay; } + // ====================================================== + // Leases (retain / prefetch) + // ====================================================== + + /** + * Keeps the queries `fn` reads or returns active (fetched, subscribed, exempt + * from GC) until `release` is called or `ttl` elapses. `fn` reruns when what + * it reads changes. First-run fetches start before `retain` returns. Call it + * from an event handler or effect, not a reactive computation. + * + * ```ts + * const release = client.retain(() => [fetchQuery(GetTokens), fetchQuery(GetPrices, { ids })]); + * release(); + * ``` + */ + retain(fn: () => unknown, options?: RetainOptions): () => void { + const owner = currentReactiveOwner(); + if (owner === undefined) return this.lease(fn, options, true); + + if (IS_DEV && !this.warnedReactiveRetain) { + this.warnedReactiveRetain = true; + this.context.log?.warn?.( + 'QueryClient.retain() (or prefetch()) was called inside a reactive computation. Call it from an event handler or effect instead.', + ); + } + + const release = this.lease(fn, options, false); + const previous = this.reactiveLeases.get(owner.ref); + if (previous === undefined || previous.run !== owner.run) { + this.reactiveLeases.set(owner.ref, { run: owner.run, releases: [release] }); + // After taking the new lease, so queries both runs read stay active. + if (previous !== undefined) for (const prior of previous.releases) prior(); + } else { + previous.releases.push(release); + } + return release; + } + + /** `startNow: false` leaves first-run fetches on their microtask, for callers inside a render. */ + private lease(fn: () => unknown, options: RetainOptions | undefined, startNow: boolean): () => void { + const w = withContexts([[QueryClientContext, this]], () => watcher(() => holdReturned(fn()))); + const unsubscribe = w.addListener(noop); + + let timer: ReturnType | undefined; + let released = false; + + const release = (): void => { + if (released) return; + released = true; + clearTimeout(timer); + this.leases.delete(release); + unsubscribe(); + }; + + this.leases.add(release); + + const outerStarts = this.leaseStarts; + const starts = startNow ? new Set>() : undefined; + this.leaseStarts = starts; + try { + // Activate now, not on Signalium's next flush. + void w.value; + } catch (error) { + release(); + throw error; + } finally { + this.leaseStarts = outerStarts; + } + + // Outside the watcher's computation now, so adapter code may run. + if (starts !== undefined) { + try { + for (const instance of starts) instance.startPendingNow(); + } catch (error) { + release(); + throw error; + } + } + + const ttl = options?.ttl; + if (ttl !== undefined && Number.isFinite(ttl)) { + timer = setTimeout(release, Math.max(0, ttl)); + } + + return release; + } + + /** + * Starts `QueryClass` now and keeps it active for `ttl` ms (default + * {@link DEFAULT_PREFETCH_TTL}) or until `release` is called, so a reader + * mounting within that window reuses the fetch. The lease ends at `ttl` even + * if the fetch is in flight, which aborts it unless a reader has joined. + */ + prefetch( + QueryClass: new () => T, + params?: Optionalize>>, + options?: PrefetchOptions, + ): () => void { + const def = QueryDefinition.for(QueryClass); + return this.retain(() => this.getQuery(def, params as QueryParams | undefined), { + ttl: options?.ttl ?? DEFAULT_PREFETCH_TTL, + }); + } + + /** + * On a cold miss, returns a promise to suspend on and holds the query active + * until the reader commits. A failed cold fetch returns `error` instead. + * @internal + */ + suspendOnColdMiss( + def: QueryDefinition, + params: QueryParams | undefined, + ): { promise: Promise | undefined; failed?: true; error?: unknown; key: number } { + const key = queryKeyFor(def, params); + const relay = this.getQuery(def, params); + if (relay.isReady) return { promise: undefined, key }; + + let hold = this.suspenseHolds.get(key); + if (hold === undefined) { + const release = this.lease(() => this.getQuery(def, params), undefined, false); + hold = { release, settled: undefined, done: false, failed: false, timer: undefined }; + this.suspenseHolds.set(key, hold); + } + + if (relay.isReady) { + // Hydrated by the activation. Keep the hold until the reader commits. + this.expireSuspenseHold(key, hold); + return { promise: undefined, key }; + } + + if (relay.isPending && hold.done) { + // Another fetch started after ours settled. Rethrowing the resolved + // promise would re-render at once. + clearTimeout(hold.timer); + hold.timer = undefined; + hold.done = false; + hold.failed = false; + hold.settled = undefined; + } + + if (!relay.isPending) { + if (hold.done) { + // Keep the hold one task: React re-renders a throwing component once + // more, and that render must throw the same error, not refetch. + const error = relay.error; + if (!hold.failed) { + hold.failed = true; + clearTimeout(hold.timer); + hold.timer = setTimeout(() => { + if (this.suspenseHolds.get(key) === hold) this.releaseSuspenseHold(key); + }, 0); + } + return { promise: undefined, failed: true, error, key }; + } + // Start now, not after the activation's refetch hop. + this.queryInstances.get(key)?.refetch(); + } + + if (hold.settled === undefined) { + const current = hold; + current.settled = new Promise(resolve => { + relay.then( + () => { + current.done = true; + this.expireSuspenseHold(key, current); + resolve(); + }, + () => { + current.done = true; + // Dropped unless the retried render claims the error first. + clearTimeout(current.timer); + current.timer = setTimeout(() => { + if (this.suspenseHolds.get(key) === current && !current.failed) this.releaseSuspenseHold(key); + }, UNCLAIMED_FAILURE_TTL); + resolve(); + }, + ); + }); + } + + return { promise: hold.settled, key }; + } + + /** Releases a `useSuspenseQuery` hold once its reader has committed. @internal */ + releaseSuspenseHold(key: number): void { + const hold = this.suspenseHolds.get(key); + if (hold === undefined) return; + this.suspenseHolds.delete(key); + clearTimeout(hold.timer); + hold.release(); + } + + private expireSuspenseHold(key: number, hold: SuspenseHold): void { + if (hold.timer !== undefined || this.suspenseHolds.get(key) !== hold) return; + hold.timer = setTimeout(() => { + if (this.suspenseHolds.get(key) === hold) this.releaseSuspenseHold(key); + }, SUSPENSE_HOLD_TTL); + } + /** * Gets or creates a MutationResult for the given mutation definition. * Mutations are cached by their definition ID. @@ -465,7 +723,20 @@ export class QueryClient { // Reactivation stagger // ====================================================== - /** Queues a reactivation refetch. Each task's queue is spread across `reactivationStaggerMs`. */ + /** + * A query queued a start on a microtask. A lease in its first run starts it now. + * @internal + */ + noteDeferredStart(instance: QueryInstance): void { + this.leaseStarts?.add(instance); + } + + /** + * Queues a reactivation refetch. One flush per task spreads the queue evenly + * across `reactivationStaggerMs` in reactivation order, the first starting + * immediately. Queries of an adapter that `coalescesRequests` all start + * immediately. + */ scheduleReactivationRefetch(instance: QueryInstance): void { this.staggerQueue.add(instance); this.staggerTimer ??= setTimeout(this.flushStaggerQueue, 0); @@ -576,6 +847,8 @@ export class QueryClient { } destroy(): void { + for (const key of [...this.suspenseHolds.keys()]) this.releaseSuspenseHold(key); + for (const release of [...this.leases]) release(); clearTimeout(this.staggerTimer); this.staggerTimer = undefined; this.staggerQueue.clear(); @@ -597,6 +870,38 @@ export class QueryClient { export const QueryClientContext: Context = context(undefined); +const noop = (): void => {}; + +/** + * The running reactive computation and its run, if any. Signalium has no public + * API for this, so read a throwaway signal and inspect the consumer it recorded. + */ +function currentReactiveOwner(): { ref: object; run: number } | undefined { + try { + const probe = signal(0); + void probe.value; + const subs = (probe as unknown as { _subs?: Map })._subs; + if (!(subs instanceof Map) || subs.size === 0) return undefined; + const [ref, run] = subs.entries().next().value!; + return typeof ref === 'object' && ref !== null && typeof run === 'number' ? { ref, run } : undefined; + } catch { + return undefined; + } +} + +/** Reads `isReady` on returned query promises so the lease's watcher keeps them active. */ +function holdReturned(returned: unknown): void { + if (Array.isArray(returned)) { + for (const item of returned) holdOne(item); + } else { + holdOne(returned); + } +} + +function holdOne(value: unknown): void { + if (value instanceof ReactivePromise) void value.isReady; +} + function nonNegative(value: unknown): number { return typeof value === 'number' && value > 0 ? value : 0; } diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 20b5550..f28527a 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -21,7 +21,7 @@ import { GcKeyType } from './GcManager.js'; import { Query, QueryDefinition, type ResolvedRetryConfig, resolveRetryConfig } from './query.js'; import { EntityInstance } from './EntityInstance.js'; import { hashValue } from 'signalium/utils'; -import { withRetry } from './retry.js'; +import { getFailedResponseStatus, withRetry, type WithRetryOptions } from './retry.js'; function isThenable(value: MaybePromise): value is Promise { return typeof (value as { then?: unknown } | null | undefined)?.then === 'function'; @@ -53,8 +53,14 @@ export class QueryInstance { private reconnectsAtDeactivate: number = 0; private currentParams: QueryParams | undefined = undefined; private debounceTimer: ReturnType | undefined = undefined; - /** Errored, not aborted. Disables the reactivation grace. */ + /** Bumped to cancel a zero-delay refetch, which runs on a microtask that can't be cleared. */ + private debounceGeneration: number = 0; + private pendingDebouncedRun: (() => void) | undefined = undefined; + private startPending: boolean = false; + private pendingRestart: (() => void) | undefined = undefined; + /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; + private rejectedByConfig: boolean = false; /** Not set by poll(): it delivers by refetching, which moves `updatedAt`. */ private lastPushAt: number | undefined = undefined; /** Lets a queued reactivation refetch detect that another fetch overtook it. */ @@ -68,7 +74,22 @@ export class QueryInstance { private _resolvedOptions: ReadonlySignal<{ config: QueryConfigOptions | undefined; retryConfig: ResolvedRetryConfig; - }> = reactiveSignal(() => this.def.resolveOptions(this._executionCtx!)); + error?: unknown; + }> = reactiveSignal(() => this.resolveOptionsSafely()); + + // A throw here would escape Signalium's flush and stall its scheduler. + private resolveOptionsSafely() { + try { + return this.def.resolveOptions(this._executionCtx!); + } catch (error) { + return { config: undefined, retryConfig: resolveRetryConfig(undefined), error }; + } + } + + private get configError(): unknown { + if (this._executionCtx === undefined) return undefined; + return this._resolvedOptions.value.error; + } get config(): QueryConfigOptions | undefined { if (this._executionCtx === undefined) return undefined; @@ -139,8 +160,7 @@ export class QueryInstance { this._isActive = false; this.reconnectsAtDeactivate = this.queryClient.networkManager.reconnects; - clearTimeout(this.debounceTimer); - this.debounceTimer = undefined; + this.cancelDebounced(); this._abortController?.abort(); this._abortController = undefined; @@ -180,9 +200,24 @@ export class QueryInstance { this.getOrCreateExecutionContext(); + const configError = this.configError; + if (configError !== undefined) { + this.stopSubscription(); + this.rejectedByConfig = true; + this.relayState.setError(configError as Error); + return; + } + + const recovering = this.rejectedByConfig; + this.rejectedByConfig = false; + if (!this.initialized) { this.queryClient.activateQuery(this); this.initialize(); + } else if (recovering) { + if (wasPaused || activating) this.queryClient.activateQuery(this); + this.reconcileSubscription(); + this.runDebounced(); } else if (wasPaused || activating) { this.queryClient.activateQuery(this); @@ -190,14 +225,10 @@ export class QueryInstance { this.reconcileSubscription(); } - // If the relay shows pending but the abort controller is gone, the - // previous fetch was aborted during deactivation. runDebounced() - // would bail out because isPending is still true from the doomed - // promise. Force an immediate refetch so the new setPromise() call - // replaces _promise, causing the stale AbortError rejection to hit - // the `promise !== this._promise` guard and be silently ignored. + // Pending with no abort controller: the fetch was aborted or never started, and its + // promise may never settle. runDebounced() would bail on that isPending. if (this.relayState.isPending && this._abortController === undefined) { - this.runQueryImmediately(); + this.restartAbortedFetch(); } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; // No grace after a reconnect: data may have been missed while offline. @@ -304,11 +335,30 @@ export class QueryInstance { ); } else { this.hydrate(loaded); - // Adapter and app code must not touch signals while the relay is the current consumer. - queueMicrotask(() => this.startSubscriptionAndFetch()); + this.startPending = true; + this.queryClient.noteDeferredStart(this); + queueMicrotask(this.runPendingStart); } } + private runPendingStart = (): void => { + if (!this.startPending) return; + this.startPending = false; + this.startSubscriptionAndFetch(); + }; + + /** + * Runs any microtask-queued start now. Called by a lease outside its reactive read. + * @internal + */ + startPendingNow(): void { + this.runPendingStart(); + const run = this.pendingDebouncedRun; + this.pendingDebouncedRun = undefined; + run?.(); + this.pendingRestart?.(); + } + private discardCorruptCache(error: unknown): void { const qc = this.queryClient; qc.store.deleteQuery(this.storageKey); @@ -331,7 +381,7 @@ export class QueryInstance { private startSubscriptionAndFetch(): void { // If deactivated meanwhile, update() fetches on reactivation. - if (!this._isActive || this.isPaused) { + if (!this._isActive || this.isPaused || this.configError !== undefined) { return; } @@ -400,7 +450,7 @@ export class QueryInstance { this.def.statics.adapterClass, ); - this._resolvedOptions = reactiveSignal(() => this.def.resolveOptions(this._executionCtx!)); + this._resolvedOptions = reactiveSignal(() => this.resolveOptionsSafely()); } return this._executionCtx; @@ -414,12 +464,16 @@ export class QueryInstance { } const ctx = this.getOrCreateExecutionContext(); + // A topic query's send() waits on its subscription, which deactivation may have torn down. + this.reconcileSubscription(); const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; + const attempt = this.attemptStatusTracker(ctx); try { const result = await withRetry( async () => { + attempt.start(); try { const freshData = await adapter.send(ctx, signal); this.updatedAt = Date.now(); @@ -436,6 +490,7 @@ export class QueryInstance { }, this.retryConfig, signal, + attempt.options, ); // An aborted fetch may have been replaced by one that failed. if (!signal.aborted) this.lastFetchFailed = false; @@ -446,10 +501,42 @@ export class QueryInstance { } } + /** + * Sets the new promise now so the aborted one can't settle the relay. The + * request runs adapter code, so it starts outside the read, on a microtask. + */ + private restartAbortedFetch(): void { + this.fetchStarts++; + this._fetchNextAbort?.abort(); + this._fetchNextAbort = undefined; + this._fetchNextPromise = undefined; + const controller = new AbortController(); + this._abortController = controller; + + let resolve!: (value: QueryResult) => void; + let reject!: (error: unknown) => void; + const promise = new Promise>((res, rej) => { + resolve = res; + reject = rej; + }); + const run = (): void => { + if (this.pendingRestart !== run) return; + this.pendingRestart = undefined; + if (controller.signal.aborted) { + reject(controller.signal.reason); + return; + } + this.runQuery().then(resolve, reject); + }; + this.pendingRestart = run; + this.queryClient.noteDeferredStart(this); + queueMicrotask(run); + this.relayState.setPromise(promise); + } + private runQueryImmediately(): void { this.fetchStarts++; - clearTimeout(this.debounceTimer); - this.debounceTimer = undefined; + this.cancelDebounced(); this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); @@ -458,19 +545,47 @@ export class QueryInstance { this.relayState.setPromise(this.runQuery()); } - private runDebounced(extraDelay: number = 0): void { + /** + * With no delay the fetch starts on a microtask (a zero timer can wait a frame on + * React Native). `nextTask` forces a timer so a deactivation flush in the same + * task can cancel the fetch instead of starting and aborting it. + */ + private runDebounced(extraDelay: number = 0, nextTask: boolean = false): void { if (this.relayState.isPending) return; - const debounce = this.config?.debounce ?? 0; + const delay = (this.config?.debounce ?? 0) + extraDelay; // Drops a queued reactivation refetch, which would replace this timer. this.reactivationQueuedAt = -1; - clearTimeout(this.debounceTimer); + this.cancelDebounced(); - this.debounceTimer = setTimeout(() => { - this.debounceTimer = undefined; + if (delay > 0 || nextTask) { + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + this.runQueryImmediately(); + }, delay); + return; + } + + const generation = this.debounceGeneration; + const run = (): void => { + if (generation !== this.debounceGeneration) return; + this.debounceGeneration++; + this.pendingDebouncedRun = undefined; + // Another path started a fetch in the meantime. + if (this.relayState.isPending && this._abortController !== undefined) return; this.runQueryImmediately(); - }, debounce + extraDelay); + }; + this.pendingDebouncedRun = run; + this.queryClient.noteDeferredStart(this); + queueMicrotask(run); + } + + private cancelDebounced(): void { + this.debounceGeneration++; + this.pendingDebouncedRun = undefined; + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; } /** Runs a task after queueing. Skipped if a fetch started since, rather than aborting and repeating it. */ @@ -479,15 +594,18 @@ export class QueryInstance { const queuedAt = this.reactivationQueuedAt; if (this.fetchStarts !== queuedAt) return; - clearTimeout(this.debounceTimer); - this.debounceTimer = setTimeout( - () => { - this.debounceTimer = undefined; - if (this.fetchStarts !== queuedAt) return; - this.runQueryImmediately(); - }, - (this.config?.debounce ?? 0) + delay, - ); + const totalDelay = (this.config?.debounce ?? 0) + delay; + if (totalDelay === 0) { + this.runDebounced(); + return; + } + + this.cancelDebounced(); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + if (this.fetchStarts !== queuedAt) return; + this.runQueryImmediately(); + }, totalDelay); } private notePush = (): void => { @@ -507,7 +625,7 @@ export class QueryInstance { markStale(): void { this.updatedAt = 0; if (this._isActive && !this.isPaused) { - this.runDebounced(); + this.runDebounced(0, true); } } @@ -568,9 +686,11 @@ export class QueryInstance { const ctx = this.getOrCreateExecutionContext(); ctx.resultData = this.rootEntity!.data; const adapter = this.queryClient.getAdapter(def.statics.adapterClass); + const attempt = this.attemptStatusTracker(ctx); return withRetry( async () => { + attempt.start(); const freshData = await adapter.sendNext!(ctx, signal); this.updatedAt = Date.now(); @@ -581,9 +701,29 @@ export class QueryInstance { }, this.retryConfig, signal, + attempt.options, ); } + /** + * Reports the status of `ctx.response` when the failed attempt set it, for a + * REST error response whose body failed validation and so carries no status. + */ + private attemptStatusTracker(ctx: Query): { start: () => void; options: WithRetryOptions } { + const holder = ctx as unknown as { response?: unknown }; + let responseBefore: unknown; + return { + start: () => { + responseBefore = holder.response; + }, + options: { + shouldRetry: this.queryClient.shouldRetry, + getAttemptStatus: () => + holder.response !== responseBefore ? getFailedResponseStatus(holder.response) : undefined, + }, + }; + } + // ====================================================== // Internal computed properties // ====================================================== diff --git a/packages/fetchium/src/__tests__/identical-redelivery.test.ts b/packages/fetchium/src/__tests__/identical-redelivery.test.ts new file mode 100644 index 0000000..5cb4b7a --- /dev/null +++ b/packages/fetchium/src/__tests__/identical-redelivery.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { EntityInstance } from '../EntityInstance.js'; +import { LiveCollectionBinding } from '../LiveCollection.js'; +import { QueryClient } from '../QueryClient.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { TopicQuery } from '../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; +import type { MutationEvent, QueryPromise } from '../types.js'; +import { testWithClient, setupTestClient } from './utils.js'; + +// Re-delivering data the store already holds must not notify, bump versions, +// write, or notify live collections. A real change notifies exactly once. + +function recordNotifies() { + const notified: string[] = []; + const original = EntityInstance.prototype.notify; + const spy = vi.spyOn(EntityInstance.prototype, 'notify').mockImplementation(function (this: EntityInstance) { + notified.push(`${this.typename}:${this.id}`); + original.call(this); + }); + return { notified, restore: () => spy.mockRestore() }; +} + +function savesOf(spy: ReturnType, typename: string) { + return spy.mock.calls.filter(([, value]) => (value as Record)?.__typename === typename); +} + +class Owner extends Entity { + __typename = t.typename('Owner'); + id = t.id; + name = t.string; +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + walletId = t.string; + symbol = t.string; + price = t.number; + issued = t.format('date-time'); + metadata = t.object({ logo: t.string, tags: t.array(t.string) }); + owner = t.entity(Owner); +} + +class Wallet extends Entity { + __typename = t.typename('Wallet'); + id = t.id; + tokens = t.liveArray(Token, { constraints: { walletId: (this as unknown as { id: string }).id } }); + tokenCount = t.liveValue(t.number, Token, { + constraints: { walletId: (this as unknown as { id: string }).id }, + onCreate: (v: number) => v + 1, + onUpdate: (v: number) => v, + onDelete: (v: number) => v - 1, + }); +} + +function token(i: number, overrides: { price?: number; owner?: string } = {}) { + return { + __typename: 'Token', + id: `tok-${i}`, + walletId: 'w-1', + symbol: `SYM${i}`, + price: overrides.price ?? i, + issued: '2026-01-01T00:00:00.000Z', + metadata: { logo: `logo-${i}.png`, tags: ['defi', 'l1'] }, + owner: { __typename: 'Owner', id: 'o-1', name: overrides.owner ?? 'Ann' }, + }; +} + +function wallet(count: number, override?: (i: number) => { price?: number; owner?: string }) { + return { + wallet: { + __typename: 'Wallet', + id: 'w-1', + tokenCount: count, + tokens: Array.from({ length: count }, (_, i) => token(i, override?.(i))), + }, + }; +} + +const tokenKey = (i: number) => hashValue(['Token', `tok-${i}`]); +const ownerKey = hashValue(['Owner', 'o-1']); +const walletKey = hashValue(['Wallet', 'w-1']); + +function versions(client: QueryClient, keys: number[]) { + return keys.map(k => client.entityMap.getEntity(k)!.version); +} + +function liveNotifies(client: QueryClient) { + const data = client.entityMap.getEntity(walletKey)!.data; + const tokens = data.tokens as LiveCollectionBinding; + const count = data.tokenCount as LiveCollectionBinding; + return { + tokens: vi.spyOn((tokens.instance as unknown as { _notifier: { notify(): void } })._notifier, 'notify'), + count: vi.spyOn((count.instance as unknown as { _notifier: { notify(): void } })._notifier, 'notify'), + }; +} + +class GetWallet extends RESTQuery { + path = '/wallet'; + result = { wallet: t.entity(Wallet) }; +} + +describe('Identical re-delivery', () => { + const getClient = setupTestClient(); + let recorder: ReturnType | undefined; + + afterEach(() => { + recorder?.restore(); + recorder = undefined; + vi.restoreAllMocks(); + }); + + async function loadWallet(count: number) { + const tc = getClient(); + tc.mockFetch.get('/wallet', wallet(count)); + let query!: QueryPromise; + await testWithClient(tc.client, async () => { + query = fetchQuery(GetWallet); + await query; + }); + return { ...tc, query }; + } + + describe('(a) query result', () => { + it('is a no-op when a refetch re-delivers identical data', async () => { + const { client, mockFetch, store, query } = await loadWallet(3); + const keys = [walletKey, ownerKey, tokenKey(0), tokenKey(1), tokenKey(2)]; + const before = versions(client, keys); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + await testWithClient(client, async () => { + mockFetch.get('/wallet', wallet(3)); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + expect(live.count).not.toHaveBeenCalled(); + }); + + it('(d) notifies only the changed parent when its nested entity is unchanged', async () => { + const { client, mockFetch, store, query } = await loadWallet(2); + const [ownerBefore, tok0Before, tok1Before] = versions(client, [ownerKey, tokenKey(0), tokenKey(1)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + await testWithClient(client, async () => { + mockFetch.get( + '/wallet', + wallet(2, i => (i === 1 ? { price: 99 } : {})), + ); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + }); + + expect(recorder.notified).toEqual(['Token:tok-1']); + expect(versions(client, [ownerKey, tokenKey(0), tokenKey(1)])).toEqual([ownerBefore, tok0Before, tok1Before + 1]); + expect(savesOf(saveEntity, 'Token')).toHaveLength(1); + expect(savesOf(saveEntity, 'Owner')).toHaveLength(0); + expect(savesOf(saveEntity, 'Wallet')).toHaveLength(0); + expect(live.tokens).not.toHaveBeenCalled(); + expect(live.count).not.toHaveBeenCalled(); + }); + }); + + describe('(b) mutation event', () => { + it('is a no-op for a full update carrying identical data, nested values included', async () => { + const { client, store } = await loadWallet(2); + const keys = [walletKey, ownerKey, tokenKey(0), tokenKey(1)]; + const before = versions(client, keys); + const metadata = client.entityMap.getEntity(tokenKey(0))!.data.metadata; + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: token(0) }); + client.applyMutationEvent({ type: 'update', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + // The live value's reducer may mutate in place, so it still notifies. + expect(live.count).toHaveBeenCalledTimes(1); + + // A `create` for an entity the store already holds is the same no-op. + client.applyMutationEvent({ type: 'create', typename: 'Token', data: token(0) }); + client.applyMutationEvent({ type: 'create', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + expect(client.entityMap.getEntity(tokenKey(0))!.data.metadata).toBe(metadata); + }); + + it('notifies exactly once for a real field change', async () => { + const { client, store } = await loadWallet(2); + const [ownerBefore, tokBefore] = versions(client, [ownerKey, tokenKey(0)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + recorder = recordNotifies(); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: token(0, { price: 42 }) }); + + expect(recorder.notified).toEqual(['Token:tok-0']); + expect(versions(client, [ownerKey, tokenKey(0)])).toEqual([ownerBefore, tokBefore + 1]); + expect(savesOf(saveEntity, 'Token')).toHaveLength(1); + expect(savesOf(saveEntity, 'Owner')).toHaveLength(0); + expect(client.entityMap.getEntity(tokenKey(0))!.data.price).toBe(42); + }); + + it('(d) notifies only the nested entity that changed inside an unchanged parent', async () => { + const { client, store } = await loadWallet(2); + const [ownerBefore, tokBefore] = versions(client, [ownerKey, tokenKey(0)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + recorder = recordNotifies(); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: token(0, { owner: 'Bea' }) }); + + expect(recorder.notified).toEqual(['Owner:o-1']); + expect(versions(client, [ownerKey, tokenKey(0)])).toEqual([ownerBefore + 1, tokBefore]); + expect(savesOf(saveEntity, 'Owner')).toHaveLength(1); + expect(savesOf(saveEntity, 'Token')).toHaveLength(0); + }); + }); +}); + +class MockTopicAdapter extends TopicQueryAdapter { + snapshots = new Map(); + + subscribe(topic: string): void { + this.fulfillTopic(topic, this.snapshots.get(topic)); + } + + unsubscribe(topic: string): void { + this.clearTopic(topic); + } + + emit(event: MutationEvent): void { + this.sendMutationEvent(event); + } +} + +class WalletTopic extends TopicQuery { + static override adapter = MockTopicAdapter; + topic = 'wallet:w-1'; + result = { wallet: t.entity(Wallet) }; +} + +describe('(c) topic events', () => { + let client: QueryClient; + let adapter: MockTopicAdapter; + let store: SyncQueryStore; + let recorder: ReturnType | undefined; + + afterEach(() => { + recorder?.restore(); + recorder = undefined; + vi.restoreAllMocks(); + client?.destroy(); + }); + + async function loadTopic(count: number) { + store = new SyncQueryStore(new MemoryPersistentStore()); + adapter = new MockTopicAdapter(); + adapter.snapshots.set('wallet:w-1', wallet(count)); + client = new QueryClient({ store, adapters: [adapter] } as never); + await testWithClient(client, async () => { + const query = fetchQuery(WalletTopic); + await query; + expect((query.value as unknown as { wallet: { tokens: unknown[] } }).wallet.tokens).toHaveLength(count); + }); + } + + it('is a no-op when a reconnect re-snapshots every entity as topic events', async () => { + await loadTopic(3); + const keys = [walletKey, ownerKey, tokenKey(0), tokenKey(1), tokenKey(2)]; + const before = versions(client, keys); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + for (let i = 0; i < 3; i++) adapter.emit({ type: 'update', typename: 'Token', data: token(i) }); + adapter.emit({ type: 'update', typename: 'Wallet', data: { id: 'w-1' } }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + // The live value's reducer notifies once per Token event. + expect(live.count).toHaveBeenCalledTimes(3); + }); + + it('notifies exactly once for a topic event that changes a field', async () => { + await loadTopic(2); + const [walletBefore, tokBefore] = versions(client, [walletKey, tokenKey(1)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + adapter.emit({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 7 } }); + + expect(recorder.notified).toEqual(['Token:tok-1']); + expect(versions(client, [walletKey, tokenKey(1)])).toEqual([walletBefore, tokBefore + 1]); + expect(savesOf(saveEntity, 'Token')).toHaveLength(1); + expect(live.tokens).not.toHaveBeenCalled(); + expect(client.entityMap.getEntity(tokenKey(1))!.data.price).toBe(7); + }); +}); diff --git a/packages/fetchium/src/__tests__/live-value.test.ts b/packages/fetchium/src/__tests__/live-value.test.ts index 3bd4455..f6cc650 100644 --- a/packages/fetchium/src/__tests__/live-value.test.ts +++ b/packages/fetchium/src/__tests__/live-value.test.ts @@ -241,6 +241,57 @@ describe('LiveValue', () => { // ============================================================ describe('entity-level liveValue lifecycle', () => { + it('notifies when a reducer mutates the value in place and returns it', async () => { + const { client, mockFetch } = getClient(); + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + listId = t.string; + } + + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + ids = t.liveValue(t.array(t.string), Item, { + constraints: { listId: (this as any).id }, + onCreate: (v: string[], item: any) => { + v.push(item.id); + return v; + }, + onUpdate: (v: string[]) => v, + onDelete: (v: string[]) => v, + }); + } + + class GetList extends RESTQuery { + params = { id: t.id }; + path = `/list/${this.params.id}`; + result = { list: t.entity(List) }; + } + + mockFetch.get('/list/[id]', { list: { __typename: 'List', id: '1', ids: [] } }); + + let length!: () => number; + await testWithClient(client, async () => { + const relay = fetchQuery(GetList, { id: '1' }); + await relay; + const list = relay.value!.list; + length = reactive(() => (list.ids as string[]).length); + expect(length()).toBe(0); + }); + + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: 'x', listId: '1' }, + }); + await sleep(5); + + await testWithClient(client, async () => { + expect(length()).toBe(1); + }); + }); + it('should increment on create and decrement on delete via applyEntityData', async () => { const { client, mockFetch } = getClient(); class Item extends Entity { diff --git a/packages/fetchium/src/__tests__/read-path-latency.test.ts b/packages/fetchium/src/__tests__/read-path-latency.test.ts new file mode 100644 index 0000000..f92713a --- /dev/null +++ b/packages/fetchium/src/__tests__/read-path-latency.test.ts @@ -0,0 +1,194 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { describe, it, expect, afterEach } from 'vitest'; +import { signal, watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { t } from '../typeDefs.js'; +import { fetchQuery } from '../query.js'; +import { createMockFetch, sleep } from './utils.js'; + +// Latency in macrotask turns: each turn drains microtasks, checks, then yields one setTimeout(0). +// Turn 0 means no timer ran. On React Native a zero timer can wait a frame. + +async function flushMicrotasks(count = 30): Promise { + for (let i = 0; i < count; i++) await Promise.resolve(); +} + +async function turnsUntil(cond: () => boolean, max = 10): Promise { + for (let turn = 0; turn <= max; turn++) { + await flushMicrotasks(); + if (cond()) return turn; + await new Promise(resolve => setTimeout(resolve, 0)); + } + return Infinity; +} + +class GetItem extends RESTQuery { + path = '/item'; + result = { n: t.number }; + config = { staleTime: 0 }; +} + +class GetFreshItem extends RESTQuery { + path = '/fresh'; + result = { n: t.number }; + config = { staleTime: 60_000 }; +} + +class GetUser extends RESTQuery { + params = { id: t.number }; + path = `/users/${this.params.id}`; + result = { n: t.number }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +function setup() { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ n: ++n })); + mockFetch.get('/fresh', () => ({ n: ++n })); + mockFetch.get('/users/[id]', () => ({ n: ++n })); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return { client, mockFetch }; +} + +function watchNow(client: QueryClient, read: () => T): { w: { value: T }; unsub: () => void } { + const w = withContexts([[QueryClientContext, client]], () => watcher(read)); + const unsub = w.addListener(() => {}); + w.value; + return { w: w as { value: T }, unsub }; +} + +async function loadThenDeactivate(client: QueryClient, Q: new () => RESTQuery): Promise { + const { unsub } = watchNow(client, () => fetchQuery(Q as any).value); + await sleep(10); + unsub(); + // Let Signalium's deactivation flush run. + await sleep(10); +} + +describe('read-path latency (macrotask turns)', () => { + it('cold miss: the first fetch starts without a macrotask', async () => { + const { client, mockFetch } = setup(); + const started = performance.now(); + const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); + const turns = await turnsUntil(() => mockFetch.calls.length === 1); + console.log(`[latency] cold miss -> fetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`); + expect(turns).toBe(0); + unsub(); + }); + + it('in-memory cache hit: the value is readable in the activating read', async () => { + const { client, mockFetch } = setup(); + await loadThenDeactivate(client, GetFreshItem); + expect(mockFetch.calls).toHaveLength(1); + + const { w, unsub } = watchNow(client, () => fetchQuery(GetFreshItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('stale reactivation: the refetch starts without a macrotask', async () => { + const { client, mockFetch } = setup(); + await loadThenDeactivate(client, GetItem); + expect(mockFetch.calls).toHaveLength(1); + + const started = performance.now(); + const { w, unsub } = watchNow(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + const turns = await turnsUntil(() => mockFetch.calls.length === 2); + console.log( + `[latency] stale reactivation -> refetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, + ); + expect(turns).toBe(0); + unsub(); + }); + + it('staggered reactivation: the first refetch starts in the stagger flush', async () => { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ n: ++n })); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + reactivationStaggerMs: 300, + }); + clients.push(client); + await loadThenDeactivate(client, GetItem); + + const started = performance.now(); + const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); + const turns = await turnsUntil(() => mockFetch.calls.length === 2); + console.log( + `[latency] staggered reactivation -> first refetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, + ); + // One timer for the stagger flush. + expect(turns).toBe(1); + unsub(); + }); + + it('Signal param change: the new fetch starts in the flush that applies it', async () => { + const { client, mockFetch } = setup(); + const id = signal(1); + const { w, unsub } = watchNow(client, () => fetchQuery(GetUser, { id }).value); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + const started = performance.now(); + id.value = 2; + w.value; + const turns = await turnsUntil(() => mockFetch.calls.length === 2); + console.log( + `[latency] param change -> fetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, + ); + // One timer for Signalium's flush. The fetch starts on its microtask. + expect(turns).toBe(1); + expect(mockFetch.calls[1].url).toContain('/users/2'); + unsub(); + }); + + it('coalesces zero-delay refetches requested in the same task', async () => { + const { client, mockFetch } = setup(); + const id = signal(1); + const { w, unsub } = watchNow(client, () => fetchQuery(GetUser, { id }).value); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + id.value = 2; + w.value; + id.value = 3; + w.value; + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + expect(mockFetch.calls[1].url).toContain('/users/3'); + unsub(); + }); + + it('a deactivation in the same task aborts the zero-delay refetch', async () => { + const { client, mockFetch } = setup(); + await loadThenDeactivate(client, GetItem); + mockFetch.reset(); + mockFetch.get('/item', { n: 2 }, { delay: 30 }); + const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); + unsub(); + await sleep(10); + // Deactivation lands a timer after the refetch's microtask started it. + // Accepted to avoid a timer on every reactivation. + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(30); + }); +}); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts new file mode 100644 index 0000000..be52a9c --- /dev/null +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -0,0 +1,736 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { reactive, signal, watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext, DEFAULT_PREFETCH_TTL } from '../QueryClient.js'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { t } from '../typeDefs.js'; +import { fetchQuery, QueryDefinition } from '../query.js'; +import { QueryInstance } from '../QueryResult.js'; +import type { MutationEvent } from '../types.js'; +import { GcManager, type GcKeyType } from '../GcManager.js'; +import { NetworkManager } from '../NetworkManager.js'; +import { createMockFetch, sleep } from './utils.js'; + +async function flushMicrotasks(count = 30): Promise { + for (let i = 0; i < count; i++) await Promise.resolve(); +} + +class GetItem extends RESTQuery { + path = '/item'; + result = { n: t.number }; + config = { staleTime: 0 }; +} + +class GetOther extends RESTQuery { + path = '/other'; + result = { n: t.number }; + config = { staleTime: 0 }; +} + +class GetUser extends RESTQuery { + params = { id: t.number }; + path = `/users/${this.params.id}`; + result = { n: t.number }; +} + +class GetFreshUser extends RESTQuery { + params = { id: t.number }; + path = `/users/${this.params.id}`; + result = { n: t.number }; + config = { staleTime: 60_000 }; +} + +let subscribed = 0; +let unsubscribed = 0; + +class GetStreamed extends RESTQuery { + path = '/streamed'; + result = { n: t.number }; + config = { + subscribe: (_onEvent: (event: MutationEvent) => void) => { + subscribed++; + return () => { + unsubscribed++; + }; + }, + }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; + subscribed = 0; + unsubscribed = 0; +}); + +class GetIdentity extends RESTQuery { + path = '/identity'; + result = { id: t.number }; +} + +class GetHoldings extends RESTQuery { + params = { owner: t.number }; + path = `/holdings/${this.params.owner}`; + result = { n: t.number }; +} + +function setup( + kv: MemoryPersistentStore = new MemoryPersistentStore(), + options: { reactivationStaggerMs?: number } = {}, +) { + const mockFetch = createMockFetch(); + let n = 0; + for (const path of ['/item', '/other', '/users/[id]', '/streamed', '/holdings/[owner]']) { + mockFetch.get(path, () => ({ n: ++n })); + } + mockFetch.get('/identity', { id: 42 }); + const client = new QueryClient({ + ...options, + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return { client, mockFetch, kv }; +} + +function mountReader(client: QueryClient, read: () => T): { w: { value: T }; unsub: () => void } { + const w = withContexts([[QueryClientContext, client]], () => watcher(read)); + const unsub = w.addListener(() => {}); + w.value; + return { w: w as { value: T }, unsub }; +} + +describe('client.prefetch()', () => { + it('issues the request before returning', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('issues the request ahead of a render already queued on the microtask queue', async () => { + const { client, mockFetch } = setup(); + let callsAtRender = -1; + // React queues its sync render for a press as a microtask before the handler's prefetch. + queueMicrotask(() => { + callsAtRender = mockFetch.calls.length; + }); + client.prefetch(GetItem); + await flushMicrotasks(); + expect(callsAtRender).toBe(1); + }); + + it('a reader activating a query leaves its fetch for the microtask, as before', async () => { + const { client, mockFetch } = setup(); + const { unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(mockFetch.calls).toHaveLength(0); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('starts a query a reader activated earlier in the same task, once', async () => { + const { client, mockFetch } = setup(); + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(mockFetch.calls).toHaveLength(0); + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + expect(w.value).toMatchObject({ n: 1 }); + unsub(); + }); + + it('a reader mounting in the same task joins the request, with no second one', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 5 }, { delay: 20 }); + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toBeUndefined(); + await sleep(40); + expect(w.value).toMatchObject({ n: 5 }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + unsub(); + }); + + it('refetches a stale, inactive query before returning', async () => { + const { client, mockFetch } = setup(); + const release = client.prefetch(GetItem); + await sleep(10); + release(); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('leaves a stale reactivation to the stagger when reactivationStaggerMs is set', async () => { + const { client, mockFetch } = setup(undefined, { reactivationStaggerMs: 100 }); + const release = client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + await sleep(10); + release(); + await sleep(10); + + client.prefetch(GetItem); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('a reader mounting after the data arrives renders it on its first read, with no second request', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetItem, undefined, { ttl: 1_000 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + // Still active, so mounting is not a reactivation. + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('a reader mounting while the fetch is in flight joins it', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 7 }, { delay: 30 }); + client.prefetch(GetItem, undefined, { ttl: 1_000 }); + await sleep(5); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toBeUndefined(); + await sleep(50); + expect(w.value).toMatchObject({ n: 7 }); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('passes params, and keys on them', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetUser, { id: 4 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].url).toContain('/users/4'); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetUser, { id: 4 }).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('serves fresh cached data from a synchronous store without a request', async () => { + const kv = new MemoryPersistentStore(); + const seed = setup(kv); + seed.client.prefetch(GetFreshUser, { id: 1 }); + await sleep(10); + expect(seed.mockFetch.calls).toHaveLength(1); + seed.client.destroy(); + + const { client, mockFetch } = setup(kv); + client.prefetch(GetFreshUser, { id: 1 }); + expect(client.getQuery(QueryDefinition.for(GetFreshUser), { id: 1 }).value).toMatchObject({ n: 1 }); + expect(mockFetch.calls).toHaveLength(0); + await sleep(10); + expect(mockFetch.calls).toHaveLength(0); + }); + + it('expires after ttl: the query deactivates, and a later mount is a reactivation', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetItem, undefined, { ttl: 20 }); + await sleep(60); + expect(mockFetch.calls).toHaveLength(1); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + unsub(); + }); + + it('a ttl that runs out mid-fetch releases the lease, aborting the fetch with no reader', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/streamed', { n: 9 }, { delay: 40 }); + client.prefetch(GetStreamed, undefined, { ttl: 10 }); + await sleep(25); + expect(unsubscribed).toBe(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(40); + }); + + it('a ttl that runs out mid-fetch leaves a reader that joined the fetch running', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/streamed', { n: 9 }, { delay: 40 }); + client.prefetch(GetStreamed, undefined, { ttl: 10 }); + await sleep(5); + const { w, unsub } = mountReader(client, () => fetchQuery(GetStreamed).value); + await sleep(60); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + expect(w.value).toMatchObject({ n: 9 }); + unsub(); + }); + + it('a ttl caps a lease whose fetch never settles', async () => { + vi.useFakeTimers(); + try { + const networkManager = new NetworkManager(false); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: createMockFetch() as any, baseUrl: 'http://localhost' })], + networkManager, + }); + clients.push(client); + // Offline: the relay stays pending. + client.prefetch(GetStreamed, undefined, { ttl: 1_000 }); + await vi.advanceTimersByTimeAsync(10); + expect(client.getQuery(QueryDefinition.for(GetStreamed), undefined).isPending).toBe(true); + await vi.advanceTimersByTimeAsync(2_000); + expect((client as unknown as { leases: Set }).leases.size).toBe(0); + client.destroy(); + clients = []; + await vi.advanceTimersByTimeAsync(1_000); + } finally { + vi.useRealTimers(); + } + }); + + it('destroy() clears the ttl timer', async () => { + vi.useFakeTimers(); + try { + const { client } = setup(); + client.prefetch(GetItem, undefined, { ttl: 60_000 }); + await vi.advanceTimersByTimeAsync(20); + client.destroy(); + clients = []; + await vi.advanceTimersByTimeAsync(50); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('defaults ttl to DEFAULT_PREFETCH_TTL', () => { + expect(DEFAULT_PREFETCH_TTL).toBe(10_000); + }); + + it('release() while a reader is mounted leaves the reader running', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 3 }, { delay: 30 }); + const release = client.prefetch(GetItem); + await sleep(5); + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + release(); + release(); + await sleep(50); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + expect(w.value).toMatchObject({ n: 3 }); + unsub(); + }); + + it('release() with no reader aborts an in-flight fetch', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 3 }, { delay: 30 }); + const release = client.prefetch(GetItem); + await sleep(5); + release(); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(40); + }); +}); + +describe('client.retain()', () => { + it('issues the first request of a chained lease before returning, and the next when its input arrives', async () => { + const { client, mockFetch } = setup(); + const release = client.retain(() => { + const identity = fetchQuery(GetIdentity); + if (!identity.isReady) return identity; + return fetchQuery(GetHoldings, { owner: identity.value.id }); + }); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/identity']); + await sleep(10); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/identity', '/holdings/42']); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetHoldings, { owner: 42 }).value); + expect(w.value).toMatchObject({ n: expect.any(Number) }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + unsub(); + release(); + }); + + it('issues both requests of a chained lease before returning when the first is cached and fresh', async () => { + const kv = new MemoryPersistentStore(); + const seed = setup(kv); + seed.client.retain(() => fetchQuery(GetFreshUser, { id: 1 })); + await sleep(10); + seed.client.destroy(); + + const { client, mockFetch } = setup(kv); + const release = client.retain(() => { + const user = fetchQuery(GetFreshUser, { id: 1 }); + if (!user.isReady) return user; + return fetchQuery(GetHoldings, { owner: user.value.n }); + }); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/holdings/1']); + release(); + }); + + it('holds the query promises the callback returns', async () => { + const { client, mockFetch } = setup(); + const release = client.retain(() => [fetchQuery(GetItem), fetchQuery(GetOther)]); + await sleep(10); + expect(mockFetch.calls.map(c => new URL(c.url).pathname).sort()).toEqual(['/item', '/other']); + + const { w, unsub } = mountReader(client, () => [fetchQuery(GetItem).value, fetchQuery(GetOther).value]); + expect(w.value).toEqual([expect.objectContaining({ n: expect.any(Number) }), expect.any(Object)]); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + unsub(); + release(); + }); + + it('holds queries whose fields the callback reads', async () => { + const { client, mockFetch } = setup(); + const release = client.retain(() => { + fetchQuery(GetItem).isReady; + }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + release(); + }); + + it('keeps a subscription running until released', async () => { + const { client } = setup(); + const release = client.retain(() => fetchQuery(GetStreamed)); + await sleep(10); + expect(subscribed).toBe(1); + + const reader = mountReader(client, () => fetchQuery(GetStreamed).value); + reader.unsub(); + await sleep(10); + expect(subscribed).toBe(1); + expect(unsubscribed).toBe(0); + + release(); + await sleep(10); + expect(unsubscribed).toBe(1); + }); + + it('follows Signal params', async () => { + const { client, mockFetch } = setup(); + const id = signal(1); + const release = client.retain(() => fetchQuery(GetUser, { id })); + await sleep(10); + id.value = 2; + await sleep(20); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/users/1', '/users/2']); + release(); + }); + + it('without ttl, holds until released', async () => { + const { client } = setup(); + const release = client.retain(() => fetchQuery(GetStreamed)); + await sleep(50); + expect(unsubscribed).toBe(0); + release(); + await sleep(10); + expect(unsubscribed).toBe(1); + }); + + it('with ttl, releases itself', async () => { + const { client } = setup(); + client.retain(() => fetchQuery(GetStreamed), { ttl: 20 }); + await sleep(10); + expect(subscribed).toBe(1); + await sleep(40); + expect(unsubscribed).toBe(1); + }); + + it('destroy() releases outstanding leases', async () => { + const { client } = setup(); + client.retain(() => fetchQuery(GetStreamed)); + await sleep(10); + client.destroy(); + await sleep(10); + expect(unsubscribed).toBe(1); + }); + + it('destroy() leaves no GC timer behind for the queries it releases', async () => { + const created: unknown[] = []; + const cleared = new Set(); + const realSetInterval = globalThis.setInterval; + const realClearInterval = globalThis.clearInterval; + vi.spyOn(globalThis, 'setInterval').mockImplementation(((fn: () => void, ms?: number) => { + const id = realSetInterval(fn, ms); + created.push(id); + return id; + }) as typeof setInterval); + vi.spyOn(globalThis, 'clearInterval').mockImplementation(((id: ReturnType) => { + cleared.add(id); + realClearInterval(id); + }) as typeof clearInterval); + + try { + const { client } = setup(); + // A window-less environment gets the no-op GcManager. + (client as unknown as { gcManager: GcManager }).gcManager = new GcManager( + (client as unknown as { handleEviction: (key: number, type: GcKeyType) => void }).handleEviction, + 1, + ); + client.retain(() => fetchQuery(GetItem)); + await sleep(20); + client.destroy(); + clients = []; + // The released query deactivates on Signalium's next flush, after destroy(). + await sleep(50); + const leaked = created.filter(id => !cleared.has(id)); + for (const id of leaked) realClearInterval(id as ReturnType); + expect(leaked).toHaveLength(0); + } finally { + vi.restoreAllMocks(); + } + }); + + it('warns, and keeps one lease, when called from a reactive computation that reruns', async () => { + const warn = vi.fn(); + const mockFetch = createMockFetch(); + mockFetch.get('/item', () => ({ n: 1 })); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + log: { warn }, + }); + clients.push(client); + const leases = (client as unknown as { leases: Set }).leases; + + const tick = signal(0); + const computation = reactive(() => { + const value = tick.value; + client.retain(() => fetchQuery(GetItem)); + return value; + }); + const { w, unsub } = mountReader(client, () => computation()); + expect(warn).toHaveBeenCalledTimes(1); + for (let i = 1; i <= 5; i++) { + tick.value = i; + w.value; + await sleep(10); + } + expect(leases.size).toBe(1); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('does not warn when called outside a reactive computation', () => { + const warn = vi.fn(); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: createMockFetch() as any, baseUrl: 'http://localhost' })], + log: { warn }, + }); + clients.push(client); + client.retain(() => fetchQuery(GetItem))(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('releases and rethrows when the callback throws', () => { + const { client } = setup(); + expect(() => + client.retain(() => { + throw new Error('boom'); + }), + ).toThrow('boom'); + }); + + it('releases and rethrows when starting a query throws', async () => { + const { client } = setup(); + const start = QueryInstance.prototype.startPendingNow; + let calls = 0; + const spy = vi.spyOn(QueryInstance.prototype, 'startPendingNow').mockImplementation(function ( + this: QueryInstance, + ) { + if (++calls === 2) throw new Error('boom'); + start.call(this); + }); + + expect(() => client.retain(() => [fetchQuery(GetStreamed), fetchQuery(GetItem)])).toThrow('boom'); + spy.mockRestore(); + + await sleep(10); + expect(subscribed).toBe(1); + expect(unsubscribed).toBe(1); + }); +}); + +describe('a getConfig() that throws after a signal change', () => { + const configBroken = signal(false); + + class GetFragile extends RESTQuery { + path = '/item'; + result = { n: t.number }; + getConfig() { + if (configBroken.value) throw new Error('bad config'); + return { staleTime: 0 }; + } + } + + class GetFragileStreamed extends RESTQuery { + path = '/streamed'; + result = { n: t.number }; + getConfig() { + if (configBroken.value) throw new Error('bad config'); + return { + staleTime: 0, + subscribe: () => { + subscribed++; + return () => { + unsubscribed++; + }; + }, + }; + } + } + + function trackUnhandled(): { errors: unknown[]; stop: () => void } { + const errors: unknown[] = []; + const onError = (error: unknown) => errors.push(error); + process.on('uncaughtException', onError); + process.on('unhandledRejection', onError); + return { + errors, + stop: () => { + process.off('uncaughtException', onError); + process.off('unhandledRejection', onError); + }, + }; + } + + async function expectSchedulerRunning(): Promise { + const s = signal(0); + const w = watcher(() => s.value); + let fired = 0; + const unsub = w.addListener(() => fired++); + await sleep(10); + const before = fired; + s.value = 1; + await sleep(10); + expect(fired).toBeGreaterThan(before); + unsub(); + } + + afterEach(() => { + configBroken.value = false; + }); + + it('a retained request that breaks a later query getConfig still releases at ttl, and the later query rejects', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/streamed', { n: 1 }); + mockFetch.get('/item', { n: 2 }); + const fetchBreakingConfig = (url: string, init?: RequestInit) => { + if (url.endsWith('/streamed')) configBroken.value = true; + return mockFetch(url, init); + }; + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: fetchBreakingConfig as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + const unhandled = trackUnhandled(); + + let fragile: ReturnType> | undefined; + const release = client.retain(() => [fetchQuery(GetStreamed), (fragile = fetchQuery(GetFragile))], { ttl: 30 }); + expect(release).toBeTypeOf('function'); + expect(subscribed).toBe(1); + + await sleep(10); + expect(fragile!.isRejected).toBe(true); + expect((fragile!.error as Error).message).toBe('bad config'); + + await sleep(40); + expect(unsubscribed).toBe(1); + + await expectSchedulerRunning(); + unhandled.stop(); + expect(unhandled.errors).toEqual([]); + }); + + it('a reader gets the error on its relay, and other watchers keep running', async () => { + const { client } = setup(); + const unhandled = trackUnhandled(); + let fragile: ReturnType> | undefined; + const reader = mountReader(client, () => (fragile = fetchQuery(GetFragile)).value); + await sleep(10); + expect(fragile!.isResolved).toBe(true); + + configBroken.value = true; + await sleep(10); + expect(fragile!.isRejected).toBe(true); + expect((fragile!.error as Error).message).toBe('bad config'); + + await expectSchedulerRunning(); + reader.unsub(); + unhandled.stop(); + expect(unhandled.errors).toEqual([]); + }); + + it('stops a running subscription when getConfig starts throwing', async () => { + const { client } = setup(); + let fragile: ReturnType> | undefined; + const reader = mountReader(client, () => (fragile = fetchQuery(GetFragileStreamed)).value); + await sleep(10); + expect(subscribed).toBe(1); + + configBroken.value = true; + await sleep(10); + expect(fragile!.isRejected).toBe(true); + expect(unsubscribed).toBe(1); + reader.unsub(); + // Let the reader tear down before afterEach clears the error. + await sleep(10); + }); + + it('recovers when getConfig stops throwing: refetches and resubscribes', async () => { + const { client, mockFetch } = setup(); + let fragile: ReturnType> | undefined; + const reader = mountReader(client, () => (fragile = fetchQuery(GetFragileStreamed)).value); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + configBroken.value = true; + await sleep(10); + expect(fragile!.isRejected).toBe(true); + + configBroken.value = false; + await sleep(10); + expect(fragile!.isResolved).toBe(true); + expect(fragile!.value).toMatchObject({ n: 2 }); + expect(mockFetch.calls).toHaveLength(2); + expect(subscribed).toBe(2); + reader.unsub(); + }); +}); diff --git a/packages/fetchium/src/__tests__/retry-should-retry.test.ts b/packages/fetchium/src/__tests__/retry-should-retry.test.ts new file mode 100644 index 0000000..40e1432 --- /dev/null +++ b/packages/fetchium/src/__tests__/retry-should-retry.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { QueryClient } from '../QueryClient.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { RESTQuery, RESTMutation } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { getMutation } from '../mutation.js'; +import { NetworkManager } from '../NetworkManager.js'; +import { getErrorStatus, type ShouldRetry } from '../retry.js'; +import { createMockFetch, testWithClient } from './utils.js'; +import { t } from '../typeDefs.js'; + +const user = { id: '1', name: 'Alice' }; + +function httpError(status: number): Error { + return Object.assign(new Error(`HTTP ${status}`), { response: { status } }); +} + +describe('shouldRetry', () => { + let mockFetch: ReturnType; + let client: QueryClient; + + function createClient(shouldRetry?: ShouldRetry): QueryClient { + return new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + networkManager: new NetworkManager(true), + shouldRetry, + }); + } + + class GetUser extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 3, retryDelay: () => 1 } }; + } + + class CreateUser extends RESTMutation { + readonly path = '/users'; + readonly method = 'POST' as const; + readonly params = { name: t.string }; + readonly body = { name: this.params.name }; + readonly result = { id: t.number }; + config = { retry: { retries: 2, retryDelay: () => 1 } }; + } + + async function runFailing(query: new () => RESTQuery = GetUser): Promise { + let caught: unknown; + await testWithClient(client, async () => { + try { + await fetchQuery(query as typeof GetUser); + } catch (error) { + caught = error; + } + }); + return caught; + } + + beforeEach(() => { + mockFetch = createMockFetch(); + client = createClient(); + }); + + afterEach(() => { + client.destroy(); + }); + + describe('default', () => { + for (const status of [400, 401, 403, 404, 408, 422, 429, 500, 503]) { + it(`retries a ${status} response whose body fails validation`, async () => { + mockFetch.get('/users/1', { error: 'nope' }, { status }); + + const error = await runFailing(); + + expect(error).toBeDefined(); + expect(mockFetch.calls).toHaveLength(4); + }); + } + + it('retries an error that carries a 4xx status', async () => { + mockFetch.get('/users/1', null, { error: httpError(401) }); + + const error = await runFailing(); + + expect(error).toMatchObject({ response: { status: 401 } }); + expect(mockFetch.calls).toHaveLength(4); + }); + + it('retries a 503 whose body is not JSON, then succeeds', async () => { + mockFetch.get('/users/1', null, { status: 503, jsonError: new SyntaxError('Unexpected token <') }); + mockFetch.get('/users/1', user); + + await testWithClient(client, async () => { + const result = fetchQuery(GetUser); + await result; + expect(result.value).toMatchObject(user); + }); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('retries network errors', async () => { + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + + it('retries a 2xx response whose body fails validation', async () => { + mockFetch.get('/users/1', { unexpected: true }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + + it('retries a 4xx page in fetchNext', async () => { + class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.string), nextPage: t.optional(t.number) }; + fetchNext = { searchParams: { page: this.result.nextPage } }; + config = { retry: { retries: 3, retryDelay: () => 1 } }; + } + mockFetch.get('/items', { items: ['a'], nextPage: 2 }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetItems); + await result; + mockFetch.get('/items', { error: 'bad page' }, { status: 400 }); + await expect(result.value!.__fetchNext()).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(5); + }); + + it('retries a mutation that enables retries, whatever the status', async () => { + mockFetch.post('/users', null, { error: httpError(422) }); + + await testWithClient(client, async () => { + const mut = getMutation(CreateUser); + await expect(mut.run({ name: 'Test' })).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(3); + }); + }); + + describe('custom', () => { + const skipClientErrors: ShouldRetry = (_error, _attempt, status) => + status === undefined || status < 400 || status >= 500 || status === 408 || status === 429; + + function useSkipClientErrors(): void { + client.destroy(); + client = createClient(skipClientErrors); + } + + it('client-level shouldRetry receives error, attempt and status', async () => { + const seen: Array<[unknown, number, number | undefined]> = []; + client.destroy(); + client = createClient((error, attempt, status) => { + seen.push([error, attempt, status]); + return attempt < 1; + }); + mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); + + const error = await runFailing(); + + expect(mockFetch.calls).toHaveLength(2); + expect(seen.map(([, attempt, status]) => [attempt, status])).toEqual([ + [0, 404], + [1, 404], + ]); + expect(seen[1][0]).toBe(error); + }); + + it('passes the status carried by the error', async () => { + const statuses: Array = []; + client.destroy(); + client = createClient((_error, _attempt, status) => { + statuses.push(status); + return true; + }); + mockFetch.get('/users/1', null, { error: httpError(401) }); + + await runFailing(); + + expect(statuses).toEqual([401, 401, 401]); + }); + + for (const status of [400, 401, 404, 422]) { + it(`client-level shouldRetry returning false for ${status} makes one attempt`, async () => { + useSkipClientErrors(); + mockFetch.get('/users/1', { error: 'nope' }, { status }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(1); + }); + } + + it('client-level shouldRetry returning false for a 4xx error makes one attempt', async () => { + useSkipClientErrors(); + mockFetch.get('/users/1', null, { error: httpError(401) }); + + const error = await runFailing(); + + expect(error).toMatchObject({ response: { status: 401 } }); + expect(mockFetch.calls).toHaveLength(1); + }); + + for (const status of [408, 429, 503]) { + it(`client-level shouldRetry still retries what it allows (${status})`, async () => { + useSkipClientErrors(); + mockFetch.get('/users/1', { error: 'busy' }, { status }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + } + + it('query-level shouldRetry returning false for 4xx makes one attempt', async () => { + class GetUserSkip4xx extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 3, retryDelay: () => 1, shouldRetry: skipClientErrors } }; + } + mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); + + await runFailing(GetUserSkip4xx); + + expect(mockFetch.calls).toHaveLength(1); + }); + + it('query-level shouldRetry overrides the client-level one', async () => { + useSkipClientErrors(); + class GetUserRetry4xx extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 2, retryDelay: () => 1, shouldRetry: () => true } }; + } + mockFetch.get('/users/1', { error: 'nope' }, { status: 400 }); + + await runFailing(GetUserRetry4xx); + + expect(mockFetch.calls).toHaveLength(3); + }); + + it('query-level shouldRetry can stop retries of a network error', async () => { + class GetUserNoRetry extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 3, retryDelay: () => 1, shouldRetry: () => false } }; + } + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await runFailing(GetUserNoRetry); + + expect(mockFetch.calls).toHaveLength(1); + }); + + it('does not pass an earlier response status for a later network error', async () => { + // Attempt 0 gets a 404, every later attempt a network error. + const statuses: Array = []; + client.destroy(); + client = createClient((_error, _attempt, status) => { + statuses.push(status); + return true; + }); + mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + expect(statuses).toEqual([404, undefined, undefined]); + }); + + it('does not pass the status of an earlier fetch for a refetch network error', async () => { + const statuses: Array = []; + client.destroy(); + client = createClient((_error, _attempt, status) => { + statuses.push(status); + return true; + }); + mockFetch.get('/users/1', user); + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetUser); + await result; + await result.value!.__refetch().catch(() => {}); + }); + + expect(mockFetch.calls).toHaveLength(5); + expect(statuses).toEqual([undefined, undefined, undefined]); + }); + + it('client-level shouldRetry returning false for a 4xx page in fetchNext makes one attempt', async () => { + useSkipClientErrors(); + class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.string), nextPage: t.optional(t.number) }; + fetchNext = { searchParams: { page: this.result.nextPage } }; + config = { retry: { retries: 3, retryDelay: () => 1 } }; + } + mockFetch.get('/items', { items: ['a'], nextPage: 2 }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetItems); + await result; + mockFetch.get('/items', { error: 'bad page' }, { status: 400 }); + await expect(result.value!.__fetchNext()).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(2); + }); + + it('applies to mutations', async () => { + useSkipClientErrors(); + mockFetch.post('/users', null, { error: httpError(422) }); + + await testWithClient(client, async () => { + const mut = getMutation(CreateUser); + await expect(mut.run({ name: 'Test' })).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(1); + }); + + it('passes the status of a mutation 4xx whose body is not JSON', async () => { + const statuses: Array = []; + client.destroy(); + client = createClient((error, attempt, status) => { + statuses.push(status); + return skipClientErrors(error, attempt, status); + }); + mockFetch.post('/users', null, { status: 422, jsonError: new SyntaxError('Unexpected token <') }); + + await testWithClient(client, async () => { + const mut = getMutation(CreateUser); + await expect(mut.run({ name: 'Test' })).rejects.toBeInstanceOf(SyntaxError); + }); + + expect(mockFetch.calls).toHaveLength(1); + expect(statuses).toEqual([422]); + }); + }); +}); + +describe('getErrorStatus', () => { + it('reads status, statusCode and response.status', () => { + expect(getErrorStatus({ status: 404 })).toBe(404); + expect(getErrorStatus({ statusCode: 401 })).toBe(401); + expect(getErrorStatus({ response: { status: 503 } })).toBe(503); + expect(getErrorStatus({ response: null })).toBeUndefined(); + expect(getErrorStatus({ status: 'failed' })).toBeUndefined(); + expect(getErrorStatus(new Error('x'))).toBeUndefined(); + expect(getErrorStatus(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/fetchium/src/__tests__/topic-query.test.ts b/packages/fetchium/src/__tests__/topic-query.test.ts index 49a2adb..51ec769 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { reactive } from 'signalium'; +import { reactive, withContexts } from 'signalium'; import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; -import { QueryClient } from '../QueryClient.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { t } from '../typeDefs.js'; import { Entity } from '../proxy.js'; import { fetchQuery } from '../query.js'; import { getMutation } from '../mutation.js'; import { RESTMutation } from '../rest/index.js'; import { reifyValue } from '../fieldRef.js'; -import { createMockFetch, testWithClient, sleep, getEntityMapSize } from './utils.js'; +import { createMockFetch, createTestWatcher, testWithClient, sleep, getEntityMapSize } from './utils.js'; import { TopicQuery } from '../topic/TopicQuery.js'; import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; @@ -2273,3 +2273,84 @@ describe('TopicQuery', () => { }); }); }); + +describe('TopicQuery reactivation before it ever loaded', () => { + class SlowTopicAdapter extends TopicQueryAdapter { + subscribes = 0; + reject = false; + private timers = new Map>(); + + subscribe(topic: string): void { + this.subscribes++; + this.timers.set( + topic, + setTimeout(() => { + if (this.reject) this.rejectTopic(topic, new Error('topic unavailable')); + else this.fulfillTopic(topic, { n: 1 }); + }, 30), + ); + } + + unsubscribe(topic: string): void { + clearTimeout(this.timers.get(topic)); + this.clearTopic(topic); + } + } + + class GetCount extends TopicQuery { + static override adapter = SlowTopicAdapter; + topic = 'count'; + result = { n: t.number }; + } + + let client: QueryClient; + let adapter: SlowTopicAdapter; + + beforeEach(() => { + adapter = new SlowTopicAdapter(); + client = new QueryClient({ store: new SyncQueryStore(new MemoryPersistentStore()), adapters: [adapter] }); + }); + + afterEach(() => { + client.destroy(); + }); + + function mount(): () => void { + return withContexts([[QueryClientContext, client]], () => createTestWatcher(() => fetchQuery(GetCount).value)) + .unsub; + } + + function relay() { + return withContexts([[QueryClientContext, client]], () => fetchQuery(GetCount)); + } + + it('loads when a reader mounts after a prefetch was released before the snapshot arrived', async () => { + const release = client.prefetch(GetCount); + await sleep(5); + release(); + await sleep(10); + + const unmount = mount(); + await sleep(100); + expect(adapter.subscribes).toBe(2); + expect(relay().isReady).toBe(true); + expect(relay().value!.n).toBe(1); + unmount(); + }); + + it('subscribes again before refetching a topic whose snapshot was rejected', async () => { + adapter.reject = true; + const unmount = mount(); + await sleep(60); + expect(relay().isRejected).toBe(true); + unmount(); + await sleep(10); + + adapter.reject = false; + const remount = mount(); + await sleep(100); + expect(adapter.subscribes).toBe(2); + expect(relay().isReady).toBe(true); + remount(); + }); +}); diff --git a/packages/fetchium/src/index.ts b/packages/fetchium/src/index.ts index 350503c..b482323 100644 --- a/packages/fetchium/src/index.ts +++ b/packages/fetchium/src/index.ts @@ -1,7 +1,7 @@ export * from './types.js'; -export { QueryClient, QueryClientContext } from './QueryClient.js'; -export type { QueryContext, QueryClientConfig, ActivitySource } from './QueryClient.js'; +export { QueryClient, QueryClientContext, DEFAULT_PREFETCH_TTL } from './QueryClient.js'; +export type { QueryContext, QueryClientConfig, ActivitySource, RetainOptions, PrefetchOptions } from './QueryClient.js'; export { QueryAdapter } from './QueryAdapter.js'; export type { IQueryClientForAdapter } from './QueryAdapter.js'; export { t, registerFormat } from './typeDefs.js'; @@ -13,3 +13,5 @@ export type { Draft } from './utils.js'; export { NetworkManager, NoOpNetworkManager, defaultNetworkManager, NetworkManagerContext } from './NetworkManager.js'; export { GcManager, NoOpGcManager } from './GcManager.js'; export { Entity } from './proxy.js'; +export { getErrorStatus } from './retry.js'; +export type { ShouldRetry } from './retry.js'; diff --git a/packages/fetchium/src/query.ts b/packages/fetchium/src/query.ts index d0c397e..c2b041c 100644 --- a/packages/fetchium/src/query.ts +++ b/packages/fetchium/src/query.ts @@ -25,6 +25,8 @@ import type { QueryAdapter, QueryAdapterClass } from './QueryAdapter.js'; export interface ResolvedRetryConfig { retries: number; retryDelay: (attempt: number) => number; + /** Undefined defers to the client's `shouldRetry`. */ + shouldRetry?: (error: unknown, attempt: number, status: number | undefined) => boolean; } export function resolveRetryConfig( @@ -40,7 +42,7 @@ export function resolveRetryConfig( } else if (typeof retryOption === 'number') { retries = retryOption; } else { - retries = retryOption.retries; + retries = retryOption.retries ?? (isServer ? 0 : 3); } const retryDelay = @@ -48,7 +50,9 @@ export function resolveRetryConfig( ? retryOption.retryDelay : (attempt: number) => 1000 * Math.pow(2, attempt); - return { retries, retryDelay }; + const shouldRetry = typeof retryOption === 'object' ? retryOption.shouldRetry : undefined; + + return { retries, retryDelay, shouldRetry }; } // ================================ diff --git a/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx b/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx new file mode 100644 index 0000000..f0ed25b --- /dev/null +++ b/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider, component } from 'signalium/react'; +import React from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { Entity } from '../../proxy.js'; +import { fetchQuery } from '../../query.js'; +import { sleep } from '../../__tests__/utils.js'; +import { TopicQuery } from '../../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../../topic/TopicQueryAdapter.js'; +import type { MutationEvent } from '../../types.js'; +import { createRenderCounter } from './utils.js'; + +class MockTopicAdapter extends TopicQueryAdapter { + snapshots = new Map(); + + subscribe(topic: string): void { + this.fulfillTopic(topic, this.snapshots.get(topic)); + } + + unsubscribe(topic: string): void { + this.clearTopic(topic); + } + + emit(event: MutationEvent): void { + this.sendMutationEvent(event); + } +} + +class Owner extends Entity { + __typename = t.typename('Owner'); + id = t.id; + name = t.string; +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + walletId = t.string; + price = t.number; + metadata = t.object({ logo: t.string, tags: t.array(t.string) }); + owner = t.entity(Owner); +} + +class Wallet extends Entity { + __typename = t.typename('Wallet'); + id = t.id; + tokens = t.liveArray(Token, { constraints: { walletId: (this as unknown as { id: string }).id } }); +} + +class WalletTopic extends TopicQuery { + static override adapter = MockTopicAdapter; + topic = 'wallet:w-1'; + result = { wallet: t.entity(Wallet) }; +} + +function token(i: number, price = i) { + return { + __typename: 'Token', + id: `tok-${i}`, + walletId: 'w-1', + price, + metadata: { logo: `logo-${i}.png`, tags: ['defi'] }, + owner: { __typename: 'Owner', id: 'o-1', name: 'Ann' }, + }; +} + +type TokenProxy = { id: string; price: number; metadata: { logo: string }; owner: { name: string } }; + +describe('React readers on identical re-delivery', () => { + let client: QueryClient; + let adapter: MockTopicAdapter; + + beforeEach(() => { + client?.destroy(); + adapter = new MockTopicAdapter(); + adapter.snapshots.set('wallet:w-1', { + wallet: { __typename: 'Wallet', id: 'w-1', tokens: [token(0), token(1)] }, + }); + client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [adapter], + } as never); + }); + + it('does not re-render entity readers when every entity is re-delivered unchanged', async () => { + const Row = createRenderCounter( + ({ token }: { token: TokenProxy }) => ( + + {token.price}:{token.metadata.logo}:{token.owner.name} + + ), + component, + ); + + const ListCounter = createRenderCounter(() => { + const result = fetchQuery(WalletTopic); + if (!result.isReady) return
Loading...
; + const tokens = result.value.wallet.tokens as unknown as TokenProxy[]; + return ( +
+ {tokens.map(tok => ( + + ))} +
+ ); + }, component); + + const { getByTestId } = render( + + + , + ); + + await expect.element(getByTestId('row-tok-1')).toHaveTextContent('1:logo-1.png:Ann'); + await sleep(20); + const rowRendersBefore = Row.renderCount; + const listRendersBefore = ListCounter.renderCount; + + for (let pass = 0; pass < 3; pass++) { + adapter.emit({ type: 'update', typename: 'Wallet', data: { id: 'w-1' } }); + adapter.emit({ type: 'update', typename: 'Token', data: token(0) }); + adapter.emit({ type: 'update', typename: 'Token', data: token(1) }); + adapter.emit({ type: 'update', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); + } + await sleep(50); + + expect(Row.renderCount).toBe(rowRendersBefore); + expect(ListCounter.renderCount).toBe(listRendersBefore); + + // The list re-renders too: its constrained live array re-filters on any member change. + adapter.emit({ type: 'update', typename: 'Token', data: token(1, 99) }); + await expect.element(getByTestId('row-tok-1')).toHaveTextContent('99:logo-1.png:Ann'); + await sleep(50); + + expect(Row.renderCount).toBe(rowRendersBefore + 1); + }); +}); diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx new file mode 100644 index 0000000..5680fc1 --- /dev/null +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -0,0 +1,410 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider } from 'signalium/react'; +import React, { Suspense } from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { RESTQuery } from '../../rest/index.js'; +import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; +import { createMockFetch, sleep } from '../../__tests__/utils.js'; +import type { MutationEvent } from '../../types.js'; +import { useQuery } from '../use-query.js'; +import { useSuspenseQuery } from '../use-suspense-query.js'; +import { withContexts } from 'signalium'; +import { fetchQuery } from '../../query.js'; + +class GetItem extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { staleTime: 0 }; +} + +class GetFreshItem extends RESTQuery { + path = '/fresh'; + result = { name: t.string }; + config = { staleTime: 60_000 }; +} + +let subscribed = 0; +let unsubscribed = 0; + +class GetStreamed extends RESTQuery { + path = '/streamed'; + result = { name: t.string }; + config = { + subscribe: (_onEvent: (event: MutationEvent) => void) => { + subscribed++; + return () => { + unsubscribed++; + }; + }, + }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; + subscribed = 0; + unsubscribed = 0; +}); + +function makeClient(mockFetch: ReturnType, kv = new MemoryPersistentStore()): QueryClient { + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return client; +} + +class Boundary extends React.Component< + { children: React.ReactNode; onError: (e: unknown) => void }, + { error: unknown } +> { + state = { error: undefined as unknown }; + static getDerivedStateFromError(error: unknown) { + return { error }; + } + componentDidCatch(error: unknown) { + this.props.onError(error); + } + render() { + return this.state.error !== undefined ?
Boundary caught
: this.props.children; + } +} + +describe('client.prefetch() with React', () => { + it('a screen mounting after a tap-time prefetch renders the data on its first render, with one request', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'prefetched' }, { delay: 20 }); + const client = makeClient(mockFetch); + + client.prefetch(GetItem, undefined, { ttl: 5_000 }); + await sleep(60); + + const renders: Array = []; + function Item(): React.ReactNode { + const item = useQuery(GetItem); + const label = item.isReady ? item.value.name : undefined; + renders.push(label); + return
{label ?? 'Loading...'}
; + } + + const { getByText } = render( + + + , + ); + + await expect.element(getByText('prefetched')).toBeInTheDocument(); + await sleep(50); + expect(renders[0]).toBe('prefetched'); + expect(renders).not.toContain(undefined); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('a screen mounting before the prefetch lands joins the in-flight request', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'joined' }, { delay: 60 }); + const client = makeClient(mockFetch); + + client.prefetch(GetItem); + await sleep(10); + + function Item(): React.ReactNode { + const item = useQuery(GetItem); + return
{item.isReady ? item.value.name : 'Loading...'}
; + } + + const { getByText } = render( + + + , + ); + + await expect.element(getByText('joined')).toBeInTheDocument(); + expect(mockFetch.calls).toHaveLength(1); + }); +}); + +describe('useSuspenseQuery', () => { + it('suspends on a cold miss, then renders the data, with one request', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'loaded' }, { delay: 20 }); + const client = makeClient(mockFetch); + + const renders: string[] = []; + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetItem); + renders.push(item.value.name); + return
{item.value.name}
; + } + + const { getByText } = render( + + Suspended}> + + + , + ); + + await expect.element(getByText('Suspended')).toBeInTheDocument(); + await expect.element(getByText('loaded')).toBeInTheDocument(); + await sleep(30); + expect(renders.every(r => r === 'loaded')).toBe(true); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('does not suspend when the value is in memory, and does not suspend for the refetch', async () => { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ name: `v${++n}` }), { delay: 20 }); + const client = makeClient(mockFetch); + + const release = client.prefetch(GetItem); + await sleep(40); + release(); + await sleep(20); + expect(mockFetch.calls).toHaveLength(1); + + let fallbacks = 0; + function Fallback(): React.ReactNode { + fallbacks++; + return
Suspended
; + } + const renders: string[] = []; + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetItem); + renders.push(item.value.name); + return
{item.value.name}
; + } + + const { getByText } = render( + + }> + + + , + ); + + // The stale reactivation refetches without suspending. + await expect.element(getByText('v2')).toBeInTheDocument(); + expect(renders[0]).toBe('v1'); + expect(fallbacks).toBe(0); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('does not suspend when a synchronous store has the value', async () => { + const kv = new MemoryPersistentStore(); + const seedFetch = createMockFetch(); + seedFetch.get('/fresh', { name: 'persisted' }); + const seed = makeClient(seedFetch, kv); + seed.prefetch(GetFreshItem); + await sleep(20); + seed.destroy(); + + const mockFetch = createMockFetch(); + const client = makeClient(mockFetch, kv); + + let fallbacks = 0; + function Fallback(): React.ReactNode { + fallbacks++; + return
Suspended
; + } + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetFreshItem); + return
{item.value.name}
; + } + + const { getByText } = render( + + }> + + + , + ); + + await expect.element(getByText('persisted')).toBeInTheDocument(); + expect(fallbacks).toBe(0); + expect(mockFetch.calls).toHaveLength(0); + }); + + it('throws a failed cold fetch to the error boundary', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', null, { status: 500 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + const errors: unknown[] = []; + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const { getByText } = render( + + errors.push(e)}> + Suspended}> + + + + , + ); + + await expect.element(getByText('Boundary caught')).toBeInTheDocument(); + expect(errors.length).toBeGreaterThan(0); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('retries a failed cold fetch when the error boundary resets', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const tree = (attempt: number) => ( + + {}}> + Suspended}> + + + + + ); + + const screen = render(tree(0)); + await expect.element(screen.getByText('Boundary caught')).toBeInTheDocument(); + await sleep(10); + + mockFetch.get('/item', { name: 'recovered' }); + screen.rerender(tree(1)); + await expect.element(screen.getByText('recovered')).toBeInTheDocument(); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('suspends on a refetch that started after the cold fetch failed, without re-rendering in a loop', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500, delay: 20 }); + mockFetch.get('/item', { name: 'second' }, { delay: 400 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + client.prefetch(GetNoRetry); + const relay = withContexts([[QueryClientContext, client]], () => fetchQuery(GetNoRetry)); + // Another refetch starts as soon as the first attempt fails. + relay.then( + () => {}, + () => { + client.queryInstances.values().next().value!.refetch(); + }, + ); + + let renders = 0; + function Item(): React.ReactNode { + renders++; + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const screen = render( + + {}}> + Suspended}> + + + + , + ); + + await expect.element(screen.getByText('second'), { timeout: 3000 }).toBeInTheDocument(); + expect(renders).toBeLessThan(10); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('makes a new attempt when a tree abandoned while suspended is mounted again after the fetch failed', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500, delay: 20 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const errors: unknown[] = []; + const tree = (show: boolean) => ( + + errors.push(e)}> + Suspended}>{show ? :
Other
}
+
+
+ ); + + const screen = render(tree(true)); + await expect.element(screen.getByText('Suspended')).toBeInTheDocument(); + // Unmount while suspended, so the fetch fails with no reader. + screen.rerender(tree(false)); + await sleep(150); + expect(mockFetch.calls).toHaveLength(1); + + mockFetch.get('/item', { name: 'recovered' }); + screen.rerender(tree(true)); + await expect.element(screen.getByText('recovered')).toBeInTheDocument(); + expect(mockFetch.calls).toHaveLength(2); + expect(errors).toEqual([]); + }); + + it('hands the query to the reader on commit, so unmounting deactivates it', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/streamed', { name: 'live' }, { delay: 10 }); + const client = makeClient(mockFetch); + + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetStreamed); + return
{item.value.name}
; + } + + const screen = render( + + Suspended}> + + + , + ); + + await expect.element(screen.getByText('live')).toBeInTheDocument(); + expect(subscribed).toBe(1); + screen.unmount(); + await sleep(30); + expect(unsubscribed).toBe(1); + }); +}); diff --git a/packages/fetchium/src/react/index.ts b/packages/fetchium/src/react/index.ts index ee11804..a01c17b 100644 --- a/packages/fetchium/src/react/index.ts +++ b/packages/fetchium/src/react/index.ts @@ -1 +1,2 @@ export { useQuery } from './use-query.js'; +export { useSuspenseQuery } from './use-suspense-query.js'; diff --git a/packages/fetchium/src/react/use-suspense-query.ts b/packages/fetchium/src/react/use-suspense-query.ts new file mode 100644 index 0000000..09b6e56 --- /dev/null +++ b/packages/fetchium/src/react/use-suspense-query.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import type { ReadyReactivePromise } from 'signalium'; +import { useContext } from 'signalium/react'; +import { ExtractType, QueryResult } from '../types.js'; +import { Query, QueryDefinition } from '../query.js'; +import { QueryClientContext, type QueryParams } from '../QueryClient.js'; +import { HasRequiredKeys, Optionalize, Signalize } from '../type-utils.js'; +import { useQuery } from './use-query.js'; + +/** + * `useQuery` under a `` boundary. Suspends only on a cold miss (no + * value in memory or a sync store), never on refetch. A failed cold fetch is + * thrown to the error boundary. Wrap param changes in `startTransition` to keep + * the previous result. Not usable inside a Signalium `component()`. + */ +export function useSuspenseQuery( + QueryClass: new () => T, + ...args: HasRequiredKeys> extends true + ? [params: Optionalize>>] + : [params?: Optionalize>> | undefined] +): ReadyReactivePromise> { + const client = useContext(QueryClientContext); + + if (client === undefined) { + throw new Error('QueryClient not found'); + } + + // Before useQuery: a suspended render never commits, so its subscription would leak. + const { promise, failed, error, key } = client.suspendOnColdMiss( + QueryDefinition.for(QueryClass), + args[0] as QueryParams | undefined, + ); + + if (failed) { + throw error; + } + + if (promise !== undefined) { + throw promise; + } + + const result = useQuery(QueryClass, ...args); + + // useQuery's subscription now keeps the query active. + useEffect(() => { + client.releaseSuspenseHold(key); + }, [client, key]); + + return result as ReadyReactivePromise>; +} diff --git a/packages/fetchium/src/rest/RESTMutation.ts b/packages/fetchium/src/rest/RESTMutation.ts index a1e2e1a..38d0228 100644 --- a/packages/fetchium/src/rest/RESTMutation.ts +++ b/packages/fetchium/src/rest/RESTMutation.ts @@ -12,6 +12,8 @@ export abstract class RESTMutation extends Mutation { headers?: HeadersInit; requestOptions?: QueryRequestOptions; + declare response: Response | undefined; + getIdentityKey(): string { return `${this.method ?? 'POST'}:${this.path ?? ''}`; } diff --git a/packages/fetchium/src/rest/RESTQueryAdapter.ts b/packages/fetchium/src/rest/RESTQueryAdapter.ts index c41901c..bba1acd 100644 --- a/packages/fetchium/src/rest/RESTQueryAdapter.ts +++ b/packages/fetchium/src/rest/RESTQueryAdapter.ts @@ -216,6 +216,8 @@ export class RESTQueryAdapter extends QueryAdapter { ...fetchOptions, }); + restCtx.response = fetchResponse as unknown as Response; + return fetchResponse.json(); } } diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index 221b853..ed8a828 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -1,5 +1,38 @@ import type { ResolvedRetryConfig } from './query.js'; +/** + * Decides whether a failed attempt is retried. Consulted only while retries + * remain. `attempt` starts at 0. `status` comes from the error (`status`, + * `statusCode`, `response.status`) or the adapter's `ctx.response`, and is + * undefined for network errors. + */ +export type ShouldRetry = (error: unknown, attempt: number, status: number | undefined) => boolean; + +function asHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' && value >= 100 && value <= 599 ? value : undefined; +} + +/** The HTTP status in an error's `status`, `statusCode` or `response.status`, if any. */ +export function getErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const e = error as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } | null }; + return asHttpStatus(e.status) ?? asHttpStatus(e.statusCode) ?? asHttpStatus(e.response?.status); +} + +/** The status of an error response (4xx/5xx), or undefined. */ +export function getFailedResponseStatus(response: unknown): number | undefined { + if (typeof response !== 'object' || response === null) return undefined; + const status = asHttpStatus((response as { status?: unknown }).status); + return status !== undefined && status >= 400 ? status : undefined; +} + +export interface WithRetryOptions { + /** Fallback when the retry config has no `shouldRetry`. */ + shouldRetry?: ShouldRetry; + /** Status of the failed attempt, for errors that carry none. */ + getAttemptStatus?: () => number | undefined; +} + /** * Safely retrieve the abort reason from a signal. Falls back to an AbortError * for engines (like Hermes) where `signal.reason` is not implemented. @@ -36,11 +69,13 @@ export async function withRetry( fn: () => Promise, config: ResolvedRetryConfig, signal?: AbortSignal, + options?: WithRetryOptions, ): Promise { if (IS_DEV && config.retries < 0) { throw new Error('retries must be non-negative'); } const retries = Math.max(0, config.retries); + const shouldRetry = config.shouldRetry ?? options?.shouldRetry; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { if (signal?.aborted) { @@ -51,6 +86,10 @@ export async function withRetry( } catch (error) { lastError = error; if (attempt >= retries) throw error; + if (shouldRetry !== undefined) { + const status = getErrorStatus(error) ?? options?.getAttemptStatus?.(); + if (!shouldRetry(error, attempt, status)) throw error; + } await sleep(config.retryDelay(attempt), signal); } } diff --git a/packages/fetchium/src/types.ts b/packages/fetchium/src/types.ts index 0004474..e19fda3 100644 --- a/packages/fetchium/src/types.ts +++ b/packages/fetchium/src/types.ts @@ -58,14 +58,20 @@ export enum NetworkMode { export interface RetryConfig { /** - * Number of retry attempts + * Number of retry attempts. Default: 3 for queries on the client, 0 on the + * server and for mutations. */ - retries: number; + retries?: number; /** * Optional custom delay function (receives attempt index starting at 0) * Default: exponential backoff (1000ms * 2^attempt) */ retryDelay?: (attemptIndex: number) => number; + /** + * Decides whether a failed attempt is retried. Overrides + * `QueryClientConfig.shouldRetry`. Default: every failed attempt is retried. + */ + shouldRetry?: (error: unknown, attempt: number, status: number | undefined) => boolean; } export const enum Mask {