@raf-query/react 0.11.1 → 0.12.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +32 -59
  3. package/dist/bind.d.mts +19 -0
  4. package/dist/bind.mjs +21 -0
  5. package/dist/browser-render.mjs +46 -0
  6. package/dist/devtools.d.mts +24 -2
  7. package/dist/devtools.mjs +236 -1
  8. package/dist/hydration-boundary/hydrate.mjs +10 -13
  9. package/dist/hydration-boundary/hydration-boundary-react-server.d.mts +4 -0
  10. package/dist/hydration-boundary/hydration-boundary-react-server.mjs +18 -9
  11. package/dist/hydration-boundary/hydration-boundary.d.mts +9 -29
  12. package/dist/hydration-boundary/hydration-boundary.mjs +6 -27
  13. package/dist/index-react-server.d.mts +7 -6
  14. package/dist/index-react-server.mjs +4 -6
  15. package/dist/index-shared.d.mts +5 -6
  16. package/dist/index-shared.mjs +3 -6
  17. package/dist/index.d.mts +7 -6
  18. package/dist/index.mjs +5 -4
  19. package/dist/provider.d.mts +4 -4
  20. package/dist/provider.mjs +5 -8
  21. package/dist/react-server-hooks.d.mts +17 -0
  22. package/dist/react-server-hooks.mjs +19 -0
  23. package/dist/tanstack-router-shared.d.mts +4 -7
  24. package/dist/tanstack-router-shared.mjs +12 -22
  25. package/dist/tanstack-router.d.mts +2 -2
  26. package/dist/tanstack-router.mjs +10 -14
  27. package/dist/tracked.d.mts +1 -0
  28. package/dist/tracked.mjs +65 -0
  29. package/dist/use-infinite-query.d.mts +27 -0
  30. package/dist/use-infinite-query.mjs +35 -0
  31. package/dist/use-is-fetching.d.mts +6 -16
  32. package/dist/use-is-fetching.mjs +11 -31
  33. package/dist/use-query.d.mts +43 -54
  34. package/dist/use-query.mjs +102 -47
  35. package/package.json +10 -9
  36. package/dist/devtools-panel.d.mts +0 -38
  37. package/dist/devtools-panel.mjs +0 -896
  38. package/dist/devtools-store.mjs +0 -104
  39. package/dist/hydration-boundary/sent.mjs +0 -16
  40. package/dist/query-provider/browser-requests.mjs +0 -38
  41. package/dist/query-provider/query-provider.d.mts +0 -21
  42. package/dist/query-provider/query-provider.mjs +0 -33
  43. package/dist/use-waterfall.d.mts +0 -18
  44. package/dist/use-waterfall.mjs +0 -41
@@ -1,40 +1,20 @@
1
- "use client";
2
1
  import { useQueryClient } from "./provider.mjs";
2
+ import { isFetching } from "@raf-query/core";
3
3
  import { useCallback, useSyncExternalStore } from "react";
4
4
  //#region src/use-is-fetching.ts
5
- function subscribeDeferred(client, onStoreChange) {
6
- let scheduled = false;
7
- return client.subscribe(() => {
8
- if (scheduled) return;
9
- scheduled = true;
10
- queueMicrotask(() => {
11
- scheduled = false;
12
- onStoreChange();
13
- });
14
- });
15
- }
16
- const none = () => 0;
5
+ const serverCount = () => 0;
17
6
  /**
18
- * How many queries have a request in flight: all of them, or those matching
19
- * `filter`. Re-renders when the count changes. It's `0` on the server and
20
- * while hydrating, so the server's HTML and the browser's first render agree.
21
- *
22
- * You probably don't need this. Prefer the router's pending UI for
23
- * navigations (`useRouterState` / `pendingComponent` in TanStack Router,
24
- * `useLinkStatus` in Next.js) and per-query `controls.isFetching` for
25
- * background refetches. Kept for TanStack Query compatibility when you want
26
- * one global count of every in-flight request:
27
- *
28
- * ```tsx
29
- * const fetching = useIsFetching();
30
- * return fetching > 0 ? <ProgressBar /> : null;
31
- * ```
7
+ * How many queries have a request in flight: all of them, or a factory's,
8
+ * a group's or a target's. For a global loading indicator. Always `0` on
9
+ * the server and while hydrating, so the HTML matches the first browser render.
32
10
  */
