Skip to content

Commit 341a4ac

Browse files
committed
feat: add agent skill and improve readonly type safety
Add .agents/studiolambda-query skill for AI agents to learn how to use the library as consumers. Also adds readonly modifiers to array parameters and return types across the public API.
1 parent 8f1f009 commit 341a4ac

6 files changed

Lines changed: 372 additions & 13 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
---
2+
name: studiolambda-query
3+
description: >
4+
Skill for using @studiolambda/query, a lightweight isomorphic SWR-style async data
5+
management library. Use when writing, editing, reviewing, or testing code that uses
6+
createQuery, useQuery, QueryProvider, cache mutations, hydration, event subscriptions,
7+
or any @studiolambda/query API. Covers the core framework-agnostic library and
8+
React 19+ bindings (hooks and components).
9+
license: MIT
10+
compatibility: Requires Node.js 18+. React bindings require React 19.1+.
11+
metadata:
12+
version: "1.4.0"
13+
author: studiolambda
14+
---
15+
16+
# @studiolambda/query
17+
18+
Lightweight (~1.7KB), isomorphic, framework-agnostic SWR-style async data management library with React 19+ bindings.
19+
20+
**Install:** `npm i @studiolambda/query`
21+
22+
**Import paths:**
23+
- Core: `@studiolambda/query`
24+
- React: `@studiolambda/query/react`
25+
26+
## Core API
27+
28+
### Creating an instance
29+
30+
```typescript
31+
import { createQuery } from '@studiolambda/query'
32+
33+
const query = createQuery({
34+
fetcher: async (key, { signal }) => {
35+
const res = await fetch(key, { signal })
36+
if (!res.ok) throw new Error(res.statusText)
37+
return res.json()
38+
},
39+
expiration: () => 5000, // cache for 5 seconds
40+
stale: true, // return stale data while revalidating (default)
41+
removeOnError: false, // keep cached item on fetch error (default)
42+
fresh: false, // respect cache (default)
43+
})
44+
```
45+
46+
### Configuration options
47+
48+
| Option | Type | Default | Description |
49+
|--------|------|---------|-------------|
50+
| `expiration` | `(item: T) => number` | `() => 2000` | Cache duration in ms |
51+
| `fetcher` | `(key: string, { signal }) => Promise<T>` | `fetch`-based JSON | Data fetcher function |
52+
| `stale` | `boolean` | `true` | Return stale data while revalidating |
53+
| `removeOnError` | `boolean` | `false` | Remove cached item on fetch error |
54+
| `fresh` | `boolean` | `false` | Always bypass cache |
55+
56+
Instance-only options: `itemsCache`, `resolversCache`, `events` (EventTarget), `broadcast` (BroadcastChannel).
57+
58+
### Query
59+
60+
```typescript
61+
const data = await query.query<User>('/api/user/1')
62+
63+
// Per-query option overrides
64+
const data = await query.query<User>('/api/user/1', {
65+
fetcher: customFetcher,
66+
stale: false,
67+
fresh: true,
68+
})
69+
```
70+
71+
### Mutations
72+
73+
```typescript
74+
// Direct value
75+
await query.mutate('/api/user', updatedUser)
76+
77+
// Function based on previous value
78+
await query.mutate<Post[]>('/api/posts', (previous) => [...(previous ?? []), newPost])
79+
80+
// Async mutation
81+
await query.mutate('/api/posts', async (previous) => {
82+
const post = await createNewPost()
83+
return [...(previous ?? []), post]
84+
})
85+
86+
// With custom expiration
87+
await query.mutate('/api/user', updatedUser, { expiration: () => 10000 })
88+
```
89+
90+
### Forget (invalidate cache)
91+
92+
```typescript
93+
await query.forget('/api/user') // Single key
94+
await query.forget(['/api/user', '/api/posts']) // Multiple keys
95+
await query.forget(/^\/api\/users(.*)/) // Regex pattern
96+
await query.forget() // All keys
97+
```
98+
99+
### Hydrate (pre-populate cache)
100+
101+
```typescript
102+
query.hydrate('/api/user', serverData, { expiration: () => 10000 })
103+
query.hydrate(['/api/post/1', '/api/post/2'], defaultPost)
104+
```
105+
106+
### Abort
107+
108+
```typescript
109+
query.abort('/api/user') // Abort single key
110+
query.abort(['/api/user', '/api/posts']) // Abort multiple
111+
query.abort() // Abort all pending
112+
query.abort('/api/user', 'cancelled') // With custom reason
113+
```
114+
115+
### Inspect cache
116+
117+
```typescript
118+
const value = await query.snapshot<User>('/api/user') // Current cached value or undefined
119+
const itemKeys = query.keys('items') // Cached item keys
120+
const resolverKeys = query.keys('resolvers') // Pending resolver keys
121+
const date = query.expiration('/api/user') // Expiration Date or undefined
122+
```
123+
124+
### Reconfigure
125+
126+
```typescript
127+
query.configure({ expiration: () => 10000, stale: false })
128+
```
129+
130+
### Events
131+
132+
Events: `refetching`, `resolved`, `mutating`, `mutated`, `aborted`, `forgotten`, `hydrated`, `error`.
133+
134+
```typescript
135+
// Subscribe (returns unsubscriber)
136+
const unsub = query.subscribe('/api/user', 'resolved', (event) => {
137+
console.log('resolved:', event.detail)
138+
})
139+
unsub()
140+
141+
// One-time listener
142+
const event = await query.once('/api/user', 'resolved')
143+
144+
// Await next fetch resolution
145+
const result = await query.next<string>('/api/user')
146+
const [a, b] = await query.next<[User, Config]>(['/api/user', '/api/config'])
147+
148+
// Stream resolutions (async generator)
149+
for await (const value of query.stream<User>('/api/user')) {
150+
console.log(value)
151+
}
152+
153+
// Stream arbitrary events (async generator)
154+
for await (const event of query.sequence<User>('/api/user', 'resolved')) {
155+
console.log(event.detail)
156+
}
157+
```
158+
159+
### Cross-tab sync
160+
161+
```typescript
162+
// Must configure broadcast manually in vanilla usage
163+
query.configure({ broadcast: new BroadcastChannel('query') })
164+
const unsub = query.subscribeBroadcast()
165+
// ... later
166+
unsub()
167+
```
168+
169+
## React Bindings
170+
171+
Designed for React 19+ with first-class Suspense and Transitions support.
172+
173+
### Setup
174+
175+
```tsx
176+
import { QueryProvider } from '@studiolambda/query/react'
177+
import { createQuery } from '@studiolambda/query'
178+
179+
const query = createQuery({ fetcher: myFetcher })
180+
181+
function App() {
182+
return (
183+
<QueryProvider query={query} clearOnForget>
184+
<Suspense fallback={<Loading />}>
185+
<MyComponents />
186+
</Suspense>
187+
</QueryProvider>
188+
)
189+
}
190+
```
191+
192+
`QueryProvider` props:
193+
- `query?` - Query instance (creates one if omitted)
194+
- `clearOnForget?` - Auto-refetch after `forget()` (default `false`)
195+
- `ignoreTransitionContext?` - Use local transitions instead of shared (default `false`)
196+
197+
`QueryProvider` automatically handles BroadcastChannel setup and cleanup.
198+
199+
### useQuery
200+
201+
The primary hook. Components using it **must** be inside `<Suspense>`.
202+
203+
```tsx
204+
import { useQuery } from '@studiolambda/query/react'
205+
206+
function UserProfile() {
207+
const { data, isPending, isRefetching, refetch, mutate, forget } = useQuery<User>('/api/user/1')
208+
209+
return (
210+
<div style={{ opacity: isPending ? 0.5 : 1 }}>
211+
<h1>{data.name}</h1>
212+
<button onClick={() => refetch()}>Refresh</button>
213+
<button onClick={() => mutate({ ...data, name: 'New Name' })}>Update</button>
214+
<button onClick={() => forget()}>Clear</button>
215+
</div>
216+
)
217+
}
218+
```
219+
220+
**Returns:**
221+
222+
| Property | Type | Description |
223+
|----------|------|-------------|
224+
| `data` | `T` | Resolved data (always available, Suspense handles loading) |
225+
| `isPending` | `boolean` | Transition pending for mutations/refetches |
226+
| `expiresAt` | `Date` | When cached data expires |
227+
| `isExpired` | `boolean` | Whether data is stale |
228+
| `isRefetching` | `boolean` | Background refetch in progress |
229+
| `isMutating` | `boolean` | Mutation in progress (async mutations only) |
230+
| `refetch` | `(options?) => Promise<T>` | Trigger fresh fetch |
231+
| `mutate` | `(value, options?) => Promise<T>` | Optimistic mutation |
232+
| `forget` | `() => Promise<void>` | Clear cached data |
233+
234+
**Options (second argument):** All core `Options` fields plus `query?`, `clearOnForget?`, `ignoreTransitionContext?`.
235+
236+
### useQueryActions
237+
238+
Actions without data subscription. Use when you need to mutate/refetch from a sibling component.
239+
240+
```tsx
241+
const { refetch, mutate, forget } = useQueryActions<User>('/api/user/1')
242+
```
243+
244+
### useQueryStatus
245+
246+
Status without data subscription.
247+
248+
```tsx
249+
const { expiresAt, isExpired, isRefetching, isMutating } = useQueryStatus('/api/user/1')
250+
```
251+
252+
### useQueryBasic
253+
254+
Minimal hook returning only `data` and `isPending`.
255+
256+
```tsx
257+
const { data, isPending } = useQueryBasic<User>('/api/user/1')
258+
```
259+
260+
### useQueryInstance
261+
262+
Get the raw `Query` instance from context. Throws if none found.
263+
264+
```tsx
265+
const queryInstance = useQueryInstance()
266+
```
267+
268+
### useQueryPrefetch
269+
270+
Prefetch keys on mount.
271+
272+
```tsx
273+
import { useMemo } from 'react'
274+
275+
const keys = useMemo(() => ['/api/user/1', '/api/config'], [])
276+
useQueryPrefetch(keys)
277+
```
278+
279+
### QueryTransition
280+
281+
Share a single transition across multiple `useQuery` calls:
282+
283+
```tsx
284+
import { QueryTransition } from '@studiolambda/query/react'
285+
import { useTransition } from 'react'
286+
287+
function App() {
288+
const [isPending, startTransition] = useTransition()
289+
return (
290+
<QueryTransition isPending={isPending} startTransition={startTransition}>
291+
<UserList />
292+
<UserDetails />
293+
</QueryTransition>
294+
)
295+
}
296+
```
297+
298+
### QueryPrefetch / QueryPrefetchTags
299+
300+
```tsx
301+
import { useMemo } from 'react'
302+
303+
const keys = useMemo(() => ['/api/user', '/api/config'], [])
304+
305+
<QueryPrefetch keys={keys}>
306+
<Content />
307+
</QueryPrefetch>
308+
309+
// Also renders <link rel="preload" as="fetch"> tags
310+
<QueryPrefetchTags keys={keys}>
311+
<Content />
312+
</QueryPrefetchTags>
313+
```
314+
315+
### Testing React components
316+
317+
```tsx
318+
import { createQuery } from '@studiolambda/query'
319+
import { useQuery } from '@studiolambda/query/react'
320+
import { act, Suspense } from 'react'
321+
import { createRoot } from 'react-dom/client'
322+
323+
it('renders user data', async ({ expect }) => {
324+
const query = createQuery({ fetcher: () => Promise.resolve({ name: 'Ada' }) })
325+
const promise = query.next<User>('/api/user')
326+
327+
function Component() {
328+
const { data } = useQuery<User>('/api/user', { query })
329+
return <span>{data.name}</span>
330+
}
331+
332+
const el = document.createElement('div')
333+
334+
await act(async () => {
335+
createRoot(el).render(
336+
<Suspense fallback="loading"><Component /></Suspense>
337+
)
338+
})
339+
340+
await act(async () => { await promise })
341+
expect(el.innerText).toBe('Ada')
342+
})
343+
```
344+
345+
Pattern: create query with mock fetcher, pass it via `{ query }` option to bypass context, use `query.next(key)` to await resolution.
346+
347+
## Gotchas
348+
349+
- **Expiration is a function, not a number.** Always `expiration: () => 5000`, not `expiration: 5000`.
350+
- **`useQuery` suspends.** Components must be inside `<Suspense>` or React throws.
351+
- **`data` from `useQuery` is always resolved.** It's never undefined or null from a loading state. Suspense handles loading.
352+
- **`hydrate` without expiration creates immediately-stale data.** The first `query()` returns the hydrated value, the second triggers a refetch.
353+
- **Mutation with `expiration: () => 0` makes the value immediately stale.** Provide a non-zero expiration in `mutate` options if you want it to persist.
354+
- **`forget` does not cancel pending fetches.** Use `abort` to cancel in-flight requests.
355+
- **`stale: false` blocks until refetch completes.** Default `stale: true` returns old data while revalidating in the background.
356+
- **`subscribe('refetching')` on a key with a pending resolver fires immediately.** This is intentional for late subscribers.
357+
- **BroadcastChannel is not auto-created in vanilla usage.** `QueryProvider` handles it in React. In core, configure it manually.
358+
- **Pass stable `keys` arrays to `useQueryPrefetch` / `QueryPrefetch`.** Use `useMemo` or a module-level constant to avoid infinite re-renders.
359+
- **`useQueryInstance` throws if no query is in context or options.** Ensure `QueryProvider` is an ancestor or pass `{ query }` in options.

