@raf-query/core 0.3.0 → 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 +16 -2
- package/README.md +89 -11
- package/dist/cache.d.mts +5 -1
- package/dist/cache.mjs +8 -7
- package/dist/entry.d.mts +5 -3
- package/dist/entry.mjs +8 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
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
|
+
|
|
13
|
+
## [0.3.1](https://github.com/KATT/raf-query/compare/0.3.0...0.3.1) (2026-10-13)
|
|
14
|
+
|
|
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))
|
|
16
|
+
|
|
3
17
|
## [0.3.0](https://github.com/KATT/raf-query/compare/0.2.8...0.3.0) (2026-10-13)
|
|
4
18
|
|
|
5
19
|
### ⚠ BREAKING CHANGES
|
|
6
20
|
|
|
7
|
-
|
|
21
|
+
- RSC apps hydrate only through HydrationBoundary; QueryProvider moves to /rsc ([#61](https://github.com/KATT/raf-query/issues/61))
|
|
8
22
|
|
|
9
23
|
### Features
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
- RSC apps hydrate only through HydrationBoundary; QueryProvider moves to /rsc ([#61](https://github.com/KATT/raf-query/issues/61)) ([772de79](https://github.com/KATT/raf-query/commit/772de796adfdf267a9184fd75624ff30ae53f4fc)), references [#59](https://github.com/KATT/raf-query/issues/59) [#59](https://github.com/KATT/raf-query/issues/59) [#59](https://github.com/KATT/raf-query/issues/59) [#59](https://github.com/KATT/raf-query/issues/59)
|
|
12
26
|
|
|
13
27
|
## [0.2.8](https://github.com/KATT/raf-query/compare/0.2.7...0.2.8) (2026-10-13)
|
|
14
28
|
|
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 |
|
|
124
|
-
| --------------------------------- | ----------------------------- |
|
|
125
|
-
| `qc.prefetch(factory, input)` | `QueryHandle` |
|
|
126
|
-
| `await qc.ensure(factory, input)` | `Promise<EnsuredQueryHandle>` |
|
|
127
|
-
| `await qc.fetch(factory, input)` | `Promise<Data>` |
|
|
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
|
|
@@ -139,6 +149,13 @@ export const Route = createFileRoute("/posts/$slug")({
|
|
|
139
149
|
});
|
|
140
150
|
```
|
|
141
151
|
|
|
152
|
+
`prefetch` and `ensure` start the request at the same moment, so neither is faster. They differ in what the user sees while it's in flight:
|
|
153
|
+
|
|
154
|
+
- **`prefetch`: granular skeletons.** The navigation commits right away. Each part of the page that reads a query still loading suspends to its nearest Suspense boundary and fills in when its data streams in.
|
|
155
|
+
- **`ensure`: a blocked navigation.** The previous page stays on screen until the data is in. In TanStack Router, a route with a `pendingComponent` shows it once `pendingMs` (1 second by default) has passed, which makes a full-page skeleton.
|
|
156
|
+
|
|
157
|
+
Choose per query. `ensure` what the page can't lay out without, where a skeleton would only flash or shift (the post itself), and `prefetch` the rest (comments, sidebars, counts). Start every query before awaiting any of them, as the `prefetch` before the `await` above does, so a blocking query never delays the others.
|
|
158
|
+
|
|
142
159
|
Await independent blocking queries together with `allKeyed`, which ships with raf-query. It's a ponyfill of the TC39 `Promise.allKeyed` proposal: a record of promises in, a promise of the same record out, rejecting on the first failure.
|
|
143
160
|
|
|
144
161
|
```ts
|
|
@@ -150,17 +167,78 @@ return await allKeyed({
|
|
|
150
167
|
});
|
|
151
168
|
```
|
|
152
169
|
|
|
153
|
-
|
|
170
|
+
### Avoid dependent queries
|
|
171
|
+
|
|
172
|
+
A query whose input comes from another query's result can't start until that one has finished. That's a waterfall: one extra round trip per level, on every navigation. Fix it where the data is shaped, so every query a route needs starts from what the URL already holds:
|
|
173
|
+
|
|
174
|
+
- **Put the input in the route.** If the page is about an organization, its id belongs in the path (`/orgs/$orgId`), not in a lookup on the current user.
|
|
175
|
+
- **Have the backend resolve it.** The server already knows the session and the relations, so an endpoint can return the child with its parent, or be keyed by what the client has (`GET /me/org` instead of `GET /me` then `GET /orgs/:id`).
|
|
176
|
+
|
|
177
|
+
<details>
|
|
178
|
+
<summary>Example: the current user's organization</summary>
|
|
179
|
+
|
|
180
|
+
A settings page needs the user and their organization, and the organization's id is a field of the user:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
export const Route = createFileRoute("/settings")({
|
|
184
|
+
loader: async (args) => {
|
|
185
|
+
const qc = args.context.queryClient;
|
|
186
|
+
const user = await qc.fetch(currentUserQuery);
|
|
187
|
+
return {
|
|
188
|
+
user: qc.prefetch(currentUserQuery),
|
|
189
|
+
org: qc.prefetch(orgQuery, { orgId: user.orgId }),
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
With a 150 ms round trip, the organization can't arrive before 300 ms, and the navigation is blocked for the first 150 even though nothing on the page needed the user that early. Each query keyed by the organization (members, billing) would add another 150.
|
|
196
|
+
|
|
197
|
+
Fix it in the backend: the server can find the session's organization itself, so give it an endpoint and a query with no input.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
export function currentOrgQuery() {
|
|
201
|
+
return queryOptions({
|
|
202
|
+
queryKey: ["org", "current"],
|
|
203
|
+
queryFn: () => getCurrentOrg(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const Route = createFileRoute("/settings")({
|
|
208
|
+
loader: (args) => {
|
|
209
|
+
const qc = args.context.queryClient;
|
|
210
|
+
return {
|
|
211
|
+
user: qc.prefetch(currentUserQuery),
|
|
212
|
+
org: qc.prefetch(currentOrgQuery),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Or fix it in the routes: links into the page come from places that already know the organization (the org switcher, a list of orgs), so they can put its id in the URL.
|
|
154
219
|
|
|
155
220
|
```ts
|
|
156
|
-
const
|
|
157
|
-
|
|
221
|
+
export const Route = createFileRoute("/orgs/$orgId/settings")({
|
|
222
|
+
loader: (args) => {
|
|
223
|
+
const qc = args.context.queryClient;
|
|
224
|
+
return {
|
|
225
|
+
user: qc.prefetch(currentUserQuery),
|
|
226
|
+
org: qc.prefetch(orgQuery, { orgId: args.params.orgId }),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
});
|
|
158
230
|
```
|
|
159
231
|
|
|
232
|
+
Either way, both requests start together, and the page is ready after one round trip instead of two. Nothing waits, so each query can be a `prefetch` or an `ensure` purely for the loading experience you want.
|
|
233
|
+
|
|
234
|
+
</details>
|
|
235
|
+
|
|
236
|
+
When the data really can't be reshaped (a third-party API you don't control), the loader can `await qc.fetch(...)` the parent and build the input from its data, or a component can start the child with [`useWaterfall`](#render-time-inputs-usewaterfall). Both pay the extra round trip; the loader blocks the navigation on it, the component lets the rest of the page render first.
|
|
237
|
+
|
|
160
238
|
Behavior:
|
|
161
239
|
|
|
162
|
-
- Every verb dedupes against the cache
|
|
163
|
-
-
|
|
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`.
|
|
164
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.
|
|
165
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.
|
|
166
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`.
|
|
@@ -241,7 +319,7 @@ function PostPage() {
|
|
|
241
319
|
|
|
242
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.
|
|
243
321
|
|
|
244
|
-
`useQuery` never refetches on mount: whatever started the query decides. Stale data refetches
|
|
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`.
|
|
245
323
|
|
|
246
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.
|
|
247
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
|
-
/**
|
|
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
|
|
59
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 (
|
|
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 };
|