@raf-query/core 0.0.13 → 0.0.14

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.14](https://github.com/KATT/raf-query/compare/0.0.13...0.0.14) (2026-10-12)
4
+
5
+ ### Features
6
+
7
+ * tracked query results via a core QueryObserver, like TanStack Query ([#13](https://github.com/KATT/raf-query/issues/13)) ([7b99b47](https://github.com/KATT/raf-query/commit/7b99b4704be110c999b41277afd5dac4c515f45f))
8
+
3
9
  ## [0.0.13](https://github.com/KATT/raf-query/compare/0.0.12...0.0.13) (2026-10-12)
4
10
 
5
11
  ### Features
6
12
 
7
- * useQuery returns data for handles from ensure ([#16](https://github.com/KATT/raf-query/issues/16)) ([a50b8fd](https://github.com/KATT/raf-query/commit/a50b8fdc51b952341377d4b55f25ebb6b21d39e1))
13
+ - useQuery returns data for handles from ensure ([#16](https://github.com/KATT/raf-query/issues/16)) ([a50b8fd](https://github.com/KATT/raf-query/commit/a50b8fdc51b952341377d4b55f25ebb6b21d39e1))
8
14
 
9
15
  ## [0.0.12](https://github.com/KATT/raf-query/compare/0.0.11...0.0.12) (2026-10-12)
10
16
 
package/README.md CHANGED
@@ -189,7 +189,7 @@ function PostPage() {
189
189
  | `hash` | Stable string form of the handle's key, for `key` props on Suspense boundaries |
190
190
  | `refetch()` | Refetch now. Returns a promise, so it can be awaited inside a transition or action |
191
191
 
192
- 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. When the **first** load fails, the promise rejects and `use()` throws to the nearest error boundary. Resetting that boundary refetches the query.
192
+ 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.
193
193
 
194
194
  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`.
195
195
 
@@ -304,7 +304,7 @@ return (
304
304
  );
305
305
  ```
306
306
 
307
- The parent re-renders when `controls` change. If it only passes the promise down, call the hook in the child instead.
307
+ A component re-renders when the promise changes, and when a control it reads changes. Controls it never reads, like `isFetching` here, don't re-render it, so a parent that only passes the promise down stays put during background refetches.
308
308
 
309
309
  ## Render-time inputs: `useWaterfall`
310
310
 
@@ -557,18 +557,19 @@ Query options, accepted by `queryOptions`, `infiniteQueryOptions` and `config.de
557
557
 
558
558
  `@raf-query/core`:
559
559
 
560
- | Export | Use |
561
- | ---------------------------------------------- | ---------------------------------------------------------- |
562
- | `QueryClient` | The cache |
563
- | `queryOptions(options)` | Typed options for a query factory |
564
- | `infiniteQueryOptions(options)` | Typed options for a paged query factory |
565
- | `allKeyed(record)` | Await a record of promises, keeping keys |
566
- | `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
567
- | `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
568
- | `CancelledError` | Rejection of a request cancelled by `clear()` or a restart |
569
- | `QueryHandle<TFactory>` | Handle type for props |
570
- | `EnsuredQueryHandle<TFactory>` | Handle type for props fed by `ensure` |
571
- | `DataOf<TFactory>`, `InputOf<TFactory>` | A factory's data and input types |
560
+ | Export | Use |
561
+ | ---------------------------------------------- | ---------------------------------------------------------------- |
562
+ | `QueryClient` | The cache |
563
+ | `queryOptions(options)` | Typed options for a query factory |
564
+ | `infiniteQueryOptions(options)` | Typed options for a paged query factory |
565
+ | `allKeyed(record)` | Await a record of promises, keeping keys |
566
+ | `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
567
+ | `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
568
+ | `QueryObserver` | One reader's view of a query, with tracked results; for bindings |
569
+ | `CancelledError` | Rejection of a request cancelled by `clear()` or a restart |
570
+ | `QueryHandle<TFactory>` | Handle type for props |
571
+ | `EnsuredQueryHandle<TFactory>` | Handle type for props fed by `ensure` |
572
+ | `DataOf<TFactory>`, `InputOf<TFactory>` | A factory's data and input types |
572
573
 
573
574
  `@raf-query/react` (plus all of the above):
574
575
 
package/dist/index.d.mts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Settled, allKeyed } from "./all-keyed.mjs";
2
2
  import { QueryKey, hashKey, matchesKey } from "./key.mjs";
3
3
  import { AnyQueryOptions, DataOf, EnsuredQueryHandle, InfiniteData, InfiniteQueryOptions, InputArgs, InputOf, IsPaged, LoadOptions, PageFunctionContext, PageOf, QueryBehavior, QueryFactory, QueryFnResult, QueryFunctionContext, QueryHandle, QueryOptions, RetryPolicy, infiniteQueryOptions, queryOptions } from "./options.mjs";
4
- import { CancelledError, FetchRequest, Query, QueryObserver, QueryState } from "./query.mjs";
4
+ import { CancelledError, FetchRequest, Query, QueryState, SubscribeOptions } from "./query.mjs";
5
5
  import { CacheEvent, LoadArgs, QueryClient, QueryClientConfig, QueryClientEffect, QueryFilter, Updater } from "./client.mjs";
6
6
  import { DehydrateOptions, DehydratedQuery, DehydratedState, HydrateOptions, canDehydrate, dehydrate, dehydrateQuery, hydrate } from "./hydration.mjs";
7
- export { type AnyQueryOptions, type CacheEvent, CancelledError, type DataOf, type DehydrateOptions, type DehydratedQuery, type DehydratedState, type EnsuredQueryHandle, type FetchRequest, type HydrateOptions, type InfiniteData, type InfiniteQueryOptions, type InputArgs, type InputOf, type IsPaged, type LoadArgs, type LoadOptions, type PageFunctionContext, type PageOf, type Query, type QueryBehavior, QueryClient, type QueryClientConfig, type QueryClientEffect, type QueryFactory, type QueryFilter, type QueryFnResult, type QueryFunctionContext, type QueryHandle, type QueryKey, type QueryObserver, type QueryOptions, type QueryState, type RetryPolicy, type Settled, type Updater, allKeyed, canDehydrate, dehydrate, dehydrateQuery, hashKey, hydrate, infiniteQueryOptions, matchesKey, queryOptions };
7
+ import { QueryObserver, QueryObserverOptions, QueryObserverResult } from "./observer.mjs";
8
+ export { type AnyQueryOptions, type CacheEvent, CancelledError, type DataOf, type DehydrateOptions, type DehydratedQuery, type DehydratedState, type EnsuredQueryHandle, type FetchRequest, type HydrateOptions, type InfiniteData, type InfiniteQueryOptions, type InputArgs, type InputOf, type IsPaged, type LoadArgs, type LoadOptions, type PageFunctionContext, type PageOf, type Query, type QueryBehavior, QueryClient, type QueryClientConfig, type QueryClientEffect, type QueryFactory, type QueryFilter, type QueryFnResult, type QueryFunctionContext, type QueryHandle, type QueryKey, QueryObserver, type QueryObserverOptions, type QueryObserverResult, type QueryOptions, type QueryState, type RetryPolicy, type Settled, type SubscribeOptions, type Updater, allKeyed, canDehydrate, dehydrate, dehydrateQuery, hashKey, hydrate, infiniteQueryOptions, matchesKey, queryOptions };
package/dist/index.mjs CHANGED
@@ -4,4 +4,5 @@ import { infiniteQueryOptions, queryOptions } from "./options.mjs";
4
4
  import { CancelledError } from "./query.mjs";
5
5
  import { QueryClient } from "./client.mjs";
6
6
  import { canDehydrate, dehydrate, dehydrateQuery, hydrate } from "./hydration.mjs";
7
- export { CancelledError, QueryClient, allKeyed, canDehydrate, dehydrate, dehydrateQuery, hashKey, hydrate, infiniteQueryOptions, matchesKey, queryOptions };
7
+ import { QueryObserver } from "./observer.mjs";
8
+ export { CancelledError, QueryClient, QueryObserver, allKeyed, canDehydrate, dehydrate, dehydrateQuery, hashKey, hydrate, infiniteQueryOptions, matchesKey, queryOptions };
@@ -0,0 +1,82 @@
1
+ import { QueryKey } from "./key.mjs";
2
+ import { QueryFactory } from "./options.mjs";
3
+ import { Query } from "./query.mjs";
4
+ //#region src/observer.d.ts
5
+ /** How one reader observes its query. */
6
+ export interface QueryObserverOptions {
7
+ /** Refetch on this interval (ms) while subscribed and the page is visible. */
8
+ readonly refetchInterval?: number;
9
+ /** Refetch on reconnect while stale. Default: the query's option, else `true`. */
10
+ readonly refetchOnReconnect?: boolean;
11
+ /** Refetch on window focus while stale. Default: the query's option, else `true`. */
12
+ readonly refetchOnWindowFocus?: boolean;
13
+ /**
14
+ * What to show while the query has no data yet:
15
+ * - `"suspend"` (default): the result's promise is the query's pending one.
16
+ * - `"previous"`: keep the last observed query's data, with `isPrevious`.
17
+ */
18
+ readonly whilePending?: "previous" | "suspend";
19
+ }
20
+ /** What a reader sees of its query. Replaced, never mutated, on every change. */
21
+ export interface QueryObserverResult<TData = unknown> {
22
+ /** Error of the last failed request. Existing data stays readable. */
23
+ readonly error: unknown;
24
+ fetchNextPage(): Promise<void>;
25
+ fetchPreviousPage(): Promise<void>;
26
+ /** Stable string form of the observed query's key. */
27
+ readonly hash: string;
28
+ readonly hasNextPage: boolean;
29
+ readonly hasPreviousPage: boolean;
30
+ /** Input of the data in `promise`; differs from the query's while `isPrevious`. */
31
+ readonly input: unknown;
32
+ /** A request is in flight: first load, new input, or background refetch. */
33
+ readonly isFetching: boolean;
34
+ readonly isFetchingNextPage: boolean;
35
+ readonly isFetchingPreviousPage: boolean;
36
+ /** No data yet for the observed query. */
37
+ readonly isPending: boolean;
38
+ /** `promise` holds a previous query's data (`whilePending: "previous"`). */
39
+ readonly isPrevious: boolean;
40
+ /** The data is older than `staleTime`, or was invalidated. */
41
+ readonly isStale: boolean;
42
+ /** Key of the data in `promise`; differs from the query's while `isPrevious`. */
43
+ readonly key: QueryKey;
44
+ /** Settles with the data to show; see `QueryState.promise`. */
45
+ readonly promise: Promise<TData>;
46
+ refetch(): Promise<void>;
47
+ /** When the data in `promise` was fetched (ms since epoch); `0` before that. */
48
+ readonly updatedAt: number;
49
+ }
50
+ type ResultKey = keyof QueryObserverResult;
51
+ /**
52
+ * One reader of one query at a time, like TanStack Query's `QueryObserver`:
53
+ * it derives the reader's result from the query's state, keeps a previous
54
+ * query's data for `whilePending: "previous"`, runs `refetchInterval`, and
55
+ * notifies its listeners only when a result property they read changed
56
+ * (see {@link QueryObserver.trackResult}). Bindings create one per hook.
57
+ */
58
+ export declare class QueryObserver<TData = unknown, TFactory extends QueryFactory = QueryFactory> {
59
+ #private;
60
+ constructor(query: Query<TData, TFactory>, options?: QueryObserverOptions);
61
+ /**
62
+ * The result for `query` with `options` right now, without switching to
63
+ * it. Bindings call this during render, before `setOptions` commits.
64
+ */
65
+ getOptimisticResult(query: Query<TData, TFactory>, options: QueryObserverOptions): QueryObserverResult<TData>;
66
+ getCurrentResult: () => QueryObserverResult<TData>;
67
+ /** Observe `query` with `options` from now on. */
68
+ setOptions(query: Query<TData, TFactory>, options: QueryObserverOptions): void;
69
+ /** Listen for result changes. The first listener subscribes to the query. */
70
+ subscribe: (listener: () => void) => (() => void);
71
+ /**
72
+ * Wrap `result` so that reading a property marks it tracked. Once any
73
+ * property is tracked, listeners are only notified when a tracked one
74
+ * changes.
75
+ */
76
+ trackResult<TResult extends QueryObserverResult<TData>>(result: TResult): TResult;
77
+ /** Mark `name` as read, for bindings that track access themselves. */
78
+ trackProp(name: ResultKey): void;
79
+ /** Recompute the result, and notify listeners if a tracked property changed. */
80
+ updateResult(): void;
81
+ }
82
+ //#endregion
@@ -0,0 +1,167 @@
1
+ //#region src/observer.ts
2
+ const resultKeys = {
3
+ error: true,
4
+ fetchNextPage: true,
5
+ fetchPreviousPage: true,
6
+ hash: true,
7
+ hasNextPage: true,
8
+ hasPreviousPage: true,
9
+ input: true,
10
+ isFetching: true,
11
+ isFetchingNextPage: true,
12
+ isFetchingPreviousPage: true,
13
+ isPending: true,
14
+ isPrevious: true,
15
+ isStale: true,
16
+ key: true,
17
+ promise: true,
18
+ refetch: true,
19
+ updatedAt: true
20
+ };
21
+ function isResultKey(name) {
22
+ return typeof name === "string" && Object.hasOwn(resultKeys, name);
23
+ }
24
+ function shallowEqual(a, b) {
25
+ for (const name of Object.keys(resultKeys)) if (isResultKey(name) && a[name] !== b[name]) return false;
26
+ return true;
27
+ }
28
+ /**
29
+ * One reader of one query at a time, like TanStack Query's `QueryObserver`:
30
+ * it derives the reader's result from the query's state, keeps a previous
31
+ * query's data for `whilePending: "previous"`, runs `refetchInterval`, and
32
+ * notifies its listeners only when a result property they read changed
33
+ * (see {@link QueryObserver.trackResult}). Bindings create one per hook.
34
+ */
35
+ var QueryObserver = class {
36
+ #current;
37
+ #interval;
38
+ #lastWithData;
39
+ #listeners = /* @__PURE__ */ new Set();
40
+ #options;
41
+ #query;
42
+ #trackedProps = /* @__PURE__ */ new Set();
43
+ #unsubscribe;
44
+ constructor(query, options = {}) {
45
+ this.#query = query;
46
+ this.#options = options;
47
+ this.#current = this.#createResult(query, options);
48
+ this.updateResult();
49
+ }
50
+ /**
51
+ * The result for `query` with `options` right now, without switching to
52
+ * it. Bindings call this during render, before `setOptions` commits.
53
+ */
54
+ getOptimisticResult(query, options) {
55
+ const result = this.#createResult(query, options);
56
+ if (!shallowEqual(this.#current, result)) this.#current = result;
57
+ return result;
58
+ }
59
+ getCurrentResult = () => this.#current;
60
+ /** Observe `query` with `options` from now on. */
61
+ setOptions(query, options) {
62
+ const previous = this.#query;
63
+ const previousInterval = this.#options.refetchInterval;
64
+ this.#query = query;
65
+ this.#options = options;
66
+ if (this.#listeners.size > 0) {
67
+ if (query !== previous) {
68
+ this.#unsubscribe?.();
69
+ this.#unsubscribe = this.#subscribeToQuery();
70
+ }
71
+ if (query !== previous || options.refetchInterval !== previousInterval) this.#startInterval();
72
+ }
73
+ this.updateResult();
74
+ }
75
+ /** Listen for result changes. The first listener subscribes to the query. */
76
+ subscribe = (listener) => {
77
+ this.#listeners.add(listener);
78
+ if (this.#listeners.size === 1) {
79
+ this.#unsubscribe = this.#subscribeToQuery();
80
+ this.#startInterval();
81
+ this.updateResult();
82
+ }
83
+ return () => {
84
+ if (!this.#listeners.delete(listener) || this.#listeners.size > 0) return;
85
+ this.#unsubscribe?.();
86
+ this.#unsubscribe = void 0;
87
+ clearInterval(this.#interval);
88
+ };
89
+ };
90
+ /**
91
+ * Wrap `result` so that reading a property marks it tracked. Once any
92
+ * property is tracked, listeners are only notified when a tracked one
93
+ * changes.
94
+ */
95
+ trackResult(result) {
96
+ return new Proxy(result, { get: (target, name, receiver) => {
97
+ if (isResultKey(name)) this.trackProp(name);
98
+ return Reflect.get(target, name, receiver);
99
+ } });
100
+ }
101
+ /** Mark `name` as read, for bindings that track access themselves. */
102
+ trackProp(name) {
103
+ this.#trackedProps.add(name);
104
+ }
105
+ /** Recompute the result, and notify listeners if a tracked property changed. */
106
+ updateResult() {
107
+ const previous = this.#current;
108
+ const next = this.#createResult(this.#query, this.#options);
109
+ if (this.#query.getState().hasData) this.#lastWithData = this.#query;
110
+ if (shallowEqual(previous, next)) return;
111
+ this.#current = next;
112
+ if (this.#shouldNotify(previous, next)) for (const listener of this.#listeners) listener();
113
+ }
114
+ #shouldNotify(previous, next) {
115
+ if (this.#trackedProps.size === 0) return true;
116
+ for (const name of this.#trackedProps) if (previous[name] !== next[name]) return true;
117
+ return false;
118
+ }
119
+ #subscribeToQuery() {
120
+ const options = () => this.#options;
121
+ return this.#query.subscribe(() => this.updateResult(), {
122
+ get refetchOnReconnect() {
123
+ return options().refetchOnReconnect;
124
+ },
125
+ get refetchOnWindowFocus() {
126
+ return options().refetchOnWindowFocus;
127
+ }
128
+ });
129
+ }
130
+ #startInterval() {
131
+ clearInterval(this.#interval);
132
+ this.#interval = void 0;
133
+ const interval = this.#options.refetchInterval;
134
+ if (interval === void 0 || !Number.isFinite(interval) || interval <= 0) return;
135
+ this.#interval = setInterval(() => {
136
+ if (!("document" in globalThis) || document.visibilityState !== "hidden") this.#query.refetch();
137
+ }, interval);
138
+ }
139
+ #createResult(query, options) {
140
+ const state = query.getState();
141
+ const last = this.#lastWithData;
142
+ const previous = options.whilePending === "previous" && !state.hasData && state.status !== "error" && last !== void 0 && last.hash !== query.hash ? last : void 0;
143
+ const shown = previous ?? query;
144
+ const shownState = previous ? previous.getState() : state;
145
+ return {
146
+ error: state.error,
147
+ fetchNextPage: query.fetchNextPage,
148
+ fetchPreviousPage: query.fetchPreviousPage,
149
+ hash: query.hash,
150
+ hasNextPage: query.hasNextPage(),
151
+ hasPreviousPage: query.hasPreviousPage(),
152
+ input: shown.handle.input,
153
+ isFetching: state.isFetching,
154
+ isFetchingNextPage: state.fetchDirection === "forward",
155
+ isFetchingPreviousPage: state.fetchDirection === "backward",
156
+ isPending: !state.hasData,
157
+ isPrevious: previous !== void 0,
158
+ isStale: state.isStale,
159
+ key: shown.handle.key,
160
+ promise: shownState.promise,
161
+ refetch: query.refetch,
162
+ updatedAt: shownState.updatedAt
163
+ };
164
+ }
165
+ };
166
+ //#endregion
167
+ export { QueryObserver };
package/dist/query.d.mts CHANGED
@@ -28,7 +28,7 @@ export interface QueryState<TData> {
28
28
  readonly updatedAt: number;
29
29
  }
30
30
  /** Per-reader preferences, registered with {@link Query.subscribe}. */
31
- export interface QueryObserver {
31
+ export interface SubscribeOptions {
32
32
  readonly refetchOnReconnect?: boolean;
33
33
  readonly refetchOnWindowFocus?: boolean;
34
34
  }
@@ -77,7 +77,7 @@ export interface Query<TData = unknown, TFactory extends QueryFactory = QueryFac
77
77
  */
78
78
  reset(): Promise<void>;
79
79
  /** Listen for state changes. Subscribed entries count as read. */
80
- subscribe(listener: () => void, observer?: QueryObserver): () => void;
80
+ subscribe(listener: () => void, options?: SubscribeOptions): () => void;
81
81
  }
82
82
  /** Thrown into a request's awaiters when the cache is cleared mid-request. */
83
83
  export declare class CancelledError extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raf-query/core",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Framework-agnostic core of raf-query, a render-as-you-fetch query library",
5
5
  "keywords": [
6
6
  "query",