Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('/hello', { query: instance })
return <p>{data}</p>
}

// 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
Expand Down
101 changes: 96 additions & 5 deletions src/query/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,52 @@ export type QueryFunction = {
<T = unknown>(key: string, options?: Options<T>): Promise<T>
}

/**
* 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<string>('/hello', { fresh: true })
* const release = request.retain()
* try { console.log(await request.promise) } finally { release() }
* ```
*/
export interface QueryResource<T = unknown> {
/**
* 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<string>('/hello')
* try { console.log(await request.promise) } catch (error) { console.error(error) }
* ```
*/
readonly promise: Promise<T>

/**
* 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.
Expand Down Expand Up @@ -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<string>('/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

Expand All @@ -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<string>('/hello', { fresh: true })
* const release = request.retain()
* try { console.log(await request.promise) } finally { release() }
* ```
*/
readonly resource: <T = unknown>(key: string, options?: Options<T>) => QueryResource<T>

/**
* Emit is able to send events to active subscribers
* with the given payload. It is a low level API
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading