@raf-query/react 0.0.2 → 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 +11 -1
- package/README.md +109 -39
- package/dist/cast.mjs +10 -0
- package/dist/hydrate.mjs +33 -0
- package/dist/index.d.mts +5 -1
- package/dist/index.mjs +4 -1
- package/dist/next.d.mts +24 -0
- package/dist/next.mjs +60 -0
- package/dist/provider.d.mts +14 -0
- package/dist/provider.mjs +25 -0
- package/dist/rsc.d.mts +25 -0
- package/dist/rsc.mjs +41 -0
- package/dist/tanstack-router.d.mts +24 -0
- package/dist/tanstack-router.mjs +130 -0
- package/dist/use-query.d.mts +64 -0
- package/dist/use-query.mjs +82 -0
- package/dist/use-waterfall.d.mts +14 -0
- package/dist/use-waterfall.mjs +20 -0
- package/package.json +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
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
|
+
|
|
9
|
+
## [0.0.3](https://github.com/KATT/raf-query/compare/0.0.2...0.0.3) (2026-10-12)
|
|
10
|
+
|
|
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))
|
|
12
|
+
|
|
3
13
|
## [0.0.2](https://github.com/KATT/raf-query/compare/0.0.1...0.0.2) (2026-10-12)
|
|
4
14
|
|
|
5
15
|
### Bug Fixes
|
|
6
16
|
|
|
7
|
-
|
|
17
|
+
- resolve workspace packages from source ([f03a57f](https://github.com/KATT/raf-query/commit/f03a57f0d54dbd66d947a0ee97dbb762bddca970))
|
|
8
18
|
|
|
9
19
|
## 0.0.1 (2026-10-12)
|
|
10
20
|
|
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
|
|
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 };
|
package/dist/hydrate.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useQueryClient } from "./provider.mjs";
|
|
3
|
+
import { hashKey, hydrate } from "@raf-query/core";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
//#region src/hydrate.tsx
|
|
6
|
+
const hydratedByClient = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
/** Hashes of entries a client received through {@link Hydrate}. */
|
|
8
|
+
function hydratedHashes(client) {
|
|
9
|
+
return hydratedByClient.get(client) ?? /* @__PURE__ */ new Set();
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Writes dehydrated entries into the provided client before its children
|
|
13
|
+
* render, so their reads find the data (or the server's pending request).
|
|
14
|
+
* Rendered by `HydrationBoundary`; apps don't use it directly.
|
|
15
|
+
*/
|
|
16
|
+
function Hydrate(props) {
|
|
17
|
+
const client = useQueryClient();
|
|
18
|
+
const state = props.state;
|
|
19
|
+
const [hydrated, setHydrated] = useState();
|
|
20
|
+
if (hydrated !== state) {
|
|
21
|
+
let hashes = hydratedByClient.get(client);
|
|
22
|
+
if (!hashes) {
|
|
23
|
+
hashes = /* @__PURE__ */ new Set();
|
|
24
|
+
hydratedByClient.set(client, hashes);
|
|
25
|
+
}
|
|
26
|
+
for (const query of state.queries) hashes.add(hashKey(query.key));
|
|
27
|
+
hydrate(client, state, props.options);
|
|
28
|
+
setHydrated(state);
|
|
29
|
+
}
|
|
30
|
+
return props.children;
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { Hydrate, hydratedHashes };
|
package/dist/index.d.mts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { QueryClientProvider, useQueryClient } from "./provider.mjs";
|
|
2
|
+
import { PagingControls, QueryControls, UseQueryControls, UseQueryOptions, UseQueryResult, useQuery } from "./use-query.mjs";
|
|
3
|
+
import { useWaterfall } from "./use-waterfall.mjs";
|
|
4
|
+
export * from "@raf-query/core";
|
|
5
|
+
export { type PagingControls, QueryClientProvider, type QueryControls, type UseQueryControls, type UseQueryOptions, type UseQueryResult, useQuery, useQueryClient, useWaterfall };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
+
import { QueryClientProvider, useQueryClient } from "./provider.mjs";
|
|
2
|
+
import { useQuery } from "./use-query.mjs";
|
|
3
|
+
import { useWaterfall } from "./use-waterfall.mjs";
|
|
1
4
|
export * from "@raf-query/core";
|
|
2
|
-
export {};
|
|
5
|
+
export { QueryClientProvider, useQuery, useQueryClient, useWaterfall };
|
package/dist/next.d.mts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DehydratedState, QueryClientConfig } from "@raf-query/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/next.d.ts
|
|
4
|
+
declare global {
|
|
5
|
+
/** Entries streamed by `QueryProvider` during SSR, until the browser client takes over. */
|
|
6
|
+
var RAF_QUERY_SSR: {
|
|
7
|
+
push(state: DehydratedState): void;
|
|
8
|
+
} | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The query client provider for Next.js App Router. Creates one client per
|
|
12
|
+
* request during SSR and one per tab in the browser.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* // app/layout.tsx
|
|
16
|
+
* <QueryProvider>{props.children}</QueryProvider>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function QueryProvider(props: {
|
|
20
|
+
readonly children?: ReactNode;
|
|
21
|
+
/** Options for the client it creates. */
|
|
22
|
+
readonly config?: QueryClientConfig;
|
|
23
|
+
}): import("react").JSX.Element;
|
|
24
|
+
//#endregion
|
package/dist/next.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { QueryClientProvider } from "./provider.mjs";
|
|
3
|
+
import { hydratedHashes } from "./hydrate.mjs";
|
|
4
|
+
import { QueryClient, dehydrateQuery, hydrate } from "@raf-query/core";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
import { useServerInsertedHTML } from "next/navigation";
|
|
8
|
+
//#region src/next.tsx
|
|
9
|
+
function escapeJson(json) {
|
|
10
|
+
return json.replaceAll("<", String.raw`\u003c`).replaceAll("\u2028", String.raw`\u2028`).replaceAll("\u2029", String.raw`\u2029`);
|
|
11
|
+
}
|
|
12
|
+
/** Hydrate what SSR streamed so far, then hydrate every later chunk on arrival. */
|
|
13
|
+
function receiveStream(client) {
|
|
14
|
+
const queued = globalThis.RAF_QUERY_SSR;
|
|
15
|
+
if (Array.isArray(queued)) for (const state of queued) hydrate(client, state);
|
|
16
|
+
globalThis.RAF_QUERY_SSR = { push: (state) => hydrate(client, state) };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Streams entries that client components loaded during SSR (`useWaterfall`)
|
|
20
|
+
* into the HTML, ahead of the markup that reads them. Entries that arrived
|
|
21
|
+
* through `HydrationBoundary` are skipped: the RSC payload carries them.
|
|
22
|
+
* Data must be JSON-serializable.
|
|
23
|
+
*/
|
|
24
|
+
function useServerStream(client) {
|
|
25
|
+
const [sent] = useState(() => /* @__PURE__ */ new Set());
|
|
26
|
+
useServerInsertedHTML(() => {
|
|
27
|
+
const hydrated = hydratedHashes(client);
|
|
28
|
+
const queries = client.getAll().filter((query) => query.getState().hasData && !sent.has(query.hash) && !hydrated.has(query.hash)).map((query) => {
|
|
29
|
+
sent.add(query.hash);
|
|
30
|
+
return dehydrateQuery(query);
|
|
31
|
+
});
|
|
32
|
+
if (queries.length === 0) return null;
|
|
33
|
+
const json = escapeJson(JSON.stringify({ queries }));
|
|
34
|
+
return /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `(self.RAF_QUERY_SSR=self.RAF_QUERY_SSR||[]).push(${json})` } });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The query client provider for Next.js App Router. Creates one client per
|
|
39
|
+
* request during SSR and one per tab in the browser.
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* // app/layout.tsx
|
|
43
|
+
* <QueryProvider>{props.children}</QueryProvider>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
function QueryProvider(props) {
|
|
47
|
+
const config = props.config;
|
|
48
|
+
const [client] = useState(() => {
|
|
49
|
+
const created = new QueryClient(config);
|
|
50
|
+
if (!created.isServer) receiveStream(created);
|
|
51
|
+
return created;
|
|
52
|
+
});
|
|
53
|
+
useServerStream(client);
|
|
54
|
+
return /* @__PURE__ */ jsx(QueryClientProvider, {
|
|
55
|
+
client,
|
|
56
|
+
children: props.children
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { QueryProvider };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { QueryClient } from "@raf-query/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/provider.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Provides a client to the tree, and refetches its read, stale queries on
|
|
6
|
+
* window focus and reconnect while mounted.
|
|
7
|
+
*/
|
|
8
|
+
export declare function QueryClientProvider(props: {
|
|
9
|
+
readonly children?: ReactNode;
|
|
10
|
+
readonly client: QueryClient;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
/** The client from the nearest {@link QueryClientProvider}. */
|
|
13
|
+
export declare function useQueryClient(): QueryClient;
|
|
14
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, use, useEffect } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/provider.tsx
|
|
5
|
+
const QueryClientContext = createContext(null);
|
|
6
|
+
/**
|
|
7
|
+
* Provides a client to the tree, and refetches its read, stale queries on
|
|
8
|
+
* window focus and reconnect while mounted.
|
|
9
|
+
*/
|
|
10
|
+
function QueryClientProvider(props) {
|
|
11
|
+
const client = props.client;
|
|
12
|
+
useEffect(() => client.mount(), [client]);
|
|
13
|
+
return /* @__PURE__ */ jsx(QueryClientContext, {
|
|
14
|
+
value: client,
|
|
15
|
+
children: props.children
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** The client from the nearest {@link QueryClientProvider}. */
|
|
19
|
+
function useQueryClient() {
|
|
20
|
+
const client = use(QueryClientContext);
|
|
21
|
+
if (!client) throw new Error("useQueryClient: no QueryClientProvider above this component");
|
|
22
|
+
return client;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { QueryClientProvider, useQueryClient };
|
package/dist/rsc.d.mts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DehydrateOptions, QueryClient } from "@raf-query/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/rsc.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Server component: sends the request client's entries to the browser cache.
|
|
6
|
+
* Pending entries cross as promises and stream in as they settle. Entries an
|
|
7
|
+
* earlier boundary already sent in this request are skipped.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const qc = getQueryClient();
|
|
11
|
+
* const comments = qc.prefetch(commentsQuery, { slug });
|
|
12
|
+
* return (
|
|
13
|
+
* <HydrationBoundary client={qc}>
|
|
14
|
+
* <Comments handle={comments} />
|
|
15
|
+
* </HydrationBoundary>
|
|
16
|
+
* );
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function HydrationBoundary(props: {
|
|
20
|
+
readonly children?: ReactNode;
|
|
21
|
+
/** The request's server client (e.g. from React's `cache()`). */
|
|
22
|
+
readonly client: QueryClient;
|
|
23
|
+
readonly options?: DehydrateOptions;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
//#endregion
|
package/dist/rsc.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Hydrate } from "./hydrate.mjs";
|
|
2
|
+
import { canDehydrate, dehydrate, hashKey } from "@raf-query/core";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/rsc.tsx
|
|
5
|
+
const sentByClient = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
/**
|
|
7
|
+
* Server component: sends the request client's entries to the browser cache.
|
|
8
|
+
* Pending entries cross as promises and stream in as they settle. Entries an
|
|
9
|
+
* earlier boundary already sent in this request are skipped.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const qc = getQueryClient();
|
|
13
|
+
* const comments = qc.prefetch(commentsQuery, { slug });
|
|
14
|
+
* return (
|
|
15
|
+
* <HydrationBoundary client={qc}>
|
|
16
|
+
* <Comments handle={comments} />
|
|
17
|
+
* </HydrationBoundary>
|
|
18
|
+
* );
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
function HydrationBoundary(props) {
|
|
22
|
+
const client = props.client;
|
|
23
|
+
let sent = sentByClient.get(client);
|
|
24
|
+
if (!sent) {
|
|
25
|
+
sent = /* @__PURE__ */ new Set();
|
|
26
|
+
sentByClient.set(client, sent);
|
|
27
|
+
}
|
|
28
|
+
const alreadySent = sent;
|
|
29
|
+
const filter = props.options?.filter ?? canDehydrate;
|
|
30
|
+
const state = dehydrate(client, {
|
|
31
|
+
...props.options,
|
|
32
|
+
filter: (query) => !alreadySent.has(query.hash) && filter(query)
|
|
33
|
+
});
|
|
34
|
+
for (const query of state.queries) alreadySent.add(hashKey(query.key));
|
|
35
|
+
return /* @__PURE__ */ jsx(Hydrate, {
|
|
36
|
+
state,
|
|
37
|
+
children: props.children
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { HydrationBoundary };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DehydrateOptions, HydrateOptions, QueryClient } from "@raf-query/core";
|
|
2
|
+
import { AnyRouter } from "@tanstack/react-router";
|
|
3
|
+
//#region src/tanstack-router.d.ts
|
|
4
|
+
export interface RouterIntegrationOptions<TRouter extends AnyRouter> {
|
|
5
|
+
readonly dehydrateOptions?: DehydrateOptions;
|
|
6
|
+
/** Navigate when a `queryFn` throws `redirect()`. Default `true`. */
|
|
7
|
+
readonly handleRedirects?: boolean;
|
|
8
|
+
readonly hydrateOptions?: HydrateOptions;
|
|
9
|
+
readonly queryClient: QueryClient;
|
|
10
|
+
readonly router: TRouter;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Connects a query client to a TanStack Router (and TanStack Start) router:
|
|
14
|
+
*
|
|
15
|
+
* - During SSR, sends the cache to the browser with the router's dehydrated
|
|
16
|
+
* state, then streams entries as they start or settle. Pending entries
|
|
17
|
+
* cross as promises, so the browser resumes them instead of refetching.
|
|
18
|
+
* - Clears the request's cache when the response finishes.
|
|
19
|
+
* - Turns `redirect()` thrown from a `queryFn` into a navigation.
|
|
20
|
+
*
|
|
21
|
+
* Call it in `getRouter`, which runs once per request on the server.
|
|
22
|
+
*/
|
|
23
|
+
export declare function setupRouterIntegration<TRouter extends AnyRouter>(options: RouterIntegrationOptions<TRouter>): void;
|
|
24
|
+
//#endregion
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { canDehydrate, dehydrateQuery, hydrate } from "@raf-query/core";
|
|
2
|
+
import { isRedirect } from "@tanstack/react-router";
|
|
3
|
+
import { Predicate } from "effect";
|
|
4
|
+
//#region src/tanstack-router.ts
|
|
5
|
+
const noop = () => {};
|
|
6
|
+
function setupServer(options) {
|
|
7
|
+
const router = options.router;
|
|
8
|
+
const queryClient = options.queryClient;
|
|
9
|
+
const dehydrateOptions = options.dehydrateOptions;
|
|
10
|
+
const shouldSend = dehydrateOptions?.filter ?? canDehydrate;
|
|
11
|
+
const originalDehydrate = router.options.dehydrate;
|
|
12
|
+
let finish = noop;
|
|
13
|
+
let cleanedUp = false;
|
|
14
|
+
const teardown = () => {
|
|
15
|
+
cleanedUp = true;
|
|
16
|
+
finish();
|
|
17
|
+
queryClient.clear();
|
|
18
|
+
};
|
|
19
|
+
router.serverSsrLifecycle = {
|
|
20
|
+
...router.serverSsrLifecycle,
|
|
21
|
+
onServerSsrAttach: [...router.serverSsrLifecycle?.onServerSsrAttach ?? [], (serverSsr) => serverSsr.onCleanup(teardown)]
|
|
22
|
+
};
|
|
23
|
+
router.options.dehydrate = async () => {
|
|
24
|
+
const dehydrated = await originalDehydrate?.();
|
|
25
|
+
if (cleanedUp) {
|
|
26
|
+
queryClient.clear();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const sent = /* @__PURE__ */ new Set();
|
|
30
|
+
const initial = [];
|
|
31
|
+
for (const query of queryClient.getAll()) if (shouldSend(query)) {
|
|
32
|
+
initial.push(dehydrateQuery(query, dehydrateOptions));
|
|
33
|
+
sent.add(query.hash);
|
|
34
|
+
}
|
|
35
|
+
let controller;
|
|
36
|
+
const stream = new ReadableStream({ start(streamController) {
|
|
37
|
+
controller = streamController;
|
|
38
|
+
} });
|
|
39
|
+
let batch;
|
|
40
|
+
const flush = () => {
|
|
41
|
+
const queries = batch;
|
|
42
|
+
batch = void 0;
|
|
43
|
+
if (!queries || !controller) return;
|
|
44
|
+
const chunk = [];
|
|
45
|
+
for (const query of queries.values()) if (!sent.has(query.hash) && shouldSend(query)) {
|
|
46
|
+
chunk.push(dehydrateQuery(query, dehydrateOptions));
|
|
47
|
+
sent.add(query.hash);
|
|
48
|
+
}
|
|
49
|
+
if (chunk.length > 0) controller.enqueue(chunk);
|
|
50
|
+
};
|
|
51
|
+
const unsubscribe = queryClient.subscribe((event) => {
|
|
52
|
+
if (event.type === "removed" || sent.has(event.query.hash)) return;
|
|
53
|
+
if (!batch) {
|
|
54
|
+
batch = /* @__PURE__ */ new Map();
|
|
55
|
+
queueMicrotask(flush);
|
|
56
|
+
}
|
|
57
|
+
batch.set(event.query.hash, event.query);
|
|
58
|
+
});
|
|
59
|
+
finish = () => {
|
|
60
|
+
finish = noop;
|
|
61
|
+
flush();
|
|
62
|
+
unsubscribe();
|
|
63
|
+
try {
|
|
64
|
+
controller?.close();
|
|
65
|
+
} catch {}
|
|
66
|
+
};
|
|
67
|
+
router.serverSsr?.onRenderFinished(() => finish());
|
|
68
|
+
return {
|
|
69
|
+
...dehydrated,
|
|
70
|
+
raf: {
|
|
71
|
+
initial,
|
|
72
|
+
stream
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function hasRafQueries(value) {
|
|
78
|
+
return Predicate.hasProperty(value, "raf") && Predicate.isObject(value.raf);
|
|
79
|
+
}
|
|
80
|
+
function setupClient(options) {
|
|
81
|
+
const router = options.router;
|
|
82
|
+
const queryClient = options.queryClient;
|
|
83
|
+
const hydrateOptions = options.hydrateOptions;
|
|
84
|
+
const originalHydrate = router.options.hydrate;
|
|
85
|
+
router.options.hydrate = async (dehydrated) => {
|
|
86
|
+
await originalHydrate?.(dehydrated);
|
|
87
|
+
if (!hasRafQueries(dehydrated)) return;
|
|
88
|
+
hydrate(queryClient, { queries: dehydrated.raf.initial }, hydrateOptions);
|
|
89
|
+
const reader = dehydrated.raf.stream.getReader();
|
|
90
|
+
(async () => {
|
|
91
|
+
try {
|
|
92
|
+
for (;;) {
|
|
93
|
+
const chunk = await reader.read();
|
|
94
|
+
if (chunk.done) return;
|
|
95
|
+
hydrate(queryClient, { queries: chunk.value }, hydrateOptions);
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error("raf-query: reading the query stream failed", error);
|
|
99
|
+
reader.cancel(error).catch(() => {});
|
|
100
|
+
} finally {
|
|
101
|
+
reader.releaseLock();
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Connects a query client to a TanStack Router (and TanStack Start) router:
|
|
108
|
+
*
|
|
109
|
+
* - During SSR, sends the cache to the browser with the router's dehydrated
|
|
110
|
+
* state, then streams entries as they start or settle. Pending entries
|
|
111
|
+
* cross as promises, so the browser resumes them instead of refetching.
|
|
112
|
+
* - Clears the request's cache when the response finishes.
|
|
113
|
+
* - Turns `redirect()` thrown from a `queryFn` into a navigation.
|
|
114
|
+
*
|
|
115
|
+
* Call it in `getRouter`, which runs once per request on the server.
|
|
116
|
+
*/
|
|
117
|
+
function setupRouterIntegration(options) {
|
|
118
|
+
const router = options.router;
|
|
119
|
+
if (router.isServer) setupServer(options);
|
|
120
|
+
else setupClient(options);
|
|
121
|
+
if (options.handleRedirects ?? true) options.queryClient.subscribe((event) => {
|
|
122
|
+
if (event.type === "failed" && isRedirect(event.error)) {
|
|
123
|
+
const redirect = event.error;
|
|
124
|
+
redirect.options._fromLocation = router.stores.location.get();
|
|
125
|
+
router.navigate(router.resolveRedirect(redirect).options);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
export { setupRouterIntegration };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DataOf, InputOf, IsPaged, QueryClient, QueryFactory, QueryHandle, QueryKey } from "@raf-query/core";
|
|
2
|
+
//#region src/use-query.d.ts
|
|
3
|
+
export interface UseQueryOptions {
|
|
4
|
+
/** Refetch on this interval (ms) while mounted and the page is visible. */
|
|
5
|
+
readonly refetchInterval?: number;
|
|
6
|
+
/** Refetch on reconnect while stale. Default: the query's option, else `true`. */
|
|
7
|
+
readonly refetchOnReconnect?: boolean;
|
|
8
|
+
/** Refetch on window focus while stale. Default: the query's option, else `true`. */
|
|
9
|
+
readonly refetchOnWindowFocus?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* What to render while the handle's input has no data yet:
|
|
12
|
+
* - `"suspend"` (default): the promise is pending, so `use()` suspends.
|
|
13
|
+
* - `"previous"`: keep returning the last input's data, with `isPrevious`.
|
|
14
|
+
*/
|
|
15
|
+
readonly whilePending?: "previous" | "suspend";
|
|
16
|
+
}
|
|
17
|
+
/** The second element of {@link useQuery}'s result. */
|
|
18
|
+
export interface QueryControls<TFactory extends QueryFactory> {
|
|
19
|
+
/** Error of the last failed request. Existing data stays readable. */
|
|
20
|
+
readonly error: unknown;
|
|
21
|
+
/** Stable string form of the handle's key, for `key` props. */
|
|
22
|
+
readonly hash: string;
|
|
23
|
+
/** Input of the data in the promise; differs from the handle's while `isPrevious`. */
|
|
24
|
+
readonly input: InputOf<TFactory>;
|
|
25
|
+
/** A request is in flight: first load, new input, or background refetch. */
|
|
26
|
+
readonly isFetching: boolean;
|
|
27
|
+
/** No data yet for the handle's input. */
|
|
28
|
+
readonly isPending: boolean;
|
|
29
|
+
/** The promise holds a previous input's data (`whilePending: "previous"`). */
|
|
30
|
+
readonly isPrevious: boolean;
|
|
31
|
+
/** The data is older than `staleTime`, or was invalidated. */
|
|
32
|
+
readonly isStale: boolean;
|
|
33
|
+
/** Key of the data in the promise; differs from the handle's while `isPrevious`. */
|
|
34
|
+
readonly key: QueryKey;
|
|
35
|
+
/** Refetch now. Resolves when the request settles; never rejects. */
|
|
36
|
+
refetch(): Promise<void>;
|
|
37
|
+
/** When the data in the promise was fetched (ms since epoch); `0` before that. */
|
|
38
|
+
readonly updatedAt: number;
|
|
39
|
+
}
|
|
40
|
+
/** Extra controls for factories that return `infiniteQueryOptions(...)`. */
|
|
41
|
+
export interface PagingControls {
|
|
42
|
+
fetchNextPage(): Promise<void>;
|
|
43
|
+
fetchPreviousPage(): Promise<void>;
|
|
44
|
+
readonly hasNextPage: boolean;
|
|
45
|
+
readonly hasPreviousPage: boolean;
|
|
46
|
+
readonly isFetchingNextPage: boolean;
|
|
47
|
+
readonly isFetchingPreviousPage: boolean;
|
|
48
|
+
}
|
|
49
|
+
export type UseQueryControls<TFactory extends QueryFactory> = IsPaged<TFactory> extends true ? QueryControls<TFactory> & PagingControls : QueryControls<TFactory>;
|
|
50
|
+
export type UseQueryResult<TFactory extends QueryFactory> = readonly [promise: Promise<DataOf<TFactory>>, controls: UseQueryControls<TFactory>];
|
|
51
|
+
/**
|
|
52
|
+
* Read the query a handle points at. Returns a promise to unwrap with
|
|
53
|
+
* `use()` and the query's controls:
|
|
54
|
+
*
|
|
55
|
+
* ```tsx
|
|
56
|
+
* const [postPromise, postState] = useQuery(postQuery, loaderData.post);
|
|
57
|
+
* const post = use(postPromise);
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* The promise is stable per data version and already fulfilled once the
|
|
61
|
+
* cache has data, so `use()` only suspends while there is nothing to show.
|
|
62
|
+
*/
|
|
63
|
+
export declare function useQuery<TFactory extends QueryFactory>(factory: TFactory, handle: QueryHandle<TFactory>, options?: UseQueryOptions): UseQueryResult<TFactory>;
|
|
64
|
+
//#endregion
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useQueryClient } from "./provider.mjs";
|
|
3
|
+
import { unsafeCast } from "./cast.mjs";
|
|
4
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
5
|
+
//#region src/use-query.ts
|
|
6
|
+
function queryFor(client, factory, input) {
|
|
7
|
+
const args = [input];
|
|
8
|
+
return client.getQuery(factory, ...args);
|
|
9
|
+
}
|
|
10
|
+
function useRefetchInterval(query, interval) {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (interval === void 0 || !Number.isFinite(interval) || interval <= 0) return;
|
|
13
|
+
const timer = setInterval(() => {
|
|
14
|
+
if (document.visibilityState !== "hidden") query.refetch();
|
|
15
|
+
}, interval);
|
|
16
|
+
return () => clearInterval(timer);
|
|
17
|
+
}, [query, interval]);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Read the query a handle points at. Returns a promise to unwrap with
|
|
21
|
+
* `use()` and the query's controls:
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const [postPromise, postState] = useQuery(postQuery, loaderData.post);
|
|
25
|
+
* const post = use(postPromise);
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* The promise is stable per data version and already fulfilled once the
|
|
29
|
+
* cache has data, so `use()` only suspends while there is nothing to show.
|
|
30
|
+
*/
|
|
31
|
+
function useQuery(factory, handle, options = {}) {
|
|
32
|
+
const query = queryFor(useQueryClient(), factory, handle.input);
|
|
33
|
+
query.read();
|
|
34
|
+
const refetchOnReconnect = options.refetchOnReconnect;
|
|
35
|
+
const refetchOnWindowFocus = options.refetchOnWindowFocus;
|
|
36
|
+
const subscribe = useCallback((onChange) => query.subscribe(onChange, {
|
|
37
|
+
refetchOnReconnect,
|
|
38
|
+
refetchOnWindowFocus
|
|
39
|
+
}), [
|
|
40
|
+
query,
|
|
41
|
+
refetchOnReconnect,
|
|
42
|
+
refetchOnWindowFocus
|
|
43
|
+
]);
|
|
44
|
+
const state = useSyncExternalStore(subscribe, query.getState, query.getState);
|
|
45
|
+
useRefetchInterval(query, options.refetchInterval);
|
|
46
|
+
const keepPrevious = options.whilePending === "previous";
|
|
47
|
+
const [lastShown, setLastShown] = useState(void 0);
|
|
48
|
+
if (keepPrevious && state.hasData && lastShown?.promise !== state.promise) setLastShown({
|
|
49
|
+
handle: query.handle,
|
|
50
|
+
hash: query.hash,
|
|
51
|
+
promise: state.promise,
|
|
52
|
+
updatedAt: state.updatedAt
|
|
53
|
+
});
|
|
54
|
+
const previous = keepPrevious && !state.hasData && state.status !== "error" && lastShown !== void 0 && lastShown.hash !== query.hash ? lastShown : void 0;
|
|
55
|
+
const shown = previous ?? {
|
|
56
|
+
handle: query.handle,
|
|
57
|
+
hash: query.hash,
|
|
58
|
+
promise: state.promise,
|
|
59
|
+
updatedAt: state.updatedAt
|
|
60
|
+
};
|
|
61
|
+
const controls = {
|
|
62
|
+
error: state.error,
|
|
63
|
+
fetchNextPage: query.fetchNextPage,
|
|
64
|
+
fetchPreviousPage: query.fetchPreviousPage,
|
|
65
|
+
hash: query.hash,
|
|
66
|
+
hasNextPage: query.hasNextPage(),
|
|
67
|
+
hasPreviousPage: query.hasPreviousPage(),
|
|
68
|
+
input: shown.handle.input,
|
|
69
|
+
isFetching: state.isFetching,
|
|
70
|
+
isFetchingNextPage: state.fetchDirection === "forward",
|
|
71
|
+
isFetchingPreviousPage: state.fetchDirection === "backward",
|
|
72
|
+
isPending: !state.hasData,
|
|
73
|
+
isPrevious: previous !== void 0,
|
|
74
|
+
isStale: state.isStale,
|
|
75
|
+
key: shown.handle.key,
|
|
76
|
+
refetch: query.refetch,
|
|
77
|
+
updatedAt: shown.updatedAt
|
|
78
|
+
};
|
|
79
|
+
return unsafeCast([shown.promise, controls]);
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { queryFor, useQuery };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { InputArgs, QueryFactory, QueryHandle } from "@raf-query/core";
|
|
2
|
+
//#region src/use-waterfall.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Start a query during render and return its handle, for inputs only known
|
|
5
|
+
* at render time (an id from a parent query's data). Each call is a request
|
|
6
|
+
* waterfall: prefer starting the query in a loader or server component.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const author = useWaterfall(authorQuery, { id: post.authorId });
|
|
10
|
+
* const [authorPromise] = useQuery(authorQuery, author);
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function useWaterfall<TFactory extends QueryFactory>(factory: TFactory, ...args: InputArgs<TFactory>): QueryHandle<TFactory>;
|
|
14
|
+
//#endregion
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useQueryClient } from "./provider.mjs";
|
|
3
|
+
//#region src/use-waterfall.ts
|
|
4
|
+
/**
|
|
5
|
+
* Start a query during render and return its handle, for inputs only known
|
|
6
|
+
* at render time (an id from a parent query's data). Each call is a request
|
|
7
|
+
* waterfall: prefer starting the query in a loader or server component.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const author = useWaterfall(authorQuery, { id: post.authorId });
|
|
11
|
+
* const [authorPromise] = useQuery(authorQuery, author);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
function useWaterfall(factory, ...args) {
|
|
15
|
+
const query = useQueryClient().getQuery(factory, ...args);
|
|
16
|
+
query.read();
|
|
17
|
+
return query.handle;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { useWaterfall };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raf-query/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "React bindings for raf-query, a render-as-you-fetch query library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"query",
|
|
@@ -32,21 +32,40 @@
|
|
|
32
32
|
"type": "module",
|
|
33
33
|
"sideEffects": false,
|
|
34
34
|
"exports": {
|
|
35
|
-
".": "./dist/index.mjs"
|
|
35
|
+
".": "./dist/index.mjs",
|
|
36
|
+
"./next": "./dist/next.mjs",
|
|
37
|
+
"./rsc": "./dist/rsc.mjs",
|
|
38
|
+
"./tanstack-router": "./dist/tanstack-router.mjs"
|
|
36
39
|
},
|
|
37
40
|
"dependencies": {
|
|
38
|
-
"@raf-query/core": "0.0.
|
|
41
|
+
"@raf-query/core": "0.0.4",
|
|
39
42
|
"effect": "^4.0.1"
|
|
40
43
|
},
|
|
41
44
|
"peerDependencies": {
|
|
45
|
+
"@tanstack/react-router": "^1.170.0",
|
|
46
|
+
"next": "^16.0.0",
|
|
42
47
|
"react": "^19.2.0"
|
|
43
48
|
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@tanstack/react-router": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"next": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
44
57
|
"devDependencies": {
|
|
45
58
|
"@effect/vitest": "4.0.1",
|
|
59
|
+
"@tanstack/react-router": "1.170.42",
|
|
60
|
+
"@testing-library/react": "16.3.3",
|
|
46
61
|
"@types/node": "26.6.4",
|
|
47
62
|
"@types/react": "19.3.0",
|
|
63
|
+
"@types/react-dom": "19.3.0",
|
|
48
64
|
"@vitest/coverage-v8": "5.0.1",
|
|
65
|
+
"happy-dom": "21.0.1",
|
|
66
|
+
"next": "16.4.0",
|
|
49
67
|
"react": "19.3.0",
|
|
68
|
+
"react-dom": "19.3.0",
|
|
50
69
|
"typescript": "7.0.2",
|
|
51
70
|
"vite-plus": "1.0.0"
|
|
52
71
|
},
|