@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 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
- * resolve workspace packages from source ([f03a57f](https://github.com/KATT/raf-query/commit/f03a57f0d54dbd66d947a0ee97dbb762bddca970))
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
- Design spec. This README describes the API being built; nothing is published yet.
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: async ({ signal }) => {
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: ({ pageParam, signal }) =>
57
- api.comments.list({ slug: input.slug, cursor: pageParam }, { signal }),
58
- initialPageParam: null as string | null,
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 ({ context: { queryClient: qc }, params }) => {
82
- const comments = qc.prefetch(commentsQuery, { slug: params.slug });
83
- const post = await qc.ensure(postQuery, { slug: params.slug });
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, post] = useQuery(postQuery, loaderData.post);
144
- const { title, body } = use(postPromise);
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={post.isFetching}
151
- onClick={() => startTransition(() => post.refetch())}
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 { pages } = use(pagesPromise);
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: ({ search }) => search,
226
- loader: ({ context: { queryClient: qc }, deps }) => ({
227
- posts: qc.prefetch(postsQuery, deps),
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 { posts } = Route.useLoaderData();
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 { slug } = await props.params;
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. Methods listed above |
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
- | `QueryHandle<TFactory>` | Handle type for props |
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 };
@@ -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
- export * from "@raf-query/core";
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 };
@@ -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.2",
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.2",
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
  },