src/query/options.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export type MutateFunction = {
182182
* The hydrate function type.
183183
*/
184184
export type HydrateFunction = {
185-
<T = unknown>(keys: string | string[], item: T, options?: HydrateOptions<T>): void
185+
<T = unknown>(keys: string | readonly string[], item: T, options?: HydrateOptions<T>): void
186186
}
187187

188188
/**
@@ -235,7 +235,7 @@ export type ConfigureFunction = {
235235
* resolvers, specific keys, or provide a custom abort reason.
236236
*/
237237
export type AbortFunction = {
238-
(key?: string | string[], reason?: unknown): void
238+
(key?: string | readonly string[], reason?: unknown): void
239239
}
240240

241241
/**
@@ -251,7 +251,7 @@ export type SnapshotFunction = {
251251
* cache (items or resolvers).
252252
*/
253253
export type KeysFunction = {
254-
(cache?: CacheType): string[]
254+
(cache?: CacheType): readonly string[]
255255
}
256256

257257
/**
@@ -267,7 +267,7 @@ export type ExpirationFunction = {
267267
* multiple keys, or keys matching a regular expression pattern.
268268
*/
269269
export type ForgetFunction = {
270-
(keys?: string | string[] | RegExp): Promise<void>
270+
(keys?: string | readonly string[] | RegExp): Promise<void>
271271
}
272272

273273
/**

0 commit comments

Comments
 (0)