@uniflowed/query 0.0.0-alpha.2

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/index.js ADDED
@@ -0,0 +1,25 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query`: asking for the same thing twice should cost once.
4
+ //
5
+ // Three behaviours an application writes by hand around `fetch` and gets
6
+ // wrong, and which are the reason a query library exists at all:
7
+ //
8
+ // * **De-duplication.** A header and a sidebar both showing the current user
9
+ // make one request, not two, and cannot disagree about the answer.
10
+ // * **Stale-while-revalidate.** A cached value is shown at once and
11
+ // refreshed behind it, so navigating back does not flash a spinner over
12
+ // data that is already there.
13
+ // * **Invalidation by prefix.** After creating a user, `["users"]`
14
+ // invalidates `["users", 1]` and `["users", 2]` without listing them.
15
+ //
16
+ // The cache is a plain object graph with no React in it, and `useQuery` is a
17
+ // `useSyncExternalStore` over it — so a value is correct during a prerender,
18
+ // two components reading one key cannot render different versions of it, and
19
+ // the cache is testable without rendering anything.
20
+
21
+ export type { Entry, QueryKey } from "./internal/cache.js";
22
+ export type { MutationResult, QueryOptions, QueryResult } from "./internal/react.js";
23
+
24
+ export { QueryCache, hash } from "./internal/cache.js";
25
+ export { QueryProvider, useMutation, useQuery, useQueryCache } from "./internal/react.js";
@@ -0,0 +1,280 @@
1
+ // @flow
2
+ //
3
+ // The cache a query reads from, and who else is reading it.
4
+ //
5
+ // This is the part that makes a query library worth having rather than a
6
+ // `useEffect` and a `useState`:
7
+ //
8
+ // * **Two components asking for the same thing make one request.** Without
9
+ // that, a page with a header and a sidebar both showing the current user
10
+ // fetches the user twice, and they can disagree.
11
+ // * **A cached answer is shown immediately and refreshed behind it.** The
12
+ // alternative is a spinner every time a reader navigates back, which is
13
+ // the single most common way an application feels slow while being fast.
14
+ // * **An answer that is old enough is refetched; one that is merely used is
15
+ // not.** Those are different questions, and conflating them either
16
+ // hammers the server or shows stale data forever.
17
+ //
18
+ // The store is deliberately not React-aware: it is a map, a set of listeners
19
+ // per key, and a promise per in-flight request. `useQuery` is a
20
+ // `useSyncExternalStore` over it, which is what makes a cached value correct
21
+ // during a prerender and free of tearing on the client.
22
+
23
+ /** A key, as a caller writes it. */
24
+ export type QueryKey = $ReadOnlyArray<mixed>;
25
+
26
+ /** What the cache holds for one key. */
27
+ export type Entry<T> = {|
28
+ readonly value: T | void,
29
+ readonly error: Error | null,
30
+ /** When the value arrived, so staleness is a question the reader can ask. */
31
+ readonly updatedAt: number,
32
+ readonly pending: boolean,
33
+ /** Bumped on every change, so a snapshot can be compared by identity. */
34
+ readonly version: number,
35
+ |};
36
+
37
+ type Slot = {
38
+ entry: Entry<mixed>,
39
+ listeners: Set<() => void>,
40
+ inFlight: Promise<mixed> | null,
41
+ /** Cleared when the last listener goes and the entry has outlived its use. */
42
+ collect: TimeoutID | null,
43
+ /**
44
+ * How to fetch this key again, recorded by `watch`.
45
+ *
46
+ * Without it, invalidation can only mark an entry stale — and a component
47
+ * already watching would re-render, see that it is stale, and do nothing,
48
+ * because nothing was asked to fetch. Keeping the query here is what lets
49
+ * the store refresh what somebody is looking at.
50
+ */
51
+ query: (() => Promise<mixed>) | null,
52
+ };
53
+
54
+ /** How long an unused entry is kept, so a quick navigation back is free. */
55
+ const DEFAULT_GARBAGE_MILLIS = 5 * 60_000;
56
+
57
+ const EMPTY: Entry<mixed> = {
58
+ value: undefined,
59
+ error: null,
60
+ updatedAt: 0,
61
+ pending: false,
62
+ version: 0,
63
+ };
64
+
65
+ /**
66
+ * A cache. An application usually has one, and a test has one per test.
67
+ *
68
+ * Explicit rather than a module-level singleton, because a singleton is shared
69
+ * with every other test in the process and one test's cached answer then
70
+ * decides another's result.
71
+ */
72
+ export class QueryCache {
73
+ readonly slots: Map<string, Slot> = new Map();
74
+ readonly garbageMillis: number;
75
+
76
+ constructor(options?: {| readonly garbageMillis?: number |}) {
77
+ this.garbageMillis = options?.garbageMillis ?? DEFAULT_GARBAGE_MILLIS;
78
+ }
79
+
80
+ /** What is known about `key` right now. */
81
+ read(key: QueryKey): Entry<mixed> {
82
+ return this.slots.get(hash(key))?.entry ?? EMPTY;
83
+ }
84
+
85
+ /** Watch `key`. Returns the unsubscribe. */
86
+ subscribe(key: QueryKey, listener: () => void): () => void {
87
+ const id = hash(key);
88
+ const slot = this.slot(id);
89
+ slot.listeners.add(listener);
90
+ if (slot.collect != null) {
91
+ clearTimeout(slot.collect);
92
+ slot.collect = null;
93
+ }
94
+ return () => {
95
+ slot.listeners.delete(listener);
96
+ this.maybeCollect(id);
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Watch `key`, and keep it fresh while anybody is watching.
102
+ *
103
+ * Subscribing *is* the signal that a value is wanted, so the fetch belongs
104
+ * here rather than in an effect beside it. That is what lets `useQuery` be a
105
+ * plain `useSyncExternalStore` over this store — no effect to trigger the
106
+ * request, no ref to hold the latest query function, and no dependency array
107
+ * to argue with. The store owns its own freshness, which is what an external
108
+ * store is for.
109
+ *
110
+ * The query function is captured per subscription, which is per key: a
111
+ * component re-rendering with a new closure does not re-subscribe, and the
112
+ * request for a given key does not change meaning between renders.
113
+ */
114
+ watch<T>(
115
+ key: QueryKey,
116
+ query: () => Promise<T>,
117
+ options: {| readonly listener: () => void, readonly staleTime: number |},
118
+ ): () => void {
119
+ const id = hash(key);
120
+ const slot = this.slot(id);
121
+ slot.query = query as $FlowFixMe;
122
+
123
+ const unsubscribe = this.subscribe(key, options.listener);
124
+ if (this.isStale(key, options.staleTime)) {
125
+ // The rejection is recorded on the entry, and every watcher is told.
126
+ // Letting it reach the console as well would report it twice.
127
+ this.fetch(key, query).catch(() => {});
128
+ }
129
+ return unsubscribe;
130
+ }
131
+
132
+ /**
133
+ * Run `query` for `key`, or join the request already in flight.
134
+ *
135
+ * The de-duplication is the whole point: two components mounting in the same
136
+ * tick both call this, and one request is made.
137
+ */
138
+ fetch<T>(key: QueryKey, query: () => Promise<T>): Promise<T> {
139
+ const id = hash(key);
140
+ const slot = this.slot(id);
141
+ if (slot.inFlight != null) {
142
+ return slot.inFlight as $FlowFixMe;
143
+ }
144
+
145
+ this.write(id, { pending: true });
146
+ const promise = query().then(
147
+ (value) => {
148
+ slot.inFlight = null;
149
+ this.write(id, { value, error: null, updatedAt: now(), pending: false });
150
+ return value;
151
+ },
152
+ (thrown) => {
153
+ slot.inFlight = null;
154
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
155
+ // The previous value is kept beside the error. A failed refresh should
156
+ // not blank a page that was showing something.
157
+ this.write(id, { error, pending: false });
158
+ throw error;
159
+ },
160
+ );
161
+ slot.inFlight = promise;
162
+ return promise;
163
+ }
164
+
165
+ /** Whether `key`'s value is older than `millis`. */
166
+ isStale(key: QueryKey, millis: number): boolean {
167
+ const entry = this.read(key);
168
+ if (entry.updatedAt === 0) {
169
+ return true;
170
+ }
171
+ return now() - entry.updatedAt >= millis;
172
+ }
173
+
174
+ /** Put a value in without running a query, for an optimistic update. */
175
+ set<T>(key: QueryKey, value: T): void {
176
+ this.write(hash(key), { value, error: null, updatedAt: now(), pending: false });
177
+ }
178
+
179
+ /**
180
+ * Mark matching keys stale so their watchers refetch.
181
+ *
182
+ * A key *prefix* matches, because that is how invalidation is actually
183
+ * expressed: after a mutation, `["users"]` should refresh `["users", 1]` and
184
+ * `["users", 2]` without the caller listing them.
185
+ */
186
+ invalidate(prefix: QueryKey): void {
187
+ const wanted = hash(prefix);
188
+ for (const [id, slot] of Array.from(this.slots)) {
189
+ if (id !== wanted && !id.startsWith(`${wanted.slice(0, -1)},`)) {
190
+ continue;
191
+ }
192
+ // `updatedAt: 0` is "never fetched", which is what makes every reader
193
+ // treat it as stale without inventing a separate flag.
194
+ this.write(id, { updatedAt: 0 });
195
+
196
+ // And refetch what somebody is looking at. Marking it stale alone would
197
+ // leave a mounted component re-rendering, seeing that it is stale, and
198
+ // doing nothing — the freshness is this store's job, not the
199
+ // component's.
200
+ const query = slot.query;
201
+ if (query != null && slot.listeners.size > 0) {
202
+ this.fetch(JSON.parse(id), query).catch(() => {});
203
+ }
204
+ }
205
+ }
206
+
207
+ /** Forget everything. */
208
+ clear(): void {
209
+ for (const slot of this.slots.values()) {
210
+ if (slot.collect != null) {
211
+ clearTimeout(slot.collect);
212
+ }
213
+ }
214
+ this.slots.clear();
215
+ }
216
+
217
+ slot(id: string): Slot {
218
+ let slot = this.slots.get(id);
219
+ if (slot == null) {
220
+ slot = {
221
+ entry: EMPTY,
222
+ listeners: new Set(),
223
+ inFlight: null,
224
+ collect: null,
225
+ query: null,
226
+ };
227
+ this.slots.set(id, slot);
228
+ }
229
+ return slot;
230
+ }
231
+
232
+ write(id: string, patch: { readonly [string]: mixed }): void {
233
+ const slot = this.slot(id);
234
+ slot.entry = {
235
+ ...slot.entry,
236
+ ...patch,
237
+ version: slot.entry.version + 1,
238
+ } as $FlowFixMe;
239
+ for (const listener of Array.from(slot.listeners)) {
240
+ if (slot.listeners.has(listener)) {
241
+ listener();
242
+ }
243
+ }
244
+ }
245
+
246
+ maybeCollect(id: string): void {
247
+ const slot = this.slots.get(id);
248
+ if (slot == null || slot.listeners.size > 0 || slot.collect != null) {
249
+ return;
250
+ }
251
+ // Kept for a while after the last watcher goes: navigating away and back
252
+ // is the common case, and it should not cost a request.
253
+ slot.collect = setTimeout(() => {
254
+ const current = this.slots.get(id);
255
+ if (current != null && current.listeners.size === 0) {
256
+ this.slots.delete(id);
257
+ }
258
+ }, this.garbageMillis);
259
+ // A timer must not hold the process open — a test that finishes before the
260
+ // collection is due should still exit.
261
+ if (typeof (slot.collect as $FlowFixMe)?.unref === "function") {
262
+ (slot.collect as $FlowFixMe).unref();
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * A key as a string.
269
+ *
270
+ * `JSON.stringify` of the array, so `["users", 1]` and `["users", "1"]` are
271
+ * different keys — they are different requests, and treating them as one is a
272
+ * bug that shows up as the wrong data rather than as an error.
273
+ */
274
+ export function hash(key: QueryKey): string {
275
+ return JSON.stringify(key);
276
+ }
277
+
278
+ function now(): number {
279
+ return Date.now();
280
+ }
@@ -0,0 +1,181 @@
1
+ // @flow
2
+ //
3
+ // The React binding: `useQuery`, `useMutation`, and the provider they read.
4
+ //
5
+ // Deliberately thin. The cache is an external store, so `useQuery` is a
6
+ // `useSyncExternalStore` over it and nothing else — no effect to trigger the
7
+ // request, no ref holding the latest query function, no dependency array to
8
+ // argue with. Subscribing is the signal that a value is wanted, so keeping it
9
+ // fresh is the store's job; the component only reads.
10
+ //
11
+ // That is not a stylistic preference. A component that reads an external store
12
+ // through the API React provides for it gets the value React commits with,
13
+ // which is what stops two components sharing a key from rendering different
14
+ // versions of it, and it states the server's value rather than falling through
15
+ // to it.
16
+
17
+ import * as React from "@uniflowed/react";
18
+ import {
19
+ createContext,
20
+ useCallback,
21
+ useContext,
22
+ useMemo,
23
+ useState,
24
+ useSyncExternalStore,
25
+ } from "@uniflowed/react";
26
+
27
+ import { useStableCallback } from "@uniflowed/hooks";
28
+
29
+ import { QueryCache, hash } from "./cache.js";
30
+ import type { Entry, QueryKey } from "./cache.js";
31
+
32
+ const CacheContext: React.Context<QueryCache | null> = createContext(null);
33
+
34
+ /**
35
+ * Make a cache available to the tree.
36
+ *
37
+ * Required rather than falling back to a module-level default: a default is
38
+ * shared with every test in the process, and one test's cached answer then
39
+ * decides another's result.
40
+ */
41
+ export component QueryProvider(cache: QueryCache, children: React.Node) {
42
+ return <CacheContext.Provider value={cache}>{children}</CacheContext.Provider>;
43
+ }
44
+
45
+ /** The cache this subtree uses. */
46
+ export function useQueryCache(): QueryCache {
47
+ const cache = useContext(CacheContext);
48
+ if (cache == null) {
49
+ throw new Error(
50
+ "useQuery needs a QueryProvider above it; render <QueryProvider cache={new QueryCache()}>",
51
+ );
52
+ }
53
+ return cache;
54
+ }
55
+
56
+ /** What a query looks like to a component. */
57
+ export type QueryResult<T> = {|
58
+ readonly value: T | void,
59
+ readonly error: Error | null,
60
+ /** A request is in flight. True on the first load and on a refresh. */
61
+ readonly pending: boolean,
62
+ /** There has never been a value, so there is nothing to show yet. */
63
+ readonly loading: boolean,
64
+ /** The value is older than `staleTime`. */
65
+ readonly stale: boolean,
66
+ readonly refetch: () => Promise<T>,
67
+ |};
68
+
69
+ /** How a query behaves. */
70
+ export type QueryOptions = {|
71
+ /** How long a value is fresh. Defaults to none, so a mount refetches. */
72
+ readonly staleTime?: number,
73
+ |};
74
+
75
+ /**
76
+ * Read `key`, fetching it when it is missing or stale.
77
+ *
78
+ * A cached value is returned immediately and refreshed behind it, so
79
+ * navigating back to a page shows it at once. `pending` says a request is in
80
+ * flight; `loading` says there is nothing to show yet — conflating those is
81
+ * why applications flash a spinner over data they already have.
82
+ */
83
+ export function useQuery<T>(
84
+ key: QueryKey,
85
+ query: () => Promise<T>,
86
+ options?: QueryOptions,
87
+ ): QueryResult<T> {
88
+ const cache = useQueryCache();
89
+ const staleTime = options?.staleTime ?? 0;
90
+
91
+ // The key's identity is its hash. A caller writes the array inline, so
92
+ // depending on the array itself would resubscribe on every render; two
93
+ // arrays with the same hash are the same request by definition, so holding
94
+ // the first one is not a stale closure.
95
+ const id = hash(key);
96
+ const stable = useMemo(() => key, [id]); // eslint-disable-line react-hooks/exhaustive-deps
97
+
98
+ // The query is something to *call*, not something to react to. React's own
99
+ // answer for that is an effect event — a function whose identity never
100
+ // changes and whose body is always the latest — and without it `subscribe`
101
+ // would change every render and `useSyncExternalStore` would resubscribe
102
+ // every render.
103
+ const fetcher = useStableCallback(query);
104
+
105
+ const subscribe = useCallback(
106
+ (listener: () => void) => cache.watch(stable, fetcher, { listener, staleTime }),
107
+ [cache, stable, fetcher, staleTime],
108
+ );
109
+
110
+ const snapshot = useCallback(() => cache.read(stable), [cache, stable]);
111
+ const entry: Entry<mixed> = useSyncExternalStore(subscribe, snapshot, snapshot);
112
+
113
+ const refetch = useCallback(() => cache.fetch(stable, fetcher), [cache, stable, fetcher]);
114
+
115
+ return useMemo(
116
+ () => ({
117
+ value: entry.value as $FlowFixMe,
118
+ error: entry.error,
119
+ pending: entry.pending,
120
+ loading: entry.pending && entry.updatedAt === 0,
121
+ stale: entry.updatedAt === 0 || Date.now() - entry.updatedAt >= staleTime,
122
+ refetch: refetch as $FlowFixMe,
123
+ }),
124
+ [entry, staleTime, refetch],
125
+ );
126
+ }
127
+
128
+ /** What a mutation looks like to a component. */
129
+ export type MutationResult<TInput, TOutput> = {|
130
+ readonly run: (input: TInput) => Promise<TOutput>,
131
+ readonly value: TOutput | void,
132
+ readonly error: Error | null,
133
+ readonly pending: boolean,
134
+ |};
135
+
136
+ /**
137
+ * Run something that changes state, and invalidate what it affected.
138
+ *
139
+ * `run` is called from an event, so the closure it captures is the one from
140
+ * the render the reader was looking at — there is no ref holding a "latest"
141
+ * anything, and nothing to go stale. Nor is there a guard against settling
142
+ * after unmount: React has not warned about that since 18, and adding one
143
+ * would only hide a real leak if there were one.
144
+ *
145
+ * `invalidates` takes key prefixes, because that is how invalidation is
146
+ * expressed: creating a user refreshes `["users"]` and every `["users", id]`
147
+ * under it without the caller listing them.
148
+ */
149
+ export function useMutation<TInput, TOutput>(
150
+ mutation: (input: TInput) => Promise<TOutput>,
151
+ options?: {| readonly invalidates?: $ReadOnlyArray<QueryKey> |},
152
+ ): MutationResult<TInput, TOutput> {
153
+ const cache = useQueryCache();
154
+ const [state, setState] = useState<{|
155
+ value: TOutput | void,
156
+ error: Error | null,
157
+ pending: boolean,
158
+ |}>({ value: undefined, error: null, pending: false });
159
+
160
+ const run = async (input: TInput): Promise<TOutput> => {
161
+ setState((current) => ({ ...current, pending: true, error: null }));
162
+ try {
163
+ const value = await mutation(input);
164
+ setState({ value, error: null, pending: false });
165
+ // After it settles, so a watcher refetching sees the change rather than
166
+ // racing it.
167
+ for (const prefix of options?.invalidates ?? []) {
168
+ cache.invalidate(prefix);
169
+ }
170
+ return value;
171
+ } catch (thrown) {
172
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
173
+ setState({ value: undefined, error, pending: false });
174
+ // Rethrown: a caller awaiting `run` has to be able to tell that it
175
+ // failed, and the failure state alone would not let it.
176
+ throw error;
177
+ }
178
+ };
179
+
180
+ return { run, value: state.value, error: state.error, pending: state.pending };
181
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@uniflowed/query",
3
+ "version": "0.0.0-alpha.2",
4
+ "description": "A query cache with de-duplication and stale-while-revalidate, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/query"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "internal"
19
+ ],
20
+ "dependencies": {
21
+ "@uniflowed/hooks": "0.0.0-alpha.2",
22
+ "@uniflowed/react": "0.0.0-alpha.2"
23
+ },
24
+ "peerDependencies": {
25
+ "react": ">=19"
26
+ }
27
+ }