From 672067b0bc06070752b05a44be8c582cf7d2d522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sat, 24 Oct 2026 14:20:37 +0200 Subject: [PATCH 1/2] fix: stabilize fresh React Suspense requests Own fresh render requests within each query instance and retain them across retries and sibling consumers. Keep imperative fresh queries independent and document request lifetimes and invalidation. BREAKING CHANGE: Hand-written implementations of Query must now implement resource(). Implement the documented retain/release request lifecycle or obtain instances from createQuery(); createQuery callers need no API migration. This instance-owned lifecycle is required because React discards initial hook state when rendering suspends. --- README.md | 43 ++++ src/query/options.ts | 101 +++++++- src/query/query.ts | 136 ++++++++++- src/query/resource.test.ts | 149 ++++++++++++ src/react/hooks/useQueryBasic.ts | 18 +- src/react/hooks/useQueryFresh.test.tsx | 321 +++++++++++++++++++++++++ 6 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 src/query/resource.test.ts create mode 100644 src/react/hooks/useQueryFresh.test.tsx diff --git a/README.md b/README.md index c183702..0c97d12 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,46 @@ 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. diff --git a/src/query/options.ts b/src/query/options.ts index c95708d..c38443b 100644 --- a/src/query/options.ts +++ b/src/query/options.ts @@ -129,6 +129,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. @@ -271,12 +317,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 @@ -286,6 +348,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 @@ -324,8 +408,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 43a35e5..55f5838 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,9 +60,28 @@ export function defaultFetcher( } /** - * Creates a new query instance. + * 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(). + * + * @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 }) + * 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. */ @@ -109,9 +148,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 them so a provider can + * connect broadcasting while its children are suspended. 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 @@ -123,6 +172,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) @@ -209,7 +313,7 @@ export function createQuery(instanceOptions?: Configuration): Query { expiresAt.setMilliseconds(expiresAt.getMilliseconds() + (options?.expiration?.(item) ?? 0)) - itemsCache.set(key, { item: result, expiresAt: expiresAt }) + cacheItem(key, { item: result, expiresAt: expiresAt }) emit(key, 'mutated', item) @@ -255,9 +359,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[] @@ -267,12 +371,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) { @@ -307,7 +414,7 @@ export function createQuery(instanceOptions?: Configuration): Query { expiresAt.setMilliseconds(expiresAt.getMilliseconds() + (options?.expiration?.(item) ?? 0)) for (const key of typeof keys === 'string' ? [keys] : keys) { - itemsCache.set(key, { item: result, expiresAt: expiresAt }) + cacheItem(key, { item: result, expiresAt: expiresAt }) emit(key, 'hydrated', item) } } @@ -400,7 +507,7 @@ export function createQuery(instanceOptions?: Configuration): Query { expiresAt.setMilliseconds(expiresAt.getMilliseconds() + expiration(item)) // Set the item to the cache. - itemsCache.set(key, { item: promise, expiresAt }) + cacheItem(key, { item: promise, expiresAt }) // Notify of the resolved item. emit(key, 'resolved', item) @@ -426,6 +533,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, @@ -635,6 +750,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..b07ca03 --- /dev/null +++ b/src/query/resource.test.ts @@ -0,0 +1,149 @@ +import { describe, it, vi } from 'vitest' +import { createQuery } from './query' +import { type FetcherFunction } from './options' + +describe.concurrent('instance-owned fresh resources', () => { + 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.ts b/src/react/hooks/useQueryBasic.ts index 04dffca..b9b32f7 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. + * * @template T - The type of the fetched data. * @param key - A unique string identifier for the cached resource. * @param options - Optional configuration for the query behavior. @@ -74,7 +82,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) @@ -84,7 +92,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, @@ -103,6 +112,8 @@ export function useQueryBasic( setData(resolved) } + useEffect(() => retain(), [retain]) + const onResolved = useEffectEvent(function (event: CustomEventInit) { startTransition(function () { setData(event.detail as T) @@ -144,13 +155,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..8854834 --- /dev/null +++ b/src/react/hooks/useQueryFresh.test.tsx @@ -0,0 +1,321 @@ +/** @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 { + /** The instance whose request lifetime is under test. */ + 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 the initial request when its provider configures broadcasting', 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( + + + + + + ) + }) + expect(fetcher).toHaveBeenCalledTimes(1) + await act(async () => requests[0]!.resolve('provided')) + expect(container.textContent).toBe('provided') + expect(fetcher).toHaveBeenCalledTimes(1) + }) +}) From 1c286c90130c07d647d38084adbd1490e7852844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sat, 24 Oct 2026 14:39:47 +0200 Subject: [PATCH 2/2] docs: clarify fresh resource notification configuration --- src/query/query.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/query/query.ts b/src/query/query.ts index ede3b56..32d54f7 100644 --- a/src/query/query.ts +++ b/src/query/query.ts @@ -192,9 +192,9 @@ export function createQuery(instanceOptions?: Configuration): Query { /** * Applies supplied defaults. Request/cache configuration invalidates fresh - * selections; notification-only changes preserve them so a provider can - * connect broadcasting while its children are suspended. Existing promises - * and active transports are not canceled; ordinary caches remain unchanged. + * 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. */