From b2920f01a32a9cfd233d23f079b4ef6c27a6a305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Fri, 1 May 2026 15:00:40 +0200 Subject: [PATCH] chore: restructure lambda-query skill with detailed reference docs Break the single monolithic SKILL.md into a concise overview with three focused reference documents: - references/core-api.md: full core API with types, edge cases, events - references/react-bindings.md: all hooks, components, and React types - references/testing-patterns.md: test setup, patterns, and gotchas --- skills/lambda-query/SKILL.md | 344 ++------------- skills/lambda-query/references/core-api.md | 407 ++++++++++++++++++ .../lambda-query/references/react-bindings.md | 380 ++++++++++++++++ .../references/testing-patterns.md | 268 ++++++++++++ 4 files changed, 1101 insertions(+), 298 deletions(-) create mode 100644 skills/lambda-query/references/core-api.md create mode 100644 skills/lambda-query/references/react-bindings.md create mode 100644 skills/lambda-query/references/testing-patterns.md diff --git a/skills/lambda-query/SKILL.md b/skills/lambda-query/SKILL.md index e6f2404..ab633fa 100644 --- a/skills/lambda-query/SKILL.md +++ b/skills/lambda-query/SKILL.md @@ -7,7 +7,7 @@ description: > or any @studiolambda/query API. Covers the core framework-agnostic library and React 19+ bindings (hooks and components). metadata: - version: "1.5.9" + version: '1.5.12' author: studiolambda --- @@ -18,12 +18,15 @@ Lightweight (~1.7KB), isomorphic, framework-agnostic SWR-style async data manage **Install:** `npm i @studiolambda/query` **Import paths:** + - Core: `@studiolambda/query` - React: `@studiolambda/query/react` -## Core API +## 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. -### Creating an instance +## Quick Start ```typescript import { createQuery } from '@studiolambda/query' @@ -34,153 +37,22 @@ const query = createQuery({ if (!res.ok) throw new Error(res.statusText) return res.json() }, - expiration: () => 5000, // cache for 5 seconds - stale: true, // return stale data while revalidating (default) - removeOnError: false, // keep cached item on fetch error (default) - fresh: false, // respect cache (default) -}) -``` - -### 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 | - -Instance-only options: `itemsCache`, `resolversCache`, `events` (EventTarget), `broadcast` (BroadcastChannel). - -### Query - -```typescript -const data = await query.query('/api/user/1') - -// Per-query option overrides -const data = await query.query('/api/user/1', { - fetcher: customFetcher, - stale: false, - fresh: true, -}) -``` - -### Mutations - -```typescript -// Direct value -await query.mutate('/api/user', updatedUser) - -// Function based on previous value -await query.mutate('/api/posts', (previous) => [...(previous ?? []), newPost]) - -// Async mutation -await query.mutate('/api/posts', async (previous) => { - const post = await createNewPost() - return [...(previous ?? []), post] -}) - -// With custom expiration -await query.mutate('/api/user', updatedUser, { expiration: () => 10000 }) -``` - -### Forget (invalidate cache) - -```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 -``` - -**Note:** `forget` only removes items from the items cache -- it does not cancel pending resolvers. Use `abort` to cancel in-flight requests. If a cached promise has rejected, `forget` handles it gracefully (emits `'forgotten'` with `undefined`). - -### Hydrate (pre-populate cache) - -```typescript -query.hydrate('/api/user', serverData, { expiration: () => 10000 }) -query.hydrate(['/api/post/1', '/api/post/2'], defaultPost) -``` - -### Abort - -```typescript -query.abort('/api/user') // Abort single key -query.abort(['/api/user', '/api/posts']) // Abort multiple -query.abort() // Abort all pending -query.abort('/api/user', 'cancelled') // With custom reason -``` - -**Note:** When `fresh: true` is used, `abort(key)` is called before `refetch(key)` to ensure a genuinely new fetch starts instead of returning the pending deduplication promise. - -### Inspect cache - -```typescript -const value = await query.snapshot('/api/user') // Current cached value or undefined -const itemKeys = query.keys('items') // readonly string[] -const resolverKeys = query.keys('resolvers') // readonly string[] -const date = query.expiration('/api/user') // Expiration Date or undefined -``` - -### Reconfigure - -```typescript -query.configure({ expiration: () => 10000, stale: false }) -``` - -### Events - -Events: `refetching`, `resolved`, `mutating`, `mutated`, `aborted`, `forgotten`, `hydrated`, `error`. - -```typescript -// Subscribe (returns unsubscriber) -const unsub = query.subscribe('/api/user', 'resolved', (event) => { - console.log('resolved:', event.detail) + expiration: () => 5000, }) -unsub() -// One-time listener (supports optional AbortSignal for cleanup) -const event = await query.once('/api/user', 'resolved') -const event = await query.once('/api/user', 'resolved', signal) // cancellable +const user = await query.query('/api/user/1') -// Await next fetch resolution (supports optional AbortSignal) -const result = await query.next('/api/user') -const [a, b] = await query.next<[User, Config]>(['/api/user', '/api/config']) -const obj = await query.next<{ user: User }>({ user: '/api/user' }) - -// Stream resolutions (async generator -- cleans up listeners on break/return) -for await (const value of query.stream('/api/user')) { - console.log(value) -} - -// Stream arbitrary events (async generator -- cleans up listeners on break/return) -for await (const event of query.sequence('/api/user', 'resolved')) { - console.log(event.detail) -} +await query.mutate('/api/user/1', { ...user, name: 'New Name' }) +await query.forget('/api/user/1') ``` -### Cross-tab sync - -```typescript -// Must configure broadcast manually in vanilla usage -query.configure({ broadcast: new BroadcastChannel('query') }) -const unsub = query.subscribeBroadcast() -// ... later -unsub() -``` - -**Note:** `subscribeBroadcast()` captures the broadcast reference at call time. If `configure()` later replaces the channel, the unsubscriber still targets the original. `emit()` wraps `postMessage` in try-catch for non-cloneable data. - -## React Bindings - -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`. - -### Setup +## React Quick Start ```tsx -import { QueryProvider } from '@studiolambda/query/react' import { createQuery } from '@studiolambda/query' +import { QueryProvider } from '@studiolambda/query/react' +import { useQuery } from '@studiolambda/query/react' +import { Suspense } from 'react' const query = createQuery({ fetcher: myFetcher }) @@ -188,184 +60,60 @@ function App() { return ( }> - + ) } -``` - -`QueryProvider` props: -- `query?` - Query instance (creates one if omitted) -- `clearOnForget?` - Auto-refetch after `forget()` (default `false`) -- `ignoreTransitionContext?` - Use local transitions instead of shared (default `false`) - -`QueryProvider` automatically handles BroadcastChannel setup, cleanup, and cross-tab event forwarding. Includes a guard for environments where `BroadcastChannel` is unavailable. - -### useQuery - -The primary hook. Components using it **must** be inside ``. - -```tsx -import { useQuery } from '@studiolambda/query/react' function UserProfile() { - const { data, isPending, isRefetching, refetch, mutate, forget } = useQuery('/api/user/1') + const { data, isPending, refetch, mutate, forget } = useQuery('/api/user/1') return (

