@raf-query/react 0.1.15 → 0.1.17

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,8 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.17](https://github.com/KATT/raf-query/compare/0.1.16...0.1.17) (2026-10-13)
4
+
5
+ ### Performance Improvements
6
+
7
+ * **react:** browser build of the TanStack Router integration ([#48](https://github.com/KATT/raf-query/issues/48)) ([75e78cf](https://github.com/KATT/raf-query/commit/75e78cf968c069188a2688ad3298fb4bee2e3b8c))
8
+
9
+ ## [0.1.16](https://github.com/KATT/raf-query/compare/0.1.15...0.1.16) (2026-10-13)
10
+
11
+ ### Bug Fixes
12
+
13
+ - stale data always refreshes when switching pages ([#49](https://github.com/KATT/raf-query/issues/49)) ([68652cd](https://github.com/KATT/raf-query/commit/68652cdf6ea06ef86d277329098ee294dba98f21))
14
+
3
15
  ## [0.1.15](https://github.com/KATT/raf-query/compare/0.1.14...0.1.15) (2026-10-13)
4
16
 
5
- * **tooling:** list a fixture app's modules by size ([#45](https://github.com/KATT/raf-query/issues/45)) ([0e1f899](https://github.com/KATT/raf-query/commit/0e1f899f2fecc1368f9be71a2c78e3efd2f3783b))
17
+ - **tooling:** list a fixture app's modules by size ([#45](https://github.com/KATT/raf-query/issues/45)) ([0e1f899](https://github.com/KATT/raf-query/commit/0e1f899f2fecc1368f9be71a2c78e3efd2f3783b))
6
18
 
7
19
  ## [0.1.14](https://github.com/KATT/raf-query/compare/0.1.13...0.1.14) (2026-10-13)
8
20
 
package/README.md CHANGED
@@ -161,7 +161,8 @@ return { org: qc.prefetch(orgQuery, { orgId: user.orgId }) };
161
161
  Behavior:
162
162
 
163
163
  - Every verb dedupes against the cache. A fresh entry is reused; a stale entry is refetched in the background.
164
- - `ensure` and `fetch` wait only on a cache miss. With stale cached data they resolve immediately and revalidate in the background.
164
+ - `ensure` and `fetch` wait only on a cache miss. With stale cached data they resolve immediately, so the navigation isn't held up, and revalidate in the background: the page renders the cached data and re-renders when the refetch lands.
165
+ - Loaders run on every navigation (TanStack Router reruns them when its own `staleTime` has passed, `0` by default), so returning to a page shows what's cached at once and then fresh data. With `defaultPreload: "intent"`, also set `defaultPreloadStaleTime: 0`: otherwise the router reuses a hover's preload for 30 seconds instead of running the loader on click, and the query cache never hears about the navigation.
165
166
  - On the server, cached data is always reused, whatever `staleTime` says. A server client renders once, so a background refetch could only repeat a request whose result is never shown. Entries marked by `invalidate` still refetch.
166
167
  - A failed `prefetch` never throws out of the loader. The error surfaces where the data is read, and is reported to the client's `onError`.
167
168
  - A failed `ensure` or `fetch` rejects, so it reaches the route's error boundary.
@@ -241,7 +242,7 @@ function PostPage() {
241
242
 
242
243
  When the cache updates — refetch, invalidation, `setData` — the hook returns a new, pre-resolved promise and the component re-renders without suspending. New data is structurally shared with the old: objects and arrays that are deeply equal keep their identity, and a refetch that returns equal data keeps the same promise, so nothing re-renders. Controls are tracked: reading `controls.isFetching` subscribes the component to `isFetching`, and a change to a control it never read doesn't re-render it. When the **first** load fails, the promise rejects and `use()` throws to the nearest error boundary. Resetting that boundary refetches the query.
243
244
 
244
- Queries do not refetch on mount. They refetch when a loader starts them again, when invalidated, on window focus or reconnect while stale, on `refetch()`, or on `refetchInterval`.
245
+ `useQuery` never refetches on mount: whatever started the query decides. Stale data refetches in the background when a loader or server component starts the query again (`prefetch`, `ensure`, `fetch`, or hydrating the server's copy), when `useWaterfall` mounts, when invalidated, on window focus or reconnect, on `refetch()`, or on `refetchInterval`.
245
246
 
246
247
  For an app-wide indicator, `useIsFetching()` returns how many queries have a request in flight, background refetches included, and re-renders when that changes. Pass a filter to count only some: `useIsFetching({ queryKey: ["comments"] })`. It's `0` on the server and during hydration, so the indicator never appears in server HTML. Outside React, `qc.isFetching(filter?)` returns the same number.
247
248
 
@@ -430,6 +431,8 @@ function PostAuthor(props: { post: Post }) {
430
431
  }
431
432
  ```
432
433
 
434
+ Like `prefetch` in a loader, `useWaterfall` refetches stale cached data in the background, once each time the component mounts or its input changes. Re-renders don't refetch.
435
+
433
436
  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.
434
437
 
435
438
  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:
@@ -567,7 +570,7 @@ export default async function Page(props: {
567
570
  ```
568
571
 
569
572
  - `getQueryClient` is scoped to one request by React's `cache()`.
570
- - `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.
573
+ - `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. The browser takes the server's data when it's newer than its own. Otherwise it keeps its copy and refetches it if stale. That covers a payload the router replays from its cache (back and forward) and a `prefetch` still pending on the server.
571
574
  - `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.
572
575
  - Server components that only render data can `await qc.fetch(...)` anywhere; the per-request client makes one request per query, whatever its `staleTime`.
573
576
  - `fetch` returns data, not a handle. When a server component renders the data and also hands the query to a client component, pass `await qc.ensure(...)`: after the `fetch` it resolves from the cache, and the client component gets the data from `useQuery` without a promise.
@@ -0,0 +1,11 @@
1
+ import { RouterIntegrationOptions } from "./tanstack-router-shared.mjs";
2
+ import { AnyRouter } from "@tanstack/react-router";
3
+ //#region src/tanstack-router-browser.d.ts
4
+ /**
5
+ * The browser build of `@raf-query/react/tanstack-router` (the `browser`
6
+ * export condition): the client half only, so the server's dehydration
7
+ * stream stays out of the app bundle.
8
+ */
9
+ export declare function setupRouterIntegration<TRouter extends AnyRouter>(options: RouterIntegrationOptions<TRouter>): void;
10
+ //#endregion
11
+ export type { RouterIntegrationOptions };
@@ -0,0 +1,13 @@
1
+ import { handleRedirects, setupClient } from "./tanstack-router-shared.mjs";
2
+ //#region src/tanstack-router-browser.ts
3
+ /**
4
+ * The browser build of `@raf-query/react/tanstack-router` (the `browser`
5
+ * export condition): the client half only, so the server's dehydration
6
+ * stream stays out of the app bundle.
7
+ */
8
+ function setupRouterIntegration(options) {
9
+ setupClient(options);
10
+ handleRedirects(options);
11
+ }
12
+ //#endregion
13
+ export { setupRouterIntegration };
@@ -0,0 +1,13 @@
1
+ import { QueryClient } from "./client.mjs";
2
+ import { DehydrateOptions, DehydratedState, HydrateOptions } from "@raf-query/core";
3
+ import { AnyRouter } from "@tanstack/react-router";
4
+ //#region src/tanstack-router-shared.d.ts
5
+ export interface RouterIntegrationOptions<TRouter extends AnyRouter> {
6
+ readonly dehydrateOptions?: DehydrateOptions;
7
+ /** Navigate when a `queryFn` throws `redirect()`. Default `true`. */
8
+ readonly handleRedirects?: boolean;
9
+ readonly hydrateOptions?: HydrateOptions;
10
+ readonly queryClient: QueryClient;
11
+ readonly router: TRouter;
12
+ }
13
+ //#endregion
@@ -0,0 +1,48 @@
1
+ import { hydrate } from "./client.mjs";
2
+ import { Predicate } from "effect";
3
+ import { isRedirect } from "@tanstack/react-router";
4
+ //#region src/tanstack-router-shared.ts
5
+ function hasRafQueries(value) {
6
+ return Predicate.hasProperty(value, "raf") && Predicate.isObject(value.raf);
7
+ }
8
+ /**
9
+ * Hydrate each state as the server's stream delivers it. Reads the stream
10
+ * directly: `Stream.fromReadableStream` + `QueryCache.hydrateStream` would
11
+ * put Effect's `Stream` and `Queue` in the browser bundle.
12
+ */
13
+ async function hydrateEach(client, stream, options) {
14
+ const reader = stream.getReader();
15
+ for (;;) {
16
+ const next = await reader.read();
17
+ if (next.done) return;
18
+ hydrate(client, next.value, options);
19
+ }
20
+ }
21
+ function setupClient(options) {
22
+ const router = options.router;
23
+ const client = options.queryClient;
24
+ const hydrateOptions = options.hydrateOptions;
25
+ const originalHydrate = router.options.hydrate;
26
+ router.options.hydrate = async (dehydrated) => {
27
+ await originalHydrate?.(dehydrated);
28
+ if (!hasRafQueries(dehydrated)) return;
29
+ const raf = dehydrated.raf;
30
+ hydrate(client, raf.initial, hydrateOptions);
31
+ hydrateEach(client, raf.stream, hydrateOptions).catch((error) => {
32
+ console.error("raf-query: reading the query stream failed", error);
33
+ });
34
+ };
35
+ }
36
+ function handleRedirects(options) {
37
+ if (!(options.handleRedirects ?? true)) return;
38
+ const router = options.router;
39
+ options.queryClient.subscribe((event) => {
40
+ if (event.type === "failed" && isRedirect(event.error)) {
41
+ const redirect = event.error;
42
+ redirect.options._fromLocation = router.stores.location.get();
43
+ router.navigate(router.resolveRedirect(redirect).options);
44
+ }
45
+ });
46
+ }
47
+ //#endregion
48
+ export { handleRedirects, setupClient };
@@ -1,15 +1,6 @@
1
- import { QueryClient } from "./client.mjs";
2
- import { DehydrateOptions, HydrateOptions } from "@raf-query/core";
1
+ import { RouterIntegrationOptions } from "./tanstack-router-shared.mjs";
3
2
  import { AnyRouter } from "@tanstack/react-router";
4
3
  //#region src/tanstack-router.d.ts
5
- export interface RouterIntegrationOptions<TRouter extends AnyRouter> {
6
- readonly dehydrateOptions?: DehydrateOptions;
7
- /** Navigate when a `queryFn` throws `redirect()`. Default `true`. */
8
- readonly handleRedirects?: boolean;
9
- readonly hydrateOptions?: HydrateOptions;
10
- readonly queryClient: QueryClient;
11
- readonly router: TRouter;
12
- }
13
4
  /**
14
5
  * Connects a query client to a TanStack Router (and TanStack Start) router:
15
6
  *
@@ -22,4 +13,5 @@ export interface RouterIntegrationOptions<TRouter extends AnyRouter> {
22
13
  * Call it in `getRouter`, which runs once per request on the server.
23
14
  */
24
15
  export declare function setupRouterIntegration<TRouter extends AnyRouter>(options: RouterIntegrationOptions<TRouter>): void;
25
- //#endregion
16
+ //#endregion
17
+ export type { RouterIntegrationOptions };
@@ -1,5 +1,5 @@
1
- import { Effect, Exit, Predicate, Scope, Stream } from "effect";
2
- import { isRedirect } from "@tanstack/react-router";
1
+ import { handleRedirects, setupClient } from "./tanstack-router-shared.mjs";
2
+ import { Effect, Exit, Scope, Stream } from "effect";
3
3
  //#region src/tanstack-router.ts
4
4
  const noop = () => {};
5
5
  function setupServer(options) {
@@ -39,25 +39,6 @@ function setupServer(options) {
39
39
  };
40
40
  };
41
41
  }
42
- function hasRafQueries(value) {
43
- return Predicate.hasProperty(value, "raf") && Predicate.isObject(value.raf);
44
- }
45
- function setupClient(options) {
46
- const router = options.router;
47
- const cache = options.queryClient.effect;
48
- const hydrateOptions = options.hydrateOptions;
49
- const originalHydrate = router.options.hydrate;
50
- router.options.hydrate = async (dehydrated) => {
51
- await originalHydrate?.(dehydrated);
52
- if (!hasRafQueries(dehydrated)) return;
53
- const raf = dehydrated.raf;
54
- Effect.runSync(cache.hydrate(raf.initial, hydrateOptions));
55
- Effect.runFork(cache.hydrateStream(Stream.fromReadableStream({
56
- evaluate: () => raf.stream,
57
- onError: (error) => error
58
- }), hydrateOptions).pipe(Effect.catchCause((cause) => Effect.logError("raf-query: reading the query stream failed", cause))));
59
- };
60
- }
61
42
  /**
62
43
  * Connects a query client to a TanStack Router (and TanStack Start) router:
63
44
  *
@@ -70,16 +51,9 @@ function setupClient(options) {
70
51
  * Call it in `getRouter`, which runs once per request on the server.
71
52
  */
72
53
  function setupRouterIntegration(options) {
73
- const router = options.router;
74
- if (router.isServer) setupServer(options);
54
+ if (options.router.isServer) setupServer(options);
75
55
  else setupClient(options);
76
- if (options.handleRedirects ?? true) options.queryClient.subscribe((event) => {
77
- if (event.type === "failed" && isRedirect(event.error)) {
78
- const redirect = event.error;
79
- redirect.options._fromLocation = router.stores.location.get();
80
- router.navigate(router.resolveRedirect(redirect).options);
81
- }
82
- });
56
+ handleRedirects(options);
83
57
  }
84
58
  //#endregion
85
59
  export { setupRouterIntegration };
@@ -4,6 +4,8 @@ import { InputArgs, QueryFactory, QueryHandle } from "@raf-query/core";
4
4
  * Start a query during render and return its handle, for inputs only known
5
5
  * at render time (an id from a parent query's data). Each call is a request
6
6
  * waterfall: prefer starting the query in a loader or server component.
7
+ * Like `prefetch` in a loader, it refetches stale cached data in the
8
+ * background, once per mount and input.
7
9
  *
8
10
  * ```tsx
9
11
  * const author = useWaterfall(authorQuery, { id: post.authorId });
@@ -1,11 +1,20 @@
1
1
  "use client";
2
2
  import { useQueryClient } from "./provider.mjs";
3
3
  import { Query } from "@raf-query/core";
4
+ import { useState } from "react";
4
5
  //#region src/use-waterfall.ts
5
6
  /**
7
+ * First loads a waterfall suspended on. A suspended mount keeps no state, so
8
+ * this is how its retry knows the data it finds is the load it just waited
9
+ * for, not cached data to revalidate.
10
+ */
11
+ const waitedFor = /* @__PURE__ */ new WeakSet();
12
+ /**
6
13
  * Start a query during render and return its handle, for inputs only known
7
14
  * at render time (an id from a parent query's data). Each call is a request
8
15
  * waterfall: prefer starting the query in a loader or server component.
16
+ * Like `prefetch` in a loader, it refetches stale cached data in the
17
+ * background, once per mount and input.
9
18
  *
10
19
  * ```tsx
11
20
  * const author = useWaterfall(authorQuery, { id: post.authorId });
@@ -14,7 +23,13 @@ import { Query } from "@raf-query/core";
14
23
  */
15
24
  function useWaterfall(factory, ...args) {
16
25
  const query = useQueryClient().getQuery(factory, ...args);
17
- Query.readUnsafe(query);
26
+ const promise = Query.readUnsafe(query);
27
+ const [started, setStarted] = useState();
28
+ if (started !== query.hash) {
29
+ if (!Query.getUnsafe(query).hasData) waitedFor.add(promise);
30
+ else if (!waitedFor.delete(promise)) Query.revalidateUnsafe(query);
31
+ setStarted(query.hash);
32
+ } else if (Query.getUnsafe(query).hasData) waitedFor.delete(promise);
18
33
  return query.handle;
19
34
  }
20
35
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raf-query/react",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "React bindings for raf-query, a render-as-you-fetch query library",
5
5
  "keywords": [
6
6
  "query",
@@ -37,10 +37,18 @@
37
37
  "./devtools/production": "./dist/devtools-production.mjs",
38
38
  "./next": "./dist/next.mjs",
39
39
  "./rsc": "./dist/rsc.mjs",
40
- "./tanstack-router": "./dist/tanstack-router.mjs"
40
+ "./tanstack-router": {
41
+ "workerd": "./dist/tanstack-router.mjs",
42
+ "worker": "./dist/tanstack-router.mjs",
43
+ "node": "./dist/tanstack-router.mjs",
44
+ "deno": "./dist/tanstack-router.mjs",
45
+ "bun": "./dist/tanstack-router.mjs",
46
+ "browser": "./dist/tanstack-router-browser.mjs",
47
+ "default": "./dist/tanstack-router.mjs"
48
+ }
41
49
  },
42
50
  "dependencies": {
43
- "@raf-query/core": "0.1.15"
51
+ "@raf-query/core": "0.1.17"
44
52
  },
45
53
  "peerDependencies": {
46
54
  "@tanstack/react-router": "^1.170.0",