33
- function useIsFetching(filter) {
11
+ function useIsFetching(target) {
34
12
  const client = useQueryClient();
35
- const subscribe = useCallback((onStoreChange) => subscribeDeferred(client, onStoreChange), [client]);
36
- const getSnapshot = () => client.isFetching(filter);
37
- return useSyncExternalStore(subscribe, getSnapshot, none);
13
+ const subscribe = useCallback((onChange) => client.subscribe((event) => {
14
+ if (event.type === "updated" || event.type === "removed") onChange();
15
+ }), [client]);
16
+ const count = () => isFetching(client, target);
17
+ return useSyncExternalStore(subscribe, count, serverCount);
38
18
  }
39
19
  //#endregion
40
20
  export { useIsFetching };
@@ -1,62 +1,51 @@
1
- import { DataOf, ErrorOf, QueryFactory, QueryHandle, QueryHandleWithData, Thenable, ThrowsOnError, UseQueryControls, UseQueryOptions, UseQueryThrowOptions } from "@raf-query/core";
2
- import { Result } from "effect";
1
+ import "./tracked.mjs";
2
+ import { DataOf, InputOf, QueryFactory, QueryHandle, SingleQueryFactory, Thenable } from "@raf-query/core";
3
+ import "@raf-query/core/internal";
3
4
  //#region src/use-query.d.ts
4
- export type UseQueryResult<TFactory extends QueryFactory, TThrow extends boolean | undefined = undefined> = ThrowsOnError<TFactory, TThrow> extends true ? readonly [promise: Thenable<DataOf<TFactory>>, controls: UseQueryControls<TFactory>] : readonly [promise: Thenable<Result.Result<DataOf<TFactory>, ErrorOf<TFactory>>>, controls: UseQueryControls<TFactory>];
5
- /** {@link useQuery}'s result for a handle from `ensure` or `fetch`. */
6
- export type UseQueryResultWithData<TFactory extends QueryFactory, TThrow extends boolean | undefined = undefined> = ThrowsOnError<TFactory, TThrow> extends true ? readonly [data: DataOf<TFactory>, controls: UseQueryControls<TFactory>] : readonly [result: Result.Result<DataOf<TFactory>, ErrorOf<TFactory>>, controls: UseQueryControls<TFactory>];
5
+ export interface UseQueryOptions {
6
+ /**
7
+ * While a new handle's query has no data: `"suspend"` (the default) shows
8
+ * the Suspense fallback; `"previous"` keeps showing the last handle's data
9
+ * and sets `isPrevious`.
10
+ */
11
+ readonly whilePending?: "previous" | "suspend";
12
+ }
13
+ /** What `useQuery` returns next to the data. */
14
+ export interface QueryState<TFactory extends QueryFactory = QueryFactory> {
15
+ /** The last request's error while it's the latest outcome. */
16
+ readonly error: unknown;
17
+ readonly hash: string;
18
+ readonly input: InputOf<TFactory>;
19
+ readonly isFetching: boolean;
20
+ /** No data yet. */
21
+ readonly isPending: boolean;
22
+ /** Showing the previous handle's data (`whilePending: "previous"`). */
23
+ readonly isPrevious: boolean;
24
+ readonly isStale: boolean;
25
+ readonly queryKey: ReturnType<TFactory>["queryKey"];
26
+ /** Fetch again, or join the request in flight. Resolves when it settles. */
27
+ refetch(): Promise<void>;
28
+ /** When the data was fetched (ms since epoch); `0` without data. */
29
+ readonly updatedAt: number;
30
+ }
7
31
  /**
8
- * Read the query a handle points at. Returns a promise to unwrap with
9
- * `use()` and the query's controls:
10
- *
11
- * ```tsx
12
- * const [postPromise, postState] = useQuery(postQuery, loaderData.post);
13
- * const post = use(postPromise);
14
- * ```
15
- *
16
- * For an Effect `queryFn` (typed {@link ErrorOf}), with `throwOnError: false`
17
- * (the call's, else the query's, else the client's, which is the default
18
- * when it has a `layer`), the first slot is a `Result` instead — `use()`
19
- * never throws:
20
- *
21
- * ```tsx
22
- * const [issueResult, issueState] = useQuery(issueQuery, loaderData.issue);
23
- * const issue = use(issueResult);
24
- * Result.match(issue, { onFailure: …, onSuccess: … });
25
- * ```
26
- *
27
- * The promise is stable per data version and already fulfilled once the
28
- * cache has data, so `use()` only suspends while there is nothing to show.
29
- * The component re-renders when the promise changes, and when a control it
30
- * has read changes; changes to controls it never reads are skipped. A
31
- * first load settles its pending promise in place: that counts as a change
32
- * once the component has read the promise's `status`, `value` or `reason`,
33
- * so reading them without `use()` re-renders when the data lands:
34
- *
35
- * ```tsx
36
- * const [tagPromise] = useQuery(tagQuery, loaderData.tag);
37
- * const tag = tagPromise.status === "fulfilled" ? tagPromise.value : "…";
38
- * ```
39
- *
40
- * A handle from `ensure` or `fetch` already has its data, so the hook
41
- * unwraps the promise itself and returns the data (or `Result`):
32
+ * What `useQuery` and `useInfiniteQuery` read, for every handle: a thenable
33
+ * to pass to `use()`.
34
+ * A loader can't promise the data is still cached when a component renders
35
+ * (a write can reset or remove it), so the read may suspend.
36
+ */
37
+ export type QueryValue<TFactory extends QueryFactory> = Thenable<DataOf<TFactory>>;
38
+ /**
39
+ * Read a query by its handle.
42
40
  *
43
41
  * ```tsx
44
- * const [post, postState] = useQuery(postQuery, loaderData.post);
42
+ * const [issueThenable, issueState] = useQuery(issueQuery, handles.issue);
43
+ * const issue = use(issueThenable);
45
44
  * ```
46
45
  *
47
- * It reads the live entry, not the handle's `data` snapshot. If that entry
48
- * has lost its data since (garbage-collected, `reset`), the hook suspends
49
- * until it is refetched.
50
- *
51
- * The hook picks by the handle at runtime (`data` in it), so a prop that
52
- * receives a handle from `ensure` / `fetch` must be typed
53
- * {@link QueryHandleWithData}: typed as `QueryHandle`, it reads as a promise
54
- * while the hook returns the data.
46
+ * Reading never refetches stale data: loaders decide freshness. A query no
47
+ * loader started fetches while rendering, which is a waterfall (see
48
+ * `waterfallHandle`). Paged queries read with `useInfiniteQuery`.
55
49
  */
56
- export declare function useQuery<TFactory extends QueryFactory, const TThrow extends boolean | undefined = undefined>(factory: TFactory, handle: QueryHandleWithData<TFactory>, options?: UseQueryOptions & UseQueryThrowOptions<TFactory> & {
57
- readonly throwOnError?: TThrow;
58
- }): UseQueryResultWithData<TFactory, TThrow>;
59
- export declare function useQuery<TFactory extends QueryFactory, const TThrow extends boolean | undefined = undefined>(factory: TFactory, handle: QueryHandle<TFactory>, options?: UseQueryOptions & UseQueryThrowOptions<TFactory> & {
60
- readonly throwOnError?: TThrow;
61
- }): UseQueryResult<TFactory, TThrow>;
50
+ export declare function useQuery<TFactory extends SingleQueryFactory>(factory: TFactory, handle: QueryHandle<TFactory>, options?: UseQueryOptions): readonly [QueryValue<TFactory>, QueryState<TFactory>];
62
51
  //#endregion
@@ -1,56 +1,111 @@
1
- "use client";
2
1
  import { useQueryClient } from "./provider.mjs";
3
- import { assertSent } from "./query-provider/browser-requests.mjs";
4
- import { Query, QueryBinding, QueryObserver, promiseControls, resultThenable, throwsOnError } from "@raf-query/core";
5
- import { use, useEffect, useState, useSyncExternalStore } from "react";
2
+ import { browserRender, leavesUnstartedToBrowser } from "./browser-render.mjs";
3
+ import { createTracker, renderedChanged, trackedState } from "./tracked.mjs";
4
+ import { entryOf, fetchEntry, isInvalidated, isStale, isWaterfallHandle, mapThenable, subscribeEntry } from "@raf-query/core/internal";
5
+ import { useCallback, useState, useSyncExternalStore } from "react";
6
6
  //#region src/use-query.ts
7
- function storeFor(query, options) {
8
- const observer = QueryObserver.makeUnsafe(query, options);
7
+ /** Whether nothing is loading the entry: no data, no error, no request in flight. */
8
+ function isIdle(entry, state) {
9
+ return !state.hasData && !entry.run && state.status !== "error";
10
+ }
11
+ /**
12
+ * Start the request of an entry nothing is loading: the reader of a
13
+ * waterfall handle, or of a query the cache dropped. A server render fed by
14
+ * server components leaves it to the browser: nothing would send its data.
15
+ */
16
+ function startIfMissing(entry, state, waterfall) {
17
+ const client = entry.client;
18
+ if (!isIdle(entry, state) || leavesUnstartedToBrowser(client)) return;
19
+ if (process.env.NODE_ENV !== "production" && !waterfall && !client.isServer) console.warn(`raf-query: ${entry.hash} was read before anything loaded it, so it fetches while rendering. Start it in a loader with load(), or build the handle with waterfallHandle() to make the waterfall explicit.`);
20
+ fetchEntry(entry);
21
+ }
22
+ /**
23
+ * In a server render fed by server components, an entry `startIfMissing`
24
+ * left to the browser reads as a rejection: a `use()` of it inside Suspense
25
+ * renders that boundary's fallback, and the browser renders the content.
26
+ */
27
+ function readOf(entry, state) {
28
+ return leavesUnstartedToBrowser(entry.client) && isIdle(entry, state) ? browserRender(entry) : entry.thenable;
29
+ }
30
+ function settled(promise) {
31
+ return promise.then(() => {});
32
+ }
33
+ /**
34
+ * One read of the entry. It takes both entries' states as arguments: core
35
+ * writes a new state object with every write (and the thenable that
36
+ * carries it), so a compiler that memoizes the call recomputes it.
37
+ */
38
+ function readQuery(current, state, shown, shownState, handle, options, extra) {
39
+ const isPrevious = options?.whilePending === "previous" && !state.hasData && shown !== current && shownState.hasData;
40
+ const entry = isPrevious ? shown : current;
41
+ const entryState = isPrevious ? shownState : state;
42
+ const added = extra?.(entry, entryState);
9
43
  return {
10
- getSnapshot: () => QueryObserver.getUnsafe(observer),
11
- observer,
12
- subscribe: (onChange) => QueryObserver.subscribeUnsafe(observer, onChange)
44
+ actions: {
45
+ refetch: () => settled(fetchEntry(current)),
46
+ ...added?.actions
47
+ },
48
+ entry,
49
+ fields: {
50
+ error: state.error,
51
+ hash: current.hash,
52
+ input: handle.input,
53
+ isFetching: state.isFetching,
54
+ isPending: !state.hasData,
55
+ isPrevious,
56
+ isStale: isStale(current),
57
+ queryKey: handle.queryKey,
58
+ updatedAt: state.updatedAt,
59
+ ...added?.fields
60
+ },
61
+ source: readOf(entry, entryState)
13
62
  };
14
63
  }
15
- function useQuery(factory, handle, options = {}) {
64
+ /**
65
+ * The read both hooks share: the thenable, and the state with the fields
66
+ * `extra` adds. The state is tracked: the component re-renders, and gets a
67
+ * new state object, only when the thenable or a field it read changes.
68
+ */
69
+ function useQueryRead(factory, handle, options, extra) {
16
70
  const client = useQueryClient();
17
- const query = QueryBinding.queryFor(client, factory, handle);
18
- assertSent(client, query);
19
- Query.readUnsafe(query);
20
- const refetchInterval = options.refetchInterval;
21
- const refetchOnReconnect = options.refetchOnReconnect;
22
- const refetchOnWindowFocus = options.refetchOnWindowFocus;
23
- const whilePending = options.whilePending;
24
- const [store] = useState(() => storeFor(query, options));
25
- const observer = store.observer;
26
- const result = QueryObserver.getOptimisticResultUnsafe(observer, query, options);
27
- useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
28
- useEffect(() => {
29
- QueryObserver.setOptionsUnsafe(observer, query, {
30
- refetchInterval,
31
- refetchOnReconnect,
32
- refetchOnWindowFocus,
33
- whilePending
71
+ const current = entryOf(client, factory, handle.input);
72
+ const [tracker] = useState(createTracker);
73
+ const subscribe = useCallback((onChange) => {
74
+ const unsubscribe = subscribeEntry(current, () => {
75
+ if (renderedChanged(tracker)) onChange();
34
76
  });
35
- }, [
36
- observer,
37
- query,
38
- refetchInterval,
39
- refetchOnReconnect,
40
- refetchOnWindowFocus,
41
- whilePending
42
- ]);
43
- QueryObserver.trackProp(observer, "promise");
44
- const controls = promiseControls(QueryObserver.trackResult(observer, result));
45
- const throwOnError = throwsOnError(query, options.throwOnError, client.throwOnError);
46
- const withData = "data" in handle;
47
- if (!throwOnError) {
48
- const resultPromise = resultThenable(result.promise);
49
- if (withData) return [use(resultPromise), controls];
50
- return [QueryObserver.trackThenable(observer, resultPromise), controls];
51
- }
52
- if (withData) return [use(result.promise), controls];
53
- return [QueryObserver.trackThenable(observer, result.promise), controls];
77
+ if (isInvalidated(current)) fetchEntry(current);
78
+ return unsubscribe;
79
+ }, [current, tracker]);
80
+ const getState = () => current.state;
81
+ const state = useSyncExternalStore(subscribe, getState, getState);
82
+ const [shown, setShown] = useState(current);
83
+ if (shown !== current && state.hasData) setShown(current);
84
+ startIfMissing(current, state, isWaterfallHandle(handle));
85
+ const read = readQuery(current, state, shown, shown.state, handle, options, extra);
86
+ const controls = trackedState(tracker, read, () => readQuery(current, current.state, shown, shown.state, handle, options, extra));
87
+ const selected = useState(() => /* @__PURE__ */ new WeakMap())[0];
88
+ const select = read.entry.options?.select;
89
+ return {
90
+ controls,
91
+ thenable: select ? mapThenable(selected, read.source, select) : read.source
92
+ };
93
+ }
94
+ /**
95
+ * Read a query by its handle.
96
+ *
97
+ * ```tsx
98
+ * const [issueThenable, issueState] = useQuery(issueQuery, handles.issue);
99
+ * const issue = use(issueThenable);
100
+ * ```
101
+ *
102
+ * Reading never refetches stale data: loaders decide freshness. A query no
103
+ * loader started fetches while rendering, which is a waterfall (see
104
+ * `waterfallHandle`). Paged queries read with `useInfiniteQuery`.
105
+ */
106
+ function useQuery(factory, handle, options) {
107
+ const read = useQueryRead(factory, handle, options);
108
+ return [read.thenable, read.controls];
54
109
  }
55
110
  //#endregion
56
- export { useQuery };
111
+ export { settled, useQuery, useQueryRead };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raf-query/react",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "React bindings for raf-query, a render-as-you-fetch query library",
5
5
  "keywords": [
6
6
  "query",
@@ -36,7 +36,6 @@
36
36
  "react-server": "./dist/index-react-server.mjs",
37
37
  "default": "./dist/index.mjs"
38
38
  },
39
- "./devtools": "./dist/devtools.mjs",
40
39
  "./tanstack-router": {
41
40
  "workerd": "./dist/tanstack-router.mjs",
42
41
  "worker": "./dist/tanstack-router.mjs",
@@ -48,11 +47,10 @@
48
47
  }
49
48
  },
50
49
  "dependencies": {
51
- "@raf-query/core": "0.11.1"
50
+ "@raf-query/core": "0.12.0"
52
51
  },
53
52
  "peerDependencies": {
54
53
  "@tanstack/react-router": "^1.170.0",
55
- "effect": "^4.0.0",
56
54
  "react": "^19.2.0"
57
55
  },
58
56
  "peerDependenciesMeta": {
@@ -61,19 +59,22 @@
61
59
  }
62
60
  },
63
61
  "devDependencies": {
64
- "@effect/vitest": "4.0.1",
62
+ "@babel/core": "7.29.7",
63
+ "@rolldown/plugin-babel": "0.2.4",
65
64
  "@tanstack/react-router": "1.170.42",
66
65
  "@testing-library/react": "16.3.3",
67
66
  "@types/node": "26.6.4",
68
67
  "@types/react": "19.3.0",
69
68
  "@types/react-dom": "19.3.0",
69
+ "@vitejs/plugin-react": "6.1.2",
70
70
  "@vitest/coverage-v8": "5.0.1",
71
- "effect": "^4.0.1",
71
+ "babel-plugin-react-compiler": "1.0.0",
72
72
  "happy-dom": "21.0.1",
73
73
  "react": "19.3.0",
74
74
  "react-dom": "19.3.0",
75
75
  "typescript": "7.0.2",
76
- "vite-plus": "1.0.0"
76
+ "vite-plus": "1.0.0",
77
+ "vitest": "5.0.1"
77
78
  },
78
79
  "publishConfig": {
79
80
  "access": "public",
@@ -84,8 +85,8 @@
84
85
  },
85
86
  "scripts": {
86
87
  "build": "vp pack",
87
- "test": "vp test",
88
- "typecheck": "tsc",
88
+ "test": "vp test --config vitest.compiler.config.ts && vp test",
89
+ "typecheck": "tsc && tsc -p tsconfig.no-node.json",
89
90
  "publish:npm": "pnpm pack --out package.tgz && npm publish package.tgz --access public && rm package.tgz"
90
91
  }
91
92
  }
@@ -1,38 +0,0 @@
1
- import { QueryClient } from "@raf-query/core";
2
- import { CSSProperties } from "react";
3
- //#region src/devtools-panel.d.ts
4
- export type DevtoolsTheme = "dark" | "light" | "system";
5
- export interface QueryDevtoolsPanelProps {
6
- /** The client to inspect. Default: the nearest `QueryClientProvider`'s. */
7
- readonly client?: QueryClient;
8
- /** Shows a close button that calls this. */
9
- readonly onClose?: () => void;
10
- /** Styles for the panel's root element, e.g. its height. */
11
- readonly style?: CSSProperties;
12
- /** Default: `"system"`. */
13
- readonly theme?: DevtoolsTheme;
14
- }
15
- export interface QueryDevtoolsProps {
16
- /** Corner of the toggle button. Default: `"bottom-right"`. */
17
- readonly buttonPosition?: "bottom-left" | "bottom-right" | "top-left" | "top-right";
18
- /** The client to inspect. Default: the nearest `QueryClientProvider`'s. */
19
- readonly client?: QueryClient;
20
- /** Open on the first visit. Later visits remember the last open state. */
21
- readonly initialIsOpen?: boolean;
22
- /** Edge of the viewport the panel docks to. Default: `"bottom"`. */
23
- readonly position?: "bottom" | "left" | "right" | "top";
24
- /** Default: `"system"`. */
25
- readonly theme?: DevtoolsTheme;
26
- }
27
- /**
28
- * The devtools panel on its own, to place anywhere in the page. Renders
29
- * nothing on the server, since the server's cache isn't the browser's.
30
- */
31
- export declare function QueryDevtoolsPanel(props: QueryDevtoolsPanelProps): import("react").JSX.Element | null;
32
- /**
33
- * A toggle button that opens the devtools panel docked to an edge of the
34
- * viewport. Renders nothing on the server. Open state and size are kept in
35
- * `localStorage`.
36
- */
37
- export declare function QueryDevtools(props: QueryDevtoolsProps): import("react").JSX.Element | null;
38
- //#endregion