diff --git a/AGENTS.md b/AGENTS.md
index 78f41d5..5b70104 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -207,7 +207,7 @@ The project uses a single GitHub Actions workflow (`main.yml`) with three jobs:
2. **build** — verifies the build succeeds (depends on check)
3. **release** — version bump, changelog, npm publish, GitHub Release (depends on build, only on push to main)
-The `build` job does not verify the build on PRs — only the `release` job builds before publishing. The separate `build` job exists as an early CI gate so build failures are caught before the release job runs. The release job builds again from scratch because GitHub Actions jobs don't share artifacts between runners.
+The `build` job verifies PRs and pushes after `check` passes. The release job builds again from scratch before publishing because GitHub Actions jobs don't share artifacts between runners.
`npm publish` uses `--ignore-scripts` to avoid double-building (the release job already ran `build:only` before publishing, so `prepack` is unnecessary).
@@ -215,5 +215,9 @@ The `build` job does not verify the build on PRs — only the `release` job buil
- Node.js 25+ (see `.nvmrc`)
- npm 11+ (package manager)
-- TypeScript ~5.9.3
+- TypeScript version: see `package.json` (currently ~6.0.3)
- React 19.2+ (peer dependency)
+
+## Library API Guidance
+
+When changing library behavior or writing consumer examples, read `skills/lambda-query/SKILL.md` and its relevant core, React, or testing reference. These cover request ownership, serialized mutations, explicit broadcast isolation, and fresh Suspense resource lifetimes.
diff --git a/skills/lambda-query/SKILL.md b/skills/lambda-query/SKILL.md
index ab633fa..df0783d 100644
--- a/skills/lambda-query/SKILL.md
+++ b/skills/lambda-query/SKILL.md
@@ -5,15 +5,14 @@ description: >
management library. Use when writing, editing, reviewing, or testing code that uses
createQuery, useQuery, QueryProvider, cache mutations, hydration, event subscriptions,
or any @studiolambda/query API. Covers the core framework-agnostic library and
- React 19+ bindings (hooks and components).
+ React 19.2+ bindings (hooks and components).
metadata:
- version: '1.5.12'
author: studiolambda
---
# @studiolambda/query
-Lightweight (~1.7KB), isomorphic, framework-agnostic SWR-style async data management library with React 19+ bindings.
+Isomorphic, framework-agnostic SWR-style async data management library with React 19.2+ bindings.
**Install:** `npm i @studiolambda/query`
@@ -24,7 +23,9 @@ Lightweight (~1.7KB), isomorphic, framework-agnostic SWR-style async data manage
## How It Works
-`createQuery()` returns a closure-scoped `Query` instance with two internal caches: **items** (resolved data + expiration) and **resolvers** (in-flight promises + AbortControllers). Fetches are deduplicated — concurrent `query(key)` calls return the same promise. Expired items return stale data immediately while revalidating in the background (SWR pattern, configurable via `stale` option). An `EventTarget` powers subscriptions, and an optional `BroadcastChannel` syncs mutations across tabs.
+`createQuery()` owns **items** (data promises + expiration), **resolvers** (in-flight fetches + AbortControllers), and fresh render-resource lifetimes. Ordinary fetches deduplicate per key; expired data can be returned while revalidating (`stale: true`). Only the current request owner may publish cache writes and completion events. Same-key mutations commit in call order. An `EventTarget` powers subscriptions; cross-tab event delivery requires an explicitly configured, caller-owned `BroadcastChannel`.
+
+For repository changes, read the implementation and regression tests for the affected path before editing. Check `package.json` for current commands and versions. Use the references below for API behavior and migration caveats rather than assuming that an action named `refetch` forces a network request.
## Quick Start
@@ -72,7 +73,7 @@ function UserProfile() {
return (
{data.name}
-
+
)
}
@@ -80,15 +81,15 @@ function UserProfile() {
## Configuration Options
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `expiration` | `(item: T) => number` | `() => 2000` | Cache duration in ms |
-| `fetcher` | `(key: string, { signal }) => Promise` | `fetch`-based JSON | Data fetcher function |
-| `stale` | `boolean` | `true` | Return stale data while revalidating |
-| `removeOnError` | `boolean` | `false` | Remove cached item on fetch error |
-| `fresh` | `boolean` | `false` | Always bypass cache and abort pending |
+| Option | Type | Default | Description |
+| --------------- | ----------------------------------------- | ------------------ | ------------------------------------- |
+| `expiration` | `(item: T) => number` | `() => 2000` | Cache duration in ms |
+| `fetcher` | `(key: string, { signal }) => Promise` | `fetch`-based JSON | Data fetcher function |
+| `stale` | `boolean` | `true` | Return stale data while revalidating |
+| `removeOnError` | `boolean` | `false` | Remove cached item on fetch error |
+| `fresh` | `boolean` | `false` | Always bypass cache and abort pending |
-Instance-only: `itemsCache`, `resolversCache` (injectable `Cache` interface), `events` (EventTarget), `broadcast` (BroadcastChannel).
+Instance-only: `itemsCache`, `resolversCache` (injectable `Cache` interface), `events` (EventTarget), `broadcast` (BroadcastChannel). `fresh` forces each imperative `query()` call; React uses `Query.resource()` to share a fresh request across Suspense retries and committed same-key consumers.
## Events
@@ -100,14 +101,17 @@ Only `mutated`, `resolved`, `hydrated`, and `forgotten` are broadcast cross-tab.
- **Expiration is a function, not a number.** Always `expiration: () => 5000`, not `expiration: 5000`.
- **Mutated/hydrated items default to 0ms expiration** — immediately stale unless you pass a custom `expiration`.
-- **`configure()` uses `??` internally** — you cannot reset a value to `undefined`/`null`/`false`/`0`.
+- **`configure()` uses `??` internally** — nullish values keep the previous setting. `false` and expiration functions returning `0` are valid overrides.
- **`useQuery` suspends.** Components must be inside ``.
-- **`data` from `useQuery` is always resolved.** Never undefined/null from loading state.
+- **`data` from `useQuery` is resolved.** Suspense handles loading, but a fetcher may itself resolve `null` or `undefined`.
- **`forget` does not cancel pending fetches.** Use `abort` to cancel in-flight requests.
-- **`fresh: true` aborts then refetches.** Ensures a genuinely new fetch.
-- **`subscribe('refetching')` fires immediately** if a resolver is already in-flight for that key.
-- **`refetch()` from `useQueryActions` defaults `stale: false`** — it blocks on fresh data, unlike the instance default.
-- **`QueryProvider` auto-creates a `BroadcastChannel('query')`** — all providers share the same channel name.
+- **Imperative `query(..., { fresh: true })` aborts then refetches.** React resource reads instead share a fresh consumer lifetime.
+- **`subscribe(key, 'refetching', listener)` queues notification in a microtask** if a resolver is already in-flight for that key.
+- **`refetch()` defaults `stale: false`, not `fresh: true`.** Unexpired cached data is reused; use `refetch({ fresh: true })` to force a request.
+- **Cross-tab sync is opt-in.** Scope caller-created channel names to the application, tenant, and session. Providers subscribe but never create or close channels.
+- **Failed background refetches preserve rendered stale data.** Observe the instance `error` event; blocking initial reads and explicit action promises still reject.
+- **Same-key mutation resolvers run serially.** A rejection preserves the last committed item and does not poison later mutations. Never await another same-key mutation inside its resolver: it queues behind itself.
+- **Mutation/hydration commits supersede older fetches.** Those callers still settle, but obsolete fetches cannot overwrite local data or emit completion errors; later revalidation need not wait for them.
- **Broadcast silently swallows `DataCloneError`** for non-structurally-cloneable payloads.
- **Pass stable `keys` arrays to `useQueryPrefetch` / `QueryPrefetch`.** Unstable references re-trigger prefetching.
- **React Compiler handles memoization.** Do NOT use `useMemo`, `useCallback`, or `React.memo`.
diff --git a/skills/lambda-query/references/core-api.md b/skills/lambda-query/references/core-api.md
index b0868b1..e4e5670 100644
--- a/skills/lambda-query/references/core-api.md
+++ b/skills/lambda-query/references/core-api.md
@@ -4,6 +4,7 @@
- [createQuery](#createquery)
- [query](#query)
+- [Render Resources](#render-resources)
- [Mutations](#mutations)
- [Forget (Cache Invalidation)](#forget-cache-invalidation)
- [Hydrate (Pre-populate Cache)](#hydrate-pre-populate-cache)
@@ -22,7 +23,7 @@
function createQuery(options?: Configuration): Query
```
-Factory that returns a closure-scoped `Query` instance. All state (caches, EventTarget, BroadcastChannel) is private.
+Factory that returns a closure-scoped `Query` instance. Defaults are isolated; injected caches, events, and channels remain caller-owned. Low-level accessors `caches()`, `events()`, and `broadcast()` expose the configured objects.
```typescript
import { createQuery } from '@studiolambda/query'
@@ -55,15 +56,15 @@ Core fetch method with deduplication, caching, and SWR behavior.
**Resolution order:**
1. **`fresh: true`**: Calls `abort(key)` first, starts a completely new fetch — bypasses dedup and cache.
-2. **Resolver exists** (in-flight fetch for same key): Returns the existing promise (deduplication).
-3. **Cache hit + not expired**: Returns cached promise, no fetch.
-4. **Cache hit + expired + `stale: true`**: Returns stale data immediately, triggers background revalidation (errors silenced).
-5. **Cache hit + expired + `stale: false`**: Blocks until revalidation completes.
-6. **Cache miss**: Fetches and caches.
+2. **Cache hit + not expired**: Returns cached promise, even if a fetch is pending.
+3. **Cache hit + expired + `stale: true`**: Returns stale data immediately, starts or joins revalidation. Background rejection is caught; the owning request still emits `error`.
+4. **Cache hit + expired + `stale: false`**, or **cache miss**: Returns the active fetch promise or starts a fetch. A fetch superseded by a committed local write is not reused.
**Post-abort guard**: If `AbortController.signal.aborted` is true after fetch resolves, the result is rejected (not written to cache).
-**On error**: Deletes resolver from `resolversCache`. If `removeOnError: true`, also deletes from `itemsCache`. Emits `'error'` event.
+**Ownership guard**: Only the request whose controller still owns the resolver slot can change shared caches or emit completion events. Late success/failure from an aborted or replaced request cannot remove its replacement's resolver or data. An aborted request that resolves rejects with its abort reason; other obsolete callers retain their own fetch result/error.
+
+**On current-request error**: Deletes its resolver. If `removeOnError: true`, also deletes its cached item. Emits `error` and rejects the caller promise. A fetch superseded by mutation/hydration performs no cache write, removal, or completion event, even if it fails.
```typescript
// Basic
@@ -79,23 +80,59 @@ const data = await query.query('/api/user/1', {
---
+## Render Resources
+
+```typescript
+import { createQuery, type QueryResource } from '@studiolambda/query'
+
+const client = createQuery({ fetcher: async () => 'ready' })
+const request: QueryResource = client.resource('status', { fresh: true })
+const release = request.retain()
+try {
+ console.log(await request.promise)
+} catch (error) {
+ console.error(error)
+} finally {
+ release()
+}
+```
+
+`resource(key, options?)` returns `{ promise: Promise, retain: () => Unsubscriber }`. This is the framework-neutral lifecycle API used by React; ordinary imperative callers should use `query()`.
+
+Migration: `createQuery()` callers need no API changes. Hand-written implementations or test doubles of the exported `Query` interface must now implement `resource` too.
+
+- Non-fresh reads delegate to `query()`; retention and cleanup are no-ops.
+- Fresh reads select one instance-owned request per key. Suspense retries and sibling consumers share it, including after expiration; the first reader selects request options for that lifetime.
+- Call `retain()` for each committed consumer and call its returned release function exactly once on cleanup. The final release evicts the selection on the next microtask, so synchronous StrictMode reconnection reuses it. A later mount starts fresh again.
+- An abandoned initial render has no committed cleanup. Its selection remains reusable until `forget`, request/cache reconfiguration, or instance garbage collection. Avoid process-global instances for request-scoped server data.
+- A rejected initial fresh selection is also retained: resetting an error boundary alone reuses that rejection. Invalidate with `forget(key)` before retrying, or explicitly start a replacement request. Recheck `resource()` and its regression tests when changing retry behavior.
+- Successful fetches, mutations, and hydration keep the selected promise aligned with committed data. Explicit `query(..., { fresh: true })` still forces a request; retained readers keep successful data until a replacement succeeds.
+- `forget` invalidates matching fresh selections, including pending selections without cached items, but does not abort their transports. Request/cache `configure` changes invalidate selections; notification-only `events`/`broadcast` changes preserve them.
+
+Source checks: `src/query/query.ts`, `src/query/options.ts`, `src/query/resource.test.ts`, and `src/react/hooks/useQueryFresh.test.tsx`.
+
+---
+
## Mutations
```typescript
-function mutate(
- key: string,
- resolver: MutationValue,
- options?: MutateOptions,
-): Promise
+function mutate(key: string, resolver: MutationValue, options?: MutateOptions): Promise
```
`resolver` can be:
+
- A direct value `T`
- A sync function `(previous?: T, expiresAt?: Date) => T`
- An async function `(previous?: T, expiresAt?: Date) => Promise`
Emits `'mutating'` with the **unresolved promise**, then `'mutated'` with the resolved value.
+Mutations on the same key execute and commit in call order, including direct values. Functional resolvers read the previous committed value and expiration after their queue turn begins. Different keys proceed independently. Resolver/expiration failures reject only that mutation, preserve committed data, and allow the next queued mutation to run; mutation failures do not emit the fetch `error` event.
+
+A resolver must not await another `mutate()` on its own key: the nested mutation waits for that resolver to finish. Compose the change in one resolver or await the second mutation outside it.
+
+Successful mutation/hydration commits supersede any older pending fetch. Its existing caller can still settle, but it cannot overwrite the local commit, emit `resolved`/`error`, or delete newer data on failure. Subsequent revalidation can start without waiting for it.
+
**Important**: Mutated items default to `0ms` expiration — they're immediately stale unless you provide a custom `expiration`.
```typescript
@@ -106,7 +143,7 @@ await query.mutate('/api/user', updatedUser)
await query.mutate('/api/posts', (previous) => [...(previous ?? []), newPost])
// Async mutation
-await query.mutate('/api/posts', async (previous) => {
+await query.mutate('/api/posts', async (previous) => {
const post = await createNewPost()
return [...(previous ?? []), post]
})
@@ -126,7 +163,7 @@ await query.mutate('/api/user', updatedUser, { expiration: () => 0 })
function forget(keys?: string | readonly string[] | RegExp): Promise
```
-Removes items from the items cache only — does **not** cancel pending resolvers.
+Removes items and matching fresh resource selections — does **not** cancel pending resolvers.
- **String**: Forget single key.
- **Array**: Forget multiple keys.
@@ -136,10 +173,10 @@ Removes items from the items cache only — does **not** cancel pending resolver
Handles rejected cached promises gracefully — emits `'forgotten'` with `undefined` for those keys and continues processing remaining keys.
```typescript
-await query.forget('/api/user') // Single key
-await query.forget(['/api/user', '/api/posts']) // Multiple keys
-await query.forget(/^\/api\/users(.*)/) // Regex pattern
-await query.forget() // All keys
+await query.forget('/api/user') // Single key
+await query.forget(['/api/user', '/api/posts']) // Multiple keys
+await query.forget(/^\/api\/users(.*)/) // Regex pattern
+await query.forget() // All keys
```
---
@@ -147,16 +184,12 @@ await query.forget() // All keys
## Hydrate (Pre-populate Cache)
```typescript
-function hydrate(
- keys: string | readonly string[],
- item: T,
- options?: HydrateOptions,
-): void
+function hydrate(keys: string | readonly string[], item: T, options?: HydrateOptions): void
```
Synchronous. Wraps `item` in `Promise.resolve()` and stores in cache.
-**Important**: Like `mutate`, defaults to `0ms` expiration — immediately stale unless custom `expiration` provided. This means the first `query()` returns hydrated data, the second triggers a refetch.
+**Important**: Like `mutate`, defaults to `0ms` expiration — immediately stale unless custom `expiration` provided. The first ordinary `query()` can already start revalidation while returning hydrated data under the default `stale: true` policy.
```typescript
query.hydrate('/api/user', serverData, { expiration: () => 10000 })
@@ -174,10 +207,10 @@ function abort(keys?: string | readonly string[], reason?: unknown): void
Calls `controller.abort(reason)` on in-flight fetches and removes them from `resolversCache`.
```typescript
-query.abort('/api/user') // Single key
-query.abort(['/api/user', '/api/posts']) // Multiple keys
-query.abort() // All pending
-query.abort('/api/user', 'cancelled') // Custom reason
+query.abort('/api/user') // Single key
+query.abort(['/api/user', '/api/posts']) // Multiple keys
+query.abort() // All pending
+query.abort('/api/user', 'cancelled') // Custom reason
```
---
@@ -189,11 +222,11 @@ query.abort('/api/user', 'cancelled') // Custom reason
const value = await query.snapshot('/api/user')
// Cache keys
-const itemKeys = query.keys('items') // readonly string[]
+const itemKeys = query.keys('items') // readonly string[]
const resolverKeys = query.keys('resolvers') // readonly string[]
// Expiration date for a cached item
-const date = query.expiration('/api/user') // Date | undefined
+const date = query.expiration('/api/user') // Date | undefined
```
---
@@ -206,7 +239,7 @@ function configure(options?: Configuration): void
Merges options into existing instance state using `??` (nullish coalescing).
-**Caveat**: You **cannot** reset a value to `undefined`, `null`, `false`, or `0` — those fall through to the previous value.
+Nullish settings keep the previous value. Boolean `false` is accepted, as are expiration functions returning `0`; `expiration` itself must be a function, not a number. Request/cache settings invalidate fresh resource selections, while notification-only settings preserve them. Replacing a channel does not close it or move existing subscriptions: unsubscribe and subscribe again explicitly.
```typescript
query.configure({ expiration: () => 10000, stale: false })
@@ -218,16 +251,16 @@ query.configure({ expiration: () => 10000, stale: false })
### Event Types
-| Event | Detail | Broadcast | Description |
-|-------|--------|-----------|-------------|
-| `refetching` | `Promise` | No | Fetch started |
-| `resolved` | `T` | Yes | Fetch completed |
-| `mutating` | `Promise` | No | Mutation started |
-| `mutated` | `T` | Yes | Mutation completed |
-| `aborted` | `unknown` (reason) | No | Fetch cancelled |
-| `forgotten` | `T \| undefined` | Yes | Cache cleared |
-| `hydrated` | `T` | Yes | Cache pre-populated |
-| `error` | `unknown` | No | Fetch error |
+| Event | Detail | Broadcast | Description |
+| ------------ | ------------------ | --------- | ------------------- |
+| `refetching` | `Promise` | No | Fetch started |
+| `resolved` | `T` | Yes | Fetch completed |
+| `mutating` | `Promise` | No | Mutation started |
+| `mutated` | `T` | Yes | Mutation completed |
+| `aborted` | `unknown` (reason) | No | Fetch cancelled |
+| `forgotten` | `T \| undefined` | Yes | Cache cleared |
+| `hydrated` | `T` | Yes | Cache pre-populated |
+| `error` | `unknown` | No | Fetch error |
### subscribe
@@ -235,11 +268,11 @@ query.configure({ expiration: () => 10000, stale: false })
function subscribe(
key: string,
event: QueryEvent,
- listener: (event: CustomEventInit) => void,
+ listener: (event: CustomEventInit) => void
): Unsubscriber
```
-Returns an unsubscribe function. If subscribing to `'refetching'` and a resolver already exists, **immediately fires** the event to the new listener.
+Returns an unsubscribe function. If subscribing to `'refetching'` and a resolver already exists, queues a microtask notification using that pending promise. This is not synchronous delivery.
```typescript
const unsub = query.subscribe('/api/user', 'resolved', (event) => {
@@ -251,11 +284,7 @@ unsub()
### once
```typescript
-function once(
- key: string,
- event: QueryEvent,
- signal?: AbortSignal,
-): Promise>
+function once(key: string, event: QueryEvent, signal?: AbortSignal): Promise>
```
Resolves on first event, auto-unsubscribes. Supports `AbortSignal` for cancellation (rejects with `signal.reason`). Handles already-aborted signals.
@@ -292,7 +321,7 @@ const obj = await query.next<{ user: User }>({ user: '/api/user' })
function stream(keys: string | readonly string[] | Record): AsyncGenerator
```
-Infinite async generator yielding `next()` results. Internal `AbortController` cancels pending listeners on `break`/`return` via `finally` block.
+Infinite async generator yielding `next()` results. Its `finally` block aborts internal listeners when the generator exits. Calling `.return()` queues behind a pending `.next()`; it does not interrupt that pending event wait. Use `next(keys, signal)` to cancel the event-listener wait directly; its signal does not cancel a fetch promise already received in the event detail.
```typescript
for await (const value of query.stream('/api/user')) {
@@ -306,7 +335,7 @@ for await (const value of query.stream('/api/user')) {
function sequence(key: string, event: QueryEvent): AsyncGenerator>
```
-Like `stream` but for arbitrary events on a single key via `once()`. Cleans up on `break`/`return`.
+Like `stream` but for arbitrary events on a single key via `once()`. Cleanup runs when the generator exits; `.return()` cannot interrupt a pending event wait. Use `once(key, event, signal)` for externally cancellable waits.
```typescript
for await (const event of query.sequence('/api/user', 'resolved')) {
@@ -322,26 +351,38 @@ for await (const event of query.sequence('/api/user', 'resolved')) {
function subscribeBroadcast(): Unsubscriber
```
-Listens on the `BroadcastChannel` and replays received events into the local `EventTarget`. Captures the broadcast reference at call time — safe against later `configure()` replacement.
+Listens on the explicitly configured `BroadcastChannel` and replays received events into the local `EventTarget`. Captures the channel reference at call time, so cleanup still removes the original listener after `configure()` replacement. Replay is event delivery, not a write into the receiving instance's item cache, and is not rebroadcast. Incoming `{ event, detail }` payloads are trusted without runtime validation or an event allowlist.
Broadcast silently catches `DataCloneError` for non-structurally-cloneable payloads.
```typescript
-// Vanilla usage (React's QueryProvider handles this automatically)
-query.configure({ broadcast: new BroadcastChannel('query') })
+// Browser-only setup; choose a non-secret scope for this app/tenant/session.
+const channel = new BroadcastChannel('orders:tenant-42:session-7')
+query.configure({ broadcast: channel })
const unsub = query.subscribeBroadcast()
// ... later
unsub()
+channel.close() // Caller owns it; close only after every user has detached.
```
+`QueryProvider` subscribes to an already configured channel and shares one incoming listener between providers using the same query. It creates no channel and never closes one. In SSR or runtimes without `BroadcastChannel`, omit this setup.
+
+Migration: replace reliance on the old implicit global `'query'` channel with an explicitly scoped channel on the supplied query. Channel names are routing labels that prevent accidental cross-talk, not security boundaries or authentication: other same-origin participants knowing the name can send and receive data. Broadcasts include full event data, even for `forgotten`. Create a new query/channel on account, tenant, or session changes, and reload old tabs that still use the implicit global channel. Send only cloneable data appropriate for those participants.
+
---
## Types
```typescript
type QueryEvent =
- | 'refetching' | 'resolved' | 'mutating' | 'mutated'
- | 'aborted' | 'forgotten' | 'hydrated' | 'error'
+ | 'refetching'
+ | 'resolved'
+ | 'mutating'
+ | 'mutated'
+ | 'aborted'
+ | 'forgotten'
+ | 'hydrated'
+ | 'error'
type FetcherFunction = (key: string, additional: FetcherAdditional) => Promise
@@ -365,9 +406,9 @@ interface Options {
readonly fresh?: boolean
}
-interface Configuration extends Options {
- readonly itemsCache?: Cache>
- readonly resolversCache?: Cache>
+interface Configuration extends Options {
+ readonly itemsCache?: Cache
+ readonly resolversCache?: Cache
readonly events?: EventTarget
readonly broadcast?: BroadcastChannel
}
diff --git a/skills/lambda-query/references/react-bindings.md b/skills/lambda-query/references/react-bindings.md
index 65f8ab6..a6e2aae 100644
--- a/skills/lambda-query/references/react-bindings.md
+++ b/skills/lambda-query/references/react-bindings.md
@@ -1,6 +1,6 @@
# React Bindings Reference
-Designed for React 19+ with first-class Suspense and Transitions support. Uses React Compiler for automatic memoization — do NOT use `useMemo`, `useCallback`, or `React.memo`.
+Requires React 19.2+ (including `useEffectEvent`) with first-class Suspense and Transitions support. Uses React Compiler for automatic memoization — do NOT use `useMemo`, `useCallback`, or `React.memo`.
## Table of Contents
@@ -35,18 +35,19 @@ function QueryProvider({
}: QueryProviderProps): JSX.Element
```
-| Prop | Type | Default | Description |
-|------|------|---------|-------------|
-| `query` | `Query` | `createQuery()` | Query instance (creates one if omitted) |
-| `clearOnForget` | `boolean` | `false` | Auto-refetch after `forget()` clears a key |
-| `ignoreTransitionContext` | `boolean` | `false` | Use local transitions instead of shared |
-| `children` | `ReactNode` | — | Child components |
+| Prop | Type | Default | Description |
+| ------------------------- | ----------- | --------------- | ------------------------------------------ |
+| `query` | `Query` | `createQuery()` | Query instance (creates one if omitted) |
+| `clearOnForget` | `boolean` | `false` | Auto-refetch after `forget()` clears a key |
+| `ignoreTransitionContext` | `boolean` | `false` | Use local transitions instead of shared |
+| `children` | `ReactNode` | — | Child components |
**Behavior:**
-- Creates a `BroadcastChannel('query')` in `useEffect` for cross-tab sync. Calls `query.configure({ broadcast })` and `query.subscribeBroadcast()`.
-- Cleans up on unmount: unsubscribes + closes channel.
-- Guards against missing `BroadcastChannel` (SSR, edge runtimes).
-- All providers share the channel name `'query'` by default.
+
+- Cross-tab sync is disabled unless the supplied query already has a channel.
+- Subscribes through `query.subscribeBroadcast()` in an effect; providers sharing a query share one incoming listener.
+- The final provider cleanup removes the listener, never closes the caller's channel.
+- For browser setup and isolation/migration rules, read [Cross-tab Sync](core-api.md#cross-tab-sync).
```tsx
import { QueryProvider } from '@studiolambda/query/react'
@@ -153,25 +154,25 @@ The primary hook. Components using it **must** be inside ``. Composes
All core `Options` fields (`expiration`, `fetcher`, `stale`, `removeOnError`, `fresh`) plus:
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `query` | `Query` | from context | Override query instance |
-| `clearOnForget` | `boolean` | from context | Auto-refetch after forget |
-| `ignoreTransitionContext` | `boolean` | from context | Use local transition |
+| Option | Type | Default | Description |
+| ------------------------- | --------- | ------------ | ------------------------- |
+| `query` | `Query` | from context | Override query instance |
+| `clearOnForget` | `boolean` | from context | Auto-refetch after forget |
+| `ignoreTransitionContext` | `boolean` | from context | Use local transition |
**Returns** (`Resource`):
-| Property | Type | Description |
-|----------|------|-------------|
-| `data` | `T` | Resolved data (always available — Suspense handles loading) |
-| `isPending` | `boolean` | Transition pending for mutations/refetches |
-| `expiresAt` | `Date` | When cached data expires |
-| `isExpired` | `boolean` | Whether data is stale (auto-updates via `setTimeout`) |
-| `isRefetching` | `boolean` | Background refetch in progress |
-| `isMutating` | `boolean` | Async mutation in progress |
-| `refetch` | `(options?: Options) => Promise` | Trigger fresh fetch (defaults `stale: false`) |
-| `mutate` | `(value: MutationValue, options?: MutateOptions) => Promise` | Optimistic mutation |
-| `forget` | `() => Promise` | Clear cached data for this key |
+| Property | Type | Description |
+| -------------- | --------------------------------------------------------------------- | -------------------------------------------------------------- |
+| `data` | `T` | Resolved data (always available — Suspense handles loading) |
+| `isPending` | `boolean` | Transition pending for mutations/refetches |
+| `expiresAt` | `Date` | When cached data expires |
+| `isExpired` | `boolean` | Whether data is stale (auto-updates via `setTimeout`) |
+| `isRefetching` | `boolean` | Background refetch in progress |
+| `isMutating` | `boolean` | Async mutation in progress |
+| `refetch` | `(options?: Options) => Promise` | Query with `stale: false`; pass `fresh: true` to force a fetch |
+| `mutate` | `(value: MutationValue, options?: MutateOptions) => Promise` | Optimistic mutation |
+| `forget` | `() => Promise` | Clear cached data for this key |
```tsx
function UserProfile() {
@@ -180,7 +181,7 @@ function UserProfile() {
return (
{data.name}
-
+
@@ -193,7 +194,10 @@ function UserProfile() {
### useQueryBasic
```tsx
-function useQueryBasic(key: string, options?: BasicResourceOptions): BasicResource
+function useQueryBasic(
+ key: string,
+ options?: BasicResourceOptions
+): BasicResource
```
Minimal hook returning only `data` and `isPending`.
@@ -201,10 +205,11 @@ Minimal hook returning only `data` and `isPending`.
**Returns:** `{ data: T, isPending: boolean }`
**Implementation details:**
-1. Calls `query(key, opts)` to get a promise, then `use(promise)` (React 19) to suspend.
+
+1. Calls `resource(key, opts)` on the query instance, then `use(promise)` (React 19) to suspend. An effect retains the committed resource and returns its release function.
2. Subscribes to 6 events: `resolved`, `mutating`, `mutated`, `hydrated`, `refetching`, `forgotten`.
3. All data updates wrapped in `startTransition` (shared or local based on `ignoreTransitionContext`).
-4. If `clearOnForget` is true, re-queries the key on `forgotten` event (triggers fresh fetch).
+4. If `clearOnForget` is true, selects a new resource on `forgotten`. This starts or joins a fetch according to the request policy.
5. Has a sync `data !== resolved` identity check — reference equality matters. New objects with same shape will re-set state.
6. Uses `useEffectEvent` for stable event handler references.
@@ -212,25 +217,32 @@ Minimal hook returning only `data` and `isPending`.
const { data, isPending } = useQueryBasic('/api/user/1')
```
+**Fresh lifecycle:** `fresh: true` (per hook or instance default) selects one fresh request for the key's shared consumer lifetime, not one per render. Suspense retries and siblings share it even after expiration. Final consumer cleanup releases the selection on the next microtask; later mounts fetch fresh again. Initial abandoned reads remain selected until explicit invalidation or instance disposal. See [Render Resources](core-api.md#render-resources) before changing this lifecycle.
+
+**Error boundaries:** Failed or aborted `refetching` observer promises leave the last rendered value in place; observe the core `error` event for owning fetch failures. Initial blocking reads still reject into an error boundary. Action promises retain rejection behavior and should be awaited/caught by the caller. This preservation applies to background fetch observers, not every mutation or `clearOnForget` transition failure.
+
---
### useQueryActions
```tsx
-function useQueryActions(key: string, options?: QueryActionsOptions): QueryActions
+function useQueryActions(
+ key: string,
+ options?: QueryActionsOptions
+): QueryActions
```
Actions without data subscription. Does NOT re-render on data changes. Use for mutating/refetching from sibling components.
**Returns:**
-| Method | Signature | Notes |
-|--------|-----------|-------|
-| `refetch` | `(options?: Options) => Promise` | Defaults `stale: false` (blocks on fresh data) |
-| `mutate` | `(value: MutationValue, options?) => Promise` | Delegates to `query.mutate(key, ...)` |
-| `forget` | `() => Promise` | Delegates to `query.forget(key)` |
+| Method | Signature | Notes |
+| --------- | --------------------------------------------------- | ---------------------------------------------------------------- |
+| `refetch` | `(options?: Options) => Promise` | Defaults `stale: false`; blocks on revalidation only when needed |
+| `mutate` | `(value: MutationValue, options?) => Promise` | Delegates to `query.mutate(key, ...)` |
+| `forget` | `() => Promise` | Delegates to `query.forget(key)` |
-**Important**: `refetch()` defaults `stale` to `false`, overriding the instance default of `true`. This means it waits for the new data rather than returning stale.
+**Important**: `refetch()` defaults `stale` to `false`, overriding the instance default of `true` unless hook options override it. It reuses unexpired data and deduplicates eligible pending fetches. Use `refetch({ fresh: true })` to bypass the cache and abort pending work. Options passed to the action override hook options.
```tsx
const { refetch, mutate, forget } = useQueryActions('/api/user/1')
@@ -248,12 +260,12 @@ Status without data subscription.
**Returns:**
-| Property | Type | Description |
-|----------|------|-------------|
-| `expiresAt` | `Date` | Expiration time (initialized from cache or `new Date()`) |
-| `isExpired` | `boolean` | Auto-flips to `true` via `setTimeout` when expiration arrives |
-| `isRefetching` | `boolean` | Set on `refetching`, cleared on `resolved`/`error` |
-| `isMutating` | `boolean` | Set on `mutating`, cleared on `mutated`/`error` |
+| Property | Type | Description |
+| -------------- | --------- | ------------------------------------------------------------- |
+| `expiresAt` | `Date` | Expiration time (initialized from cache or `new Date()`) |
+| `isExpired` | `boolean` | Auto-flips to `true` via `setTimeout` when expiration arrives |
+| `isRefetching` | `boolean` | Set on `refetching`, cleared on `resolved`/`error` |
+| `isMutating` | `boolean` | Set on `mutating`, cleared on `mutated`/`error` |
Subscribes to 7 events: `mutating`, `mutated`, `hydrated`, `resolved`, `forgotten`, `refetching`, `error`.
@@ -285,13 +297,15 @@ function useQueryPrefetch(keys: readonly string[], options?: QueryInstance): voi
Fires `query(key)` for each key in `useEffect`. Effect deps: `[query, keys]`.
-**Important**: Since `keys` is in the dep array, passing a new array literal every render re-triggers prefetching. Stabilize with a module-level constant or `useMemo`.
+**Important**: Since `keys` is in the dep array, new array references re-trigger prefetching. For static keys use a module-level constant. This repository uses React Compiler, so no manual hook memoization is needed.
```tsx
-import { useMemo } from 'react'
+const prefetchKeys = ['/api/user/1', '/api/config']
-const keys = useMemo(() => ['/api/user/1', '/api/config'], [])
-useQueryPrefetch(keys)
+function PrefetchUser() {
+ useQueryPrefetch(prefetchKeys)
+ return null
+}
```
---
diff --git a/skills/lambda-query/references/testing-patterns.md b/skills/lambda-query/references/testing-patterns.md
index 4e1184a..04dd72a 100644
--- a/skills/lambda-query/references/testing-patterns.md
+++ b/skills/lambda-query/references/testing-patterns.md
@@ -1,268 +1,113 @@
# Testing Patterns
-## Table of Contents
+Use Vitest with happy-dom. Read adjacent regression tests before extending an affected path. Tests import source through the repository's `query:` aliases; consumer examples use the published `@studiolambda/query` paths.
-- [Test Setup](#test-setup)
-- [Testing Core Query](#testing-core-query)
-- [Testing React Components](#testing-react-components)
-- [Common Patterns](#common-patterns)
-- [Gotchas](#gotchas)
+## Core: deterministic overlap
----
-
-## Test Setup
-
-- Framework: Vitest with `happy-dom` environment
-- Use `describe.concurrent()` for parallel test execution
-- Destructure `expect` from test context: `it('...', async ({ expect }) => { ... })`
-- React tests use `act()` and `createRoot` (not `render` from testing-library)
+Control pending work with `Promise.withResolvers()` rather than real sleeps. Assert caller results, cache contents, events, and resolver ownership separately: an obsolete caller settling is not evidence that it may publish.
```typescript
import { describe, it, vi } from 'vitest'
-import { createQuery } from '@studiolambda/query'
-```
-
----
-
-## Testing Core Query
-
-Create a query instance with a mock fetcher and exercise methods directly.
-
-```typescript
-describe.concurrent('query', function () {
- it('can query resources', async ({ expect }) => {
- function fetcher(key: string) {
- return Promise.resolve(key)
- }
-
- const { query } = createQuery({ fetcher })
-
- const result = await query('example-key')
- expect(result).toBe('example-key')
- })
-
- it('deduplicates in-flight requests', async ({ expect }) => {
- let times = 0
-
- function fetcher() {
- times++
- return new Promise(function (resolve) {
- setTimeout(function () {
- resolve('done')
- }, 50)
- })
- }
-
- const { query } = createQuery({ fetcher })
-
- const [a, b] = await Promise.all([
- query('key'),
- query('key'),
- ])
-
- expect(a).toBe('done')
- expect(b).toBe('done')
- expect(times).toBe(1)
- })
-
- it('returns stale data while revalidating', async ({ expect }) => {
- function fetcher() {
- return Promise.resolve('example')
- }
-
- const { query } = createQuery({ fetcher, expiration: () => 100 })
-
- await query('key')
- await new Promise((r) => setTimeout(r, 100))
-
- // Returns stale data immediately (default stale: true)
- const resource = await query('key')
- expect(resource).toBe('example')
- })
-
- it('can mutate cached data', async ({ expect }) => {
- function fetcher() {
- return Promise.resolve('original')
- }
-
- const q = createQuery({ fetcher })
-
- await q.query('key')
- await q.mutate('key', 'updated')
+import { createQuery } from 'query:index'
+
+describe.concurrent('mutation ordering', function () {
+ it('serializes same-key functional mutations', async ({ expect }) => {
+ const client = createQuery()
+ client.hydrate('count', 0)
+ const gate = Promise.withResolvers()
+ const first = client.mutate('count', async (previous) => {
+ await gate.promise
+ return (previous ?? 0) + 1
+ })
+ const second = client.mutate('count', (previous) => (previous ?? 0) + 1)
- const value = await q.snapshot('key')
- expect(value).toBe('updated')
+ gate.resolve()
+ expect(await Promise.all([first, second])).toEqual([1, 2])
+ expect(await client.snapshot('count')).toBe(2)
})
- it('can subscribe to events', async ({ expect }) => {
- const fetcher = vi.fn().mockResolvedValue('data')
- const q = createQuery({ fetcher })
-
- const values: string[] = []
- q.subscribe('key', 'resolved', function (event) {
- values.push(event.detail!)
- })
-
- await q.query('key')
- expect(values).toEqual(['data'])
+ it('does not publish a fetch superseded by hydration', async ({ expect }) => {
+ const pending = Promise.withResolvers()
+ const client = createQuery({ fetcher: () => pending.promise })
+ const resolved = vi.fn()
+ const unsubscribe = client.subscribe('key', 'resolved', resolved)
+ const request = client.query('key')
+
+ client.hydrate('key', 'local')
+ pending.resolve('remote')
+ expect(await request).toBe('remote')
+ expect(await client.snapshot('key')).toBe('local')
+ expect(resolved).not.toHaveBeenCalled()
+ unsubscribe()
})
})
```
----
+For immediately stale data, hydrate with the default zero lifetime; the next ordinary query starts revalidation immediately. For valid cached data, use a long explicit expiration. Use controlled time only when testing the timer itself. Tests using global fake timers or global `BroadcastChannel` substitutes should run in a sequential suite and restore globals in cleanup.
-## Testing React Components
+## React: render, settle, clean up
-**Pattern**: Create query with mock fetcher, pass via `{ query }` option to bypass context, use `query.next(key)` to await resolution.
+Use `act()` and `createRoot`, and always unmount roots. A query option bypasses the need for a provider when testing an isolated hook. Include a provider when testing broadcast ownership or context behavior.
```tsx
import { describe, it } from 'vitest'
-import { createQuery } from '@studiolambda/query'
-import { useQuery } from '@studiolambda/query/react'
import { act, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
-
-describe.concurrent('useQuery', function () {
- it('can query data', async ({ expect }) => {
- function fetcher() {
- return Promise.resolve('works')
- }
-
- const query = createQuery({ fetcher })
- const options = { query }
-
+import { createQuery } from 'query:index'
+import { useQuery } from 'query/react:hooks/useQuery'
+
+describe.concurrent('fresh Suspense', function () {
+ it('shares a fresh request across initial retries', async ({ expect }) => {
+ const pending = Promise.withResolvers()
+ let calls = 0
+ const query = createQuery({
+ fetcher: () => {
+ calls += 1
+ return pending.promise
+ },
+ fresh: true,
+ })
function Component() {
- const { data } = useQuery('/user', options)
- return data
+ const { data } = useQuery('key', { query })
+ return {data}
}
-
const container = document.createElement('div')
- const promise = query.next('/user')
-
- // oxlint-disable-next-line
- await act(async function () {
- createRoot(container).render(
-
-
-
- )
- })
-
- await act(async function () {
- const result = await promise
- expect(result).toBe('works')
- })
-
- expect(container.innerText).toBe('works')
- })
-})
-```
-
-### Step-by-step breakdown
-
-1. **Create a mock fetcher** returning a resolved promise.
-2. **Create a query instance** with the mock fetcher.
-3. **Pass `{ query }` as options** to the hook — bypasses `QueryProvider` context.
-4. **Call `query.next(key)` before rendering** — captures a promise that resolves when the query completes.
-5. **Render inside `act()` + ``** — required because `useQuery` suspends.
-6. **Await the `next()` promise inside a second `act()`** — ensures React processes the state update.
-7. **Assert on `container.innerText`** — the rendered output.
-
----
-
-## Common Patterns
-
-### Testing mutations
-
-```tsx
-it('can mutate data', async ({ expect }) => {
- let value = 'initial'
-
- function fetcher() {
- return Promise.resolve(value)
- }
-
- const query = createQuery({ fetcher })
-
- function Component() {
- const { data, mutate } = useQuery('/key', { query })
- return (
-
- {data}
-
-
- )
- }
-
- const container = document.createElement('div')
- const promise = query.next('/key')
-
- await act(async function () {
- createRoot(container).render(
-
-
-
- )
- })
-
- await act(async function () {
- await promise
- })
-
- expect(container.querySelector('span')!.textContent).toBe('initial')
-
- // Trigger mutation
- await act(async function () {
- container.querySelector('button')!.click()
+ const root = createRoot(container)
+
+ try {
+ await act(async () => {
+ root.render(
+
+
+
+ )
+ })
+ expect(container.textContent).toBe('loading')
+ await act(async () => {
+ pending.resolve('ready')
+ await pending.promise
+ })
+ expect(container.textContent).toBe('ready')
+ expect(calls).toBe(1)
+ } finally {
+ await act(async () => root.unmount())
+ }
})
})
```
-### Testing with delayed fetchers
+When using `query.next(key)`, register it before the triggering render/action. It listens for `refetching`, not a generic cache update: mutations, hydration, and unexpired cache hits need their own synchronization. Catch expected action rejections inside `act()`; separately assert the instance's `error` event and stale rendered data. Use an error boundary when asserting initial blocking failures.
-```typescript
-function delayedFetcher(value: T, ms: number) {
- return function () {
- return new Promise(function (resolve) {
- setTimeout(function () {
- resolve(value)
- }, ms)
- })
- }
-}
-```
-
-### Testing error handling
-
-```typescript
-it('emits error event on fetch failure', async ({ expect }) => {
- function fetcher() {
- return Promise.reject(new Error('fail'))
- }
-
- const q = createQuery({ fetcher })
- const errorPromise = q.once('key', 'error')
-
- try {
- await q.query('key')
- } catch {
- // expected
- }
-
- const event = await errorPromise
- expect(event.detail).toBeInstanceOf(Error)
-})
-```
+## Regression checklist
----
+Choose scenarios for the behavior being changed; do not add a new evaluation framework for a factual documentation update.
-## Gotchas
+| Change | Scenarios | Existing tests |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| Request ownership | Abort old fetch, start replacement, settle old success/failure; replacement remains owner | `src/query/requestOwnership.test.ts` |
+| Mutation ordering | Two same-key functional updates; rejection recovery; independent keys; fetch superseded by mutation/hydration | `src/query/ordering.test.ts` |
+| Broadcast isolation | No implicit channel; scoped delivery; shared provider listener; caller-owned close | `src/react/components/QueryProvider.broadcast.test.tsx` |
+| Background errors | Expired stale view survives failure/abort; action still rejects | `src/react/hooks/useQueryBasic.errors.test.tsx` |
+| Fresh Suspense | Initial retries, siblings, StrictMode, rerenders after expiration, final release/remount, invalidation, instance defaults | `src/react/hooks/useQueryFresh.test.tsx`, `src/query/resource.test.ts` |
-- **Always wrap renders in `act()`** — React state updates outside `act()` cause warnings and flaky tests.
-- **Use two `act()` blocks** — one for initial render (triggers Suspense), one for awaiting the query resolution.
-- **`query.next(key)` must be called before render** — it listens for the `refetching` event, which fires during the first render.
-- **Pass `{ query }` directly to hooks** in tests — avoids needing `QueryProvider` in the test tree.
-- **Use `describe.concurrent()`** — tests are independent and safe to run in parallel.
-- **Destructure `expect` from context** — required for concurrent test isolation in Vitest.
-- **Use `// oxlint-disable-next-line`** before `await act(async function () { ... })` — OxLint flags the floating promise pattern.
-- **`vi.fn()` for mock fetchers** — use when you need to assert call counts or arguments.
+Use test-context `expect` in concurrent suites. Keep mutable fixtures local; restore mocks, unsubscribe listeners, and close caller-created channels. Assert expected failures with `await expect(promise).rejects...` rather than swallowing errors. Check `package.json` for the current test/build commands.