@raf-query/core 0.3.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/KATT/raf-query/compare/0.3.1...0.4.0) (2026-10-13)
4
+
5
+ ### ⚠ BREAKING CHANGES
6
+
7
+ * **core:** fetch waits for stale data, as TanStack's fetchQuery does ([#63](https://github.com/KATT/raf-query/issues/63))
8
+
9
+ ### Bug Fixes
10
+
11
+ * **core:** fetch waits for stale data, as TanStack's fetchQuery does ([#63](https://github.com/KATT/raf-query/issues/63)) ([c9785d5](https://github.com/KATT/raf-query/commit/c9785d5e99c0578c1748de3330823cc0e65f2378))
12
+
3
13
  ## [0.3.1](https://github.com/KATT/raf-query/compare/0.3.0...0.3.1) (2026-10-13)
4
14
 
5
- * teach avoiding dependent queries; frame prefetch vs ensure as loading UX ([#62](https://github.com/KATT/raf-query/issues/62)) ([ec62760](https://github.com/KATT/raf-query/commit/ec627609cbbee9fa4ff0cf568f69964341e27daa))
15
+ - teach avoiding dependent queries; frame prefetch vs ensure as loading UX ([#62](https://github.com/KATT/raf-query/issues/62)) ([ec62760](https://github.com/KATT/raf-query/commit/ec627609cbbee9fa4ff0cf568f69964341e27daa))
6
16
 
7
17
  ## [0.3.0](https://github.com/KATT/raf-query/compare/0.2.8...0.3.0) (2026-10-13)
8
18
 
package/README.md CHANGED
@@ -120,11 +120,21 @@ declare module "@raf-query/react" {
120
120
 
121
121
  All three verbs live on the query client and accept a factory plus its input.
122
122
 
123
- | Call | Returns | Blocks? | Use for |
124
- | --------------------------------- | ----------------------------- | ------- | ---------------------------------------------------------------------- |
125
- | `qc.prefetch(factory, input)` | `QueryHandle` | No | Data the route can render without; it streams in |
126
- | `await qc.ensure(factory, input)` | `Promise<EnsuredQueryHandle>` | Yes | Data the route must not render without; `useQuery` returns it directly |
127
- | `await qc.fetch(factory, input)` | `Promise<Data>` | Yes | Rendering data in a server component |
123
+ | Call | Returns | Use for |
124
+ | --------------------------------- | ----------------------------- | ---------------------------------------------------------------------- |
125
+ | `qc.prefetch(factory, input)` | `QueryHandle` | Data the route can render without; it streams in |
126
+ | `await qc.ensure(factory, input)` | `Promise<EnsuredQueryHandle>` | Data the route must not render without; `useQuery` returns it directly |
127
+ | `await qc.fetch(factory, input)` | `Promise<Data>` | Rendering data in a server component |
128
+
129
+ They differ in what they wait for:
130
+
131
+ | Verb | Cache miss | Stale data | Fresh data | TanStack Query |
132
+ | ---------- | --------------------- | --------------------------------------------- | ---------------- | ---------------------------------------------- |
133
+ | `prefetch` | Starts the request | Refetches in the background | Reused | `prefetchQuery`, not awaited |
134
+ | `ensure` | Waits for the request | Resolves at once, refetches in the background | Resolves at once | `ensureQueryData({ revalidateIfStale: true })` |
135
+ | `fetch` | Waits for the request | Waits for the refetch | Resolves at once | `fetchQuery` |
136
+
137
+ So `prefetch` never holds anything up, `ensure` holds up only the first visit (returning to a page shows the cached data at once, then fresh data), and `fetch` always returns fresh data, at the cost of a round trip whenever the cache is stale. On the server, data counts as stale only once `invalidate` has marked it (see below).
128
138
 
129
139
  ```tsx
130
140
  // TanStack Start
@@ -227,8 +237,8 @@ When the data really can't be reshaped (a third-party API you don't control), th
227
237
 
228
238
  Behavior:
229
239
 
230
- - Every verb dedupes against the cache. A fresh entry is reused; a stale entry is refetched in the background.
231
- - `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.
240
+ - Every verb dedupes against the cache: a request already in flight for the entry is joined, not repeated.
241
+ - A stale `ensure` doesn't hold up the navigation: the page renders the cached data and re-renders when the background refetch lands. A stale `fetch` in a loader would, so loaders use `ensure`.
232
242
  - 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.
233
243
  - 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.
234
244
  - 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`.
@@ -309,7 +319,7 @@ function PostPage() {
309
319
 
310
320
  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.
311
321
 
312
- `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`.
322
+ `useQuery` never refetches on mount: whatever started the query decides. Stale data refetches 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`.
313
323
 
314
324
  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.
315
325
 
package/dist/cache.d.mts CHANGED
@@ -78,7 +78,11 @@ export interface QueryCache {
78
78
  ensure<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<EnsuredQueryHandle<TFactory>, ErrorOf<TFactory> | CancelledError, ServicesOf<TFactory>>;
79
79
  /** Cache events from the moment the stream starts. */
80
80
  readonly events: Stream.Stream<Event>;
81
- /** Like `ensure`, but succeeds with the data. */
81
+ /**
82
+ * Start a query and succeed with its data. Waits on a cache miss and on
83
+ * stale data (on the server, only invalidated data), so the data is fresh.
84
+ * Fails if that request fails.
85
+ */
82
86
  fetch<TFactory extends QueryFactory>(factory: TFactory, ...args: LoadArgs<TFactory>): Effect.Effect<DataOf<TFactory>, ErrorOf<TFactory> | CancelledError, ServicesOf<TFactory>>;
83
87
  /**
84
88
  * Cached data for a factory call. `Some(undefined)` when the data itself
package/dist/cache.mjs CHANGED
@@ -3,7 +3,7 @@ import { Platform } from "./platform.mjs";
3
3
  import { unsafeCast } from "./cast.mjs";
4
4
  import { listen } from "./listen.mjs";
5
5
  import { hashKey, matchesKey } from "./key.mjs";
6
- import { changes, emit, fetch, invalidate, isObserved, make as make$1, onPlatformEvent, provide, remove, reset, revalidate, setData, setOptions, start } from "./entry.mjs";
6
+ import { changes, emit, fetch, invalidate, isObserved, make as make$1, needsRefetch, onPlatformEvent, provide, remove, reset, revalidate, setData, setOptions, start } from "./entry.mjs";
7
7
  import { dehydrateEntries, dehydrateStream, hydrateEntries } from "./hydration.mjs";
8
8
  import { optionsFor } from "./options.mjs";
9
9
  import { Clock, Context, Effect, Layer, Option, Predicate, RcRef, Stream } from "effect";
@@ -55,11 +55,12 @@ function keyMatcher(filter) {
55
55
  return (entry) => matchesKey(entry.key, prefix);
56
56
  }
57
57
  /**
58
- * Waits for an entry's data on a cache miss. Cached data returns at once,
59
- * and revalidates in the background (see `Entry.revalidate`).
58
+ * Waits for an entry's data on a cache miss, and with `fresh` also while it
59
+ * needs a refetch (see `Entry.needsRefetch`). Otherwise cached data returns at
60
+ * once, and revalidates in the background.
60
61
  */
61
- function load(entry, pages) {
62
- if (!entry.state.hasData) return unsafeCast(fetch(entry, { pages }));
62
+ function load(entry, pages, fresh) {
63
+ if (!entry.state.hasData || fresh && needsRefetch(entry)) return unsafeCast(fetch(entry, { pages }));
63
64
  revalidate(entry);
64
65
  return Effect.succeed(dataOf(entry));
65
66
  }
@@ -134,12 +135,12 @@ const make = /*#__PURE__*/ Effect.fnUntraced(function* (config = {}) {
134
135
  }),
135
136
  dehydrate: (options = {}) => Effect.sync(() => dehydrateEntries(state.entries.values(), options)),
136
137
  dehydrateStream: (options = {}) => dehydrateStream(() => state.entries.values(), subscribeUnsafe, options),
137
- ensure: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages).pipe(Effect.as({
138
+ ensure: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages, false).pipe(Effect.as({
138
139
  ...handleOf(entry),
139
140
  ensured: true
140
141
  }))),
141
142
  events: listen(subscribeUnsafe),
142
- fetch: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages)),
143
+ fetch: (factory, ...args) => Effect.flatMap(buildFor(factory, args[0]), (entry) => load(entry, args[1]?.pages, true)),
143
144
  getData: (factory, ...args) => Effect.sync(() => {
144
145
  const options = optionsFor(factory, args[0]);
145
146
  const entry = state.entries.get(hashKey(options.queryKey));
package/dist/entry.d.mts CHANGED
@@ -6,7 +6,7 @@ import { Pending, Thenable } from "./thenable.mjs";
6
6
  import { FetchRequest, Query, State, SubscribeOptions } from "./query.mjs";
7
7
  import { Cause, Clock, Context, Deferred, Effect, Scope, Stream } from "effect";
8
8
  declare namespace entry_d_exports {
9
- export { CacheState, CancelledError, Entry, TypeId, changes, emit, entryOf, fetch, hasNextPageUnsafe, hasPreviousPageUnsafe, invalidate, isObserved, isQuery, isStaleUnsafe, make, metaOf, now, onPlatformEvent, provide, readUnsafe, remove, reset, resume, revalidate, setData, setOptions, start, subscribeUnsafe };
9
+ export { CacheState, CancelledError, Entry, TypeId, changes, emit, entryOf, fetch, hasNextPageUnsafe, hasPreviousPageUnsafe, invalidate, isObserved, isQuery, isStaleUnsafe, make, metaOf, needsRefetch, now, onPlatformEvent, provide, readUnsafe, remove, reset, resume, revalidate, setData, setOptions, start, subscribeUnsafe };
10
10
  }
11
11
  export declare const TypeId = "~@raf-query/core/Query";
12
12
  declare const CancelledError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Cause.YieldableError & {
@@ -112,10 +112,12 @@ export declare function readUnsafe(entry: Entry): Thenable<unknown>;
112
112
  /** Start a request unless one is in flight, without waiting for it. */
113
113
  export declare function start(entry: Entry): void;
114
114
  /**
115
- * Background refetch of cached data that was asked for again. A server cache
116
- * renders once, so a refetch there could only duplicate a request whose
115
+ * Whether cached data that was asked for again should be refetched. A server
116
+ * cache renders once, so a refetch there could only duplicate a request whose
117
117
  * result is never shown; it refetches only what `invalidate` marked.
118
118
  */
119
+ export declare function needsRefetch(entry: Entry): boolean;
120
+ /** Background refetch of cached data that was asked for again. */
119
121
  export declare function revalidate(entry: Entry): void;
120
122
  export declare function fetch(entry: Entry, request?: FetchRequest): Effect.Effect<unknown, unknown>;
121
123
  export declare function invalidate(entry: Entry): Effect.Effect<void>;
package/dist/entry.mjs CHANGED
@@ -189,14 +189,17 @@ function start(entry) {
189
189
  begin(entry, {});
190
190
  }
191
191
  /**
192
- * Background refetch of cached data that was asked for again. A server cache
193
- * renders once, so a refetch there could only duplicate a request whose
192
+ * Whether cached data that was asked for again should be refetched. A server
193
+ * cache renders once, so a refetch there could only duplicate a request whose
194
194
  * result is never shown; it refetches only what `invalidate` marked.
195
195
  */
196
+ function needsRefetch(entry) {
197
+ return entry.cache.isServer ? entry.state.isInvalidated : isStaleUnsafe(entry);
198
+ }
199
+ /** Background refetch of cached data that was asked for again. */
196
200
  function revalidate(entry) {
197
201
  const state = entry.state;
198
- if (!state.hasData || state.isFetching) return;
199
- if (entry.cache.isServer ? state.isInvalidated : isStaleUnsafe(entry)) start(entry);
202
+ if (state.hasData && !state.isFetching && needsRefetch(entry)) start(entry);
200
203
  }
201
204
  function fetch(entry, request = {}) {
202
205
  return Effect.suspend(() => {
@@ -496,4 +499,4 @@ function scheduleStale(entry) {
496
499
  entry.tasks.fork("stale", Effect.sleep(Math.max(remaining, 0) + 1).pipe(Effect.andThen(Effect.sync(() => set(entry, {})))));
497
500
  }
498
501
  //#endregion
499
- export { CancelledError, TypeId, changes, emit, entryOf, fetch, hasNextPageUnsafe, hasPreviousPageUnsafe, invalidate, isObserved, isQuery, isStaleUnsafe, make, metaOf, now, onPlatformEvent, provide, readUnsafe, remove, reset, resume, revalidate, setData, setOptions, start, subscribeUnsafe };
502
+ export { CancelledError, TypeId, changes, emit, entryOf, fetch, hasNextPageUnsafe, hasPreviousPageUnsafe, invalidate, isObserved, isQuery, isStaleUnsafe, make, metaOf, needsRefetch, now, onPlatformEvent, provide, readUnsafe, remove, reset, resume, revalidate, setData, setOptions, start, subscribeUnsafe };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raf-query/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-agnostic core of raf-query, a render-as-you-fetch query library",
5
5
  "keywords": [
6
6
  "query",