From 721def5e52e1d79d00cdd40fb7dc2a238dd58578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sat, 24 Oct 2026 14:56:03 +0200 Subject: [PATCH] docs: document all source APIs with checked examples --- src/query/cache.ts | 114 ++++- src/query/index.ts | 20 + src/query/options.ts | 463 +++++++++++++++++-- src/query/query.ts | 331 ++++++++++++- src/react/components/QueryPrefetch.tsx | 28 +- src/react/components/QueryPrefetchTags.tsx | 30 +- src/react/components/QueryProvider.tsx | 32 ++ src/react/components/QueryTransition.tsx | 37 +- src/react/context.ts | 21 + src/react/hooks/useQuery.ts | 69 ++- src/react/hooks/useQueryActions.ts | 97 +++- src/react/hooks/useQueryBasic.ts | 106 ++++- src/react/hooks/useQueryContext.ts | 8 +- src/react/hooks/useQueryInstance.ts | 28 +- src/react/hooks/useQueryPrefetch.ts | 13 +- src/react/hooks/useQueryStatus.ts | 124 ++++- src/react/hooks/useQueryTransitionContext.ts | 14 +- src/react/index.ts | 25 + src/react/transition.ts | 27 ++ src/solid/components/QueryProvider.tsx | 30 +- src/solid/context.ts | 43 +- src/solid/index.ts | 16 + 22 files changed, 1555 insertions(+), 121 deletions(-) diff --git a/src/query/cache.ts b/src/query/cache.ts index 29abb8b..17283a8 100644 --- a/src/query/cache.ts +++ b/src/query/cache.ts @@ -1,76 +1,164 @@ /** - * Determines the shape of the Cache instance. + * Minimal synchronous storage contract used by the query caches. + * Values are retained, not cloned; Map implements this contract. Custom + * stores must expose current keys and return undefined for missing entries. + * + * @example + * const cache: Cache = new Map() + * cache.set('count', 1) + * console.log(cache.get('count')) // 1 */ export interface Cache { /** - * Gets an item from the cache. + * Reads the retained entry without fetching or checking expiration. + * Missing keys return undefined; the stored object is not copied. + * + * @example + * const cache: Cache = new Map([['count', 1]]) + * console.log(cache.get('count')) // 1 */ readonly get: (key: string) => T | undefined /** - * Sets an item to the cache. + * Retains a value under its exact key, replacing any existing entry. + * Query instances rely on this write being visible synchronously. + * + * @example + * const cache: Cache = new Map() + * cache.set('count', 2) + * console.log(cache.get('count')) // 2 */ readonly set: (key: string, value: T) => void /** - * Removes a key-value pair from the cache. + * Removes the entry synchronously; deleting a missing key is harmless. + * Direct deletion does not emit query events or cancel a request. + * + * @example + * const cache: Cache = new Map([['count', 1]]) + * cache.delete('count') + * console.log(cache.get('count')) // undefined */ readonly delete: (key: string) => void /** - * Returns the current cached keys. + * Iterates the stored keys in the underlying store's iteration order. + * No expiration filtering is performed by this storage interface. + * + * @example + * const cache: Cache = new Map([['count', 1]]) + * console.log(Array.from(cache.keys())) // ['count'] */ readonly keys: () => IterableIterator } /** - * Determines how we store items in the items cache. + * Retained result promise and its expiration date in the items cache. + * Expired entries remain readable until overwritten or explicitly forgotten. + * + * @example + * const entry: ItemsCacheItem = { + * item: Promise.resolve(1), expiresAt: new Date(0), + * } + * console.log(await entry.item) // 1 */ export interface ItemsCacheItem { /** - * Stores the cache item. + * Keeps the result promise so valid cache reads preserve promise identity. + * Awaiting a custom cache's rejected promise can reject a snapshot read. + * + * @example + * const entry: ItemsCacheItem = { item: Promise.resolve(1), expiresAt: new Date(0) } + * console.log(await entry.item) // 1 */ readonly item: Promise /** - * Determines the expiration date of the item. + * Absolute time after which a query may revalidate this entry. + * The Date is retained, not cloned; do not mutate it after insertion. + * + * @example + * const entry: ItemsCacheItem = { item: Promise.resolve(1), expiresAt: new Date(0) } + * console.log(entry.expiresAt.getTime()) // 0 */ readonly expiresAt: Date } /** - * Determines how we store items in the resolvers cache. + * Active fetch promise and its cancellation/ownership controller. + * Query instances remove the entry when its owning request settles. + * + * @example + * const entry: ResolversCacheItem = { + * item: Promise.resolve(1), controller: new AbortController(), + * } + * console.log(await entry.item) // 1 */ export interface ResolversCacheItem { /** - * The resolvable item. + * Pending request promise shared by deduplicated callers of this key. + * It can reject when fetching fails or cancellation is observed. + * + * @example + * const entry: ResolversCacheItem = { + * item: Promise.resolve(1), controller: new AbortController(), + * } + * console.log(await entry.item) // 1 */ readonly item: Promise /** - * The abort controller for the request. + * Signals cancellation and identifies which request owns the resolver. + * A fetcher must observe its signal to cancel underlying work promptly. + * + * @example + * const entry: ResolversCacheItem = { + * item: Promise.resolve(1), controller: new AbortController(), + * } + * entry.controller.abort('navigation') */ readonly controller: AbortController } /** - * Determines the cache type. + * Selects stored results or active requests for key enumeration. + * The default for query.keys() is the items cache. + * + * @example + * const cache: CacheType = 'resolvers' + * console.log(createQuery().keys(cache)) // [] */ export type CacheType = 'resolvers' | 'items' /** - * The caches available on the query. + * Live storage references returned by query.caches(), not a snapshot copy. + * Prefer query methods for writes so ownership and events stay consistent. + * + * @example + * const caches: Caches = createQuery().caches() + * console.log(Array.from(caches.items.keys())) // [] */ export interface Caches { /** * A cache that contains the resolved items alongside * their expiration time. + * Direct access bypasses event delivery and request ownership rules. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * console.log(await query.caches().items.get('count')?.item) // 1 */ readonly items: Cache /** * A cache that contains the resolvers alongside * their abort controllers. + * Inspect it for diagnostics; use query.abort() to cancel with events. + * + * @example + * const query = createQuery() + * console.log(Array.from(query.caches().resolvers.keys())) // [] */ readonly resolvers: Cache } diff --git a/src/query/index.ts b/src/query/index.ts index b54d635..d6ce832 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -1,3 +1,23 @@ +/** + * Framework-independent promise cache with keyed events and explicit writes. + * createQuery owns request selection, expiration, cancellation signals, and + * per-key mutation ordering. Supplied objects are retained, not cloned. + * Fetched values live for 2000 ms by default; local writes expire immediately. + * Channels are opt-in and caller-owned. Channel names are not authorization. + * + * Examples in this directory use named exports from this package entry point. + * Fetch failures reject caller promises; stale background failures emit error. + * For framework render lifetimes use Query.resource rather than repeatedly + * forcing fresh imperative queries. + * + * @example + * import { createQuery } from '@studiolambda/query' + * const query = createQuery({ fetcher: async () => ({ name: 'Ada' }) }) + * try { console.log(await query.query('profile')) } + * catch (error) { console.error(error) } + * + * @module + */ export * from './query' export * from './cache' export * from './options' diff --git a/src/query/options.ts b/src/query/options.ts index 6c9076b..e167c57 100644 --- a/src/query/options.ts +++ b/src/query/options.ts @@ -9,26 +9,52 @@ import { /** * Represents the available configuration options * for a query instance. + * Supplied caches, events, and channels are retained, not cloned or closed. + * Per-call Options can override fetch policy without replacing those stores. + * + * @example + * const options: Configuration = { stale: false, expiration: () => 60000 } + * const query = createQuery(options) + * console.log(query.keys()) // [] */ export interface Configuration extends Options { /** - * Determines the resolved items cache to use. + * Supplies retained result storage; defaults to a new Map per instance. + * Sharing a cache shares entries but not the instances' event listeners. + * + * @example + * const itemsCache = new Map() + * const query = createQuery({ itemsCache }) + * query.hydrate('count', 1) + * console.log(itemsCache.has('count')) // true */ readonly itemsCache?: Cache /** - * Determines the resolvers cache to use. + * Supplies retained in-flight request storage; defaults to a new Map. + * Shared resolver storage also shares request deduplication and ownership. + * + * @example + * const resolversCache = new Map() + * const query = createQuery({ resolversCache }) + * console.log(query.caches().resolvers === resolversCache) // true */ readonly resolversCache?: Cache /** - * Stores the event system. + * Supplies the event target for keyed CustomEvents; defaults to a new one. + * The instance retains this target and does not remove external listeners. + * + * @example + * const events = new EventTarget() + * const query = createQuery({ events }) + * console.log(query.events() === events) // true */ readonly events?: EventTarget /** * Opts into broadcasting full query event payloads between browser - * contexts. No channel is used by default, including in QueryProvider. + * contexts. No channel is used by default, including in React's QueryProvider. * Scope the name to the account, tenant, and session whose data may be * shared. Channel names are routing, not an authorization boundary: * other code on the same origin can subscribe to the same name. @@ -53,27 +79,56 @@ export interface Configuration extends Options { /** * Additional parameters passed to the fetcher function, * providing an abort signal for cancellation support. + * The fetcher owns cancellation of its underlying I/O, not the query cache. + * + * @example + * const additional: FetcherAdditional = { signal: new AbortController().signal } + * console.log(additional.signal.aborted) // false */ export interface FetcherAdditional { /** - * An abort signal to cancel pending queries. + * Signals cancellation of this particular fetch attempt. + * Pass it to I/O APIs; ignoring it can leave underlying work running. + * + * @example + * const fetcher: FetcherFunction = async (_key, { signal }) => { + * signal.throwIfAborted() + * return 'ready' + * } + * console.log(await fetcher('status', { signal: new AbortController().signal })) */ readonly signal: AbortSignal } /** - * The available options for query. + * Per-request fetching policy, falling back to instance defaults when absent. + * Fresh bypasses cache/deduplication; stale only affects expired entries. + * + * @example + * const options: Options = { fetcher: async () => 'ready', stale: false } + * console.log(await createQuery().query('status', options)) // 'ready' */ export interface Options { /** * Determines the item deduplication interval. * This determines how many milliseconds an item * is considered valid. + * Defaults to 2000 for fetched results. The clock starts on fetch commit; + * this does not change hydrate/mutate's zero-millisecond defaults. + * + * @example + * const query = createQuery({ expiration: () => 60000 }) + * console.log(query.keys()) // [] */ readonly expiration?: ExpirationOptionFunction /** - * Determines the fetcher function to use. + * Retrieves a value from the exact cache key and cancellation signal. + * Defaults to defaultFetcher(fetch), which parses successful JSON responses. + * + * @example + * const query = createQuery({ fetcher: async (key) => ({ key }) }) + * console.log(await query.query('profile')) // { key: 'profile' } */ readonly fetcher?: FetcherFunction @@ -83,6 +138,11 @@ export interface Options { * stored in the cache if it has expired. It will attempt * to revalidate it in the background. If `false`, the returned * promise will be the revalidation promise. + * Defaults to true. Unexpired entries are reused even when stale is false. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * console.log(await query.query('count', { stale: false })) // 1 */ readonly stale?: boolean @@ -90,52 +150,104 @@ export interface Options { * Removes the stored item if there is an error in the request. * By default, we don't remove the item upon failure, only the resolver * is removed from the cache. + * This defaults to false and applies only to the owning fetch's failure. + * + * @example + * const query = createQuery({ removeOnError: true, fetcher: async () => 1 }) + * console.log(await query.query('count')) // 1 */ readonly removeOnError?: boolean /** * Determines if the result should be a fresh fetched * instance regardless of any cached value or its expiration time. + * Defaults to false. True also aborts the previous active request for the + * key; that request's caller still needs to handle its own rejection. + * Query.resource applies freshness once per shared render selection, not + * once per retry or committed sibling read of that selection. + * + * @example + * const query = createQuery({ fetcher: async () => 2 }) + * query.hydrate('count', 1, { expiration: () => 60000 }) + * console.log(await query.query('count', { fresh: true })) // 2 */ readonly fresh?: boolean } /** - * The mutation function type. + * Computes a replacement from the preceding committed value and expiration. + * Mutations serialize per key. Missing entries supply undefined; a rejection + * preserves the previous entry. Never await a mutation of the same key here. + * + * @example + * const increment: MutationFunction = (previous) => (previous ?? 0) + 1 + * console.log(await createQuery().mutate('count', increment)) // 1 */ export type MutationFunction = { (previous?: T, expiresAt?: Date): T | Promise } /** - * The available mutation values. + * Direct replacement value or callback computing the next committed value. + * Function values are always treated as mutation callbacks, not stored data. + * + * @example + * const replacement: MutationValue = 2 + * console.log(await createQuery().mutate('count', replacement)) // 2 */ export type MutationValue = T | MutationFunction /** - * The broadcast payload. + * Wire shape used to replay a keyed event in another browser context. + * Receiving it dispatches a local event; it does not write the local cache. + * + * @example + * const payload: BroadcastPayload = { event: 'hydrated:count', detail: 1 } + * console.log(payload.event) // 'hydrated:count' */ export interface BroadcastPayload { /** - * The event name. + * Event kind and exact key separated by a colon. + * Keys may themselves contain colons; consumers receive the full name. + * + * @example + * const payload: BroadcastPayload = { event: 'hydrated:user:1', detail: 'Ada' } + * console.log(payload.event) // 'hydrated:user:1' */ readonly event: `${QueryEvent}:${string}` /** - * The event detail. + * Structured-cloneable payload delivered to remote event listeners. + * It is unknown at this boundary; consumers must know their event schema. + * + * @example + * const payload: BroadcastPayload = { event: 'hydrated:count', detail: 1 } + * console.log(payload.detail) // 1 */ readonly detail: unknown } /** - * The function type for the broadcast subscription. + * Starts receiving events on the currently configured optional channel. + * Cleanup removes that listener without closing the caller-owned channel. + * A missing channel makes both subscription and cleanup harmless no-ops. + * + * @example + * const subscribe: SubscribeBroadcastFunction = createQuery().subscribeBroadcast + * const stop = subscribe() + * stop() */ export type SubscribeBroadcastFunction = { (): Unsubscriber } /** - * The query function type. + * Gets a cached or fetched value according to per-call and instance policy. + * Returned promises can reject on fetch failure or observed cancellation. + * + * @example + * const read: QueryFunction = createQuery({ fetcher: async () => 1 }).query + * try { console.log(await read('count')) } catch (error) { console.error(error) } */ export type QueryFunction = { (key: string, options?: Options): Promise @@ -145,6 +257,9 @@ export type QueryFunction = { * A render-safe request selected by {@link Query.resource}. Fresh requests * are shared by key within one instance, including across Suspense retries. * Retain only after a consumer commits; release exactly once on cleanup. + * Failed or abandoned uncommitted selections remain cached until forget, + * request/cache configuration changes, or instance collection. The object + * does not itself subscribe or notify consumers; keyed query events do that. * * @example * ```ts @@ -175,6 +290,8 @@ export interface QueryResource { * The last release evicts a fresh request on the next microtask, allowing * synchronous effect reconnection without starting another request. * Ordinary cached requests need no retention, so their cleanup is a no-op. + * Release functions are not idempotent: match each retain with one release. + * Releasing a fresh selection does not abort its transport or forget its data. * * @example * ```ts @@ -192,6 +309,11 @@ export interface QueryResource { * for a cached item based on its value. * * @template T - The type of the cached item. + * @returns Lifetime in milliseconds, added to the commit time. A zero or + * negative lifetime makes the entry eligible for immediate revalidation. + * @example + * const expiration: ExpirationOptionFunction = (value) => value ? 60000 : 0 + * console.log(expiration('ready')) // 60000 */ export type ExpirationOptionFunction = { (item: T): number @@ -202,55 +324,105 @@ export type ExpirationOptionFunction = { * Receives an abort signal for cancellation support. * * @template T - The type of the fetched data. + * @returns A promise for the value; rejecting it fails this fetch attempt. + * @example + * const fetcher: FetcherFunction = async (key, { signal }) => { + * signal.throwIfAborted() + * return key.toUpperCase() + * } + * console.log(await createQuery({ fetcher }).query('ready')) // 'READY' */ export type FetcherFunction = { (key: string, additional: FetcherAdditional): Promise } /** - * The hydrate function options. + * Lifetime policy for manually seeded data, independent of fetch defaults. + * Without an expiration callback hydrated values expire immediately. + * + * @example + * const options: HydrateOptions = { expiration: () => 60000 } + * const query = createQuery() + * query.hydrate('count', 1, options) */ export interface HydrateOptions { /** * Custom expiration function for the hydrated item, overriding - * the default expiration configuration. + * the zero-millisecond hydration default, not the instance fetch policy. + * The callback runs before cache writes and may throw to the caller. + * + * @example + * createQuery().hydrate('count', 1, { expiration: () => 60000 }) */ readonly expiration?: ExpirationOptionFunction } /** - * The mutate function options. + * Lifetime policy applied when an optimistic mutation commits. + * Without an expiration callback mutations expire immediately. + * + * @example + * const options: MutateOptions = { expiration: () => 60000 } + * console.log(await createQuery().mutate('count', 1, options)) // 1 */ export interface MutateOptions { /** * Custom expiration function for the mutated item, overriding - * the default expiration configuration. + * the zero-millisecond mutation default, not the instance fetch policy. + * A thrown callback rejects the mutation before changing the cache. + * + * @example + * await createQuery().mutate('count', 1, { expiration: () => 60000 }) */ readonly expiration?: ExpirationOptionFunction } /** - * The mutate function type. + * Queues a local replacement and resolves with its committed value. + * Same-key calls commit in order; failures reject without blocking later + * mutations. Commits supersede older fetch writes, not their caller results. + * + * @example + * const mutate: MutateFunction = createQuery().mutate + * try { console.log(await mutate('count', 1)) } catch (error) { console.error(error) } */ export type MutateFunction = { (key: string, item: MutationValue, options?: MutateOptions): Promise } /** - * The hydrate function type. + * Synchronously seeds one or more keys with the same retained value/promise. + * Supersedes older fetch writes; custom expiration errors throw before writes. + * + * @example + * const hydrate: HydrateFunction = createQuery().hydrate + * hydrate(['primary', 'alias'], { name: 'Ada' }, { expiration: () => 60000 }) */ export type HydrateFunction = { (keys: string | readonly string[], item: T, options?: HydrateOptions): void } /** - * The unsubscriber function. + * Releases an acquired event subscription without closing shared resources. + * Call it when the subscriber stops using a query to avoid retained listeners. + * + * @example + * const stop: Unsubscriber = createQuery().subscribe('count', 'hydrated', () => {}) + * stop() */ export type Unsubscriber = () => void /** * Function type that returns the first event that occurs for a given key. * Subscribes to the event and automatically unsubscribes after receiving it. + * An optional signal cancels only the wait, rejecting with its abort reason. + * + * @example + * const query = createQuery() + * const once: OnceFunction = query.once + * const pending = once('count', 'hydrated') + * query.hydrate('count', 1) + * console.log((await pending).detail) // 1 */ export type OnceFunction = { (key: string, event: QueryEvent, signal?: AbortSignal): Promise> @@ -259,6 +431,17 @@ export type OnceFunction = { /** * Function type that returns an async generator yielding events as they * occur for a given key, allowing iteration over a sequence of events. + * Listens only during each pending next(); it does not buffer intervening + * events. Calling return() cannot interrupt an already pending event wait. + * + * @example + * const query = createQuery() + * const sequence: SequenceFunction = query.sequence + * const events = sequence('count', 'hydrated') + * const pending = events.next() + * query.hydrate('count', 1) + * console.log((await pending).value?.detail) // 1 + * await events.return(undefined) */ export type SequenceFunction = { (key: string, event: QueryEvent): AsyncGenerator> @@ -267,6 +450,15 @@ export type SequenceFunction = { /** * Function type that waits for and returns the next resolved value(s) * for one or more keys after a refetching event occurs. + * Object/array inputs preserve their shape. It does not initiate fetching; + * an optional signal cancels event waits, not already received fetch promises. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const next: NextFunction = query.next + * const pending = next('count') + * await query.query('count') + * console.log(await pending) // 1 */ export type NextFunction = { (keys: string | { [K in keyof T]: string }, signal?: AbortSignal): Promise @@ -275,6 +467,17 @@ export type NextFunction = { /** * Function type that returns an async generator yielding resolved values * as they come in, allowing continuous streaming of query results. + * It waits for refetching events without initiating queries or buffering + * between iterations. return() cannot interrupt an already pending wait. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const stream: StreamFunction = query.stream + * const values = stream('count') + * const pending = values.next() + * await query.query('count') + * console.log((await pending).value) // 1 + * await values.return(undefined) */ export type StreamFunction = { (keys: string | { [K in keyof T]: string }): AsyncGenerator @@ -283,6 +486,14 @@ export type StreamFunction = { /** * Function type for updating the query instance configuration at runtime. * Allows partial updates to the configuration options. + * Undefined retains existing values, including channels; explicit false + * updates boolean options. Replacing stores does not migrate old entries. + * Supplying any request/cache property invalidates render selections, even + * if its value is undefined; events/broadcast-only updates preserve them. + * + * @example + * const configure: ConfigureFunction = createQuery({ fresh: true }).configure + * configure({ fresh: false, stale: false }) */ export type ConfigureFunction = { (options?: Configuration): void @@ -291,6 +502,12 @@ export type ConfigureFunction = { /** * Function type for aborting pending query resolvers. Can abort all * resolvers, specific keys, or provide a custom abort reason. + * Removes selected resolvers and emits aborted; fetchers must honor their + * signals for prompt I/O cancellation. Existing callers still settle later. + * + * @example + * const abort: AbortFunction = createQuery().abort + * abort(['profile', 'settings'], 'navigation') */ export type AbortFunction = { (key?: string | readonly string[], reason?: unknown): void @@ -299,6 +516,13 @@ export type AbortFunction = { /** * Function type that returns the current cached value for a key without * triggering a fetch. Returns undefined if the key is not in the cache. + * Expiration is ignored. A custom stored rejected promise rejects this read. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * const snapshot: SnapshotFunction = query.snapshot + * console.log(await snapshot('count')) // 1 */ export type SnapshotFunction = { (key: string): Promise @@ -307,6 +531,11 @@ export type SnapshotFunction = { /** * Function type that returns all keys currently stored in the specified * cache (items or resolvers). + * Defaults to items, includes expired entries, and preserves store order. + * + * @example + * const keys: KeysFunction = createQuery().keys + * console.log(keys('resolvers')) // [] */ export type KeysFunction = { (cache?: CacheType): readonly string[] @@ -315,6 +544,11 @@ export type KeysFunction = { /** * Function type that returns the expiration date of a cached item. * Returns undefined if the key is not in the cache. + * Returns the retained Date, not a clone; callers must not mutate it. + * + * @example + * const expiration: ExpirationFunction = createQuery().expiration + * console.log(expiration('missing')) // undefined */ export type ExpirationFunction = { (key: string): Date | undefined @@ -323,6 +557,17 @@ export type ExpirationFunction = { /** * Function type for removing cached data. Can forget specific keys, * multiple keys, or keys matching a regular expression pattern. + * Omitted keys select all items. It does not abort requests, which may + * repopulate the cache. Events follow awaiting each removed result promise. + * Also drops matching fresh render selections, including failed/abandoned + * reads with no item entry. Patterns and omitted keys cover both stores. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * const forget: ForgetFunction = query.forget + * await forget(/^count$/) + * console.log(query.keys()) // [] */ export type ForgetFunction = { (keys?: string | readonly string[] | RegExp): Promise @@ -345,11 +590,14 @@ export interface Query { * Updates supplied defaults. Request/cache options invalidate fresh * selections; changing only events/broadcast preserves them. Existing * promises/transports remain active; ordinary cached items remain. + * Undefined keeps the prior setting; false and expiration callbacks returning + * zero are valid. Nullish values cannot remove a configured channel. * * @example * ```ts * const instance = createQuery({ fetcher: async () => 'hello' }) * instance.configure({ fresh: true }) + * instance.configure({ fresh: false, expiration: () => 0 }) * ``` */ readonly configure: ConfigureFunction @@ -357,6 +605,11 @@ export interface Query { /** * Fetches the key information using a fetcher. * The returned promise contains the result item. + * Valid cached promises are reused unless fresh is true. See QueryFunction. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * try { console.log(await query.query('count')) } catch (error) { console.error(error) } */ readonly query: QueryFunction @@ -386,6 +639,14 @@ export interface Query { * Emit is able to send events to active subscribers * with the given payload. It is a low level API * and should be used with care. + * It dispatches events, not cache writes. Runtime success completion + * payloads are values; refetching and mutating payloads are promises. + * + * @example + * const query = createQuery() + * const stop = query.subscribe('count', 'hydrated', (event) => console.log(event.detail)) + * query.emit('count', 'hydrated', 1) + * stop() */ readonly emit: EmitFunction @@ -393,6 +654,15 @@ export interface Query { * Subscribes to a given event & key. The event handler * does have a payload parameter that will contain relevant * information depending on the event type. + * Runtime refetching/mutating detail is a promise; resolved, mutated, + * hydrated, and forgotten detail is the value, despite legacy overloads. + * Errors/aborts carry their reason. Cleanup removes the keyed listener. + * + * @example + * const query = createQuery() + * const stop = query.subscribe('count', 'hydrated', (event) => console.log(event.detail)) + * query.hydrate('count', 1) + * stop() */ readonly subscribe: SubscribeFunction @@ -401,13 +671,25 @@ export interface Query { * to listen for other browser context * events and reproduce them in the current * context. + * No channel is created by default. Remote events do not update caches; + * cleanup removes the listener without closing the captured channel. + * + * @example + * const query = createQuery() + * const stop = query.subscribeBroadcast() // No configured channel: no-op. + * stop() */ readonly subscribeBroadcast: SubscribeBroadcastFunction /** - * Mutates the key with a given optimistic value. - * The mutated value is considered expired and will be - * replaced immediately if a refetch happens. + * Mutates the key with a given optimistic value, defaulting to immediate + * expiration unless an explicit mutation lifetime is set. + * Same-key mutations serialize; failed callbacks preserve the old entry. + * + * @example + * const query = createQuery() + * try { await query.mutate('count', (value) => (value ?? 0) + 1) } + * catch (error) { console.error(error) } */ readonly mutate: MutateFunction @@ -416,6 +698,10 @@ export interface Query { * by calling `.abort()` on the `AbortController`. * The fetcher is responsible for using the * `AbortSignal` to cancel the job. + * Omitted keys select every resolver. Aborting does not erase cached data. + * + * @example + * createQuery().abort(['profile', 'settings'], 'navigation') */ readonly abort: AbortFunction @@ -435,23 +721,45 @@ export interface Query { /** * Hydrates the given keys on the cache * with the given value and expiration time. + * Defaults to immediate expiration and supersedes older fetch publication. + * The supplied object is shared across the selected keys, not cloned. + * + * @example + * createQuery().hydrate(['profile', 'alias'], { name: 'Ada' }, { expiration: () => 60000 }) */ readonly hydrate: HydrateFunction /** - * Returns the given keys for the given cache. + * Copies keys from items (the default) or resolvers in store iteration order. + * Expired stored entries are included; the returned array is independent. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * console.log(query.keys()) // ['count'] */ readonly keys: KeysFunction /** * Returns the expiration date of a given key item. * If the item is not in the cache, it will return `undefined`. + * The returned Date is retained by the cache and must not be mutated. + * + * @example + * const query = createQuery() + * console.log(query.expiration('missing')) // undefined */ readonly expiration: ExpirationFunction /** * Returns the current snapshot of the given key. * If the item is not in the items cache, it will return `undefined`. + * Reads expired entries too, without fetching or changing their lifetime. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * console.log(await query.snapshot('count')) // 1 */ readonly snapshot: SnapshotFunction @@ -460,43 +768,99 @@ export interface Query { * * It does so by subscribing and unsubscribing after event has * been emitted. + * The optional signal rejects the wait with its reason and cleans up. + * + * @example + * const query = createQuery() + * const pending = query.once('count', 'hydrated') + * query.hydrate('count', 1) + * console.log((await pending).detail) // 1 */ readonly once: OnceFunction /** - * A generator that is able to stream events as they come in. + * Yields one event per iteration without buffering between iterations. + * Cleanup runs on generator exit; return() does not interrupt a pending wait. + * + * @example + * const query = createQuery() + * const events = query.sequence('count', 'hydrated') + * const pending = events.next() + * query.hydrate('count', 1) + * console.log((await pending).value?.detail) // 1 + * await events.return(undefined) */ readonly sequence: SequenceFunction /** - * Returns the next queries performed by lambda query. + * Awaits refetching events and their result promises, preserving key shape. + * Does not initiate a fetch or wait on cached reads that emit no such event. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const pending = query.next('count') + * await query.query('count') + * console.log(await pending) // 1 */ readonly next: NextFunction /** * Returns an async generator that returns the next queries * as they come in. + * Iterations wait for new refetching events; intervening events are not + * buffered. return() cannot interrupt an already pending next() wait. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const values = query.stream('count') + * const pending = values.next() + * await query.query('count') + * console.log((await pending).value) // 1 + * await values.return(undefined) */ readonly stream: StreamFunction /** - * Returns the current cache instances in use. + * Returns live references to both current stores, not copies of their data. + * Direct writes bypass query event delivery and ownership protection. + * + * @example + * const query = createQuery() + * console.log(Array.from(query.caches().items.keys())) // [] */ readonly caches: () => Caches /** - * Returns the event system in use. + * Returns the current retained EventTarget for low-level integration. + * Prefer subscribe() for typed keyed listeners and explicit cleanup. + * + * @example + * const query = createQuery() + * console.log(query.events() instanceof EventTarget) // true */ readonly events: () => EventTarget /** - * Returns the broadcast channel in use. + * Returns the caller-owned channel, or undefined when broadcasting is off. + * Query never closes this channel; scope names are not authorization. + * + * @example + * console.log(createQuery().broadcast()) // undefined */ readonly broadcast: () => BroadcastChannel | undefined } /** - * Available events on query. + * Lifecycle event names scoped to an exact cache key by subscribe()/emit(). + * Refetching/mutating carry promises; completion events carry runtime values; + * error/aborted carry reasons. Broadcasting only forwards completion events. + * + * @example + * const event: QueryEvent = 'hydrated' + * const query = createQuery() + * const stop = query.subscribe('count', event, (value) => console.log(value.detail)) + * query.hydrate('count', 1) + * stop() */ export type QueryEvent = | 'refetching' @@ -513,11 +877,29 @@ export type QueryEvent = * with its detail payload when the subscribed event occurs. * * @template T - The type of the event detail payload. + * @example + * const listener: SubscribeListener = (event) => console.log(event.detail) + * const query = createQuery() + * const stop = query.subscribe('count', 'hydrated', listener) + * query.hydrate('count', 1) + * stop() */ export type SubscribeListener = (event: CustomEventInit) => void /** - * The function type for the event subscription. + * Registers a keyed listener and returns its cleanup function. + * Pending refetching is replayed in a microtask to new subscribers. Runtime + * completion details are T, not Promise; legacy success-event overloads + * also describe completion events as promises. Use an explicit listener + * type or the generic QueryEvent overload when consuming those values. + * + * @example + * const query = createQuery() + * const subscribe: SubscribeFunction = query.subscribe + * const listener: SubscribeListener = (event) => console.log(event.detail) + * const stop = subscribe('count', 'hydrated', listener) + * query.hydrate('count', 1) + * stop() */ export interface SubscribeFunction { ( @@ -530,7 +912,17 @@ export interface SubscribeFunction { } /** - * The emit function to manually emit events. + * Dispatches keyed events without altering cache contents. + * Completion events use runtime values, although legacy overloads also + * accept promises. Configured broadcasts forward cloneable completion + * payloads; postMessage failures are intentionally ignored. + * + * @example + * const query = createQuery() + * const emit: EmitFunction = query.emit + * const stop = query.subscribe('count', 'hydrated', (event) => console.log(event.detail)) + * emit('count', 'hydrated', 1) + * stop() */ export interface EmitFunction { ( @@ -545,5 +937,10 @@ export interface EmitFunction { /** * Function type used internally to trigger a deferred promise resolution. * Undefined when no trigger is pending. + * This is an implementation-support type, not a query scheduling API. + * + * @example + * const trigger: TriggerFunction = async () => {} + * await trigger?.() */ export type TriggerFunction = undefined | (() => Promise) diff --git a/src/query/query.ts b/src/query/query.ts index 32d54f7..811e32e 100644 --- a/src/query/query.ts +++ b/src/query/query.ts @@ -25,17 +25,46 @@ import { /** * An instance-owned fresh request and its committed consumer count. The * promise follows explicit cache writes; only the owning instance mutates it. + * Uncommitted selections, including failed/abandoned renders, stay reachable + * until invalidation or instance collection. Counts represent committed leases. + * + * @example + * const instance = createQuery({ fetcher: async () => 'Ada' }) + * const request = instance.resource('profile', { fresh: true }) + * const release = request.retain() + * try { console.log(await request.promise) } finally { release() } */ interface FreshResource extends QueryResource { - /** Pending until first commit, then the latest successful cached request. */ + /** + * Selected promise, updated by cache commits to keep retained readers aligned. + * An uncommitted selection can also follow a new imperative fresh request. + * + * @example + * const request = createQuery({ fetcher: async () => 1 }).resource('count', { fresh: true }) + * console.log(await request.promise) // 1 + */ promise: Promise - /** Active committed consumers; suspended initial renders do not retain. */ + /** + * Number of committed leases, not attempted or suspended render reads. + * Every retain must have exactly one release to keep the count accurate. + * + * @example + * const request = createQuery({ fetcher: async () => 1 }).resource('count', { fresh: true }) + * const release = request.retain() // Adds one committed consumer. + * release() // Removes that consumer; eviction is deferred one microtask. + */ consumers: number } /** * Ordinary cached reads have no fresh lease to retain. Keeping this function * stable also avoids reconnecting React effects on ordinary rerenders. + * + * @returns A no-op release function matching the fresh-resource protocol. + * @example + * const request = createQuery({ fetcher: async () => 1 }).resource('count') + * const release = request.retain() + * release() // Ordinary cache reads require no fresh-selection eviction. */ function retainCachedResource(): () => void { return function () {} @@ -44,6 +73,18 @@ function retainCachedResource(): () => void { /** * Creates a default fetcher function that performs JSON requests * using the provided fetch implementation. + * Forwards the request's AbortSignal, rejects non-OK responses with an Error, + * and propagates fetch/JSON parsing failures. It does not validate JSON shape. + * + * @param fetch - Fetch-compatible implementation, retained by the returned function. + * @returns A key-to-JSON fetcher suitable for createQuery configuration. + * @example + * const fetcher = defaultFetcher<{ name: string }>(async () => + * new Response(JSON.stringify({ name: 'Ada' }), { status: 200 }) + * ) + * try { + * console.log(await fetcher('/profile', { signal: new AbortController().signal })) + * } catch (error) { console.error(error) } */ export function defaultFetcher( fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise @@ -67,9 +108,14 @@ export function defaultFetcher( * Mutations commit in call order per key; independent keys do not wait for * one another. Committed mutations and hydration supersede older fetches * without changing the results returned to those fetches' callers. + * Defaults: JSON fetcher, 2000 ms fetched lifetime, stale reads enabled, + * fresh/removeOnError disabled, new Map stores/EventTarget, no channel. + * Supplied dependencies are retained. The caller owns broadcast closure and + * subscription cleanup; scope names do not provide authorization. + * + * @param instanceOptions - Optional storage, event, and fetch policy overrides. + * @returns A client whose methods retain this instance's configuration/state. * - * @param instanceOptions - Optional cache, fetcher and policy defaults. - * @returns An instance with no shared process-global request state. * @example * ```ts * const instance = createQuery({ fetcher: async () => 'hello', fresh: true }) @@ -82,41 +128,79 @@ export function createQuery(instanceOptions?: Configuration): Query { * Fresh render requests survive discarded initial renders. The map belongs * to this instance, never to a process-global React cache. Abandoned reads * live until invalidation or instance disposal, like other cached requests. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const first = query.resource('count', { fresh: true }) + * console.log(first === query.resource('count', { fresh: true })) // true */ const freshResources = new Map() /** - * Stores the default expiration function. + * Gives fetched values a two-second deduplication lifetime in milliseconds. + * Hydration and mutation deliberately use their own zero-lifetime default. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * await query.query('count') + * console.log(query.expiration('count') instanceof Date) // true */ function defaultExpiration() { return 2000 } /** - * Stores the items cache. + * Retains resolved result promises and absolute expiration dates. + * Configuration may replace the store without migrating its entries. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * console.log(await query.caches().items.get('count')?.item) // 1 */ let itemsCache = instanceOptions?.itemsCache ?? new Map() /** - * Stores the resolvers cache. + * Retains active request promises and their ownership controllers. + * Only an owning request may publish completion into shared state. + * + * @example + * console.log(Array.from(createQuery().caches().resolvers.keys())) // [] */ let resolversCache = instanceOptions?.resolversCache ?? new Map() /** * Tracks only pending mutation tails so functional mutations read the * previous committed value. Settled tails are removed, including failures. + * + * @example + * const query = createQuery() + * await Promise.all([query.mutate('count', 1), query.mutate('count', (n) => (n ?? 0) + 1)]) + * console.log(await query.snapshot('count')) // 2 */ const mutations = new Map>() /** * Marks fetch promises invalidated by committed local writes. Weak entries * do not retain completed requests or accumulate per-key generations. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const pending = query.query('count') + * query.hydrate('count', 2) + * await pending + * console.log(await query.snapshot('count')) // 2 */ const supersededFetches = new WeakSet>() /** * Prevents the current fetch from publishing over a committed local write. * The fetch still settles normally for callers already awaiting it. + * No cancellation is requested by a local write. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) // Marks any older fetch as superseded. */ function supersedeFetch(key: string): void { /** Only the resolver active at the local commit loses publication rights. */ @@ -129,6 +213,14 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Retires a superseded fetch without deleting a later revalidation's * resolver. Returns whether its cache writes and events must be skipped. + * Promise identity prevents an old completion from deleting a newer request. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const pending = query.query('count') + * query.hydrate('count', 2) + * await pending + * console.log(await query.snapshot('count')) // 2 */ function retireSupersededFetch(key: string, promise: Promise): boolean { if (!supersededFetches.has(promise)) { @@ -143,28 +235,42 @@ export function createQuery(instanceOptions?: Configuration): Query { } /** - * Event manager. + * Retains the current target used for keyed local lifecycle events. + * Replacing it leaves existing subscribers attached to their old target. + * + * @example + * const events = new EventTarget() + * console.log(createQuery({ events }).events() === events) // true */ let events = instanceOptions?.events ?? new EventTarget() /** - * Broadcast channel. This is useful for communicating - * between tabs and windows (browser contexts). + * Retains an optional caller-owned channel for cross-context event delivery. + * No default channel is created. The caller must close a supplied channel + * only after all users and subscriptions are done; scope is not authorization. * - * By default it does not use any broadcast channel. - * If a broadcast channel is provided, query - * won't close automatically, therefore, the responsibility - * of closing the broadcast channel is up to the user. + * @example + * console.log(createQuery().broadcast()) // undefined */ let broadcast = instanceOptions?.broadcast /** - * Stores the expiration time of an item. + * Retains the fetched-value lifetime callback, measured in milliseconds. + * Nullish configure values preserve it; a callback returning zero is valid. + * + * @example + * const query = createQuery({ expiration: () => 60000 }) + * query.configure({ expiration: () => 0 }) */ let instanceExpiration = instanceOptions?.expiration ?? defaultExpiration /** - * Determines the fetcher function to use. + * Retains the default key-to-value fetcher for future requests. + * Per-call fetchers override this without changing the instance default. + * + * @example + * const query = createQuery({ fetcher: async () => 'Ada' }) + * console.log(await query.query('profile')) // 'Ada' */ let instanceFetcher = instanceOptions?.fetcher ?? defaultFetcher(fetch) @@ -173,20 +279,33 @@ export function createQuery(instanceOptions?: Configuration): Query { * If `true`, it will return the previous stale item * stored in the cache if it has expired. It will attempt * to revalidate it in the background. If `false`, the returned - * promise will be the revalidation promise. + * promise will be the revalidation promise. This defaults to true. + * + * @example + * const query = createQuery({ stale: false, fetcher: async () => 1 }) + * console.log(await query.query('count')) // 1 */ let instanceStale = instanceOptions?.stale ?? true /** * Removes the stored item if there is an error in the request. * By default, we don't remove the item upon failure, only the resolver - * is removed from the cache. + * is removed from the cache. Superseded requests cannot remove newer items. + * + * @example + * const query = createQuery({ removeOnError: true, fetcher: async () => 1 }) + * console.log(await query.query('count')) // 1 */ let instanceRemoveOnError = instanceOptions?.removeOnError ?? false /** * Determines if the result should be a fresh fetched - * instance regardless of any cached value or its expiration time. + * instance regardless of any cached value or its expiration time. Render + * resources instead share this policy for one retained selection lifetime. + * + * @example + * const query = createQuery({ fresh: true, fetcher: async () => 1 }) + * console.log(await query.query('count')) // 1 */ let instanceFresh = instanceOptions?.fresh ?? false @@ -195,6 +314,13 @@ export function createQuery(instanceOptions?: Configuration): Query { * selections; notification-only changes preserve the chosen fetch because * event routing does not change its request policy. Existing promises and * active transports are not canceled; ordinary caches remain unchanged. + * Applies non-nullish settings without resetting unspecified values. + * Explicit false updates boolean defaults. Replacing caches or events does + * not migrate data/listeners; undefined cannot remove an existing channel. + * + * @example + * const query = createQuery({ fresh: true }) + * query.configure({ fresh: false, expiration: () => 0 }) */ function configure(options?: Configuration): void { /** Notification setup must not restart a request selected during render. */ @@ -218,6 +344,12 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Writes a resolved cache value and keeps fresh render consumers aligned * with hydration, mutation and completed requests from the imperative API. + * Both stores retain the same result promise; no data objects are cloned. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * console.log(await query.snapshot('count')) // 1 */ function cacheItem(key: string, value: ItemsCacheItem): void { itemsCache.set(key, value) @@ -232,6 +364,19 @@ export function createQuery(instanceOptions?: Configuration): Query { * Selects a stable fresh request without treating a Suspense retry as a new * imperative force-fetch. Siblings share the first read's request options. * Ordinary requests delegate unchanged to the existing cache policy. + * A failed or abandoned uncommitted selection is retained until forget, + * request/cache reconfiguration, or instance collection. Notification-only + * configure changes preserve it. Consumers must retain only on commit and + * release exactly once; final release evicts on the next microtask. + * + * @param key - Exact key shared by fresh consumers within this instance. + * @param options - First fresh reader's policy, ignored by later joiners. + * @returns A promise selection and committed-consumer retention function. + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const request = query.resource('count', { fresh: true }) + * const release = request.retain() + * try { console.log(await request.promise) } finally { release() } */ function resource(key: string, options?: Options): QueryResource { /** Per-read freshness overrides the instance default, including false. */ @@ -253,6 +398,13 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Retains only committed consumers. Cleanup waits one microtask so * StrictMode's synchronous effect replay preserves the same request. + * Each returned release must be called exactly once; it is not idempotent. + * The identity check prevents late cleanup from removing a newer selection. + * + * @example + * const request = createQuery({ fetcher: async () => 1 }).resource('count', { fresh: true }) + * const release = request.retain() + * release() */ retain() { request.consumers += 1 @@ -278,6 +430,11 @@ export function createQuery(instanceOptions?: Configuration): Query { * @param key - The cache key associated with the event. * @param event - The type of event to emit. * @param detail - The payload to include with the event. + * @example + * const query = createQuery() + * const stop = query.subscribe('count', 'hydrated', (event) => console.log(event.detail)) + * query.emit('count', 'hydrated', 1) + * stop() */ function emit(key: string, event: QueryEvent, detail: T) { events.dispatchEvent(new CustomEvent(`${event}:${key}`, { detail })) @@ -302,6 +459,15 @@ export function createQuery(instanceOptions?: Configuration): Query { * information depending on the event type. * If there's a pending resolver for that key, the `refetching` * event is fired immediately. + * The replay is queued in a microtask. Completion events carry values; + * refetching/mutating carry promises, and error/aborted carry reasons. + * + * @returns Cleanup for the keyed listener; it does not close resources. + * @example + * const query = createQuery() + * const stop = query.subscribe('count', 'hydrated', (event) => console.log(event.detail)) + * query.hydrate('count', 1) + * stop() */ function subscribe( key: string, @@ -349,6 +515,12 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Resolves and commits one queued mutation. Resolver and expiration errors * propagate to its caller before any cache write or fetch invalidation. + * The outer method publishes this promise as the per-key queue tail before + * listeners can enqueue further mutations. + * + * @example + * const query = createQuery() + * await query.mutate('count', (previous) => (previous ?? 0) + 1) */ async function action(resolver: MutationValue) { await previous?.catch(() => {}) @@ -399,13 +571,24 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Returns the current snapshot of the given key. * If the item is not in the items cache, it will return `undefined`. + * Ignores expiration and never fetches. Custom rejected stored promises + * propagate their rejection to the caller. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * console.log(await query.snapshot('count')) // 1 */ async function snapshot(key: string): Promise { return (await itemsCache.get(key)?.item) as T } /** - * Returns all keys currently stored in the specified cache. + * Copies keys in underlying store order, including expired stored items. + * Defaults to the items cache; resolvers enumerates only active slots. + * + * @example + * console.log(createQuery().keys('resolvers')) // [] */ function keys(type: CacheType = 'items'): readonly string[] { return Array.from(type === 'items' ? itemsCache.keys() : resolversCache.keys()) @@ -417,6 +600,11 @@ export function createQuery(instanceOptions?: Configuration): Query { * The fetcher is responsible for using the * `AbortSignal` to cancel the job. * If no keys are provided, all resolvers are aborted. + * Removes selected resolver slots and emits aborted without erasing items. + * Fetchers ignoring the signal may continue, but lose publication rights. + * + * @example + * createQuery().abort(['profile', 'settings'], 'navigation') */ function abort(cacheKeys?: string | readonly string[], reason?: unknown): void { const resolverKeys = @@ -438,6 +626,15 @@ export function createQuery(instanceOptions?: Configuration): Query { * Forgets resolved items and fresh render selections, including abandoned * pending selections. Active transports are not aborted. Omitted keys * clear all selections; a pattern matches both item and fresh-resource keys. + * Removes each entry before awaiting its result for the forgotten event. + * Rejected entries emit undefined; pending entries can delay later removals. + * Still-active requests may subsequently repopulate forgotten keys. + * + * @example + * const query = createQuery() + * query.hydrate('count', 1) + * await query.forget(/^count$/) + * console.log(query.keys()) // [] */ async function forget(cacheKeys?: string | readonly string[] | RegExp): Promise { let itemKeys: readonly string[] @@ -505,6 +702,10 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Returns the expiration date of a given key item. * If the item is not in the cache, it will return `undefined`. + * Returns the retained Date without cloning; callers must not mutate it. + * + * @example + * console.log(createQuery().expiration('missing')) // undefined */ function expiration(key: string): Date | undefined { return itemsCache.get(key)?.expiresAt @@ -570,6 +771,11 @@ export function createQuery(instanceOptions?: Configuration): Query { * their own fetch result (or abort reason). * Requests superseded by a local write cannot delay revalidation, even * when their fetchers ignore cancellation or remain pending indefinitely. + * Fetch/expiration failures reject callers; only the owner emits errors. + * + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * console.log(await query.query('count', { stale: false })) // 1 */ function refetch(key: string): Promise { // Check if there's a pending resolver for that data. @@ -718,21 +924,33 @@ export function createQuery(instanceOptions?: Configuration): Query { } /** - * Returns the current cache instances. + * Returns live storage references for diagnostics and custom integration. + * Direct writes bypass event delivery and request ownership protection. + * + * @example + * console.log(Array.from(createQuery().caches().items.keys())) // [] */ function caches(): Caches { return { items: itemsCache, resolvers: resolversCache } } /** - * Returns the event system. + * Exposes the retained EventTarget without wrapping or copying it. + * Prefer subscribe() to obtain keyed listener cleanup automatically. + * + * @example + * console.log(createQuery().events() instanceof EventTarget) // true */ function localEvents() { return events } /** - * Returns the broadcast channel. + * Exposes the caller-owned optional channel without creating one. + * The query never closes it, even when subscriptions are removed. + * + * @example + * console.log(createQuery().broadcast()) // undefined */ function localBroadcast() { return broadcast @@ -743,6 +961,13 @@ export function createQuery(instanceOptions?: Configuration): Query { * to listen for other browser context * events and reproduce them in the current * context. + * Captures the current channel for cleanup even if configure replaces it. + * Remote messages only dispatch local events; they do not populate caches. + * With no channel this subscription and its cleanup are harmless no-ops. + * + * @example + * const stop = createQuery().subscribeBroadcast() + * stop() */ function subscribeBroadcast(): Unsubscriber { // Capture the current broadcast reference so that the unsubscriber @@ -750,6 +975,15 @@ export function createQuery(instanceOptions?: Configuration): Query { // configure() replaces the broadcast channel later. const currentBroadcast = broadcast + /** + * Replays a received event on the current local target without rebroadcast. + * Channel routing is not an authorization boundary; payloads are trusted + * according to the caller's same-origin channel sharing policy. + * + * @example + * const stop = createQuery().subscribeBroadcast() + * stop() // Disposes the listener that invokes this replay callback. + */ function onBroadcastMessage(message: MessageEvent) { events.dispatchEvent(new CustomEvent(message.data.event, { detail: message.data.detail })) } @@ -771,6 +1005,12 @@ export function createQuery(instanceOptions?: Configuration): Query { * * @param keys - A single key, array of keys, or object mapping names to keys. * @returns A promise that resolves with the fetched value(s). + * @param signal - Cancels event waits, not fetch promises already received. + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const pending = query.next<{ count: number }>({ count: 'count' }) + * await query.query('count') + * console.log(await pending) // { count: 1 } */ async function next( keys: string | { [K in keyof T]: string }, @@ -803,6 +1043,16 @@ export function createQuery(instanceOptions?: Configuration): Query { * * @param keys - A single key or an object mapping property names to keys. * @yields The resolved value(s) each time a refetch completes. + * No events are buffered while the consumer processes a yielded value. + * The finally block cleans up on exit; return() does not interrupt an + * already pending next() call waiting for an event or fetch result. + * @example + * const query = createQuery({ fetcher: async () => 1 }) + * const values = query.stream('count') + * const pending = values.next() + * await query.query('count') + * console.log((await pending).value) // 1 + * await values.return(undefined) */ async function* stream(keys: string | { [K in keyof T]: string }) { const controller = new AbortController() @@ -823,6 +1073,12 @@ export function createQuery(instanceOptions?: Configuration): Query { * @param key - The cache key to listen for events on. * @param event - The type of event to wait for. * @returns A promise that resolves with the event details. + * @param signal - Optional cancellation, rejecting with signal.reason. + * @example + * const query = createQuery() + * const pending = query.once('count', 'hydrated') + * query.hydrate('count', 1) + * console.log((await pending).detail) // 1 */ function once(key: string, event: QueryEvent, signal?: AbortSignal) { return new Promise>(function (resolve, reject) { @@ -831,11 +1087,31 @@ export function createQuery(instanceOptions?: Configuration): Query { cleanup() }) + /** + * Releases both listeners after an event or cancellation wins the wait. + * Removing an already-removed DOM listener is harmless. + * + * @example + * const query = createQuery() + * const pending = query.once('count', 'hydrated') + * query.hydrate('count', 1) + * await pending // The event path has now released its listeners. + */ function cleanup() { unsubscribe() signal?.removeEventListener('abort', onAbort) } + /** + * Rejects the event wait with the supplied signal's exact abort reason. + * Called only when a signal exists, including pre-aborted signals. + * + * @example + * const controller = new AbortController() + * const pending = createQuery().once('count', 'hydrated', controller.signal) + * controller.abort('navigation') + * try { await pending } catch (error) { console.log(error) } + */ function onAbort() { cleanup() reject(signal!.reason) @@ -858,6 +1134,15 @@ export function createQuery(instanceOptions?: Configuration): Query { * @param key - The cache key to listen for events on. * @param event - The type of event to stream. * @yields The event details each time the event occurs. + * There is no buffering between iterations. Cleanup runs on generator exit; + * return() does not interrupt an already pending next() event wait. + * @example + * const query = createQuery() + * const events = query.sequence('count', 'hydrated') + * const pending = events.next() + * query.hydrate('count', 1) + * console.log((await pending).value?.detail) // 1 + * await events.return(undefined) */ async function* sequence(key: string, event: QueryEvent) { const controller = new AbortController() diff --git a/src/react/components/QueryPrefetch.tsx b/src/react/components/QueryPrefetch.tsx index 0e62d03..33b7018 100644 --- a/src/react/components/QueryPrefetch.tsx +++ b/src/react/components/QueryPrefetch.tsx @@ -5,15 +5,33 @@ import { useQueryPrefetch } from 'query/react:hooks/useQueryPrefetch' /** * Props for the QueryPrefetch component, extending the query instance * options with keys to prefetch and optional children. + * Prefetch is client-effect work; children render without waiting for requests. + * + * @example + * const props: QueryPrefetchProps = { + * keys: ['profile'], query: createQuery({ fetcher: async () => 'Ada' }), + * } + * const view = Dashboard */ export interface QueryPrefetchProps extends QueryInstance { /** * An array of cache keys to prefetch when the component mounts. + * The array reference is an effect dependency; keep it stable when possible. + * Normal cache policy applies, rather than unconditional network fetches. + * + * @example + * const keys = ['profile'] + * const view = 'Ada' })}>Dashboard */ keys: readonly string[] /** - * The child elements to render after initiating the prefetch. + * Content rendered immediately, before the client prefetch effect runs. + * Children are not delayed until queries resolve or wrapped in Suspense. + * + * @example + * const view = Dashboard */ children?: ReactNode } @@ -23,18 +41,22 @@ export interface QueryPrefetchProps extends QueryInstance { * Useful for warming the cache with data that will be needed soon, * improving perceived performance when users navigate to views that * depend on this data. + * It does not prefetch during SSR or block rendering, cancel on unmount, + * catch fetch rejections, or subscribe children to data changes. * * @param props - The prefetch configuration and children. * * @example * ```tsx + * const query = createQuery({ fetcher: async () => 'Ada' }) * function Dashboard() { * return ( - * - * + * + * Dashboard content * * ) * } + * const view = * ``` */ export function QueryPrefetch({ keys, query, children }: QueryPrefetchProps) { diff --git a/src/react/components/QueryPrefetchTags.tsx b/src/react/components/QueryPrefetchTags.tsx index bb3b905..bf1bee5 100644 --- a/src/react/components/QueryPrefetchTags.tsx +++ b/src/react/components/QueryPrefetchTags.tsx @@ -5,21 +5,41 @@ import { useQueryPrefetch } from 'query/react:hooks/useQueryPrefetch' /** * Combined type that merges HTML link element attributes with * query instance options for the QueryPrefetchTags component. + * Link attributes are forwarded after generated defaults, allowing overrides. + * + * @example + * const attributes: Additional = { crossOrigin: 'anonymous', query: createQuery() } + * const view = */ type Additional = LinkHTMLAttributes & QueryInstance /** * Props for the QueryPrefetchTags component, extending link attributes * and query instance options with keys to prefetch and optional children. + * Forwarded link attributes can override rel, href, and as on every generated tag. + * + * @example + * const props: QueryPrefetchTagsProps = { keys: [], query: createQuery(), crossOrigin: 'anonymous' } + * const view = Dashboard */ export interface QueryPrefetchTagsProps extends Additional { /** * An array of cache keys to prefetch when the component mounts. + * Each also becomes a link href by default; keep the array stable to avoid + * repeating the client effect. Browser hints and query fetches are independent. + * + * @example + * const view = 'Ada' })} /> */ keys: readonly string[] /** * The child elements to render alongside the prefetch link tags. + * They render immediately without waiting for cache warming to finish. + * + * @example + * const view = Dashboard */ children?: ReactNode } @@ -28,19 +48,23 @@ export interface QueryPrefetchTagsProps extends Additional { * A React component that prefetches multiple query keys and renders * corresponding link tags with rel="preload" for browser-level prefetching. * Combines the query library's prefetch mechanism with native browser - * resource hints for optimal performance. + * resource hints. The browser may make a separate request; hints do not + * guarantee deduplication with the query fetcher. The client effect does not + * catch fetch rejections or cancel requests on unmount. * * @param props - The prefetch configuration, link attributes, and children. * * @example * ```tsx + * const query = createQuery({ fetcher: async () => 'Ada' }) * function App() { * return ( - * - * + * + * Application content * * ) * } + * const view = * ``` */ export function QueryPrefetchTags({ keys, children, query, ...linkProps }: QueryPrefetchTagsProps) { diff --git a/src/react/components/QueryProvider.tsx b/src/react/components/QueryProvider.tsx index 6d8470d..c7575a8 100644 --- a/src/react/components/QueryProvider.tsx +++ b/src/react/components/QueryProvider.tsx @@ -5,16 +5,29 @@ import { createQuery, type Query, type Unsubscriber } from 'query:index' /** * Tracks the providers using one query's incoming broadcast subscription. * The last provider removes the listener, but never closes the channel. + * The record is shared by identity so unmount order does not affect cleanup. + * + * @example + * const record: ProviderSubscription = { providers: 1, unsubscribe: () => {} } + * record.unsubscribe() // Called by the provider when its count reaches zero. */ interface ProviderSubscription { /** * Counts mounted provider effects, not renders. Reaching zero releases * the listener so StrictMode remounts can acquire a fresh subscription. + * + * @example + * const record: ProviderSubscription = { providers: 1, unsubscribe: () => {} } + * record.providers -= 1 */ providers: number /** * Removes the core listener from its captured channel without closing it. * Called only after the final provider releases this subscription. + * + * @example + * const record: ProviderSubscription = { providers: 0, unsubscribe: () => {} } + * record.unsubscribe() */ readonly unsubscribe: Unsubscriber } @@ -22,6 +35,12 @@ interface ProviderSubscription { /** * Shares incoming listeners between providers using the same query, avoiding * duplicate event delivery. Weak keys do not retain unused query instances. + * Listener ownership counts committed effects only, never suspended renders. + * + * @example + * const query = createQuery() + * const view = <>One + * Two */ const subscriptions = new WeakMap() @@ -30,6 +49,10 @@ const subscriptions = new WeakMap() * Cleanup releases only this interest; other providers keep receiving events. * No channel is created, configured, or closed here. With no configured * channel, the core subscription and its cleanup are harmless no-ops. + * + * @example + * const release = subscribeProviderBroadcast(createQuery()) + * release() // Releases one provider, not the caller-owned channel. */ function subscribeProviderBroadcast(query: Query): Unsubscriber { /** @@ -66,6 +89,10 @@ function subscribeProviderBroadcast(query: Query): Unsubscriber { export interface QueryProviderProps extends ContextValue { /** * The child elements to render within the provider context. + * They inherit this query and policy; the provider adds no loading boundary. + * + * @example + * const view = Application */ children?: ReactNode } @@ -78,6 +105,11 @@ export interface QueryProviderProps extends ContextValue { * `broadcast` channel. The caller must close that channel after all users * are done; provider cleanup only removes its incoming subscription. * Multiple providers sharing a query share one incoming listener. + * Incoming listeners are acquired in client effects, never during SSR. + * Pass a stable instance when cache ownership must outlive this provider. + * The source creates its fallback instance in render; the published TSX build + * can memoize that creation through React Compiler. Provider teardown does + * not abort requests or close caller-owned resources. * * @param props - The provider configuration and children. * diff --git a/src/react/components/QueryTransition.tsx b/src/react/components/QueryTransition.tsx index e377760..2352816 100644 --- a/src/react/components/QueryTransition.tsx +++ b/src/react/components/QueryTransition.tsx @@ -4,20 +4,53 @@ import { type ReactNode, type TransitionStartFunction } from 'react' /** * Props for the QueryTransition component, providing transition state * and control function to be shared with descendant query components. + * Obtain both values from the same useTransition call for a coherent scope. + * + * @example + * function Layout() { + * const [isPending, startTransition] = useTransition() + * const props: QueryTransitionProps = { isPending, startTransition, children: 'Application' } + * return + * } + * const view = */ export interface QueryTransitionProps { /** * Indicates whether a transition is currently pending. + * Use the state paired with startTransition, not a query cache status flag. + * + * @example + * function Layout() { + * const [isPending, startTransition] = useTransition() + * return App + * } + * const view = */ readonly isPending: boolean /** * The function to start a transition, typically from useTransition. + * Descendant query hooks use it unless ignoreTransitionContext is enabled. + * + * @example + * function Layout() { + * const [isPending, startTransition] = useTransition() + * return App + * } + * const view = */ readonly startTransition: TransitionStartFunction /** * The child elements that will have access to the transition context. + * This scope supplies transition values only, not a query or error boundary. + * + * @example + * function Layout() { + * const [isPending, startTransition] = useTransition() + * return App + * } + * const view = */ readonly children?: ReactNode } @@ -37,11 +70,11 @@ export interface QueryTransitionProps { * * return ( * - * - * + * Application content * * ) * } + * const view = * ``` */ export function QueryTransition({ children, startTransition, isPending }: QueryTransitionProps) { diff --git a/src/react/context.ts b/src/react/context.ts index 966ffca..185ed70 100644 --- a/src/react/context.ts +++ b/src/react/context.ts @@ -5,17 +5,32 @@ import { type QueryInstance } from './hooks/useQueryInstance' * The value type for the query context, extending the query instance * with additional configuration options that affect query behavior * throughout the component tree. + * An explicit hook option overrides the corresponding provider value. + * + * @example + * const value: ContextValue = { query: createQuery(), clearOnForget: true } + * const view = Application */ export interface ContextValue extends QueryInstance { /** * When true, components will automatically refetch data after the * cache for a key is cleared via the forget method. + * Defaults to false in useQueryBasic; forgetting alone does not cancel I/O. + * + * @example + * const value: ContextValue = { query: createQuery(), clearOnForget: true } + * const view = Application */ readonly clearOnForget?: boolean /** * When true, query components will use their own local transition * instead of the shared transition from QueryTransition context. + * Defaults to false; an absent shared transition still falls back locally. + * + * @example + * const value: ContextValue = { query: createQuery(), ignoreTransitionContext: true } + * const view = Application */ readonly ignoreTransitionContext?: boolean } @@ -23,6 +38,12 @@ export interface ContextValue extends QueryInstance { /** * React context that provides the query instance and configuration * to all descendant components. Created with default undefined values. + * Prefer QueryProvider, which also manages incoming broadcast subscriptions. + * Rendering Context directly supplies values but acquires no subscriptions. + * + * @example + * const value: ContextValue = { query: createQuery() } + * const view = Application */ export const Context = createContext({ query: undefined, diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 8e49f56..9cd4ed0 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -8,6 +8,16 @@ import { useQueryBasic, type BasicResource } from './useQueryBasic' /** * Combined type that merges query actions, basic resource data, * and status information into a single interface. + * This local composition keeps Resource aligned with the three hooks it uses. + * + * @example + * function Profile() { + * const resource = useQuery('profile') + * return {resource.data} + * } + * const view = 'Ada' })}> + * + * */ type AdditionalHooks = QueryActions & BasicResource & Status @@ -16,15 +26,44 @@ type AdditionalHooks = QueryActions & BasicResource & Status * pending state, query actions (refetch, mutate, forget), and cache status. * * @template T - The type of the fetched data. + * @example + * function Profile() { + * const resource: Resource = useQuery('profile') + * return {resource.data} + * } + * const view = 'Ada' })}> + * + * */ export interface Resource extends AdditionalHooks { /** - * The fetched data for the given key. + * Current resolved value, retained rather than cloned from the cache. + * Treat it as immutable; blocking initial reads suspend instead of + * returning undefined as a loading sentinel. + * + * @example + * function Profile() { + * const resource = useQuery('profile') + * return {resource.data} + * } + * const view = 'Ada' })}> + * + * */ readonly data: T /** - * Indicates whether a transition is currently pending. + * Reports the selected local or shared React transition's pending state. + * It is not an initial-loading flag; initial blocking reads use Suspense. + * + * @example + * function Profile() { + * const resource = useQuery('profile') + * return {resource.data} + * } + * const view = 'Ada' })}> + * + * */ readonly isPending: boolean } @@ -34,6 +73,12 @@ export interface Resource extends AdditionalHooks { * query options, and an optional query instance override. * * @template T - The type of the fetched data. + * @example + * const options: ResourceOptions = { + * query: createQuery({ fetcher: async () => 'Ada' }), stale: false, + * } + * function Profile() { return {useQuery('profile', options).data} } + * const view = */ export type ResourceOptions = ContextValue & Options & QueryInstance @@ -51,10 +96,24 @@ export type ResourceOptions = ContextValue & Options & QueryInst * * @example * ```tsx - * const { data, isPending, refetch } = useQuery('/api/user/1') + * import { Suspense } from 'react' + * import { createRoot } from 'react-dom/client' + * import { ErrorBoundary } from 'react-error-boundary' + * import { createQuery } from '@studiolambda/query' + * import { QueryProvider, useQuery } from '@studiolambda/query/react' * - * if (isPending) return - * return + * const query = createQuery({ fetcher: async () => 'Ada' }) + * function Profile() { + * const { data, isPending } = useQuery('profile') + * return {data} + * } + * createRoot(document.createElement('div')).render( + * + * Unavailable}> + * + * + * + * ) * ``` */ export function useQuery(key: string, options?: ResourceOptions): Resource { diff --git a/src/react/hooks/useQueryActions.ts b/src/react/hooks/useQueryActions.ts index 98758f5..e5f1406 100644 --- a/src/react/hooks/useQueryActions.ts +++ b/src/react/hooks/useQueryActions.ts @@ -7,14 +7,35 @@ import { useQueryInstance, type QueryInstance } from './useQueryInstance' * clearing cached data. * * @template T - The type of the cached data. + * @example + * function Refresh() { + * const actions: QueryActions = useQueryActions('profile') + * return + * } + * const view = 'Ada' })}> + * + * */ export interface QueryActions { /** - * Triggers a fresh fetch of the data, bypassing the stale cache. + * Reads using the query policy, defaulting stale to false for this action. + * A valid cache entry is still reused unless fresh is explicitly true. * * @param refetchOptions - Optional configuration to override the default * query options for this refetch. - * @returns A promise that resolves with the newly fetched data. + * @returns The cached or fetched value; fetch failures reject the promise. + * @example + * function Refresh() { + * const { refetch } = useQueryActions('profile') + * return + * } + * const view = 'Ada' })}> + * + * */ readonly refetch: (refetchOptions?: Options) => Promise @@ -26,14 +47,27 @@ export interface QueryActions { * value and returns the new value. * @param options - Optional configuration for the mutation. * @returns A promise that resolves with the mutated data. + * @example + * function Rename() { + * const { mutate } = useQueryActions('profile') + * return + * } + * const view = */ readonly mutate: (value: MutationValue, options?: MutateOptions) => Promise /** - * Removes the cached data for the key, triggering a fresh fetch on the - * next query if clearOnForget is enabled. + * Removes this key's cached result without aborting an active request. + * Mounted data hooks with clearOnForget opt into requerying on the event; + * any subsequent query also fetches when the entry is missing. * * @returns A promise that resolves when the data has been forgotten. + * @example + * function Clear() { + * const { forget } = useQueryActions('profile') + * return + * } + * const view = */ readonly forget: () => Promise } @@ -43,6 +77,13 @@ export interface QueryActions { * options with an optional query instance override. * * @template T - The type of the cached data. + * @example + * const options: QueryActionsOptions = { query: createQuery(), fresh: true } + * function Controls() { + * const actions = useQueryActions('profile', options) + * return + * } + * const view = */ export type QueryActionsOptions = Options & QueryInstance @@ -58,11 +99,15 @@ export type QueryActionsOptions = Options & QueryInstance * * @example * ```tsx - * const { refetch, mutate, forget } = useQueryActions('/api/user/1') - * - * const handleRefresh = () => refetch() - * const handleUpdate = (name: string) => mutate({ name }) - * const handleClear = () => forget() + * function Refresh() { + * const { refetch } = useQueryActions('profile') + * return + * } + * const view = 'Ada' })}> + * + * * ``` */ export function useQueryActions( @@ -79,6 +124,18 @@ export function useQueryActions( const { query, mutate, forget } = useQueryInstance(options) + /** + * Applies captured hook policy and then the action's explicit overrides. + * stale defaults to false, but valid entries still require fresh to bypass. + * Fetch failures remain the caller's responsibility through the promise. + * + * @example + * function Refresh() { + * const actions = useQueryActions('profile') + * return + * } + * const view = 'Ada' })}> + */ function refetch(refetchOptions?: Options) { return query(key, { stale: oStale ?? false, @@ -90,10 +147,32 @@ export function useQueryActions( }) } + /** + * Binds the instance mutation queue to this hook's current key. + * Mutation policy is supplied per action, not inherited from fetch options. + * + * @example + * function SetName() { + * const actions = useQueryActions('profile') + * return + * } + * const view = + */ function localMutate(value: MutationValue, options?: MutateOptions) { return mutate(key, value, options) } + /** + * Binds item removal to this key and preserves asynchronous event completion. + * It does not abort the key's current fetch or erase unrelated entries. + * + * @example + * function Clear() { + * const actions = useQueryActions('profile') + * return + * } + * const view = + */ async function localForget() { await forget(key) } diff --git a/src/react/hooks/useQueryBasic.ts b/src/react/hooks/useQueryBasic.ts index 7a8d4a0..29fb3dd 100644 --- a/src/react/hooks/useQueryBasic.ts +++ b/src/react/hooks/useQueryBasic.ts @@ -10,15 +10,29 @@ import { useQueryTransitionContext } from './useQueryTransitionContext' * the fetched data and the current pending state. * * @template T - The type of the fetched data. + * @example + * const resource: BasicResource = { data: 'Ada', isPending: false } + * console.log(resource.data) // 'Ada' */ export interface BasicResource { /** - * The fetched data for the given key. + * Latest resolved value retained from the selected query key. + * Initial blocking reads suspend instead of returning a loading sentinel; + * callers must treat shared cached objects as immutable. + * + * @example + * const resource: BasicResource = { data: 'Ada', isPending: false } + * console.log(resource.data) // 'Ada' */ readonly data: T /** - * Indicates whether a transition is currently pending. + * Pending state of the local or provider-selected React transition. + * This is separate from initial Suspense loading and cache expiration. + * + * @example + * const resource: BasicResource = { data: 'Ada', isPending: false } + * const view = {resource.data} */ readonly isPending: boolean } @@ -28,6 +42,12 @@ export interface BasicResource { * query options, and an optional query instance override. * * @template T - The type of the fetched data. + * @example + * const options: BasicResourceOptions = { + * query: createQuery({ fetcher: async () => 'Ada' }), clearOnForget: true, + * } + * function Profile() { return {useQueryBasic('profile', options).data} } + * const view = */ export type BasicResourceOptions = ContextValue & Options & QueryInstance @@ -44,7 +64,8 @@ export type BasicResourceOptions = ContextValue & Options & Quer * lifetime, not each render. Initial retries and same-key fresh consumers * share one request, even after expiration. The last consumer's cleanup * releases it; a later mount starts fresh again. Explicit refetch/query - * actions still force new requests. An abandoned initial render remains + * actions can force new requests with effective fresh: true; otherwise they + * retain normal cache policy. An abandoned initial render remains * reusable until the key is forgotten, request configuration changes, or the * instance is garbage-collected. Notification-only configuration preserves it. * @@ -60,10 +81,24 @@ export type BasicResourceOptions = ContextValue & Options & Quer * * @example * ```tsx - * const { data, isPending } = useQueryBasic('/api/user/1') + * import { Suspense } from 'react' + * import { createRoot } from 'react-dom/client' + * import { ErrorBoundary } from 'react-error-boundary' + * import { createQuery } from '@studiolambda/query' + * import { QueryProvider, useQueryBasic } from '@studiolambda/query/react' * - * if (isPending) return - * return + * const query = createQuery({ fetcher: async () => 'Ada' }) + * function Profile() { + * const { data, isPending } = useQueryBasic('profile') + * return {data} + * } + * createRoot(document.createElement('div')).render( + * + * Unavailable}> + * + * + * + * ) * ``` */ export function useQueryBasic( @@ -119,12 +154,32 @@ export function useQueryBasic( useEffect(() => retain(), [retain]) + /** + * Applies a completed fetch value inside the selected transition. + * The effect event reads the latest transition without resubscribing. + * + * @example + * function Profile() { return {useQueryBasic('profile').data} } + * const view = 'Ada' })}> + * + * + */ const onResolved = useEffectEvent(function (event: CustomEventInit) { startTransition(function () { setData(event.detail as T) }) }) + /** + * Tracks an asynchronous mutation in React's pending transition state. + * Awaited results are committed inside another transition after resolution. + * + * @example + * function Profile() { return {useQueryBasic('profile').data} } + * const query = createQuery({ fetcher: async () => 'Ada' }) + * const view = + * await query.mutate('profile', 'Grace') + */ const onMutating = useEffectEvent(function (event: CustomEventInit>) { startTransition(async function () { const value = await (event.detail as Promise) @@ -135,12 +190,32 @@ export function useQueryBasic( }) }) + /** + * Reflects the value committed by the core's per-key mutation queue. + * Completion details are values, not the mutating event's pending promise. + * + * @example + * function Profile() { return {useQueryBasic('profile').data} } + * const query = createQuery({ fetcher: async () => 'Ada' }) + * const view = + * await query.mutate('profile', 'Grace') + */ const onMutated = useEffectEvent(function (event: CustomEventInit) { startTransition(function () { setData(event.detail as T) }) }) + /** + * Reflects explicitly seeded data without performing another query. + * The shared value is retained; no clone is made for component state. + * + * @example + * function Profile() { return {useQueryBasic('profile').data} } + * const query = createQuery({ fetcher: async () => 'Ada' }) + * const view = + * query.hydrate('profile', 'Grace') + */ const onHydrated = useEffectEvent(function (event: CustomEventInit) { startTransition(function () { setData(event.detail as T) @@ -152,6 +227,12 @@ export function useQueryBasic( * transition errors. The core owns error events and caller rejections; * a failed or aborted request has no new value for this subscription. * This also contains late rejections after the subscription key changes. + * + * @example + * function Profile() { return {useQueryBasic('profile').data} } + * const query = createQuery({ fetcher: async () => 'Ada' }) + * const view = + * await query.query('profile', { fresh: true }) */ const onRefetching = useEffectEvent(function (event: CustomEventInit>) { startTransition(async function () { @@ -171,6 +252,19 @@ export function useQueryBasic( }) }) + /** + * Reselects forgotten data only when clearOnForget is enabled. + * Fresh reads join the instance-owned selection; ordinary reads follow + * cache policy. The selected transition wraps the replacement value. + * + * @example + * function Profile() { return {useQueryBasic('profile').data} } + * const query = createQuery({ fetcher: async () => 'Ada' }) + * const view = + * + * + * await query.forget('profile') + */ const onForgotten = useEffectEvent(function () { if (clearOnForget) { startTransition(async function () { diff --git a/src/react/hooks/useQueryContext.ts b/src/react/hooks/useQueryContext.ts index b273f31..98491b1 100644 --- a/src/react/hooks/useQueryContext.ts +++ b/src/react/hooks/useQueryContext.ts @@ -5,13 +5,19 @@ import { use } from 'react' * A React hook that retrieves the current query context value from the * nearest QueryProvider ancestor. Returns the context configuration * including the query instance and options like clearOnForget. + * Without a provider, returns the context's undefined defaults rather than + * throwing; useQueryInstance enforces the requirement for an actual client. * * @returns The current query context value containing the query instance * and configuration options. * * @example * ```tsx - * const { query, clearOnForget } = useQueryContext() + * function Settings() { + * const { clearOnForget } = useQueryContext() + * return {String(clearOnForget)} + * } + * const view = * ``` */ export function useQueryContext(): ContextValue { diff --git a/src/react/hooks/useQueryInstance.ts b/src/react/hooks/useQueryInstance.ts index a0d6bce..5cbeace 100644 --- a/src/react/hooks/useQueryInstance.ts +++ b/src/react/hooks/useQueryInstance.ts @@ -4,10 +4,21 @@ import { useQueryContext } from 'query/react:hooks/useQueryContext' /** * Options interface that allows providing a custom query instance, * overriding the one from the context. + * Omission uses the nearest provider; if both are absent, the hook throws. + * + * @example + * const options: QueryInstance = { query: createQuery() } + * function Count() { return {useQueryInstance(options).keys().length} } + * const view = */ export interface QueryInstance { /** * An optional query instance to use instead of the context query. + * The reference is retained; the hook neither configures nor disposes it. + * + * @example + * const options: QueryInstance = { query: createQuery() } + * console.log(options.query?.keys()) // [] */ readonly query?: Query } @@ -15,6 +26,15 @@ export interface QueryInstance { /** * Error thrown when no query instance is available from either * the options or the context. + * This shared Error instance is thrown by useQueryInstance; callers normally + * prevent it by supplying QueryProvider or an explicit query option. + * + * @example + * function MissingProvider() { useQueryInstance(); return null } + * const view = Provide a query} + * onError={(error) => console.log(error === ErrNoQueryInstanceFound)}> + * + * */ export const ErrNoQueryInstanceFound = new Error( 'No query instance was found. Please provide one via the resource options or the query context.' @@ -31,8 +51,12 @@ export const ErrNoQueryInstanceFound = new Error( * * @example * ```tsx - * const query = useQueryInstance() - * const data = await query('key') + * const query = createQuery() + * function CachedKeys() { + * const instance = useQueryInstance({ query }) + * return {instance.keys().join(', ')} + * } + * const view = * ``` */ export function useQueryInstance(options?: QueryInstance): Query { diff --git a/src/react/hooks/useQueryPrefetch.ts b/src/react/hooks/useQueryPrefetch.ts index f332d04..b574c12 100644 --- a/src/react/hooks/useQueryPrefetch.ts +++ b/src/react/hooks/useQueryPrefetch.ts @@ -5,14 +5,23 @@ import { type QueryInstance, useQueryInstance } from './useQueryInstance' * A React hook that prefetches multiple query keys on mount. Useful for * warming the cache with data that will be needed soon, improving perceived * performance when the user navigates to views that use these keys. + * Fetching starts in a client effect, not during SSR. The array reference is + * an effect dependency; changing it reissues queries under normal cache policy. + * This hook does not expose or catch request promises, cancel on unmount, or + * guarantee success. Use query.query() directly when failures need handling. * * @param keys - An array of cache keys to prefetch. * @param options - Optional configuration containing a query instance. * * @example * ```tsx - * // Prefetch user data when the dashboard loads - * useQueryPrefetch(['/api/user/1', '/api/user/2', '/api/user/3']) + * const keys = ['profile', 'settings'] + * const query = createQuery({ fetcher: async (key) => key }) + * function Dashboard() { + * useQueryPrefetch(keys, { query }) + * return Dashboard + * } + * const view = * ``` */ export function useQueryPrefetch(keys: readonly string[], options?: QueryInstance) { diff --git a/src/react/hooks/useQueryStatus.ts b/src/react/hooks/useQueryStatus.ts index 307b601..cdee3cb 100644 --- a/src/react/hooks/useQueryStatus.ts +++ b/src/react/hooks/useQueryStatus.ts @@ -4,28 +4,65 @@ import { useQueryInstance, type QueryInstance } from './useQueryInstance' /** * The return type of the useQueryStatus hook, providing real-time * information about the cache status for a specific key. + * These are event/timer-derived hints, not a comprehensive request state + * machine. In particular, expiration is not synchronously reset by a write. + * + * @example + * const status: Status = { + * expiresAt: new Date(0), isExpired: true, isRefetching: false, isMutating: false, + * } + * console.log(status.isExpired) // true */ export interface Status { /** * The timestamp when the cached data will expire. After this time, - * the next query will trigger a background refetch. + * a later query may revalidate according to its stale/fresh policy. + * Missing entries use the hook's current time. Do not mutate this Date. + * + * @example + * function Timestamp() { + * const status = useQueryStatus('profile') + * return + * } + * const view = */ readonly expiresAt: Date /** * Indicates whether the cached data has expired. When true, the data - * is considered stale and will be refetched on the next query. + * is considered stale. A timer marks this true; the current implementation + * does not reset it to false when an entry gets a later expiration date. + * + * @example + * function Staleness() { + * return {String(useQueryStatus('profile').isExpired)} + * } + * const view = */ readonly isExpired: boolean /** - * Indicates whether the data is currently being refetched in the - * background while serving stale data. + * Tracks refetching events until resolved/error events clear the flag. + * This can describe blocking or background fetches. Aborted events are not + * observed here, so the flag is not an authoritative active-request count. + * + * @example + * function Progress() { + * return Profile + * } + * const view = */ readonly isRefetching: boolean /** - * Indicates whether a mutation is currently in progress for this key. + * Tracks mutating events until a mutated/error event clears the flag. + * It is a boolean event hint, not a count of queued mutations. + * + * @example + * function Progress() { + * return Profile + * } + * const view = */ readonly isMutating: boolean } @@ -41,14 +78,11 @@ export interface Status { * * @example * ```tsx - * const { isExpired, isRefetching, isMutating } = useQueryStatus('/api/user/1') - * - * return ( - *
- * {isRefetching && } - * {isExpired && } - *
- * ) + * function Progress() { + * const { isRefetching } = useQueryStatus('profile') + * return Profile + * } + * const view = * ``` */ export function useQueryStatus(key: string, options?: QueryInstance): Status { @@ -60,6 +94,14 @@ export function useQueryStatus(key: string, options?: QueryInstance): Status { useEffect( function () { + /** + * Marks the observed entry expired when its scheduled deadline passes. + * The effect cleanup cancels this timer when the deadline changes. + * + * @example + * function Expiration() { return {String(useQueryStatus('profile').isExpired)} } + * const view = + */ function handler() { setIsExpired(true) } @@ -75,32 +117,88 @@ export function useQueryStatus(key: string, options?: QueryInstance): Status { useEffect( function () { + /** + * Marks mutation activity when a queue operation starts. + * This boolean intentionally does not count multiple queued operations. + * + * @example + * function Progress() { return Profile } + * const view = + */ function onMutating() { setIsMutating(true) } + /** + * Ends the mutation hint and reads the committed entry's deadline. + * isExpired is left to its existing timer-derived behavior. + * + * @example + * function Progress() { return Profile } + * const view = + */ function onMutated() { setIsMutating(false) setExpiresAt(expiration(key) ?? new Date()) } + /** + * Reschedules expiration after explicit cache seeding. + * A missing local entry, including remote-only events, uses current time. + * + * @example + * function Deadline() { return } + * const view = + */ function onHydrated() { setExpiresAt(expiration(key) ?? new Date()) } + /** + * Reads the fetch commit's expiration and ends the refetch activity hint. + * Remote events do not guarantee that a local cache entry exists. + * + * @example + * function Progress() { return Profile } + * const view = + */ function onResolved() { setExpiresAt(expiration(key) ?? new Date()) setIsRefetching(false) } + /** + * Updates the deadline after removal, normally to the current time. + * Actual requerying belongs to useQueryBasic's clearOnForget policy. + * + * @example + * function Deadline() { return } + * const view = + */ function onForgotten() { setExpiresAt(expiration(key) ?? new Date()) } + /** + * Starts the refetch hint for a new or replayed active request event. + * No request is initiated by this status observer. + * + * @example + * function Progress() { return Profile } + * const view = + */ function onRefetching() { setIsRefetching(true) } + /** + * Clears both activity hints after a core error event for this key. + * This observer neither retries requests nor handles caller rejections. + * + * @example + * function Progress() { return Profile } + * const view = + */ function onError() { setIsRefetching(false) setIsMutating(false) diff --git a/src/react/hooks/useQueryTransitionContext.ts b/src/react/hooks/useQueryTransitionContext.ts index 2d56ba9..11c91e7 100644 --- a/src/react/hooks/useQueryTransitionContext.ts +++ b/src/react/hooks/useQueryTransitionContext.ts @@ -6,13 +6,25 @@ import { use } from 'react' * nearest QueryTransition ancestor. Provides access to the shared transition * state and startTransition function for coordinating UI updates across * multiple query components. + * Without a provider both members are undefined, allowing data hooks to + * choose their local transition instead of requiring a shared scope. * * @returns The current transition context value containing isPending state * and startTransition function. * * @example * ```tsx - * const { isPending, startTransition } = useQueryTransitionContext() + * function Progress() { + * const { isPending } = useQueryTransitionContext() + * return Status + * } + * function Layout() { + * const [isPending, startTransition] = useTransition() + * return + * + * + * } + * const view = * ``` */ export function useQueryTransitionContext(): QueryTransitionContextValue { diff --git a/src/react/index.ts b/src/react/index.ts index 18c4696..119eb39 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,3 +1,28 @@ +/** + * React 19 bindings for a core query instance and shared render resources. + * Supply a stable QueryProvider instance, Suspense for blocking reads, and + * an error boundary for rejected initial requests. Hook options override + * context defaults; useQuery combines data, actions, and status hints. + * + * Fresh reads share an instance-owned selection across suspended retries and + * mounted siblings. Effects retain committed consumers and release on cleanup. + * Prefetch and incoming broadcast subscriptions are client-effect work only. + * Refetch defaults stale to false, but needs fresh: true to bypass valid cache. + * Source hook .ts files are not transformed by the TSX-only React Compiler. + * Examples use this entry point plus core exports and React's named APIs. + * + * @example + * import { Suspense } from 'react' + * import { createQuery } from '@studiolambda/query' + * import { QueryProvider, useQuery } from '@studiolambda/query/react' + * const query = createQuery({ fetcher: async () => 'Ada' }) + * function Profile() { return {useQuery('profile').data} } + * const view = + * + * + * + * @module + */ export * from 'query/react:hooks/useQuery' export * from 'query/react:hooks/useQueryBasic' export * from 'query/react:hooks/useQueryStatus' diff --git a/src/react/transition.ts b/src/react/transition.ts index 779ae4f..f081a7b 100644 --- a/src/react/transition.ts +++ b/src/react/transition.ts @@ -4,17 +4,40 @@ import { createContext, type TransitionStartFunction } from 'react' * The value type for the transition context, providing shared transition * state and control function for coordinating UI updates across multiple * query components. + * Undefined members allow hooks to fall back to their local transition. + * + * @example + * function Layout() { + * const [isPending, startTransition] = useTransition() + * const value: QueryTransitionContextValue = { isPending, startTransition } + * return Application + * } + * const view = */ export interface QueryTransitionContextValue { /** * Indicates whether a transition is currently pending. When true, * components can show loading indicators or reduce visual updates. + * This reports React transition work, not cache expiration or initial loading. + * + * @example + * const value: QueryTransitionContextValue = { isPending: false } + * console.log(value.isPending) // false */ readonly isPending?: boolean /** * The function to start a transition, allowing components to wrap * state updates in a transition for smoother UI behavior. + * When absent, query hooks use their own local transition starter. + * + * @example + * function Layout() { + * const [isPending, startTransition] = useTransition() + * const value: QueryTransitionContextValue = { isPending, startTransition } + * return Application + * } + * const view = */ readonly startTransition?: TransitionStartFunction } @@ -22,6 +45,10 @@ export interface QueryTransitionContextValue { /** * React context that provides shared transition state and control * to all descendant query components. Created with default undefined values. + * QueryTransition is the usual provider; hooks can opt out per resource. + * + * @example + * const view = Application */ export const TransitionContext = createContext({ isPending: undefined, diff --git a/src/solid/components/QueryProvider.tsx b/src/solid/components/QueryProvider.tsx index ad5b506..cdad9eb 100644 --- a/src/solid/components/QueryProvider.tsx +++ b/src/solid/components/QueryProvider.tsx @@ -6,16 +6,31 @@ import { createQuery, type Query, type Configuration } from 'query:index' /** * Combined configuration type that merges query configuration options * with context-specific options for the QueryProvider component. + * This source-only intersection does not add an npm-exported Solid API. + * + * @example + * const options: Options = { fetcher: async () => 'Ada', clearOnForget: true } + * const view = Application */ type Options = Configuration & QueryContextOptions /** * Props for the Solid.js QueryProvider component, extending all * query configuration and context options with optional children. + * Unlike the React provider, this source-only provider creates its own query + * and has no query-instance prop. Supplied broadcast channels are caller-owned. + * + * @example + * const props: QueryProps = { fetcher: async () => 'Ada', children: 'Application' } + * const view = */ export interface QueryProps extends Options { /** * The child elements to render within the provider context. + * Children receive reactive context accessors, not a loading boundary. + * + * @example + * const view = 'Ada'}>Application */ children?: JSX.Element } @@ -25,20 +40,29 @@ export interface QueryProps extends Options { * Creates a query instance with the provided configuration, sets up a * BroadcastChannel for cross-tab synchronization, and makes the query * available to all child components via Solid.js context. + * This binding is unfinished and unpublished: import it from repository source + * only. In client effects it uses a supplied caller-owned channel or creates + * a channel named "query", which it closes on cleanup. That default differs + * from the opt-in React/core policy and is unsuitable for tenant isolation. + * Prefer an explicitly scoped channel; its name is not authorization. + * Reactive configuration can replace the query instance. The provider does + * not abort old requests or close a supplied channel on replacement/unmount. * * @param props - The provider configuration and children. * * @example * ```tsx - * import { QueryProvider } from '@studiolambda/query/solid' + * // Repository source example; this npm subpath is not published. + * import { QueryProvider } from 'query/solid:components/QueryProvider' * * function App() { * return ( - * - * + * 'Ada'}> + * Application content * * ) * } + * const view = * ``` */ export function QueryProvider(props: QueryProps) { diff --git a/src/solid/context.ts b/src/solid/context.ts index 7f30ac3..cacfc41 100644 --- a/src/solid/context.ts +++ b/src/solid/context.ts @@ -4,11 +4,22 @@ import { type Query } from 'query:index' /** * Additional configuration options for the Solid.js query context * that affect query behavior throughout the component tree. + * These bindings are source-only: no Solid subpath is published in package.json. + * The provider passes this setting through; there is no Solid data hook here + * to implement clear-on-forget behavior. + * + * @example + * const options: QueryContextOptions = { clearOnForget: true } + * console.log(options.clearOnForget) // true */ export interface QueryContextOptions { /** - * When true, components will automatically refetch data after the - * cache for a key is cleared via the forget method. + * Carries a requested clear-on-forget policy for prospective data consumers. + * The current source-only Solid provider stores it but does not refetch data. + * + * @example + * const options: QueryContextOptions = { clearOnForget: true } + * console.log(options.clearOnForget) // true */ readonly clearOnForget?: boolean } @@ -16,15 +27,32 @@ export interface QueryContextOptions { /** * The value type for the Solid.js query context, providing reactive * accessors to the query instance and additional configuration options. + * Accessors are optional outside a provider; consumers must handle absence. + * + * @example + * const query = createQuery() + * const value: QueryContextValue = { query: () => query, additional: () => ({}) } + * console.log(value.query?.().keys()) // [] */ export interface QueryContextValue { /** * Reactive accessor to the query instance. + * The provider can replace the instance when its reactive inputs change. + * + * @example + * const instance = createQuery() + * const value: QueryContextValue = { query: () => instance } + * console.log(value.query?.().keys()) // [] */ readonly query?: Accessor /** * Reactive accessor to additional context options. + * Missing context yields undefined; the provider returns its current policy. + * + * @example + * const value: QueryContextValue = { additional: () => ({ clearOnForget: true }) } + * console.log(value.additional?.().clearOnForget) // true */ readonly additional?: Accessor } @@ -32,6 +60,17 @@ export interface QueryContextValue { /** * Solid.js context that provides the query instance and configuration * to all descendant components. Created with default undefined values. + * It is available through repository source imports only, not an npm subpath. + * + * @example + * import { useContext } from 'solid-js' + * function Consumer() { + * const value = useContext(QueryContext) + * return {value.query?.().keys().length ?? 0} + * } + * const view = createQuery() }}> + * + * */ export const QueryContext = createContext({ query: undefined, diff --git a/src/solid/index.ts b/src/solid/index.ts index 9dbf72c..fa287a3 100644 --- a/src/solid/index.ts +++ b/src/solid/index.ts @@ -1,3 +1,19 @@ +/** + * Unfinished Solid bindings, available through repository source imports only. + * package.json publishes no @studiolambda/query/solid entry point. The provider + * exposes reactive query/options accessors; no Solid data hook is implemented. + * + * Unlike the core/React defaults, the client provider creates a channel named + * "query" unless supplied one. It closes only its own channel on cleanup. + * Shared channel names are not tenant isolation or authorization boundaries. + * Prefer a caller-owned explicitly scoped channel when using these sources. + * + * @example + * import { QueryProvider } from 'query/solid:components/QueryProvider' + * const view = 'Ada'}>Application + * + * @module + */ // export * from 'query/solid:useQuery' export * from 'query/solid:context' export * from 'query/solid:components/QueryProvider'