{data.name}

- -
) } ``` -**Returns:** - -| 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 | -| `isRefetching` | `boolean` | Background refetch in progress | -| `isMutating` | `boolean` | Mutation in progress (async mutations only) | -| `refetch` | `(options?) => Promise` | Trigger fresh fetch | -| `mutate` | `(value, options?) => Promise` | Optimistic mutation | -| `forget` | `() => Promise` | Clear cached data | - -**Options (second argument):** All core `Options` fields plus `query?`, `clearOnForget?`, `ignoreTransitionContext?`. - -### useQueryActions - -Actions without data subscription. Use when you need to mutate/refetch from a sibling component. - -```tsx -const { refetch, mutate, forget } = useQueryActions('/api/user/1') -``` - -### useQueryStatus - -Status without data subscription. - -```tsx -const { expiresAt, isExpired, isRefetching, isMutating } = useQueryStatus('/api/user/1') -``` - -### useQueryBasic - -Minimal hook returning only `data` and `isPending`. Correctly resets data when the key changes to a different cached value. - -```tsx -const { data, isPending } = useQueryBasic('/api/user/1') -``` - -### useQueryInstance - -Get the raw `Query` instance from context. Throws if none found. - -```tsx -const queryInstance = useQueryInstance() -``` - -### useQueryPrefetch - -Prefetch keys on mount. - -```tsx -import { useMemo } from 'react' - -const keys = useMemo(() => ['/api/user/1', '/api/config'], []) -useQueryPrefetch(keys) -``` - -### QueryTransition - -Share a single transition across multiple `useQuery` calls: - -```tsx -import { QueryTransition } from '@studiolambda/query/react' -import { useTransition } from 'react' - -function App() { - const [isPending, startTransition] = useTransition() - return ( - - - - - ) -} -``` - -### QueryPrefetch / QueryPrefetchTags - -```tsx -import { useMemo } from 'react' - -const keys = useMemo(() => ['/api/user', '/api/config'], []) - - - - - -// Also renders tags - - - -``` - -### Testing React components +## Configuration Options -```tsx -import { createQuery } from '@studiolambda/query' -import { useQuery } from '@studiolambda/query/react' -import { act, Suspense } from 'react' -import { createRoot } from 'react-dom/client' - -it('renders user data', async ({ expect }) => { - const query = createQuery({ fetcher: () => Promise.resolve({ name: 'Ada' }) }) - const promise = query.next('/api/user') - - function Component() { - const { data } = useQuery('/api/user', { query }) - return {data.name} - } +| 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 | - const el = document.createElement('div') +Instance-only: `itemsCache`, `resolversCache` (injectable `Cache` interface), `events` (EventTarget), `broadcast` (BroadcastChannel). - await act(async () => { - createRoot(el).render( - - ) - }) +## Events - await act(async () => { await promise }) - expect(el.innerText).toBe('Ada') -}) -``` +Events: `refetching`, `resolved`, `mutating`, `mutated`, `aborted`, `forgotten`, `hydrated`, `error`. -Pattern: create query with mock fetcher, pass it via `{ query }` option to bypass context, use `query.next(key)` to await resolution. +Only `mutated`, `resolved`, `hydrated`, and `forgotten` are broadcast cross-tab. Errors, aborts, refetching, and mutating are local-only. ## Gotchas - **Expiration is a function, not a number.** Always `expiration: () => 5000`, not `expiration: 5000`. -- **`useQuery` suspends.** Components must be inside `` or React throws. -- **`data` from `useQuery` is always resolved.** Never undefined/null from loading state. Suspense handles loading. -- **`hydrate` without expiration creates immediately-stale data.** The first `query()` returns the hydrated value, the second triggers a refetch. -- **Mutation with `expiration: () => 0` makes the value immediately stale.** Provide a non-zero expiration if you want it to persist. -- **`forget` does not cancel pending fetches.** Only removes items from the items cache. Use `abort` to cancel in-flight requests. -- **`stale: false` blocks until refetch completes.** Default `stale: true` returns old data while revalidating in the background. -- **`subscribe('refetching')` on a key with a pending resolver fires immediately.** Intentional for late subscribers. -- **BroadcastChannel is not auto-created in vanilla usage.** `QueryProvider` handles it in React. In core, configure it manually. -- **Pass stable `keys` arrays to `useQueryPrefetch` / `QueryPrefetch`.** Use `useMemo` or a module-level constant to avoid infinite re-renders. -- **`useQueryInstance` throws if no query is in context or options.** Ensure `QueryProvider` is an ancestor or pass `{ query }` in options. -- **React Compiler handles memoization.** Do NOT use `useMemo`, `useCallback`, or `React.memo` -- the compiler does it automatically. -- **`once()` and `next()` accept an optional `AbortSignal`.** Use to cancel pending listeners when breaking out of generators. -- **`stream()` and `sequence()` clean up on break.** Internal `AbortController` cancels pending listeners via `finally` block. -- **Abort race condition is handled.** If `abort()` fires after fetch resolves but before cache write, the result is discarded and the promise rejects. -- **`next()` supports object keys.** `await query.next<{ user: User }>({ user: '/api/user' })` returns an object with the same shape. -- **`fresh: true` aborts then refetches.** Ensures a genuinely new fetch instead of returning the pending deduplication promise. +- **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`. +- **`useQuery` suspends.** Components must be inside ``. +- **`data` from `useQuery` is always resolved.** Never undefined/null from loading state. +- **`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. +- **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`. + +## When to Load References + +- Using core API (query, mutate, forget, hydrate, abort, events, streams) -> [core-api.md](references/core-api.md) +- Using React hooks or components (useQuery, QueryProvider, transitions, prefetch) -> [react-bindings.md](references/react-bindings.md) +- Writing or reviewing tests for components using query -> [testing-patterns.md](references/testing-patterns.md) diff --git a/skills/lambda-query/references/core-api.md b/skills/lambda-query/references/core-api.md new file mode 100644 index 0000000..b0868b1 --- /dev/null +++ b/skills/lambda-query/references/core-api.md @@ -0,0 +1,407 @@ +# Core API Reference + +## Table of Contents + +- [createQuery](#createquery) +- [query](#query) +- [Mutations](#mutations) +- [Forget (Cache Invalidation)](#forget-cache-invalidation) +- [Hydrate (Pre-populate Cache)](#hydrate-pre-populate-cache) +- [Abort](#abort) +- [Cache Inspection](#cache-inspection) +- [Reconfigure](#reconfigure) +- [Events and Subscriptions](#events-and-subscriptions) +- [Cross-tab Sync](#cross-tab-sync) +- [Types](#types) + +--- + +## createQuery + +```typescript +function createQuery(options?: Configuration): Query +``` + +Factory that returns a closure-scoped `Query` instance. All state (caches, EventTarget, BroadcastChannel) is private. + +```typescript +import { createQuery } from '@studiolambda/query' + +const query = createQuery({ + fetcher: async (key, { signal }) => { + const res = await fetch(key, { signal }) + if (!res.ok) throw new Error(res.statusText) + return res.json() + }, + expiration: () => 5000, + stale: true, + removeOnError: false, + fresh: false, +}) +``` + +**Default fetcher** (`defaultFetcher`): Uses global `fetch`, parses JSON, throws `new Error('Unable to fetch the data: ' + statusText)` on non-ok responses. + +--- + +## query + +```typescript +function query(key: string, options?: Options): Promise +``` + +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. + +**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. + +```typescript +// Basic +const data = await query.query('/api/user/1') + +// Per-query overrides +const data = await query.query('/api/user/1', { + fetcher: customFetcher, + stale: false, + fresh: true, +}) +``` + +--- + +## Mutations + +```typescript +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. + +**Important**: Mutated items default to `0ms` expiration — they're immediately stale unless you provide a custom `expiration`. + +```typescript +// Direct value +await query.mutate('/api/user', updatedUser) + +// Function based on previous value +await query.mutate('/api/posts', (previous) => [...(previous ?? []), newPost]) + +// Async mutation +await query.mutate('/api/posts', async (previous) => { + const post = await createNewPost() + return [...(previous ?? []), post] +}) + +// With custom expiration (persists in cache) +await query.mutate('/api/user', updatedUser, { expiration: () => 10000 }) + +// Immediately stale (triggers refetch on next query) +await query.mutate('/api/user', updatedUser, { expiration: () => 0 }) +``` + +--- + +## Forget (Cache Invalidation) + +```typescript +function forget(keys?: string | readonly string[] | RegExp): Promise +``` + +Removes items from the items cache only — does **not** cancel pending resolvers. + +- **String**: Forget single key. +- **Array**: Forget multiple keys. +- **RegExp**: Pattern-based clearing (e.g., `/^\/api\/users(.*)/`). +- **No arguments**: Clear entire items cache. + +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 +``` + +--- + +## Hydrate (Pre-populate Cache) + +```typescript +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. + +```typescript +query.hydrate('/api/user', serverData, { expiration: () => 10000 }) +query.hydrate(['/api/post/1', '/api/post/2'], defaultPost) +``` + +--- + +## Abort + +```typescript +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 +``` + +--- + +## Cache Inspection + +```typescript +// Current cached value or undefined (does NOT trigger a fetch) +const value = await query.snapshot('/api/user') + +// Cache keys +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 +``` + +--- + +## Reconfigure + +```typescript +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. + +```typescript +query.configure({ expiration: () => 10000, stale: false }) +``` + +--- + +## Events and Subscriptions + +### 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 | + +### subscribe + +```typescript +function subscribe( + key: string, + event: QueryEvent, + 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. + +```typescript +const unsub = query.subscribe('/api/user', 'resolved', (event) => { + console.log('resolved:', event.detail) +}) +unsub() +``` + +### once + +```typescript +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. + +```typescript +const event = await query.once('/api/user', 'resolved') +const event = await query.once('/api/user', 'resolved', signal) // cancellable +``` + +### next + +```typescript +function next(key: string, signal?: AbortSignal): Promise +function next(keys: readonly string[], signal?: AbortSignal): Promise +function next(keys: { [K in keyof T]: string }, signal?: AbortSignal): Promise +``` + +Waits for the next `'refetching'` event and **awaits the detail promise**. Three shapes: + +```typescript +// Single key +const result = await query.next('/api/user') + +// Array of keys (returns array) +const [a, b] = await query.next<[User, Config]>(['/api/user', '/api/config']) + +// Object of keys (returns object with same shape) +const obj = await query.next<{ user: User }>({ user: '/api/user' }) +``` + +### stream (async generator) + +```typescript +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. + +```typescript +for await (const value of query.stream('/api/user')) { + console.log(value) +} +``` + +### sequence (async generator) + +```typescript +function sequence(key: string, event: QueryEvent): AsyncGenerator> +``` + +Like `stream` but for arbitrary events on a single key via `once()`. Cleans up on `break`/`return`. + +```typescript +for await (const event of query.sequence('/api/user', 'resolved')) { + console.log(event.detail) +} +``` + +--- + +## Cross-tab Sync + +```typescript +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. + +Broadcast silently catches `DataCloneError` for non-structurally-cloneable payloads. + +```typescript +// Vanilla usage (React's QueryProvider handles this automatically) +query.configure({ broadcast: new BroadcastChannel('query') }) +const unsub = query.subscribeBroadcast() +// ... later +unsub() +``` + +--- + +## Types + +```typescript +type QueryEvent = + | 'refetching' | 'resolved' | 'mutating' | 'mutated' + | 'aborted' | 'forgotten' | 'hydrated' | 'error' + +type FetcherFunction = (key: string, additional: FetcherAdditional) => Promise + +interface FetcherAdditional { + readonly signal: AbortSignal +} + +type ExpirationOptionFunction = (item: T) => number + +type MutationFunction = (previous?: T, expiresAt?: Date) => T | Promise +type MutationValue = T | MutationFunction + +type SubscribeListener = (event: CustomEventInit) => void +type Unsubscriber = () => void + +interface Options { + readonly expiration?: ExpirationOptionFunction + readonly fetcher?: FetcherFunction + readonly stale?: boolean + readonly removeOnError?: boolean + readonly fresh?: boolean +} + +interface Configuration extends Options { + readonly itemsCache?: Cache> + readonly resolversCache?: Cache> + readonly events?: EventTarget + readonly broadcast?: BroadcastChannel +} + +interface HydrateOptions { + readonly expiration?: ExpirationOptionFunction +} + +interface MutateOptions { + readonly expiration?: ExpirationOptionFunction +} + +// Cache is injectable — any backing store implementing this interface works +interface Cache { + get(key: string): T | undefined + set(key: string, value: T): void + delete(key: string): void + keys(): IterableIterator +} + +interface ItemsCacheItem { + item: Promise + expiresAt: Date +} + +interface ResolversCacheItem { + item: Promise + controller: AbortController +} + +type CacheType = 'resolvers' | 'items' + +interface BroadcastPayload { + event: `${QueryEvent}:${string}` + detail: unknown +} +``` diff --git a/skills/lambda-query/references/react-bindings.md b/skills/lambda-query/references/react-bindings.md new file mode 100644 index 0000000..65f8ab6 --- /dev/null +++ b/skills/lambda-query/references/react-bindings.md @@ -0,0 +1,380 @@ +# 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`. + +## Table of Contents + +- [Components](#components) + - [QueryProvider](#queryprovider) + - [QueryTransition](#querytransition) + - [QueryPrefetch](#queryprefetch) + - [QueryPrefetchTags](#queryprefetchtags) +- [Hooks](#hooks) + - [useQuery](#usequery) + - [useQueryBasic](#usequerybasic) + - [useQueryActions](#usequeryactions) + - [useQueryStatus](#usequerystatus) + - [useQueryInstance](#usequeryinstance) + - [useQueryPrefetch](#usequeryprefetch) + - [useQueryContext](#usequerycontext) + - [useQueryTransitionContext](#usequerytransitioncontext) +- [Types](#types) + +--- + +## Components + +### QueryProvider + +```tsx +function QueryProvider({ + children, + query, + clearOnForget, + ignoreTransitionContext, +}: 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 | + +**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. + +```tsx +import { QueryProvider } from '@studiolambda/query/react' +import { createQuery } from '@studiolambda/query' + +const query = createQuery({ fetcher: myFetcher }) + +function App() { + return ( + + }> + + + + ) +} +``` + +--- + +### QueryTransition + +```tsx +function QueryTransition({ + isPending, + startTransition, + children, +}: QueryTransitionProps): JSX.Element +``` + +Shares a single `useTransition` across multiple `useQuery` calls so they coordinate updates together. + +```tsx +import { QueryTransition } from '@studiolambda/query/react' +import { useTransition } from 'react' + +function App() { + const [isPending, startTransition] = useTransition() + return ( + + + + + ) +} +``` + +Without this, each `useQuery` creates its own local `useTransition`. + +--- + +### QueryPrefetch + +```tsx +function QueryPrefetch({ keys, query, children }: QueryPrefetchProps): ReactNode +``` + +Fires `query(key)` for each key on mount via `useQueryPrefetch`. Renders children passthrough (no extra DOM). + +```tsx + + + +``` + +**Important**: Pass a stable `keys` reference. Unstable arrays re-trigger prefetching on every render. + +--- + +### QueryPrefetchTags + +```tsx +function QueryPrefetchTags({ + keys, + query, + children, + ...linkProps +}: QueryPrefetchTagsProps): JSX.Element +``` + +Same as `QueryPrefetch` plus renders `` for each key. Extra `linkProps` (any `LinkHTMLAttributes`) are spread onto each ``. + +```tsx + + + +``` + +--- + +## Hooks + +All hooks require a `` ancestor (or a `query` option) unless noted otherwise. + +### useQuery + +```tsx +function useQuery(key: string, options?: ResourceOptions): Resource +``` + +The primary hook. Components using it **must** be inside ``. Composes `useQueryBasic` + `useQueryActions` + `useQueryStatus`. + +**Options** (`ResourceOptions`): + +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 | + +**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 | + +```tsx +function UserProfile() { + const { data, isPending, isRefetching, refetch, mutate, forget } = useQuery('/api/user/1') + + return ( +
+

