diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index 3bc22c9..a636cbc 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -7,6 +7,19 @@ 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. +Use these hooks in ordinary React function components. Inside a Signalium `component()`, which is already reactive, call `fetchQuery` directly instead: + +```tsx +import { component } from 'signalium/react'; +import { fetchQuery } from 'fetchium'; + +const UserList = component(() => { + const query = fetchQuery(GetUsers); + if (!query.isReady) return
Loading...
; + return
{query.value.total} users
; +}); +``` + ```ts import { useQuery, useSuspenseQuery } from 'fetchium/react'; ``` @@ -60,12 +73,11 @@ The resolved `QueryResult` includes pagination helpers: #### Requirements - A `QueryClient` must be provided via `QueryClientContext` using Signalium's `ContextProvider`. -- The component must be wrapped in a Signalium `component()` or use `useReactive` for the reactive system to function. +- Call it from an ordinary React function component, not inside a Signalium `component()`. #### Example ```tsx -import { component } from 'signalium/react'; import { useQuery } from 'fetchium/react'; class GetUsers extends RESTQuery { @@ -77,7 +89,7 @@ class GetUsers extends RESTQuery { }; } -const UserList = component(() => { +function UserList() { const query = useQuery(GetUsers); if (query.isPending) { @@ -100,17 +112,18 @@ const UserList = component(() => { ); -}); +} ``` #### With parameters ```tsx -const UserProfile = component(({ userId }: { userId: string }) => { +function UserProfile({ userId }: { userId: string }) { const query = useQuery(GetUser, { id: userId }); + if (!query.isReady) return null; return
{query.value.name}
; -}); +} ``` #### With reactive parameters @@ -120,20 +133,21 @@ import { signal } from 'signalium'; const searchTerm = signal(''); -const SearchResults = component(() => { +function SearchResults() { const query = useQuery(SearchUsers, { q: searchTerm }); + if (!query.isReady) return null; - // Component re-renders when searchTerm changes and the query refetches + // Re-renders when searchTerm changes and the query refetches return
{query.value.results.length} results
; -}); +} ``` #### Notes - `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. Treat it as read-only: in development builds the snapshot and every nested object and array in it are frozen, so an in-place `sort()`, a `push()` or an assignment in render throws a `TypeError` at that line. Production snapshots are not frozen, and a mutation there is carried forward until the entity changes. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` if you need a mutable copy for mutations. -- `useQuery` never suspends. Reading `.value` while pending returns `undefined`. Use [`useSuspenseQuery`](#usesuspensequery) to suspend on a cold miss. +- The snapshot is a read-only copy of the query result. Development builds freeze it, so mutating it throws a `TypeError`. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` when you need a mutable copy. +- `useQuery` never suspends: `.value` is `undefined` until the first value arrives. Use [`useSuspenseQuery`](#usesuspensequery) to suspend. --- @@ -146,13 +160,11 @@ function useSuspenseQuery( ): 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. +Like `useQuery`, but suspends while the query has no value yet. Refetches don't suspend, so `value` is always defined. A failed first fetch is thrown to the nearest error boundary, and resetting the boundary retries it. -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 suspends again. Wrap the change in `startTransition` to keep showing the previous result. -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. +Like `useQuery`, call it from an ordinary React component, not inside a Signalium `component()`. ```tsx import { Suspense } from 'react'; @@ -167,5 +179,3 @@ function UserName({ id }: { id: number }) { ; ``` - -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 824ffde..04b87a9 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,38 +225,38 @@ 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. `Infinity` never refetches on reactivation. 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. A value that is not a finite positive number counts as 0. | -| `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. A value without `isActive` and `subscribe` functions is ignored, with a warning in development. | -| `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. A value that is not a finite positive number counts as 0. Per-poll override: `poll({ interval, resumeJitterMs })`. | - -Every config key, including keys this table does not list, also reaches query and mutation code as `this.context`, so you can pass your own services through the config. The names in the table are reserved: a value under one of them is read as that option, so do not reuse them for your own context values. +| 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 remounts with data younger than this doesn't refetch. Data a subscription pushed counts as fresh. Reconnects, `__refetch()`, invalidation and a failed last fetch still refetch. `Infinity` never refetches. Per query: `reactivationGraceMs`. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Spreads the refetches of queries that remount together across this window. | +| `shouldRetry` | `(error, attempt, status) => boolean` | — | Return `false` to stop retrying an error, such as a 404. `status` is the HTTP status when known. Per query: `retry.shouldRetry`. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }`. `poll()` pauses while inactive. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. Spreads polls that are overdue on resume across this window. Per poll: `poll({ interval, resumeJitterMs })`. | + +Other config keys reach queries and mutations as `this.context`. Don't reuse the option names above for your own values. #### Methods -| Method | Signature | Description | -| -------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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, releases outstanding leases, and unsubscribes the store listeners it registered. | +| Method | Signature | Description | +| -------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query and keeps it active for `ttl` ms (default 10 s). | +| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until `release()` is called, or for `ttl` ms. | +| `destroy` | `(): void` | Aborts fetches in flight and releases everything the client holds. Queries read afterwards reject with an `AbortError`. | #### `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. +Both keep queries active without a component reading them and return a `release` function. A component that mounts meanwhile reuses the running request. Releasing, or reaching the `ttl`, aborts a request still in flight unless a reader has joined it, and never interrupts joined readers. Call them from event handlers or effects, not during render. -`prefetch` is for the moment a user commits to a navigation, such as a tap. Call it in the handler, before navigating: +Use `prefetch` when the user commits to a navigation: ```ts function onPressToken(id: string) { @@ -265,21 +265,17 @@ function onPressToken(id: string) { } ``` -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: +Use `retain` to keep a screen's queries warm. `fn` reruns when what it reads changes, and the lease lasts until `release()` unless you pass a `ttl`: ```ts const release = queryClient.retain(() => [ fetchQuery(GetPortfolio), fetchQuery(GetPrices, { ids }), ]); -// when the surface is gone for good: +// later release(); ``` -Without a `ttl`, a `retain` lease lasts until `release` is called. - --- ### `QueryAdapter` (abstract) @@ -300,9 +296,9 @@ Base class for transport adapters. An adapter handles sending queries and mutati #### Properties -| Property | Type | Description | -| ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `coalescesRequests` | `boolean \| undefined` | Optional. Set when the adapter merges requests sent close together. `reactivationStaggerMs` then starts its queries' reactivation refetches together, unspread. | +| Property | Type | Description | +| ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `coalescesRequests` | `boolean \| undefined` | Optional. Set if the adapter batches requests sent close together. Its queries' refetches are then not staggered. | #### Protected properties @@ -654,16 +650,16 @@ The `t` object provides a declarative type definition DSL for describing query p #### `QueryConfigOptions` properties -| Property | Type | Default | Description | -| ------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `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 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. | -| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Skip the reactivation refetch while data is younger than this. Overrides the client's `reactivationGraceMs`. | -| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | +| Property | Type | Default | Description | +| ------------------------- | ---------------------------------- | --------------------------- | ------------------------------------------------------------------------ | +| `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 refetches. | +| `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. | +| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Overrides the client's `reactivationGraceMs`. | +| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | ### Mutation types @@ -693,9 +689,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; shouldRetry?: (error, attempt, status) => boolean }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's `shouldRetry`. | +| Type | Definition | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's. | ### Utility types @@ -712,8 +708,8 @@ The `t` object provides a declarative type definition DSL for describing query p ### Store types -| Type | Definition | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). Besides the required methods, a store may implement `onDelete` (the client then skips writing entities whose records are current, and writes one again once the store reports its record dropped), `onPersisted` (for a store that applies writes later), `isSettled`, `hasEntity` (lets a streamed event refresh the record of an entity that is not in memory) and `mergeEntity` (merges the fields streamed events supplied for an entity built from them over its record; without it, such an entity is written whole through `saveEntity`). A store without `onDelete` is written on every apply, as before 0.6; when `hasEntity` is missing or answers `undefined`, an event for an entity that is not in memory and that no live array retains is written only as a `mergeEntity` call with `ifStored`, which writes nothing when the store holds no record, and not at all without `mergeEntity`. | -| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | -| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | +| Type | Definition | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). | +| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | +| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | diff --git a/docs/src/app/api/stores-async/page.md b/docs/src/app/api/stores-async/page.md index ab56842..0dee90b 100644 --- a/docs/src/app/api/stores-async/page.md +++ b/docs/src/app/api/stores-async/page.md @@ -41,19 +41,19 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a save-entity message with `merge: true`; the writer merges `fields` over the stored record, if there is one (see `SyncQueryStore.mergeEntity`). With `ifStored` and no stored record, the writer writes nothing and reports the key to `onDelete` listeners. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. A queue persisted with a different `maxCount` is resized by the writer: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | -| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. In writer mode it runs through the same serial queue as the messages, after everything queued before it; a reader with its own `delegate` runs it directly. Only operates when a `delegate` is available. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every record the writer drops on its own (an LRU eviction, a reference-count cascade, a purge) or fails to write. Returns an unsubscribe function. | -| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every entity record a queued write has stored. Returns an unsubscribe function. | -| `isSettled` | `(): boolean` | Whether every operation handed to the writer has been applied (nothing queued, nothing mid-flight). Always `true` in reader mode. | -| `hasEntity` | `(key: number): boolean \| undefined` | Writer mode only. Whether the delegate holds a record for the key, answered from a set the writer reads from the delegate when it starts (alongside its queue, not ahead of it) and keeps current with every write it queues and every deletion it performs. `undefined` for a key it has not written itself until that read lands; after a read that keeps failing, nothing is assumed held. An event for an entity not in memory is sent as an `ifStored` merge while the answer is `undefined`, and always from a reader. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a merge of `fields` over the stored record. With `ifStored`, the writer skips it when no record is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. | +| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | +| `purgeStaleQueries` | `(): Promise` | Removes expired entries. Requires a `delegate`. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Writer only. Calls `listener` with each record the writer deletes or fails to write. Returns an unsubscribe function. | +| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer only. Calls `listener` with each entity record the writer stores. Returns an unsubscribe function. | +| `isSettled` | `(): boolean` | Whether the writer has applied everything queued. Always `true` for a reader. | +| `hasEntity` | `(key: number): boolean \| undefined` | Writer only. Whether a record is stored for `key`, or `undefined` until the writer has read its keys. | --- @@ -131,7 +131,7 @@ type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; - maxCount?: number; // absent from readers built before 0.6; the writer then keeps the queue's size + maxCount?: number; refIds?: number[]; } | { @@ -139,7 +139,8 @@ type StoreMessage = entityKey: number; value: unknown; refIds?: number[]; - merge?: boolean; // `value` holds only some fields: merge them over the stored record + merge?: boolean; // merge `value` over the stored record + ifStored?: boolean; // with `merge`: skip if no record is stored } | { type: 2; // ActivateQuery diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index c4f4235..845cfda 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,17 +32,17 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | -| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity and its child references. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Merges `fields` over the stored record, or writes them as the record if none is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of its class's LRU queue, evicting the oldest entry when full. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Calls `listener` with each record the store deletes on its own (eviction, cascade, purge). Returns an unsubscribe function. | +| `hasEntity` | `(key: number): boolean` | Whether a record is stored for `key`. | --- diff --git a/docs/src/app/core/entities/page.md b/docs/src/app/core/entities/page.md index 11745ae..71b430b 100644 --- a/docs/src/app/core/entities/page.md +++ b/docs/src/app/core/entities/page.md @@ -58,7 +58,7 @@ class GetPost extends RESTQuery { ``` {% callout type="warning" %} -Entities are **read-only**, and so are the snapshots `useQuery` and `useReactive` take of them. Attempting to set a property on an entity, or on a snapshot in a development build, will throw an error. To update entity data, use mutations or streaming updates. +Entities are **read-only**, and so are the snapshots `useQuery` takes of them. Setting a property throws (on snapshots, in development). Use mutations or streaming updates to change entity data. {% /callout %} --- diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index 41ca8da..1dd053f 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -207,13 +207,13 @@ class MyStreamAdapter extends TopicQueryAdapter { The adapter has several protected helper methods: -| Method | Description | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | -| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | -| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass the `topic` it arrived on so that topic's query counts as current (see `reactivationGraceMs`). | -| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | -| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | +| Method | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | +| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | +| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass `topic` so its query counts the event as fresh data. | +| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | +| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | ### Registering the adapter @@ -343,14 +343,15 @@ When the subscription fires a `create` event for a `ChatMessage` whose `channelI ```tsx import { component } from 'signalium/react'; -import { useQuery } from 'fetchium/react'; +import { fetchQuery } from 'fetchium'; const ChatRoom = component(({ channelId }: { channelId: string }) => { - const { messages } = useQuery(GetMessages, { channelId }); + const query = fetchQuery(GetMessages, { channelId }); + if (!query.isReady) return null; return (
- {messages.map((msg) => ( + {query.value.messages.map((msg) => (
{msg.author.name}: {msg.text}
diff --git a/docs/src/app/data/caching/page.md b/docs/src/app/data/caching/page.md index c3582a0..feacbd1 100644 --- a/docs/src/app/data/caching/page.md +++ b/docs/src/app/data/caching/page.md @@ -133,7 +133,7 @@ class GetUser extends RESTQuery { } ``` -`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. Changing `maxCount` between releases resizes the queue already persisted for the class the first time the store touches that class after starting: growing keeps every entry, shrinking evicts the oldest entries that no longer fit. +`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. --- diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index c44362b..8eeb44e 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -147,7 +147,7 @@ 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. +Every failed attempt is retried, including `4xx` responses. Use `shouldRetry` to stop retrying errors you know are permanent. It gets the error, the attempt index and the HTTP status when known. Set it per query in `retry`, or for every query on the `QueryClient`. ```ts class GetUser extends RESTQuery { @@ -161,7 +161,7 @@ class GetUser extends RESTQuery { } ``` -`getErrorStatus(error)` reads the status from an error, if you need it elsewhere. +`getErrorStatus(error)` reads the status from an error. To disable retries entirely: @@ -311,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 --- 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. +To let an error boundary handle query failures, throw the error yourself. Reading `.value` doesn't throw. ```tsx import { ErrorBoundary } from 'react-error-boundary'; @@ -319,7 +319,6 @@ import { ErrorBoundary } from 'react-error-boundary'; function UserProfile({ userId }: { userId: number }) { const result = useQuery(GetUser, { id: userId }); - // Hand a failure with nothing to show to the ErrorBoundary above if (result.isRejected && !result.isReady) throw result.error; if (!result.isReady) return ; @@ -339,7 +338,7 @@ 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. +`useSuspenseQuery` does this for you when the query has no value yet. 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/docs/src/app/setup/project-setup/page.md b/docs/src/app/setup/project-setup/page.md index b7ca2b8..7e2761b 100644 --- a/docs/src/app/setup/project-setup/page.md +++ b/docs/src/app/setup/project-setup/page.md @@ -289,8 +289,8 @@ Fetchium uses a compile-time constant `IS_DEV` to enable additional runtime chec - **Parse validation warnings** --- detailed logs when response data doesn't match your type definitions - **Entity write protection** --- throws when you accidentally try to mutate an entity property directly (entities are read-only) -- **Frozen snapshots** --- the snapshots `useQuery` and `useReactive` hand out are frozen, so mutating one (an in-place `sort()`, an assignment in render) throws a `TypeError` at the offending line instead of silently corrupting what the next render shows -- **Stale-snapshot detection** --- a snapshot taken at an unchanged entity version re-reads only the fields whose values live elsewhere; development builds re-read the rest too and, if one changed, serve the re-read value, log the discrepancy through the client's `log.error`, and raise it once per typename and field as an uncaught error +- **Frozen snapshots** --- mutating a snapshot from `useQuery` throws a `TypeError` +- **Stale-snapshot detection** --- logs an error when a snapshot misses an entity change - **Reference validation** --- catches common mistakes like using conditional logic in class field definitions In production builds, all `IS_DEV` code paths are tree-shaken, so there is zero runtime cost. diff --git a/packages/fetchium/CHANGELOG.md b/packages/fetchium/CHANGELOG.md index 4206483..5a5fdf5 100644 --- a/packages/fetchium/CHANGELOG.md +++ b/packages/fetchium/CHANGELOG.md @@ -4,79 +4,54 @@ ### Major Changes -- 46e237f: Breaking changes. Most apps need no code changes, but tests that depend on loading frames, render counts or timers may. - - **Results are frozen in development.** Mutating query data throws a `TypeError`; copy it first (`[...data.items].sort()`). - - **Cached data shows on the first render** with `SyncQueryStore`. Update tests that wait for a loading state or count renders. - - **Fetches start sooner.** The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`; update tests that advance timers to see them. - - **Identical data doesn't re-render.** A refetch, poll or streamed update with the same data skips the render; read `isFetching` instead of counting renders. - - **Nested values change identity only when they change.** `t.object`, `t.record` and live-collection values are new objects after a change, so memoized children re-render; drop workarounds that forced it. - - **`t.record` and `t.result` fields apply updates** instead of keeping their old value. - - **Changing a Signal param mid-fetch aborts that fetch** and fetches the new params; the old response is dropped. - - **`QueryClient.destroy()` aborts fetches in flight.** Code that awaits a query during teardown gets an `AbortError`; catch it. - - **Awaiting a query can wait for a remount.** If the last component using a query unmounts before its first data arrives, `await fetchQuery(...)` waits for the next mount, `gcTime` collection or `destroy()` instead of rejecting with an `AbortError`. - - **New reserved `QueryClientConfig` names:** `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are read as options; rename custom context values that use them. - - **`retry: {}` and `retry: { retryDelay }` now retry** like `retry: true`; use `retry: false` for no retries. - - **`AsyncQueryStore` honors `cache.maxCount`.** Check the value if you set one. - - **Custom `QueryStore`s** get new optional methods (`mergeEntity`, `onDelete`, `onPersisted`, `isSettled`, `hasQueuedDeletes`, `hasEntity`); without them nothing changes. +- 46e237f: Most apps need no changes. Tests that depend on loading frames, render counts or timers may. + - Query results are frozen in development. Copy data before mutating it. + - With `SyncQueryStore`, cached data shows on the first render. + - The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`. + - Identical data no longer re-renders. Check `isFetching` instead of counting renders. Dates, typed arrays and class instances always count as changed. + - Nested `t.object`, `t.record` and live-collection values are new objects when they change. + - `t.record` and `t.result` fields apply updates. + - Changing a Signal param mid-fetch aborts that fetch. + - After `QueryClient.destroy()`, queries reject with an `AbortError` and send no requests. + - If the last reader unmounts before a query's first data, `await fetchQuery(...)` waits for the next mount, collection or `destroy()`. A topic query mounted and unmounted in the same task still rejects. + - `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. + - `retry: {}` and `retry: { retryDelay }` now retry like `retry: true`. + - `AsyncQueryStore` honors `cache.maxCount`. ### Minor Changes -- 82d67fc: 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. - -- 46e004f: New `QueryClient` options that make coming back to a screen, or to the app, quieter. All are off by default. - - `reactivationGraceMs` (also per query): a query that mounts again with data younger than this doesn't refetch. Data a live subscription kept current counts as fresh. - - `reactivationStaggerMs`: refetches from queries that mount together are spread across this window instead of all starting at once. - - `activity: { isActive(), subscribe(listener) }`: `poll()` stops while the app is inactive and resumes when it's active again. On React Native, wrap `AppState`. `pollResumeJitterMs` spreads out polls that were overdue on resume. - - Pass the topic to `TopicQueryAdapter.sendMutationEvent(event, topic)` so that topic's query counts the push as fresh data. - -- 82d67fc: 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. - -- 5c6824d: A refetch, poll or streamed update that returns the same data no longer re-renders your components. `isPending` and `isFetching` still change as before. - - Values that can't be compared reliably (a `Date`, a typed array, a class instance) always count as changed. - - `t.record(...)` fields now apply updates. Previously a record field kept its old value. - -### Patch Changes - -- a23ad4b: Fixed spurious errors and wrong data when a query mounts, unmounts or changes params quickly. - - Unmounting and remounting a query, in the same task or mid-fetch (switching a chart timeframe and back), no longer shows an `AbortError`. The query shows its cached data, or stays pending, while it refetches. One exception: a topic query with no data that mounts and unmounts in the same task shows the `AbortError` on its next mount until its data arrives (before, it stayed pending forever). - - Changing a Signal param mid-fetch fetches the new params instead of showing the old params' response. A query whose result is not an entity now caches each params value separately; a cold start could show another params value's data. - - `QueryClient.destroy()` aborts every fetch in flight, and code awaiting one gets an `AbortError`. - -- 33458ce: `AsyncQueryStore` now honors a query's `cache.maxCount`, as `SyncQueryStore` does. It used to always keep the default of 50 keys. -- a23ad4b: `useSuspenseQuery` no longer refetches in a loop when a slow retry render takes a while to reach the component. The error goes to the error boundary, and resetting the boundary retries. - - Custom `QueryClientConfig` keys still reach queries as `this.context`, except the reserved option names `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs`. An invalid value for one of these options is ignored instead of breaking queries. - -- 5c6824d: Query results rebuild faster after an update: entities whose data didn't change are not read again. - - List results also keep each row's object identity when the list is inserted into or re-sorted, so memoized rows don't re-render. Development builds still re-read everything to check the result, so the speedup shows in production builds only. - -- a23ad4b: Fixed memoized children (`component()` or `React.memo`) showing a stale value when given a nested value such as `token.price` as a prop. A nested `t.object`, `t.record` or live-collection value is now a new object when it changes, and keeps its identity when it doesn't. -- 5c636ff: Streamed updates write each changed entity to the store once instead of two or three times, and an update that changes nothing writes nothing. - - A `create` event for an entity that no live collection shows is no longer written to the store, where it was never cleaned up. - -- 46e237f: Fixed stale data in components when a second query loads entities that are already in memory, and streamed updates that were lost on restart. - - Development builds freeze query results, so mutating one (such as calling `sort()` on a list in render) throws a `TypeError`. Copy the data first, and treat production results as read-only too. - - In development, the stale-data check logs an error instead of throwing and blocking other components' updates. - - Custom stores can add the new optional `QueryStore.mergeEntity`, so an entity built from streamed updates doesn't overwrite stored fields those updates didn't include. Without it, writes work as before. - -- 33458ce: Fixed components not updating when an entity changes inside a union that has an array or record member, such as `t.union(t.array(t.entity(A)), t.object({ ... }))`. -- 33458ce: Fixed cached queries failing to load after a restart ("the query cache may be corrupted or invalid") when a query had more than `cache.maxCount` cached params and refetched unchanged data. - - Custom stores: `QueryStore` has a new optional `onDelete(listener)`. Call the listener with every id the store deletes on its own. A store without it keeps writing every update, as before. - - A `t.array(t.entity(X))` field whose typename two entity classes share now shows an item as soon as it gains the fields `X` requires. - -- 0600009: With `SyncQueryStore`, a cached query renders its data on the first render instead of showing a loading frame first. - - The first fetch starts on a microtask instead of after `setTimeout(0)`. Tests that wait for a loading state or count renders may need updating. - - A cached entry that can't be loaded is treated as a cache miss and fetched. Previously a fresh-looking entry could leave the query pending without fetching. - -- a23ad4b: Faster streamed updates with `SyncQueryStore` for entities that aren't in memory, which is common when two entity classes share a typename. +- 82d67fc: New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before a component reads them. A component that mounts meanwhile reuses the request. + - New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no value. + - Refetches without `debounce` start on a microtask instead of after `setTimeout(0)`. +- 46e004f: New `QueryClient` options, all off by default: + - `reactivationGraceMs` (also per query): skip the refetch when a query remounts with data younger than this. + - `reactivationStaggerMs`: spread the refetches of queries that remount together across this window. + - `activity` and `pollResumeJitterMs`: pause `poll()` while the app is inactive and spread overdue polls on resume. + - `TopicQueryAdapter.sendMutationEvent(event, topic)` counts the event as fresh data for that topic's query. +- 82d67fc: New `shouldRetry(error, attempt, status)` option on `QueryClientConfig` and `retry` configs. Return `false` to stop retrying, for example on a 404. `getErrorStatus` is exported and `retry.retries` is now optional. +- 5c6824d: A refetch, poll or streamed update with identical data no longer re-renders components. Dates, typed arrays and class instances always count as changed. `t.record` fields now apply updates. + +### Patch Changes + +- a23ad4b: Fixed errors and wrong data when a query mounts, unmounts or changes params quickly. + - Remounting a query after an aborted fetch shows its cached data, or stays pending, instead of an `AbortError`. A topic query with no data that mounts and unmounts in the same task is the exception: it shows the `AbortError` until its data arrives, and its first awaiter rejects. + - Changing a Signal param mid-fetch fetches the new params. Each params value of a non-entity result is cached separately. + - `QueryClient.destroy()` aborts fetches in flight. Queries read afterwards reject with an `AbortError` and send no requests. +- 33458ce: `AsyncQueryStore` now honors a query's `cache.maxCount` instead of always keeping 50 entries. +- a23ad4b: Fixed `useSuspenseQuery` refetching in a loop when a retry render is slow. + - A query whose `getConfig()` throws now rejects with that error instead of breaking other queries. + - `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. Other keys still reach queries as `this.context`. +- 5c6824d: Query results rebuild faster after an update in production builds, and list rows keep their identity when the list is re-sorted or inserted into. +- a23ad4b: Fixed memoized children not updating when passed a nested value such as `token.price`. Nested `t.object`, `t.record` and live-collection values are new objects when they change. +- 5c636ff: Streamed updates write each changed entity once. With a store that implements `onDelete`, unchanged updates write nothing. +- 46e237f: Fixed stale data when a query loads entities already in memory, and streamed updates lost on restart. + - Development builds freeze query results. Copy data before mutating it. + - Custom stores can implement the new optional `QueryStore.mergeEntity`. +- 33458ce: Fixed components not updating when an entity changes inside a union with an array or record member. +- 33458ce: Fixed cached queries failing to load after a restart when a query had more than `cache.maxCount` cached entries. + - Custom stores can implement the new optional `QueryStore.onDelete(listener)`. +- 0600009: With `SyncQueryStore`, cached data shows on the first render, and the first fetch starts on a microtask. A cache entry that fails to load is refetched. +- a23ad4b: Faster streamed updates with `SyncQueryStore` for entities not in memory. ## 0.5.2 diff --git a/packages/fetchium/plugin/agents/fetchium.md b/packages/fetchium/plugin/agents/fetchium.md index 2d8189a..004f7ea 100644 --- a/packages/fetchium/plugin/agents/fetchium.md +++ b/packages/fetchium/plugin/agents/fetchium.md @@ -137,9 +137,9 @@ function UserProfile() { } ``` -`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. +`useQuery` returns `ReactivePromise` with: `value`, `isReady`, `isPending`, `isResolved`, `isRejected`, `error`. It never suspends; `value` is `undefined` until data arrives. `useSuspenseQuery` takes the same arguments and suspends only while there's no data. A failed first fetch goes 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. +Call `queryClient.prefetch(QueryClass, params, { ttl })` when the user commits to a navigation, and `queryClient.retain(fn, { ttl })` to keep a screen's queries active. Both return `release()`. See the API reference. ## React + Signalium Usage diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index d1a82ae..53156d8 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -497,15 +497,7 @@ function reportDrift(source: EntitySnapshotSource, key: string, what: string): v snapshotDriftHandler(error, source.instance._queryClient); } -/** - * Checks the fast path's assumptions: `data` only changes through a `notify()` - * that bumps `version`, and every field outside `keys.dynamic` is a pure - * function of `data`. Returns the static fields that drifted, if any. - * Dynamic fields are skipped, since re-reading them is not identity-stable. - * - * The re-read cancels the fast path's savings, so the speedup only shows in - * production builds. - */ +/** Dev-only: returns the static fields whose cached value no longer matches a re-read of `data`. */ function verifyStaticFields( source: EntitySnapshotSource, keys: EntityKeys, diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 4735402..6033245 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -53,40 +53,21 @@ export interface QueryClientConfig { }; evictionMultiplier?: number; /** - * Milliseconds. A query that reactivates (a watcher returns, or a paused - * scope resumes) with data younger than this is not refetched, even if stale. - * Subscription pushes count as fresh data. Queries can override it with - * `reactivationGraceMs`. Reconnects, `refetch()`, invalidation, `markStale()` - * and a failed last fetch still refetch. Default: 0. `Infinity` never - * refetches on reactivation. + * Ms a reactivated query's data counts as fresh, skipping the refetch. Reconnects, + * `refetch()`, invalidation and failed fetches still refetch. Queries can override it. + * Default 0. */ reactivationGraceMs?: number; - /** - * 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. - */ + /** Decides whether a failed attempt is retried. A query's or mutation's `retry.shouldRetry` wins. */ 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. Values that - * are not finite and positive count as 0. Default: 0. + * Ms window over which refetches from queries that reactivate together are spread, instead of + * all starting at once. Default 0. */ reactivationStaggerMs?: number; - /** - * Foreground/background source. When set, `poll()` stops its timers while - * the app is inactive and resumes them when it becomes active again. Values - * without `isActive` and `subscribe` are ignored. Default: undefined. - */ + /** Foreground/background source. `poll()` stops while it reports inactive. */ activity?: ActivitySource; - /** - * Milliseconds. A `poll()` tick that is overdue on resume, or whose timer - * fires over a second late, runs at a random point within this window - * instead of immediately. Values that are not finite and positive count as - * 0. Default: 0. - */ + /** Ms window over which `poll()` ticks that are overdue on resume are randomized. Default 0. */ pollResumeJitterMs?: number; } diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index e3eec41..3b0f578 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -100,11 +100,10 @@ export interface QueryStore { saveEntity(entityKey: number, value: unknown, refIds?: Set): void; /** - * Writes the fields streamed events supplied, merged over the stored record - * if one exists (references derived from the merged value). Without it the - * client calls `saveEntity`, dropping fields the events did not carry. - * With `ifStored`, nothing is written when no record is stored, and a store - * with `onDelete` reports the key as dropped. + * Merges streamed fields over the stored record, deriving `refIds` from the + * merged value. Without it, `saveEntity` overwrites the record. With + * `ifStored`, writes nothing if no record exists and, if the store has + * `onDelete`, reports the key as dropped. */ mergeEntity?(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void; @@ -115,36 +114,29 @@ export interface QueryStore { purgeStaleQueries?(): MaybePromise; /** - * Called with the key of every record the store drops on its own (eviction, - * cascade, purge). May return an unsubscribe, called from `destroy()`. + * Reports every record the store drops on its own (eviction, cascade, purge). * Without it, entities are written on every apply. */ onDelete?(listener: (key: number) => void): void | (() => void); /** - * Async stores: called with each entity key once its write is applied. The - * client treats an entity as persisted only after this acknowledgement. + * Async stores: reports each entity key once its write is applied. Without it, an entity + * counts as persisted when `saveEntity` returns. */ onPersisted?(listener: (key: number) => void): void | (() => void); /** - * Async stores: whether every queued operation has been processed. While - * `false` the client does not skip unchanged entity writes. Absent means - * always settled. + * Async stores: whether every queued operation has been processed. Absent means settled. + * While `false`, unchanged entity writes aren't skipped. */ isSettled?(): boolean; - /** - * Async stores: a narrower `isSettled` that ignores the client's own entity - * writes, reporting only queued operations that could delete a record. - * Takes precedence over `isSettled` when present. - */ + /** Like `isSettled`, but ignores the client's own entity writes. Wins over `isSettled` if both are set. */ hasQueuedDeletes?(): boolean; /** - * Whether the store holds a record for this key, or `undefined` if it can't - * tell yet. An event for an entity not in memory is written as an `ifStored` - * merge unless this returns `false`. Without `mergeEntity`, only when `true`. + * Whether the store holds a record for this key, or `undefined` if unknown. When unknown, + * streamed events are merged only if the store has `mergeEntity`. */ hasEntity?(key: number): boolean | undefined; } diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index 280aa8a..85c63b5 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -1,10 +1,8 @@ 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. + * Decides whether a failed attempt is retried. Called only while retries remain. `attempt` + * starts at 0; `status` is undefined for network errors. */ export type ShouldRetry = (error: unknown, attempt: number, status: number | undefined) => boolean; diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 2c6518c..8f393cc 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -175,7 +175,6 @@ export class AsyncQueryStore implements QueryStore { console.error('Ignoring a message the store does not understand:', msg); return; } - // Enqueue the message for serial processing this.enqueueMessage(msg); }