diff --git a/README.md b/README.md index 682dacb..c29f0e5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,49 @@ Find out about the project and discover the features at the [Documentation](http npm i @studiolambda/query ``` +## Fresh React requests and Suspense + +`useQuery` and `useQueryBasic` share a **fresh request lifetime per instance and +cache key**. This applies to both hook options and `createQuery({ fresh: true })`: + +```tsx +import { createQuery } from '@studiolambda/query' +import { useQuery } from '@studiolambda/query/react' + +const instance = createQuery({ fetcher: async () => 'hello', fresh: true }) + +function Greeting() { + const { data } = useQuery('/hello', { query: instance }) + return

{data}

+} + +// Render Greeting inside a Suspense boundary and an error boundary. +``` + +- The first fresh reader bypasses cached data. Suspense retries and same-key + fresh siblings reuse that request, using the first reader's fetch options. +- Rerenders and expiration do not restart a live fresh request. The last + committed consumer's unmount releases it on the next microtask; a later + mount starts fresh again. Changing the key or instance selects its own request. +- Explicit `refetch({ fresh: true })` or `instance.query(key, { fresh: true })` + still force a new fetch and abort any pending fetch for that key. Hydration, + mutation and successful refreshes update the shared resource's promise. + Committed consumers keep their last successful value during a refresh; + uncommitted Suspense readers follow a replacement pending request. +- A discarded initial render cannot run cleanup. Its request (including a + rejection) remains available for retries until `forget`, request/cache + reconfiguration, or garbage collection of the instance. Changing only + `events`/`broadcast` preserves it. Before resetting an error boundary, + forget the failed key or explicitly refresh it. Forgetting does not abort + pending transports; use `abort` when cancellation is also required. +- `fresh: false` overrides instance freshness. Non-fresh reads retain the + existing cache, expiration and stale-while-revalidate behavior. + +The core `instance.resource(key, options)` API owns this lifecycle; React retains +it after commit and releases it on cleanup. Applications using `createQuery` +need no API migration. Hand-written implementations of the `Query` interface +must now implement `resource` as well. + ## Migration: cross-tab synchronization is now opt-in **Breaking change:** React `QueryProvider` no longer creates or joins the diff --git a/src/query/options.ts b/src/query/options.ts index 4e283eb..6c9076b 100644 --- a/src/query/options.ts +++ b/src/query/options.ts @@ -141,6 +141,52 @@ export type QueryFunction = { (key: string, options?: Options): Promise } +/** + * 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. + * + * @example + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello' }) + * const request = instance.resource('/hello', { fresh: true }) + * const release = request.retain() + * try { console.log(await request.promise) } finally { release() } + * ``` + */ +export interface QueryResource { + /** + * The selected request. Initial readers follow pending replacements; + * retained consumers keep the last successful request until a refresh + * succeeds. Successful mutations and hydration update it too. Initial + * errors reject as they do for ordinary queries. + * + * @example + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello' }) + * const request = instance.resource('/hello') + * try { console.log(await request.promise) } catch (error) { console.error(error) } + * ``` + */ + readonly promise: Promise + + /** + * Marks a committed consumer as active and returns its cleanup function. + * 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. + * + * @example + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello' }) + * const request = instance.resource('/hello', { fresh: true }) + * const release = request.retain() + * release() // Call once when this consumer stops using the resource. + * ``` + */ + readonly retain: () => Unsubscriber +} + /** * Function type that calculates the expiration time in milliseconds * for a cached item based on its value. @@ -283,12 +329,28 @@ export type ForgetFunction = { } /** - * Represents the methods a query - * should implement. + * An isolated async-data instance. Imperative queries use cache policy on + * every call; render resources can share a fresh request across retries and + * committed sibling consumers. Obtain an implementation from createQuery(). + * + * @example + * ```ts + * const instance: Query = createQuery({ fetcher: async () => 'hello' }) + * try { console.log(await instance.query('/hello')) } + * catch (error) { console.error(error) } + * ``` */ export interface Query { /** - * Configures the current instance of 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. + * + * @example + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello' }) + * instance.configure({ fresh: true }) + * ``` */ readonly configure: ConfigureFunction @@ -298,6 +360,28 @@ export interface Query { */ readonly query: QueryFunction + /** + * Selects a request for a render consumer. Ordinary requests keep query's + * cache/expiration behavior. With effective fresh: true, the first read + * bypasses the cache; retries and same-key fresh consumers share that + * request until the last retained consumer releases it. Uncommitted reads + * remain available until forget, request/cache reconfiguration or disposal. + * Query options are selected by the first fresh read, not by each sibling. + * Explicit query calls remain independent and can force a new request. + * Rejected selections are retained too: forget the key or explicitly + * refresh it before retrying an error boundary. Cache events remain the + * notification mechanism; the resource itself does not subscribe consumers. + * + * @example + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello' }) + * const request = instance.resource('/hello', { fresh: true }) + * const release = request.retain() + * try { console.log(await request.promise) } finally { release() } + * ``` + */ + readonly resource: (key: string, options?: Options) => QueryResource + /** * Emit is able to send events to active subscribers * with the given payload. It is a low level API @@ -336,8 +420,15 @@ export interface Query { readonly abort: AbortFunction /** - * Forgets the given keys from the items cache. - * Does not remove any resolvers. + * Forgets items and fresh render selections for matching keys, including + * selections from abandoned pending renders. Does not abort transports. + * Omit keys to forget all, or pass a string, list or regular expression. + * + * @example + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello' }) + * await instance.forget('/hello') + * ``` */ readonly forget: ForgetFunction diff --git a/src/query/query.ts b/src/query/query.ts index 70858fa..32d54f7 100644 --- a/src/query/query.ts +++ b/src/query/query.ts @@ -16,11 +16,31 @@ import { type Options, type Query, type QueryEvent, + type QueryResource, type SubscribeListener, type TriggerFunction, type Unsubscriber, } from 'query:options' +/** + * An instance-owned fresh request and its committed consumer count. The + * promise follows explicit cache writes; only the owning instance mutates it. + */ +interface FreshResource extends QueryResource { + /** Pending until first commit, then the latest successful cached request. */ + promise: Promise + /** Active committed consumers; suspended initial renders do not retain. */ + consumers: number +} + +/** + * Ordinary cached reads have no fresh lease to retain. Keeping this function + * stable also avoids reconnecting React effects on ordinary rerenders. + */ +function retainCachedResource(): () => void { + return function () {} +} + /** * Creates a default fetcher function that performs JSON requests * using the provided fetch implementation. @@ -40,17 +60,31 @@ export function defaultFetcher( } /** - * Creates an isolated query instance with cache, events, and fetch defaults. + * Creates an isolated cache, event system and request lifecycle. React's + * bindings use resource() to share fresh requests across Suspense retries; + * imperative query() calls retain their force-fresh behavior. Cache and + * transport configuration can be supplied or changed through configure(). * 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. * + * @param instanceOptions - Optional cache, fetcher and policy defaults. + * @returns An instance with no shared process-global request state. * @example - * const client = createQuery({ fetcher: async () => 'server value' }) - * const value = await client.query('profile') - * await client.mutate('profile', value.toUpperCase()) + * ```ts + * const instance = createQuery({ fetcher: async () => 'hello', fresh: true }) + * try { console.log(await instance.query('/hello')) } + * catch (error) { console.error(error) } + * ``` */ 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. + */ + const freshResources = new Map() + /** * Stores the default expiration function. */ @@ -157,9 +191,19 @@ export function createQuery(instanceOptions?: Configuration): Query { let instanceFresh = instanceOptions?.fresh ?? false /** - * Configures the current instance of query. + * Applies supplied defaults. Request/cache configuration invalidates fresh + * 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. */ function configure(options?: Configuration): void { + /** Notification setup must not restart a request selected during render. */ + const changesRequests = Object.keys(options ?? {}).some( + (key) => key !== 'broadcast' && key !== 'events' + ) + if (changesRequests) { + freshResources.clear() + } itemsCache = options?.itemsCache ?? itemsCache resolversCache = options?.resolversCache ?? resolversCache events = options?.events ?? events @@ -171,6 +215,61 @@ export function createQuery(instanceOptions?: Configuration): Query { instanceFresh = options?.fresh ?? instanceFresh } + /** + * Writes a resolved cache value and keeps fresh render consumers aligned + * with hydration, mutation and completed requests from the imperative API. + */ + function cacheItem(key: string, value: ItemsCacheItem): void { + itemsCache.set(key, value) + /** A live fresh selection must follow the same cache write as new readers. */ + const resource = freshResources.get(key) + if (resource !== undefined) { + resource.promise = value.item + } + } + + /** + * 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. + */ + function resource(key: string, options?: Options): QueryResource { + /** Per-read freshness overrides the instance default, including false. */ + const fresh = options?.fresh ?? instanceFresh + if (!fresh) { + return { promise: query(key, options), retain: retainCachedResource } + } + + /** Retained or not-yet-committed readers share this existing selection. */ + const cached = freshResources.get(key) + if (cached !== undefined) { + return cached as QueryResource + } + + /** The first reader owns request options for the shared key lifetime. */ + const request: FreshResource = { + promise: query(key, options), + consumers: 0, + /** + * Retains only committed consumers. Cleanup waits one microtask so + * StrictMode's synchronous effect replay preserves the same request. + */ + retain() { + request.consumers += 1 + return function () { + request.consumers -= 1 + queueMicrotask(() => { + if (request.consumers === 0 && freshResources.get(key) === request) { + freshResources.delete(key) + } + }) + } + }, + } + freshResources.set(key, request) + return request as QueryResource + } + /** * Emits an event to all active subscribers for a given key. * Also broadcasts certain events (mutated, resolved, hydrated, forgotten) @@ -273,7 +372,7 @@ export function createQuery(instanceOptions?: Configuration): Query { expiresAt.setMilliseconds(expiresAt.getMilliseconds() + (options?.expiration?.(item) ?? 0)) supersedeFetch(key) - itemsCache.set(key, { item: result, expiresAt }) + cacheItem(key, { item: result, expiresAt }) emit(key, 'mutated', item) return item @@ -336,9 +435,9 @@ export function createQuery(instanceOptions?: Configuration): Query { } /** - * Forgets the given keys from the items cache. - * Does not remove any resolvers. - * If no keys are provided the items cache is cleared. + * 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. */ async function forget(cacheKeys?: string | readonly string[] | RegExp): Promise { let itemKeys: readonly string[] @@ -348,12 +447,15 @@ export function createQuery(instanceOptions?: Configuration): Query { } else if (Array.isArray(cacheKeys)) { itemKeys = cacheKeys } else if (cacheKeys instanceof RegExp) { - itemKeys = keys('items').filter((key) => key.match(cacheKeys)) + itemKeys = [...new Set([...keys('items'), ...freshResources.keys()])].filter((key) => + key.match(cacheKeys) + ) } else { - itemKeys = keys('items') + itemKeys = [...new Set([...keys('items'), ...freshResources.keys()])] } for (const key of itemKeys) { + freshResources.delete(key) const item = itemsCache.get(key) if (item !== undefined) { @@ -395,7 +497,7 @@ export function createQuery(instanceOptions?: Configuration): Query { for (const key of typeof keys === 'string' ? [keys] : keys) { supersedeFetch(key) - itemsCache.set(key, { item: result, expiresAt: expiresAt }) + cacheItem(key, { item: result, expiresAt: expiresAt }) emit(key, 'hydrated', item) } } @@ -517,7 +619,7 @@ export function createQuery(instanceOptions?: Configuration): Query { expiresAt.setMilliseconds(expiresAt.getMilliseconds() + expiration(item)) // Set the item to the cache. - itemsCache.set(key, { item: result, expiresAt }) + cacheItem(key, { item: result, expiresAt }) // Removes this request's resolver after its cache write succeeds. resolversCache.delete(key) @@ -554,6 +656,14 @@ export function createQuery(instanceOptions?: Configuration): Query { // Adds the resolver to the cache. resolversCache.set(key, { item: result, controller }) + /** + * Suspended initial readers follow a replacement pending request. + * Committed readers keep their last value until a refresh succeeds. + */ + const resource = freshResources.get(key) + if (resource !== undefined && resource.consumers === 0) { + resource.promise = result + } queueMicrotask(() => emit(key, 'refetching', result)) // The promise executor runs synchronously, @@ -763,6 +873,7 @@ export function createQuery(instanceOptions?: Configuration): Query { return { query, + resource, emit, subscribe, subscribeBroadcast, diff --git a/src/query/resource.test.ts b/src/query/resource.test.ts new file mode 100644 index 0000000..4b60513 --- /dev/null +++ b/src/query/resource.test.ts @@ -0,0 +1,193 @@ +import { describe, it, vi } from 'vitest' +import { createQuery } from './query' +import { type FetcherFunction } from './options' + +describe.concurrent('instance-owned fresh resources', () => { + it('keeps a hydrated resource when its superseded initial fetch completes', async ({ + expect, + }) => { + /** The original caller still receives its result without publishing it. */ + const pending = Promise.withResolvers() + const instance = createQuery({ fetcher: () => pending.promise, fresh: true }) + const resource = instance.resource('/hydrated-resource') + const original = resource.promise + instance.hydrate('/hydrated-resource', 'hydrated') + const hydrated = resource.promise + await expect(hydrated).resolves.toBe('hydrated') + pending.resolve('obsolete') + await expect(original).resolves.toBe('obsolete') + expect(resource.promise).toBe(hydrated) + expect(instance.caches().items.get('/hydrated-resource')!.item).toBe(hydrated) + expect(instance.keys('resolvers')).toEqual([]) + }) + + it('keeps the replacement resource and resolver after an obsolete request rejects', async ({ + expect, + }) => { + /** An obsolete request may reject after a replacement has taken ownership. */ + const obsolete = Promise.withResolvers() + const replacement = Promise.withResolvers() + const error = new Error('obsolete request failed') + const fetcher = vi + .fn>() + .mockReturnValueOnce(obsolete.promise) + .mockReturnValueOnce(replacement.promise) + const instance = createQuery({ fetcher, fresh: true }) + const resource = instance.resource('/ownership') + const rejected = expect(resource.promise).rejects.toBe(error) + const current = instance.query('/ownership', { fresh: true }) + obsolete.reject(error) + await rejected + expect(instance.query('/ownership', { fresh: false })).toBe(current) + expect(resource.promise).toBe(current) + replacement.resolve('current') + await expect(current).resolves.toBe('current') + expect(instance.caches().items.get('/ownership')?.item).toBe(current) + expect(resource.promise).toBe(current) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('shares a request until its final committed consumer releases it', async ({ expect }) => { + const fetcher = vi.fn>(async () => 'value') + const instance = createQuery({ fetcher, fresh: true, expiration: () => 0 }) + const resource = instance.resource('/resource') + const firstRelease = resource.retain() + const secondRelease = resource.retain() + await resource.promise + firstRelease() + await Promise.resolve() + expect(instance.resource('/resource')).toBe(resource) + secondRelease() + await Promise.resolve() + const next = instance.resource('/resource') + expect(next).not.toBe(resource) + await next.promise + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('keeps ordinary expiration and imperative freshness unchanged', async ({ expect }) => { + const fetcher = vi.fn>(async () => 'value') + const instance = createQuery({ fetcher, expiration: () => 0, stale: false }) + await instance.resource('/resource').promise + await instance.resource('/resource').promise + expect(fetcher).toHaveBeenCalledTimes(2) + + const first = instance.resource('/resource', { fresh: true }) + await first.promise + await instance.query('/resource', { fresh: true }) + expect(instance.resource('/resource', { fresh: true })).toBe(first) + expect(fetcher).toHaveBeenCalledTimes(4) + }) + + it('shares the first fresh reader options rather than sibling fetchers', async ({ expect }) => { + const firstFetcher = vi.fn>(async () => 'first') + const siblingFetcher = vi.fn>(async () => 'sibling') + const instance = createQuery({ fresh: true }) + const first = instance.resource('/resource', { fetcher: firstFetcher }) + const sibling = instance.resource('/resource', { fetcher: siblingFetcher }) + expect(sibling).toBe(first) + await expect(sibling.promise).resolves.toBe('first') + expect(siblingFetcher).not.toHaveBeenCalled() + }) + + it('updates active resources on hydration and mutation', async ({ expect }) => { + const instance = createQuery({ fetcher: async () => 'first', fresh: true }) + const resource = instance.resource('/resource') + await resource.promise + instance.hydrate('/resource', 'hydrated') + await expect(resource.promise).resolves.toBe('hydrated') + await instance.mutate('/resource', 'mutated') + await expect(resource.promise).resolves.toBe('mutated') + expect(instance.resource('/resource')).toBe(resource) + }) + + it('forgets abandoned pending resources by key, pattern and all keys', async ({ expect }) => { + const fetcher = vi.fn>(() => new Promise(() => {})) + const instance = createQuery({ fetcher, fresh: true }) + const keyed = instance.resource('/key') + await instance.forget('/key') + expect(instance.resource('/key')).not.toBe(keyed) + const patterned = instance.resource('/pattern') + await instance.forget(/^\/pattern$/) + expect(instance.resource('/pattern')).not.toBe(patterned) + const all = instance.resource('/all') + await instance.forget() + expect(instance.resource('/all')).not.toBe(all) + instance.abort() + }) + + it('invalidates old selections when the instance is reconfigured', async ({ expect }) => { + const instance = createQuery({ fetcher: async () => 'first', fresh: true }) + const first = instance.resource('/resource') + await first.promise + instance.configure({ fetcher: async () => 'second' }) + const second = instance.resource('/resource') + expect(second).not.toBe(first) + await expect(second.promise).resolves.toBe('second') + }) + + it('preserves selections during notification-only configuration', async ({ expect }) => { + const instance = createQuery({ fetcher: async () => 'first', fresh: true }) + const resource = instance.resource('/resource') + await resource.promise + instance.configure({ events: new EventTarget() }) + expect(instance.resource('/resource')).toBe(resource) + instance.configure() + expect(instance.resource('/resource')).toBe(resource) + }) + + it('retains rejected requests for retries until explicitly invalidated', async ({ expect }) => { + const error = new Error('failed') + const fetcher = vi.fn>(async () => { + throw error + }) + const instance = createQuery({ fetcher, fresh: true }) + const resource = instance.resource('/resource') + await expect(resource.promise).rejects.toBe(error) + expect(instance.resource('/resource')).toBe(resource) + await instance.forget('/resource') + fetcher.mockResolvedValue('recovered') + await expect(instance.resource('/resource').promise).resolves.toBe('recovered') + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('lets an imperative fresh query replace and abort a pending render request', async ({ + expect, + }) => { + const pending = Promise.withResolvers() + const replacement = Promise.withResolvers() + const signals: AbortSignal[] = [] + const fetcher = vi.fn>((_key, { signal }) => { + signals.push(signal) + return signals.length === 1 ? pending.promise : replacement.promise + }) + const instance = createQuery({ fetcher, fresh: true }) + const resource = instance.resource('/resource') + const original = resource.promise + const rejection = expect(original).rejects.toBeInstanceOf(DOMException) + const refreshed = instance.query('/resource', { fresh: true }) + expect(signals[0]!.aborted).toBe(true) + expect(signals[1]!.aborted).toBe(false) + expect(resource.promise).toBe(refreshed) + pending.resolve('discarded') + replacement.resolve('replacement') + await rejection + await expect(refreshed).resolves.toBe('replacement') + await expect(resource.promise).resolves.toBe('replacement') + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('keeps a committed selection when an explicit refresh fails', async ({ expect }) => { + const fetcher = vi.fn>(async () => 'first') + const instance = createQuery({ fetcher, fresh: true }) + const resource = instance.resource('/resource') + await resource.promise + const release = resource.retain() + const successful = resource.promise + fetcher.mockRejectedValue(new Error('refresh failed')) + await expect(instance.query('/resource', { fresh: true })).rejects.toThrow('refresh failed') + expect(resource.promise).toBe(successful) + await expect(resource.promise).resolves.toBe('first') + release() + }) +}) diff --git a/src/react/hooks/useQueryBasic.errors.test.tsx b/src/react/hooks/useQueryBasic.errors.test.tsx index 5d266fd..e63c97e 100644 --- a/src/react/hooks/useQueryBasic.errors.test.tsx +++ b/src/react/hooks/useQueryBasic.errors.test.tsx @@ -8,7 +8,7 @@ import { describe, it, onTestFinished, vi } from 'vitest' import { act, Suspense } from 'react' import { createRoot, type RootOptions } from 'react-dom/client' import { ErrorBoundary } from 'react-error-boundary' -import { createQuery, type Query } from 'query:index' +import { createQuery, type FetcherFunction, type Query } from 'query:index' import { useQuery, type Resource } from './useQuery' /** @@ -80,6 +80,41 @@ function createView(query: Query) { // React's act queue is global, so these root/transition tests must not overlap. describe('useQueryBasic refetch errors', () => { + it('preserves a committed fresh resource through failure and recovers on refresh', async ({ + expect, + }) => { + /** The initial and failed requests settle only when the test releases them. */ + const initial = Promise.withResolvers() + const refresh = Promise.withResolvers() + const error = new Error('fresh refresh failed') + const fetcher = vi + .fn>() + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(refresh.promise) + .mockResolvedValueOnce('recovered') + const query = createQuery({ fetcher, fresh: true, expiration: () => 0 }) + const view = createView(query) + await view.render('/fresh-failure') + await act(async () => initial.resolve('initial')) + expect(view.container.innerText).toBe('initial') + + await act(async () => { + const assertion = expect(view.resource!.refetch({ fresh: true })).rejects.toBe(error) + refresh.reject(error) + await assertion + }) + expect(view.container.innerText).toBe('initial') + expect(view.container.querySelector('p')?.dataset.pending).toBe('false') + expect(view.onCaughtError).not.toHaveBeenCalled() + + await act(async () => { + expect(await view.resource!.refetch({ fresh: true })).toBe('recovered') + }) + expect(view.container.innerText).toBe('recovered') + expect(view.onCaughtError).not.toHaveBeenCalled() + expect(fetcher).toHaveBeenCalledTimes(3) + }) + it('retains stale rendered data and reports a background error', async ({ expect }) => { /** Controlled fetch settlement keeps the refetch subscription observable. */ const fetch = Promise.withResolvers() diff --git a/src/react/hooks/useQueryBasic.ts b/src/react/hooks/useQueryBasic.ts index a1bf28a..7a8d4a0 100644 --- a/src/react/hooks/useQueryBasic.ts +++ b/src/react/hooks/useQueryBasic.ts @@ -40,6 +40,14 @@ export type BasicResourceOptions = ContextValue & Options & Quer * additional actions (refetch, mutate, forget) or status information. * Use useQuery for a more complete solution. * + * Freshness (from options or the instance) applies to a shared request + * 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 + * reusable until the key is forgotten, request configuration changes, or the + * instance is garbage-collected. Notification-only configuration preserves it. + * * Refetch subscription failures preserve the rendered value; the query * instance reports them through its error event. Blocking render promises * still reject into the nearest error boundary, and action promises keep @@ -79,7 +87,7 @@ export function useQueryBasic( useQueryTransitionContext() const [isLocalPending, startLocalTransition] = useTransition() - const { query, subscribe } = useQueryInstance(options) + const { resource, subscribe } = useQueryInstance(options) const ignoreTransitionContext = oIgnoreTransitionContext ?? cIgnoreTransitionContext ?? false const isPending = ignoreTransitionContext ? isLocalPending : (isContextPending ?? isLocalPending) @@ -89,7 +97,8 @@ export function useQueryBasic( const clearOnForget = oClearOnForget ?? cClearOnForget ?? false - const promise = query(key, { + /** Instance-owned selection survives initial renders discarded by Suspense. */ + const { promise, retain } = resource(key, { expiration: oExpiration, fetcher: oFetcher, stale: oStale, @@ -108,6 +117,8 @@ export function useQueryBasic( setData(resolved) } + useEffect(() => retain(), [retain]) + const onResolved = useEffectEvent(function (event: CustomEventInit) { startTransition(function () { setData(event.detail as T) @@ -163,13 +174,14 @@ export function useQueryBasic( const onForgotten = useEffectEvent(function () { if (clearOnForget) { startTransition(async function () { - const data = await query(key, { + const { promise } = resource(key, { expiration: oExpiration, fetcher: oFetcher, stale: oStale, removeOnError: oRemoveOnError, fresh: oFresh, }) + const data = await promise startTransition(function () { setData(data) diff --git a/src/react/hooks/useQueryFresh.test.tsx b/src/react/hooks/useQueryFresh.test.tsx new file mode 100644 index 0000000..f3d5697 --- /dev/null +++ b/src/react/hooks/useQueryFresh.test.tsx @@ -0,0 +1,417 @@ +/** @jsxImportSource react */ +import { afterEach, describe, it, vi } from 'vitest' +import { act, StrictMode, Suspense } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { createQuery, type FetcherFunction, type Query } from 'query:index' +import { useQueryBasic } from './useQueryBasic' +import { useQuery } from './useQuery' +import { QueryProvider } from '../components/QueryProvider' + +/** + * Inputs for an uncompiled hook consumer. Freshness can come from either + * the hook or its instance; undefined deliberately preserves instance defaults. + */ +interface ConsumerProps { + /** Optional instance override; omit it to exercise provider context lookup. */ + readonly instance?: Query + /** The cache key, changed without remounting to exercise hook state. */ + readonly resourceKey: string + /** An optional per-hook override of instance freshness. */ + readonly fresh?: boolean + /** Enables the hook's existing automatic reload after forgetting a key. */ + readonly clearOnForget?: boolean +} + +/** + * Reads through the actual hook under React DOM Suspense, rather than mocking + * React's retry behavior. The caller supplies the surrounding boundary. + */ +function Consumer({ instance, resourceKey, fresh, clearOnForget }: ConsumerProps) { + const { data } = useQueryBasic(resourceKey, { query: instance, fresh, clearOnForget }) + return

{data}

+} + +/** + * Exercises the public hook's explicit refresh action independently of the + * render request lifecycle. The test resolves all requests successfully. + */ +function RefreshConsumer({ instance, resourceKey }: ConsumerProps) { + const { data, refetch } = useQuery(resourceKey, { query: instance }) + return +} + +/** + * Creates manually delayed, abort-aware requests. Tests release each request + * explicitly, so no wall-clock sleeps or scheduler timing determine success. + */ +function createDelayedFetcher() { + const requests: Array>> = [] + const fetcher = vi.fn>((_key, { signal }) => { + const request = Promise.withResolvers() + requests.push(request) + return request.promise.then((value) => { + signal.throwIfAborted() + return value + }) + }) + return { fetcher, requests } +} + +describe('fresh Suspense requests', () => { + /** Roots are unmounted after every assertion path, including failed tests. */ + let root: Root | undefined + + afterEach(async () => { + await act(async () => root?.unmount()) + }) + + it('shares per-hook freshness across retries, rerenders and siblings', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, expiration: () => 0 }) + instance.hydrate('/fresh', 'old', { expiration: () => 10000 }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + ) + }) + expect(container.textContent).toBe('loading') + expect(fetcher).toHaveBeenCalledTimes(1) + + await act(async () => requests[0]!.resolve('new')) + expect(container.textContent).toBe('new') + + await act(async () => { + root!.render( + + + + + ) + }) + expect(container.textContent).toBe('newnew') + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('honors instance freshness and a per-hook false override', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + instance.hydrate('/fresh', 'old', { expiration: () => 10000 }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + ) + }) + expect(container.textContent).toBe('old') + expect(fetcher).not.toHaveBeenCalled() + + await act(async () => { + root!.render( + + + + ) + }) + expect(fetcher).toHaveBeenCalledTimes(1) + await act(async () => requests[0]!.resolve('new')) + expect(container.textContent).toBe('new') + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('starts a new request on key and instance changes', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const first = createQuery({ fetcher, fresh: true }) + const second = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + + /** Updates props on the same component rather than forcing a React remount. */ + async function render(instance: Query, resourceKey: string) { + await act(async () => { + root!.render( + + + + ) + }) + } + + await render(first, '/a') + await act(async () => requests[0]!.resolve('a')) + expect(container.textContent).toBe('a') + + await render(first, '/b') + expect(fetcher).toHaveBeenCalledTimes(2) + await act(async () => requests[1]!.resolve('b')) + expect(container.textContent).toBe('b') + + await render(second, '/b') + expect(fetcher).toHaveBeenCalledTimes(3) + await act(async () => requests[2]!.resolve('other b')) + expect(container.textContent).toBe('other b') + expect(fetcher).toHaveBeenCalledTimes(3) + }) + + it('refreshes explicitly and gives later siblings the refreshed result', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + ) + }) + await act(async () => requests[0]!.resolve('first')) + + await act(async () => { + const refreshed = instance.query('/fresh', { fresh: true }) + requests[1]!.resolve('refreshed') + await refreshed + }) + expect(container.textContent).toBe('refreshed') + + await act(async () => { + root!.render( + + + + + ) + }) + expect(container.textContent).toBe('refreshedrefreshed') + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('starts fresh again after the last consumer unmounts', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + + /** Mounts a consumer again after the previous one has fully released its lease. */ + async function render() { + await act(async () => { + root!.render( + + + + ) + }) + } + + await render() + await act(async () => requests[0]!.resolve('first')) + await act(async () => root!.render(null)) + await render() + expect(fetcher).toHaveBeenCalledTimes(2) + await act(async () => requests[1]!.resolve('second')) + expect(container.textContent).toBe('second') + }) + + it('shares initially suspended siblings and survives StrictMode effect replay', async ({ + expect, + }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true, expiration: () => 0 }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + + + + ) + }) + expect(fetcher).toHaveBeenCalledTimes(1) + await act(async () => requests[0]!.resolve('shared')) + expect(container.textContent).toBe('sharedshared') + + await act(async () => { + root!.render( + + + + + + ) + }) + expect(container.textContent).toBe('shared') + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('preserves an explicit fresh hook action', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + ) + }) + await act(async () => requests[0]!.resolve('first')) + await act(async () => container.querySelector('button')!.click()) + expect(fetcher).toHaveBeenCalledTimes(2) + await act(async () => requests[1]!.resolve('refreshed')) + expect(container.textContent).toBe('refreshed') + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('shares the automatic reload after forgetting a fresh resource', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + ) + }) + await act(async () => requests[0]!.resolve('first')) + await act(async () => instance.forget('/fresh')) + expect(fetcher).toHaveBeenCalledTimes(2) + await act(async () => requests[1]!.resolve('reloaded')) + expect(container.textContent).toBe('reloaded') + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('keeps a fresh request and caller channel ownership through provider cleanup', async ({ + expect, + }) => { + const { fetcher, requests } = createDelayedFetcher() + /** Synchronous channel double avoids cross-tab I/O while tracking ownership. */ + const broadcast: BroadcastChannel = Object.assign(new EventTarget(), { + name: 'fresh:account-a', + onmessage: null, + onmessageerror: null, + postMessage: vi.fn(), + close: vi.fn(), + }) + const add = vi.spyOn(broadcast, 'addEventListener') + const remove = vi.spyOn(broadcast, 'removeEventListener') + const instance = createQuery({ fetcher, fresh: true, broadcast }) + const container = document.createElement('div') + root = createRoot(container) + + await act(async () => { + root!.render( + + + + + + ) + }) + expect(fetcher).toHaveBeenCalledTimes(1) + await act(async () => requests[0]!.resolve('provided')) + expect(container.textContent).toBe('provided') + expect(fetcher).toHaveBeenCalledTimes(1) + expect(instance.broadcast()).toBe(broadcast) + expect(broadcast.postMessage).toHaveBeenCalledTimes(1) + expect(add).toHaveBeenCalledTimes(1) + await act(async () => root!.unmount()) + root = undefined + expect(remove).toHaveBeenCalledWith('message', add.mock.calls[0]![1]) + expect(broadcast.close).not.toHaveBeenCalled() + }) + + it('keeps ordered mutation commits after an older fresh refresh resolves', async ({ expect }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + await act(async () => { + root!.render( + + + + ) + }) + await act(async () => requests[0]!.resolve('initial')) + + /** The background request remains pending while local mutations commit. */ + let pending: Promise + const mutation = Promise.withResolvers() + await act(async () => { + pending = instance.query('/mutated-fresh', { fresh: true }) + const first = instance.mutate('/mutated-fresh', () => mutation.promise) + const second = instance.mutate('/mutated-fresh', (value) => `${value} second`) + mutation.resolve('first') + await Promise.all([first, second]) + }) + expect(container.textContent).toBe('first second') + + await act(async () => { + requests[1]!.resolve('obsolete server value') + await pending + }) + expect(container.textContent).toBe('first second') + await expect(instance.snapshot('/mutated-fresh')).resolves.toBe('first second') + expect(instance.resource('/mutated-fresh').promise).toBe( + instance.caches().items.get('/mutated-fresh')!.item + ) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('keeps hydration and replacement ownership when a fresh refresh is aborted', async ({ + expect, + }) => { + const { fetcher, requests } = createDelayedFetcher() + const instance = createQuery({ fetcher, fresh: true }) + const container = document.createElement('div') + root = createRoot(container) + await act(async () => { + root!.render( + + + + ) + }) + await act(async () => requests[0]!.resolve('initial')) + + const obsolete = instance.query('/hydrated-fresh', { fresh: true }) + const rejected = expect(obsolete).rejects.toBeInstanceOf(DOMException) + await act(async () => instance.hydrate('/hydrated-fresh', 'hydrated')) + await expect(instance.snapshot('/hydrated-fresh')).resolves.toBe('hydrated') + await expect(instance.resource('/hydrated-fresh').promise).resolves.toBe('hydrated') + + /** A new explicit refresh aborts the old request but owns a separate slot. */ + const replacement = instance.query('/hydrated-fresh', { fresh: true }) + await act(async () => { + requests[1]!.resolve('obsolete') + await rejected + }) + await expect(instance.snapshot('/hydrated-fresh')).resolves.toBe('hydrated') + expect(instance.caches().resolvers.get('/hydrated-fresh')!.item).toBe(replacement) + await act(async () => { + requests[2]!.resolve('replacement') + await replacement + }) + expect(container.textContent).toBe('replacement') + expect(instance.caches().items.get('/hydrated-fresh')!.item).toBe(replacement) + expect(fetcher).toHaveBeenCalledTimes(3) + }) +})