{data.name}

+ + + +
+ ) +} +``` + +--- + +### useQueryBasic + +```tsx +function useQueryBasic(key: string, options?: BasicResourceOptions): BasicResource +``` + +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. +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). +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. + +```tsx +const { data, isPending } = useQueryBasic('/api/user/1') +``` + +--- + +### useQueryActions + +```tsx +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)` | + +**Important**: `refetch()` defaults `stale` to `false`, overriding the instance default of `true`. This means it waits for the new data rather than returning stale. + +```tsx +const { refetch, mutate, forget } = useQueryActions('/api/user/1') +``` + +--- + +### useQueryStatus + +```tsx +function useQueryStatus(key: string, options?: QueryInstance): Status +``` + +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` | + +Subscribes to 7 events: `mutating`, `mutated`, `hydrated`, `resolved`, `forgotten`, `refetching`, `error`. + +```tsx +const { expiresAt, isExpired, isRefetching, isMutating } = useQueryStatus('/api/user/1') +``` + +--- + +### useQueryInstance + +```tsx +function useQueryInstance(options?: QueryInstance): Query +``` + +Get the raw `Query` instance. Prefers `options.query` over context. Throws `ErrNoQueryInstanceFound` if neither available. + +```tsx +const queryInstance = useQueryInstance() +``` + +--- + +### useQueryPrefetch + +```tsx +function useQueryPrefetch(keys: readonly string[], options?: QueryInstance): void +``` + +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`. + +```tsx +import { useMemo } from 'react' + +const keys = useMemo(() => ['/api/user/1', '/api/config'], []) +useQueryPrefetch(keys) +``` + +--- + +### useQueryContext + +```tsx +function useQueryContext(): ContextValue +``` + +Returns the context value from the nearest `QueryProvider`. Uses React 19's `use()`. + +--- + +### useQueryTransitionContext + +```tsx +function useQueryTransitionContext(): QueryTransitionContextValue +``` + +Returns the transition context from the nearest `QueryTransition`. Uses React 19's `use()`. + +--- + +## Types + +```typescript +interface QueryInstance { + readonly query?: Query +} + +interface ContextValue extends QueryInstance { + readonly clearOnForget?: boolean + readonly ignoreTransitionContext?: boolean +} + +interface QueryTransitionContextValue { + readonly isPending?: boolean + readonly startTransition?: TransitionStartFunction +} + +type ResourceOptions = ContextValue & Options & QueryInstance + +interface Resource extends QueryActions, BasicResource, Status { + readonly data: T + readonly isPending: boolean +} + +interface BasicResource { + readonly data: T + readonly isPending: boolean +} + +interface QueryActions { + readonly refetch: (options?: Options) => Promise + readonly mutate: (value: MutationValue, options?: MutateOptions) => Promise + readonly forget: () => Promise +} + +interface Status { + readonly expiresAt: Date + readonly isExpired: boolean + readonly isRefetching: boolean + readonly isMutating: boolean +} + +interface QueryProviderProps extends ContextValue { + readonly children?: ReactNode +} + +interface QueryTransitionProps { + readonly isPending: boolean + readonly startTransition: TransitionStartFunction + readonly children?: ReactNode +} + +interface QueryPrefetchProps extends QueryInstance { + readonly keys: readonly string[] + readonly children?: ReactNode +} + +interface QueryPrefetchTagsProps extends LinkHTMLAttributes, QueryInstance { + readonly keys: readonly string[] + readonly children?: ReactNode +} +``` diff --git a/skills/lambda-query/references/testing-patterns.md b/skills/lambda-query/references/testing-patterns.md new file mode 100644 index 0000000..4e1184a --- /dev/null +++ b/skills/lambda-query/references/testing-patterns.md @@ -0,0 +1,268 @@ +# Testing Patterns + +## Table of Contents + +- [Test Setup](#test-setup) +- [Testing Core Query](#testing-core-query) +- [Testing React Components](#testing-react-components) +- [Common Patterns](#common-patterns) +- [Gotchas](#gotchas) + +--- + +## 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) + +```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') + + const value = await q.snapshot('key') + expect(value).toBe('updated') + }) + + 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']) + }) +}) +``` + +--- + +## Testing React Components + +**Pattern**: Create query with mock fetcher, pass via `{ query }` option to bypass context, use `query.next(key)` to await resolution. + +```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 } + + function Component() { + const { data } = useQuery('/user', options) + 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() + }) +}) +``` + +### Testing with delayed fetchers + +```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) +}) +``` + +--- + +## Gotchas + +- **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.