@raf-query/core 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/README.md +109 -39
- package/dist/all-keyed.d.mts +18 -0
- package/dist/all-keyed.mjs +19 -0
- package/dist/cast.mjs +10 -0
- package/dist/client.d.mts +99 -0
- package/dist/client.mjs +234 -0
- package/dist/hydration.d.mts +50 -0
- package/dist/hydration.mjs +50 -0
- package/dist/index.d.mts +7 -4
- package/dist/index.mjs +7 -5
- package/dist/key.d.mts +18 -0
- package/dist/key.mjs +36 -0
- package/dist/notify.mjs +21 -0
- package/dist/options.d.mts +111 -0
- package/dist/options.mjs +35 -0
- package/dist/query.d.mts +77 -0
- package/dist/query.mjs +394 -0
- package/dist/thenable.mjs +37 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.0.4](https://github.com/KATT/raf-query/compare/0.0.3...0.0.4) (2026-10-12)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* @raf-query/core, @raf-query/react and the example apps ([#1](https://github.com/KATT/raf-query/issues/1)) ([3881014](https://github.com/KATT/raf-query/commit/3881014b7f07d17d733256e184b627ead1f5788b))
|
|
8
|
+
|
|
3
9
|
## [0.0.3](https://github.com/KATT/raf-query/compare/0.0.2...0.0.3) (2026-10-12)
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
- lint-fix staged files in pre-commit hook ([#2](https://github.com/KATT/raf-query/issues/2)) ([858a4e0](https://github.com/KATT/raf-query/commit/858a4e02563daec4c6de9a07d438f03573d0339b))
|
|
6
12
|
|
|
7
13
|
## [0.0.2](https://github.com/KATT/raf-query/compare/0.0.1...0.0.2) (2026-10-12)
|
|
8
14
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ The cache's mechanics (dedupe, staleness, retries, infinite paging, SSR hydratio
|
|
|
8
8
|
|
|
9
9
|
## Status
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Early. Both packages are on npm at `0.0.x`, and the API can still change between patch releases. [`examples/`](examples/) has the same issue tracker built on TanStack Start and on Next.js App Router.
|
|
12
12
|
|
|
13
13
|
## Packages
|
|
14
14
|
|
|
@@ -39,13 +39,13 @@ A query is a **factory**: a function of zero or one argument that returns `query
|
|
|
39
39
|
```ts
|
|
40
40
|
import { infiniteQueryOptions, queryOptions } from "@raf-query/react";
|
|
41
41
|
|
|
42
|
+
// TanStack Start server functions (`createServerFn`); in Next.js, server actions.
|
|
43
|
+
import { getPost, listComments } from "./posts.functions.ts";
|
|
44
|
+
|
|
42
45
|
export function postQuery(input: { slug: string }) {
|
|
43
46
|
return queryOptions({
|
|
44
47
|
queryKey: ["post", input.slug],
|
|
45
|
-
queryFn:
|
|
46
|
-
const res = await fetch(`/api/posts/${input.slug}`, { signal });
|
|
47
|
-
return (await res.json()) as Post;
|
|
48
|
-
},
|
|
48
|
+
queryFn: () => getPost({ data: input }),
|
|
49
49
|
staleTime: 30_000,
|
|
50
50
|
});
|
|
51
51
|
}
|
|
@@ -53,9 +53,9 @@ export function postQuery(input: { slug: string }) {
|
|
|
53
53
|
export function commentsQuery(input: { slug: string }) {
|
|
54
54
|
return infiniteQueryOptions({
|
|
55
55
|
queryKey: ["comments", input.slug],
|
|
56
|
-
queryFn: (
|
|
57
|
-
|
|
58
|
-
initialPageParam:
|
|
56
|
+
queryFn: (context) =>
|
|
57
|
+
listComments({ data: { slug: input.slug, cursor: context.pageParam } }),
|
|
58
|
+
initialPageParam: 0,
|
|
59
59
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
60
60
|
});
|
|
61
61
|
}
|
|
@@ -63,6 +63,8 @@ export function commentsQuery(input: { slug: string }) {
|
|
|
63
63
|
|
|
64
64
|
Factories must take at most one argument; the type rejects two or more. The single argument is the query's **input** and must be serializable, because it is what a handle carries.
|
|
65
65
|
|
|
66
|
+
A `queryFn` returns a value, a promise, or an [Effect](#effect). It receives `queryKey` and an abort `signal`; paged ones also get `pageParam` and `direction`. TypeScript infers the page type from `queryFn`, so if `getNextPageParam` comes first in the object literal (sorted keys), pass the types instead: `infiniteQueryOptions<CommentPage, number>({ ... })`.
|
|
67
|
+
|
|
66
68
|
The factory is the only place a query is declared paged. Every other API — `prefetch`, `ensure`, `fetch`, `useQuery`, `useWaterfall`, `invalidate`, `setData` — takes either kind and infers the rest from the factory's type.
|
|
67
69
|
|
|
68
70
|
## Starting queries
|
|
@@ -78,9 +80,10 @@ All three verbs live on the query client and accept a factory plus its input.
|
|
|
78
80
|
```tsx
|
|
79
81
|
// TanStack Start
|
|
80
82
|
export const Route = createFileRoute("/posts/$slug")({
|
|
81
|
-
loader: async (
|
|
82
|
-
const
|
|
83
|
-
const
|
|
83
|
+
loader: async (args) => {
|
|
84
|
+
const qc = args.context.queryClient;
|
|
85
|
+
const comments = qc.prefetch(commentsQuery, { slug: args.params.slug });
|
|
86
|
+
const post = await qc.ensure(postQuery, { slug: args.params.slug });
|
|
84
87
|
return { post, comments };
|
|
85
88
|
},
|
|
86
89
|
component: PostPage,
|
|
@@ -133,26 +136,26 @@ Handles hold no data and no functions. Data reaches the client cache through the
|
|
|
133
136
|
const [promise, controls] = useQuery(factory, handle, options?);
|
|
134
137
|
```
|
|
135
138
|
|
|
136
|
-
`options` takes [`whilePending`](#when-the-url-changes) and per-read refetch settings (`refetchInterval`, `refetchOnWindowFocus`). There is no option to change the input; the handle decides what is read.
|
|
139
|
+
`options` takes [`whilePending`](#when-the-url-changes) and per-read refetch settings (`refetchInterval`, `refetchOnWindowFocus`, `refetchOnReconnect`). There is no option to change the input; the handle decides what is read.
|
|
137
140
|
|
|
138
141
|
`promise` is a stable `Promise<Data>` owned by the cache. Unwrap it with `use()`. When the cache already has data, the promise is pre-resolved and `use()` returns synchronously without suspending.
|
|
139
142
|
|
|
140
143
|
```tsx
|
|
141
144
|
function PostPage() {
|
|
142
145
|
const loaderData = Route.useLoaderData();
|
|
143
|
-
const [postPromise,
|
|
144
|
-
const
|
|
146
|
+
const [postPromise, postState] = useQuery(postQuery, loaderData.post);
|
|
147
|
+
const post = use(postPromise);
|
|
145
148
|
|
|
146
149
|
return (
|
|
147
150
|
<article>
|
|
148
|
-
<h1>{title}</h1>
|
|
151
|
+
<h1>{post.title}</h1>
|
|
149
152
|
<button
|
|
150
|
-
disabled={
|
|
151
|
-
onClick={() => startTransition(() =>
|
|
153
|
+
disabled={postState.isFetching}
|
|
154
|
+
onClick={() => startTransition(() => postState.refetch())}
|
|
152
155
|
>
|
|
153
156
|
Refresh
|
|
154
157
|
</button>
|
|
155
|
-
<p>{body}</p>
|
|
158
|
+
<p>{post.body}</p>
|
|
156
159
|
<Suspense fallback={<CommentsSkeleton />}>
|
|
157
160
|
<Comments handle={loaderData.comments} />
|
|
158
161
|
</Suspense>
|
|
@@ -186,11 +189,11 @@ With an `infiniteQueryOptions` factory, `useQuery` returns the same tuple. The p
|
|
|
186
189
|
```tsx
|
|
187
190
|
function Comments(props: { handle: QueryHandle<typeof commentsQuery> }) {
|
|
188
191
|
const [pagesPromise, comments] = useQuery(commentsQuery, props.handle);
|
|
189
|
-
const
|
|
192
|
+
const data = use(pagesPromise);
|
|
190
193
|
|
|
191
194
|
return (
|
|
192
195
|
<>
|
|
193
|
-
{pages
|
|
196
|
+
{data.pages
|
|
194
197
|
.flatMap((page) => page.items)
|
|
195
198
|
.map((comment) => (
|
|
196
199
|
<Comment key={comment.id} {...comment} />
|
|
@@ -222,16 +225,16 @@ export const Route = createFileRoute("/posts")({
|
|
|
222
225
|
sort: search.sort === "oldest" ? "oldest" : "newest",
|
|
223
226
|
page: Number(search.page ?? 1),
|
|
224
227
|
}),
|
|
225
|
-
loaderDeps: (
|
|
226
|
-
loader: (
|
|
227
|
-
posts:
|
|
228
|
+
loaderDeps: (args) => args.search,
|
|
229
|
+
loader: (args) => ({
|
|
230
|
+
posts: args.context.queryClient.prefetch(postsQuery, args.deps),
|
|
228
231
|
}),
|
|
229
232
|
component: PostsPage,
|
|
230
233
|
});
|
|
231
234
|
|
|
232
235
|
function PostsPage() {
|
|
233
|
-
const
|
|
234
|
-
const [postsPromise, postsState] = useQuery(postsQuery, posts, {
|
|
236
|
+
const loaderData = Route.useLoaderData();
|
|
237
|
+
const [postsPromise, postsState] = useQuery(postsQuery, loaderData.posts, {
|
|
235
238
|
whilePending: "previous",
|
|
236
239
|
});
|
|
237
240
|
|
|
@@ -305,6 +308,13 @@ function PostAuthor(props: { post: Post }) {
|
|
|
305
308
|
|
|
306
309
|
The name is the warning: each waterfall is an extra round trip after render. First try to get the input from route params so the loader can start it, or have the backend return it with its parent.
|
|
307
310
|
|
|
311
|
+
During SSR the `queryFn` runs on the server. TanStack Start server functions work there. Next.js rejects server actions called while rendering, so in Next.js start the dependent query in the server component instead:
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
const post = await qc.fetch(postQuery, { slug });
|
|
315
|
+
const author = qc.prefetch(authorQuery, { id: post.authorId });
|
|
316
|
+
```
|
|
317
|
+
|
|
308
318
|
For event-driven preloading, call the client directly: `onPointerEnter={() => qc.prefetch(postQuery, { slug })}`.
|
|
309
319
|
|
|
310
320
|
## Mutations
|
|
@@ -331,7 +341,7 @@ function EditTitle(props: { post: Post }) {
|
|
|
331
341
|
}
|
|
332
342
|
```
|
|
333
343
|
|
|
334
|
-
`await qc.invalidate(...)` resolves once every query currently being read has refetched, so the action's pending state lasts until the fresh data is on screen.
|
|
344
|
+
`updatePost` is a server function (or a Next.js server action). `await qc.invalidate(...)` resolves once every query currently being read has refetched, so the action's pending state lasts until the fresh data is on screen. For an error message, wrap the action in `useActionState` and return the caught error.
|
|
335
345
|
|
|
336
346
|
| Call | Effect |
|
|
337
347
|
| ------------------------------------- | ----------------------------------------------------------------------- |
|
|
@@ -367,7 +377,7 @@ export function getRouter() {
|
|
|
367
377
|
}
|
|
368
378
|
```
|
|
369
379
|
|
|
370
|
-
`setupRouterIntegration` streams queries during SSR, clears the request's cache when the response finishes, and turns `redirect()` thrown from a `queryFn` into a router navigation. `getRouter` runs per request on the server, so each request gets its own client.
|
|
380
|
+
`setupRouterIntegration` streams queries during SSR, clears the request's cache when the response finishes, and turns `redirect()` thrown from a `queryFn` into a router navigation. `getRouter` runs per request on the server, so each request gets its own client. Options: `dehydrateOptions` (`filter`, `keepErrors`, `serializeData`), `hydrateOptions` (`deserializeData`), and `handleRedirects: false` to handle redirects yourself. By default a server error crosses to the browser as a generic error, so its message doesn't leak into the page.
|
|
371
381
|
|
|
372
382
|
### Next.js App Router
|
|
373
383
|
|
|
@@ -401,11 +411,11 @@ import { HydrationBoundary } from "@raf-query/react/rsc";
|
|
|
401
411
|
export default async function Page(props: {
|
|
402
412
|
params: Promise<{ slug: string }>;
|
|
403
413
|
}) {
|
|
404
|
-
const
|
|
414
|
+
const params = await props.params;
|
|
405
415
|
const qc = getQueryClient();
|
|
406
416
|
|
|
407
|
-
const comments = qc.prefetch(commentsQuery, { slug });
|
|
408
|
-
const post = await qc.fetch(postQuery, { slug });
|
|
417
|
+
const comments = qc.prefetch(commentsQuery, { slug: params.slug });
|
|
418
|
+
const post = await qc.fetch(postQuery, { slug: params.slug });
|
|
409
419
|
|
|
410
420
|
return (
|
|
411
421
|
<HydrationBoundary client={qc}>
|
|
@@ -422,25 +432,85 @@ export default async function Page(props: {
|
|
|
422
432
|
|
|
423
433
|
- `getQueryClient` is scoped to one request by React's `cache()`.
|
|
424
434
|
- `HydrationBoundary` sends the request client's entries to the browser cache; pending ones stream as promises. Render one in each server component that starts queries for client components. Entries already sent in this request are skipped.
|
|
425
|
-
- `QueryProvider` creates the browser client, and streams queries that client components start during SSR (`useWaterfall`) into the HTML.
|
|
435
|
+
- `QueryProvider` creates the browser client, and streams queries that client components start during SSR (`useWaterfall`) into the HTML. Pass `config` for the client's options; it must be serializable.
|
|
426
436
|
- Server components that only render data can `await qc.fetch(...)` anywhere; the per-request client dedupes them.
|
|
437
|
+
- Factories can call server actions: server components call them directly, and the browser calls them on refetch. Validate their arguments, since anyone can call them.
|
|
427
438
|
|
|
428
439
|
#### Server-only queries
|
|
429
440
|
|
|
430
441
|
A factory that imports server-only code (a database client) can't be imported by a client component, so it can't be read with `useQuery`. Render its data in a server component, or pass a plain promise prop and `use()` it on the client. Refresh it with `router.refresh()`.
|
|
431
442
|
|
|
443
|
+
## Effect
|
|
444
|
+
|
|
445
|
+
raf-query runs requests on [Effect](https://effect.website), and Effect code can use it without wrapping:
|
|
446
|
+
|
|
447
|
+
- A `queryFn` can return an `Effect`. Cancelling the request interrupts it.
|
|
448
|
+
- `retry` accepts a `Schedule` as well as a count or predicate.
|
|
449
|
+
- `qc.effect` has Effect versions of `ensure`, `fetch` and `invalidate`, plus `watch`, a `Stream` of the entry's state.
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
import { Effect, Schedule, Stream } from "effect";
|
|
453
|
+
|
|
454
|
+
export function issueQuery(input: { id: number }) {
|
|
455
|
+
return queryOptions({
|
|
456
|
+
queryKey: ["issue", input.id],
|
|
457
|
+
queryFn: () => IssueRepo.get(input.id), // Effect<Issue, IssueNotFound>
|
|
458
|
+
retry: Schedule.exponential("200 millis").pipe(Schedule.upTo({ times: 3 })),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const program = Effect.gen(function* () {
|
|
463
|
+
const issue = yield* qc.effect.fetch(issueQuery, { id: 1 });
|
|
464
|
+
yield* qc.effect.watch(issueQuery, { id: 1 }).pipe(
|
|
465
|
+
Stream.map((state) => state.data?.title),
|
|
466
|
+
Stream.runForEach((title) => Effect.log(title)),
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
`watch` counts as a reader while it runs, so invalidation and window focus refetch the entry.
|
|
472
|
+
|
|
432
473
|
## API reference
|
|
433
474
|
|
|
475
|
+
`QueryClient`:
|
|
476
|
+
|
|
477
|
+
| Member | Use |
|
|
478
|
+
| --------------------------------------- | ------------------------------------------------------------------------------- |
|
|
479
|
+
| `new QueryClient(config?)` | `config.defaults` (query options below), `config.onError`, `config.isServer` |
|
|
480
|
+
| `prefetch`, `ensure`, `fetch` | [Start queries](#starting-queries) |
|
|
481
|
+
| `invalidate`, `setData`, `getData` | [Write and read the cache](#mutations) |
|
|
482
|
+
| `effect` | [Effect versions](#effect) of `ensure`, `fetch`, `invalidate`, plus `watch` |
|
|
483
|
+
| `subscribe(listener)` | Cache events: `added`, `updated`, `removed`, and `failed` (with the `error`) |
|
|
484
|
+
| `getAll()`, `getQuery(factory, input?)` | Cache entries, for devtools and integrations |
|
|
485
|
+
| `clear()` | Drop every entry and abort in-flight requests |
|
|
486
|
+
| `mount()` | Start window-focus and reconnect refetching. The providers call it |
|
|
487
|
+
| `isServer` | Detected from `window`. Server clients don't retry, never gc, skip stale timers |
|
|
488
|
+
|
|
489
|
+
Query options, accepted by `queryOptions`, `infiniteQueryOptions` and `config.defaults`:
|
|
490
|
+
|
|
491
|
+
| Option | Default |
|
|
492
|
+
| ----------------------------------------------------- | ----------------------------------------- |
|
|
493
|
+
| `staleTime` | `0` ms |
|
|
494
|
+
| `gcTime` | 5 minutes; `Infinity` on the server |
|
|
495
|
+
| `retry` (count, `boolean`, predicate, `Schedule`) | `3`; `0` on the server |
|
|
496
|
+
| `retryDelay` (ms, or a function of the failure count) | Doubling from 1 s, at most 30 s |
|
|
497
|
+
| `refetchOnWindowFocus`, `refetchOnReconnect` | `true` (only read, stale queries refetch) |
|
|
498
|
+
|
|
499
|
+
`infiniteQueryOptions` also takes `getPreviousPageParam` and `maxPages`.
|
|
500
|
+
|
|
434
501
|
`@raf-query/core`:
|
|
435
502
|
|
|
436
|
-
| Export | Use
|
|
437
|
-
| ---------------------------------------------- |
|
|
438
|
-
| `QueryClient` | The cache
|
|
439
|
-
| `queryOptions(options)` | Typed options for a query factory
|
|
440
|
-
| `infiniteQueryOptions(options)` | Typed options for a paged query factory
|
|
441
|
-
| `allKeyed(record)` | Await a record of promises, keeping keys
|
|
442
|
-
| `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations
|
|
443
|
-
| `
|
|
503
|
+
| Export | Use |
|
|
504
|
+
| ---------------------------------------------- | ---------------------------------------------------------- |
|
|
505
|
+
| `QueryClient` | The cache |
|
|
506
|
+
| `queryOptions(options)` | Typed options for a query factory |
|
|
507
|
+
| `infiniteQueryOptions(options)` | Typed options for a paged query factory |
|
|
508
|
+
| `allKeyed(record)` | Await a record of promises, keeping keys |
|
|
509
|
+
| `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
|
|
510
|
+
| `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
|
|
511
|
+
| `CancelledError` | Rejection of a request cancelled by `clear()` or a restart |
|
|
512
|
+
| `QueryHandle<TFactory>` | Handle type for props |
|
|
513
|
+
| `DataOf<TFactory>`, `InputOf<TFactory>` | A factory's data and input types |
|
|
444
514
|
|
|
445
515
|
`@raf-query/react` (plus all of the above):
|
|
446
516
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/all-keyed.d.ts
|
|
2
|
+
/** The resolved form of a record passed to {@link allKeyed}. */
|
|
3
|
+
export type Settled<TRecord> = { -readonly [TKey in keyof TRecord]: Awaited<TRecord[TKey]>; };
|
|
4
|
+
/**
|
|
5
|
+
* Ponyfill of the TC39 `Promise.allKeyed` proposal: a record of promises in,
|
|
6
|
+
* a promise of the same record out, rejecting on the first failure.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const data = await allKeyed({
|
|
10
|
+
* post: qc.ensure(postQuery, { slug }),
|
|
11
|
+
* author: qc.ensure(authorQuery, { slug }),
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export declare function allKeyed<TRecord extends {
|
|
16
|
+
readonly [key: string]: unknown;
|
|
17
|
+
}>(record: TRecord): Promise<Settled<TRecord>>;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/all-keyed.ts
|
|
2
|
+
/**
|
|
3
|
+
* Ponyfill of the TC39 `Promise.allKeyed` proposal: a record of promises in,
|
|
4
|
+
* a promise of the same record out, rejecting on the first failure.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const data = await allKeyed({
|
|
8
|
+
* post: qc.ensure(postQuery, { slug }),
|
|
9
|
+
* author: qc.ensure(authorQuery, { slug }),
|
|
10
|
+
* });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
async function allKeyed(record) {
|
|
14
|
+
const keys = Object.keys(record);
|
|
15
|
+
const values = await Promise.all(keys.map((key) => record[key]));
|
|
16
|
+
return Object.fromEntries(keys.map((key, index) => [key, values[index]]));
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { allKeyed };
|
package/dist/cast.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/cast.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one place a value's static type is replaced. Every call site states
|
|
4
|
+
* the invariant that makes it sound in a `SAFETY:` comment.
|
|
5
|
+
*/
|
|
6
|
+
function unsafeCast(value) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
export { unsafeCast };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { QueryKey } from "./key.mjs";
|
|
2
|
+
import { DataOf, InputArgs, InputOf, LoadOptions, QueryBehavior, QueryFactory, QueryHandle } from "./options.mjs";
|
|
3
|
+
import { Query, QueryState } from "./query.mjs";
|
|
4
|
+
import { Effect, Stream } from "effect";
|
|
5
|
+
//#region src/client.d.ts
|
|
6
|
+
export interface QueryClientConfig {
|
|
7
|
+
/** Defaults for every query; a factory's own options win. */
|
|
8
|
+
readonly defaults?: QueryBehavior;
|
|
9
|
+
/**
|
|
10
|
+
* Whether this client runs on the server. Detected from `window`. Server
|
|
11
|
+
* clients don't retry, never garbage-collect, and skip stale timers.
|
|
12
|
+
*/
|
|
13
|
+
readonly isServer?: boolean;
|
|
14
|
+
/** Called with every failed request, including failed prefetches. */
|
|
15
|
+
onError?(error: unknown, query: Query): void;
|
|
16
|
+
}
|
|
17
|
+
/** Emitted by {@link QueryClient.subscribe} for every cache change. */
|
|
18
|
+
export type CacheEvent = {
|
|
19
|
+
readonly query: Query;
|
|
20
|
+
readonly type: "added" | "removed" | "updated";
|
|
21
|
+
} | {
|
|
22
|
+
/** A request failed (after retries), with this error. */
|
|
23
|
+
readonly error: unknown;
|
|
24
|
+
readonly query: Query;
|
|
25
|
+
readonly type: "failed";
|
|
26
|
+
};
|
|
27
|
+
/** Matches cache entries by key prefix, or by exact key with `exact`. */
|
|
28
|
+
export interface QueryFilter {
|
|
29
|
+
readonly exact?: boolean;
|
|
30
|
+
readonly queryKey: QueryKey;
|
|
31
|
+
}
|
|
32
|
+
/** `ensure` / `fetch` arguments: the input, then load options for paged factories. */
|
|
33
|
+
export type LoadArgs<TFactory extends QueryFactory> = InputArgs<TFactory> | [input: InputOf<TFactory>, options: LoadOptions<TFactory>];
|
|
34
|
+
/** A new value, or a function of the current one. */
|
|
35
|
+
export type Updater<TData> = TData | ((previous: TData | undefined) => TData);
|
|
36
|
+
/** Effect-returning twins of the client's async methods. */
|
|
37
|
+
export interface QueryClientEffect {
|
|
38
|
+
ensure<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<QueryHandle<TFactory>, unknown>;
|
|
39
|
+
fetch<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<DataOf<TFactory>, unknown>;
|
|
40
|
+
invalidate<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Effect.Effect<void>;
|
|
41
|
+
invalidate(filter: QueryFilter): Effect.Effect<void>;
|
|
42
|
+
/**
|
|
43
|
+
* The entry's current state, then every change. Counts as a reader while
|
|
44
|
+
* the stream runs, so invalidation and focus refetch it.
|
|
45
|
+
*/
|
|
46
|
+
watch<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Stream.Stream<QueryState<DataOf<TFactory>>>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The query cache. Create one per request on the server and one per browser
|
|
50
|
+
* tab.
|
|
51
|
+
*/
|
|
52
|
+
export declare class QueryClient {
|
|
53
|
+
#private;
|
|
54
|
+
/** Effect-returning versions of `ensure`, `fetch`, `invalidate`, plus `watch`. */
|
|
55
|
+
readonly effect: QueryClientEffect;
|
|
56
|
+
readonly isServer: boolean;
|
|
57
|
+
constructor(config?: QueryClientConfig);
|
|
58
|
+
/**
|
|
59
|
+
* The cache entry for a factory call, created if missing. Doesn't fetch.
|
|
60
|
+
* For bindings; apps use the verbs.
|
|
61
|
+
*/
|
|
62
|
+
getQuery<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Query<DataOf<TFactory>, TFactory>;
|
|
63
|
+
/**
|
|
64
|
+
* Start a query without waiting and return its handle. Fresh data is
|
|
65
|
+
* reused; stale data refetches in the background. Errors surface where the
|
|
66
|
+
* data is read, never here.
|
|
67
|
+
*/
|
|
68
|
+
prefetch<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): QueryHandle<TFactory>;
|
|
69
|
+
/**
|
|
70
|
+
* Start a query and wait for it on a cache miss. Rejects if that request
|
|
71
|
+
* fails. Stale cached data resolves immediately and revalidates.
|
|
72
|
+
*/
|
|
73
|
+
ensure<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Promise<QueryHandle<TFactory>>;
|
|
74
|
+
/** Like `ensure`, but resolves to the data. */
|
|
75
|
+
fetch<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Promise<DataOf<TFactory>>;
|
|
76
|
+
/**
|
|
77
|
+
* Mark queries stale and refetch the ones being read. Resolves once those
|
|
78
|
+
* refetches settle, so an action stays pending until fresh data is on
|
|
79
|
+
* screen.
|
|
80
|
+
*/
|
|
81
|
+
invalidate<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): Promise<void>;
|
|
82
|
+
invalidate(filter: QueryFilter): Promise<void>;
|
|
83
|
+
/** Write data for a factory call. Readers re-render without suspending. */
|
|
84
|
+
setData<TFactory extends QueryFactory>(factory: TFactory, input: InputOf<TFactory>, updater: Updater<DataOf<TFactory>>): void;
|
|
85
|
+
/** Cached data for a factory call, or `undefined`. */
|
|
86
|
+
getData<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): DataOf<TFactory> | undefined;
|
|
87
|
+
/** Every entry, for devtools and integrations. */
|
|
88
|
+
getAll(): ReadonlyArray<Query>;
|
|
89
|
+
/** Listen to entries being added, updated, and removed. */
|
|
90
|
+
subscribe(listener: (event: CacheEvent) => void): () => void;
|
|
91
|
+
/** Drop every entry and abort in-flight requests. */
|
|
92
|
+
clear(): void;
|
|
93
|
+
/**
|
|
94
|
+
* Start refetching read, stale queries on window focus and reconnect.
|
|
95
|
+
* Returns the matching unmount. Providers call this; mounts are counted.
|
|
96
|
+
*/
|
|
97
|
+
mount(): () => void;
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { unsafeCast } from "./cast.mjs";
|
|
2
|
+
import { hashKey, matchesKey } from "./key.mjs";
|
|
3
|
+
import { optionsFor } from "./options.mjs";
|
|
4
|
+
import { QueryEntry } from "./query.mjs";
|
|
5
|
+
import { Effect, Predicate, Queue, Stream } from "effect";
|
|
6
|
+
//#region src/client.ts
|
|
7
|
+
const caches = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
function cacheOf(client) {
|
|
9
|
+
const cache = caches.get(client);
|
|
10
|
+
if (!cache) throw new Error("Not a QueryClient");
|
|
11
|
+
return cache;
|
|
12
|
+
}
|
|
13
|
+
const noop = () => {};
|
|
14
|
+
const identity = (value) => value;
|
|
15
|
+
function isUpdateFunction(updater) {
|
|
16
|
+
return Predicate.isFunction(updater);
|
|
17
|
+
}
|
|
18
|
+
function typed(entry) {
|
|
19
|
+
return unsafeCast(entry);
|
|
20
|
+
}
|
|
21
|
+
function handleOf(entry) {
|
|
22
|
+
return entry.handle;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The query cache. Create one per request on the server and one per browser
|
|
26
|
+
* tab.
|
|
27
|
+
*/
|
|
28
|
+
var QueryClient = class {
|
|
29
|
+
/** Effect-returning versions of `ensure`, `fetch`, `invalidate`, plus `watch`. */
|
|
30
|
+
effect;
|
|
31
|
+
isServer;
|
|
32
|
+
#cache = /* @__PURE__ */ new Map();
|
|
33
|
+
#host;
|
|
34
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
35
|
+
#mounts = 0;
|
|
36
|
+
#unmountWindow = noop;
|
|
37
|
+
constructor(config = {}) {
|
|
38
|
+
this.isServer = config.isServer ?? !("window" in globalThis);
|
|
39
|
+
const emit = (event) => {
|
|
40
|
+
for (const listener of this.#listeners) listener(event);
|
|
41
|
+
};
|
|
42
|
+
this.#host = {
|
|
43
|
+
changed: (query) => emit({
|
|
44
|
+
query,
|
|
45
|
+
type: "updated"
|
|
46
|
+
}),
|
|
47
|
+
defaults: config.defaults ?? {},
|
|
48
|
+
isServer: this.isServer,
|
|
49
|
+
remove: (query) => {
|
|
50
|
+
if (this.#cache.get(query.hash) === query) {
|
|
51
|
+
this.#cache.delete(query.hash);
|
|
52
|
+
query.destroy();
|
|
53
|
+
emit({
|
|
54
|
+
query,
|
|
55
|
+
type: "removed"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
reportError: (error, query) => {
|
|
60
|
+
config.onError?.(error, query);
|
|
61
|
+
emit({
|
|
62
|
+
error,
|
|
63
|
+
query,
|
|
64
|
+
type: "failed"
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
caches.set(this, {
|
|
69
|
+
all: () => [...this.#cache.values()],
|
|
70
|
+
entryFor: (key) => this.#entryFor(key, hashKey(key))
|
|
71
|
+
});
|
|
72
|
+
this.effect = this.#effectApi();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The cache entry for a factory call, created if missing. Doesn't fetch.
|
|
76
|
+
* For bindings; apps use the verbs.
|
|
77
|
+
*/
|
|
78
|
+
getQuery(factory, ...args) {
|
|
79
|
+
return typed(this.#build(factory, args[0]));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Start a query without waiting and return its handle. Fresh data is
|
|
83
|
+
* reused; stale data refetches in the background. Errors surface where the
|
|
84
|
+
* data is read, never here.
|
|
85
|
+
*/
|
|
86
|
+
prefetch(factory, ...args) {
|
|
87
|
+
const entry = this.#build(factory, args[0]);
|
|
88
|
+
const state = entry.getState();
|
|
89
|
+
if (!state.isFetching && (!state.hasData || entry.isStaleNow())) entry.fetch().catch(noop);
|
|
90
|
+
return handleOf(entry);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Start a query and wait for it on a cache miss. Rejects if that request
|
|
94
|
+
* fails. Stale cached data resolves immediately and revalidates.
|
|
95
|
+
*/
|
|
96
|
+
async ensure(factory, ...args) {
|
|
97
|
+
const entry = this.#build(factory, args[0]);
|
|
98
|
+
await this.#load(entry, args[1]?.pages);
|
|
99
|
+
return handleOf(entry);
|
|
100
|
+
}
|
|
101
|
+
/** Like `ensure`, but resolves to the data. */
|
|
102
|
+
async fetch(factory, ...args) {
|
|
103
|
+
const entry = this.#build(factory, args[0]);
|
|
104
|
+
await this.#load(entry, args[1]?.pages);
|
|
105
|
+
const data = typed(entry).getState().data;
|
|
106
|
+
if (data === void 0) throw new Error(`Query ${entry.hash} has no data`);
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
async invalidate(target, input) {
|
|
110
|
+
await Promise.all(this.#match(target, input).map((entry) => entry.invalidate()));
|
|
111
|
+
}
|
|
112
|
+
/** Write data for a factory call. Readers re-render without suspending. */
|
|
113
|
+
setData(factory, input, updater) {
|
|
114
|
+
const entry = this.#build(factory, input);
|
|
115
|
+
const next = isUpdateFunction(updater) ? updater(typed(entry).getState().data) : updater;
|
|
116
|
+
entry.setData(next);
|
|
117
|
+
}
|
|
118
|
+
/** Cached data for a factory call, or `undefined`. */
|
|
119
|
+
getData(factory, ...args) {
|
|
120
|
+
const options = optionsFor(factory, args[0]);
|
|
121
|
+
const entry = this.#cache.get(hashKey(options.queryKey));
|
|
122
|
+
return entry ? typed(entry).getState().data : void 0;
|
|
123
|
+
}
|
|
124
|
+
/** Every entry, for devtools and integrations. */
|
|
125
|
+
getAll() {
|
|
126
|
+
return [...this.#cache.values()];
|
|
127
|
+
}
|
|
128
|
+
/** Listen to entries being added, updated, and removed. */
|
|
129
|
+
subscribe(listener) {
|
|
130
|
+
this.#listeners.add(listener);
|
|
131
|
+
return () => {
|
|
132
|
+
this.#listeners.delete(listener);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Drop every entry and abort in-flight requests. */
|
|
136
|
+
clear() {
|
|
137
|
+
const entries = [...this.#cache.values()];
|
|
138
|
+
this.#cache.clear();
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
entry.destroy();
|
|
141
|
+
for (const listener of this.#listeners) listener({
|
|
142
|
+
query: entry,
|
|
143
|
+
type: "removed"
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Start refetching read, stale queries on window focus and reconnect.
|
|
149
|
+
* Returns the matching unmount. Providers call this; mounts are counted.
|
|
150
|
+
*/
|
|
151
|
+
mount() {
|
|
152
|
+
this.#mounts++;
|
|
153
|
+
if (this.#mounts === 1 && !this.isServer) this.#unmountWindow = this.#listenToWindow();
|
|
154
|
+
let mounted = true;
|
|
155
|
+
return () => {
|
|
156
|
+
if (!mounted) return;
|
|
157
|
+
mounted = false;
|
|
158
|
+
this.#mounts--;
|
|
159
|
+
if (this.#mounts === 0) {
|
|
160
|
+
this.#unmountWindow();
|
|
161
|
+
this.#unmountWindow = noop;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
#entryFor(key, hash) {
|
|
166
|
+
const existing = this.#cache.get(hash);
|
|
167
|
+
if (existing) return existing;
|
|
168
|
+
const entry = new QueryEntry(this.#host, key, hash);
|
|
169
|
+
this.#cache.set(hash, entry);
|
|
170
|
+
for (const listener of this.#listeners) listener({
|
|
171
|
+
query: entry,
|
|
172
|
+
type: "added"
|
|
173
|
+
});
|
|
174
|
+
return entry;
|
|
175
|
+
}
|
|
176
|
+
#build(factory, input) {
|
|
177
|
+
const options = optionsFor(factory, input);
|
|
178
|
+
const entry = this.#entryFor(options.queryKey, hashKey(options.queryKey));
|
|
179
|
+
entry.setOptions(options, input);
|
|
180
|
+
return entry;
|
|
181
|
+
}
|
|
182
|
+
#load(entry, pages) {
|
|
183
|
+
const state = entry.getState();
|
|
184
|
+
if (!state.hasData) return entry.fetch({ pages });
|
|
185
|
+
if (!state.isFetching && entry.isStaleNow()) entry.fetch().catch(noop);
|
|
186
|
+
return Promise.resolve(state.data);
|
|
187
|
+
}
|
|
188
|
+
#match(target, input) {
|
|
189
|
+
if (Predicate.isFunction(target)) {
|
|
190
|
+
const entry = this.#cache.get(hashKey(optionsFor(target, input).queryKey));
|
|
191
|
+
return entry ? [entry] : [];
|
|
192
|
+
}
|
|
193
|
+
const hash = hashKey(target.queryKey);
|
|
194
|
+
return [...this.#cache.values()].filter((entry) => target.exact ? entry.hash === hash : matchesKey(entry.key, target.queryKey));
|
|
195
|
+
}
|
|
196
|
+
#listenToWindow() {
|
|
197
|
+
const each = (event) => {
|
|
198
|
+
for (const entry of this.#cache.values()) if (entry.isObserved) entry.onWindowEvent(event);
|
|
199
|
+
};
|
|
200
|
+
const onVisibilityChange = () => {
|
|
201
|
+
if (document.visibilityState === "visible") each("focus");
|
|
202
|
+
};
|
|
203
|
+
const onOnline = () => each("reconnect");
|
|
204
|
+
window.addEventListener("visibilitychange", onVisibilityChange);
|
|
205
|
+
window.addEventListener("online", onOnline);
|
|
206
|
+
return () => {
|
|
207
|
+
window.removeEventListener("visibilitychange", onVisibilityChange);
|
|
208
|
+
window.removeEventListener("online", onOnline);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
#effectApi() {
|
|
212
|
+
const invalidate = (target, input) => Effect.promise(() => Promise.all(this.#match(target, input).map((entry) => entry.invalidate()))).pipe(Effect.asVoid);
|
|
213
|
+
return {
|
|
214
|
+
ensure: (factory, ...args) => Effect.tryPromise({
|
|
215
|
+
catch: identity,
|
|
216
|
+
try: () => this.ensure(factory, ...args)
|
|
217
|
+
}),
|
|
218
|
+
fetch: (factory, ...args) => Effect.tryPromise({
|
|
219
|
+
catch: identity,
|
|
220
|
+
try: () => this.fetch(factory, ...args)
|
|
221
|
+
}),
|
|
222
|
+
invalidate,
|
|
223
|
+
watch: (factory, ...args) => Stream.callback((queue) => {
|
|
224
|
+
const query = this.getQuery(factory, ...args);
|
|
225
|
+
return Effect.acquireRelease(Effect.sync(() => {
|
|
226
|
+
Queue.offerUnsafe(queue, query.getState());
|
|
227
|
+
return query.subscribe(() => Queue.offerUnsafe(queue, query.getState()));
|
|
228
|
+
}), (unsubscribe) => Effect.sync(unsubscribe));
|
|
229
|
+
})
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
export { QueryClient, cacheOf };
|