@uniflowed/query 0.0.0-alpha.10
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/cache.js +136 -0
- package/client.js +289 -0
- package/index.js +158 -0
- package/infinite.js +311 -0
- package/key.js +124 -0
- package/mutation.js +249 -0
- package/observer.js +501 -0
- package/package.json +37 -0
- package/presence.js +146 -0
- package/query.js +518 -0
- package/react.js +251 -0
- package/retry.js +192 -0
- package/structural.js +131 -0
package/cache.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/cache`: every entry, and how to say which ones you mean.
|
|
4
|
+
//
|
|
5
|
+
// The cache is a `Map` from a key's hash to its [`Query`], plus the vocabulary
|
|
6
|
+
// for describing a *set* of entries. Those are two different jobs and this
|
|
7
|
+
// module owns the second: a query knows what happened to one key, and the
|
|
8
|
+
// cache knows how "everything under `["users"]` that somebody is looking at"
|
|
9
|
+
// turns into a list of them.
|
|
10
|
+
//
|
|
11
|
+
// # Why filters are a shape rather than a list of methods
|
|
12
|
+
//
|
|
13
|
+
// Invalidation, cancellation, refetching and removal all need the same
|
|
14
|
+
// question answered — which entries does this describe? — and they need it
|
|
15
|
+
// answered identically, or `invalidateQueries` and `cancelQueries` given the
|
|
16
|
+
// same argument would act on different entries. So there is one
|
|
17
|
+
// [`QueryFilters`] shape and one [`findAll`], and every operation on the
|
|
18
|
+
// client is that plus a verb.
|
|
19
|
+
//
|
|
20
|
+
// `type: "active"` is the one that carries weight. Invalidation has to refetch
|
|
21
|
+
// what is on screen and merely mark the rest, because refetching an entry
|
|
22
|
+
// nobody is watching is a request whose answer will be garbage-collected
|
|
23
|
+
// before it is read. Without the distinction, invalidating `["users"]` in an
|
|
24
|
+
// application with a hundred cached users makes a hundred requests.
|
|
25
|
+
//
|
|
26
|
+
// # Why the cache is explicit rather than a module-level default
|
|
27
|
+
//
|
|
28
|
+
// A singleton is shared with every other test in the process, and one test's
|
|
29
|
+
// cached answer then decides another test's result — a class of flake that
|
|
30
|
+
// costs hours to attribute because the failing test is not the one at fault.
|
|
31
|
+
// It is also wrong in production: a server rendering two requests must not let
|
|
32
|
+
// one reader's data reach the other. One cache per [`QueryClient`], one client
|
|
33
|
+
// per tree, one tree per request.
|
|
34
|
+
|
|
35
|
+
import { hashKey, matchesKey } from "./key.js";
|
|
36
|
+
import type { QueryKey } from "./key.js";
|
|
37
|
+
import { DEFAULT_GC_TIME, Query } from "./query.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which entries an operation applies to.
|
|
41
|
+
*
|
|
42
|
+
* Everything is optional and everything narrows: no filter at all means every
|
|
43
|
+
* entry, which is what `invalidateQueries()` with no argument means and why
|
|
44
|
+
* that call is worth writing.
|
|
45
|
+
*/
|
|
46
|
+
export type QueryFilters = {|
|
|
47
|
+
/** A key prefix, or an exact key with `exact`. */
|
|
48
|
+
readonly queryKey?: QueryKey,
|
|
49
|
+
readonly exact?: boolean,
|
|
50
|
+
/** `active` is "somebody is watching"; see the module docs. */
|
|
51
|
+
readonly type?: "all" | "active" | "inactive",
|
|
52
|
+
readonly predicate?: (query: Query<mixed>) => boolean,
|
|
53
|
+
|};
|
|
54
|
+
|
|
55
|
+
export class QueryCache {
|
|
56
|
+
readonly queries: Map<string, Query<mixed>> = new Map();
|
|
57
|
+
|
|
58
|
+
/** The entry for `hash`, if there is one. Never creates. */
|
|
59
|
+
get(hash: string): Query<mixed> | void {
|
|
60
|
+
return this.queries.get(hash);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The entry for `key`, creating it if it is not there.
|
|
65
|
+
*
|
|
66
|
+
* Creating is a mutation, which is why nothing on the render path calls
|
|
67
|
+
* this: a component reads with [`get`] and returns "nothing yet" when the
|
|
68
|
+
* answer is nothing yet. The entry is built in the effect that subscribes,
|
|
69
|
+
* where a mutation is allowed to happen and where React can undo it.
|
|
70
|
+
*/
|
|
71
|
+
build(key: QueryKey, gcTime: number = DEFAULT_GC_TIME): Query<mixed> {
|
|
72
|
+
const hash = hashKey(key);
|
|
73
|
+
const existing = this.queries.get(hash);
|
|
74
|
+
if (existing != null) {
|
|
75
|
+
return existing;
|
|
76
|
+
}
|
|
77
|
+
const query = new Query<mixed>(this, key, gcTime);
|
|
78
|
+
this.queries.set(hash, query);
|
|
79
|
+
return query;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Drop `query`, if it is still the entry under its key.
|
|
84
|
+
*
|
|
85
|
+
* The identity check matters: garbage collection is scheduled on a timer,
|
|
86
|
+
* and by the time it fires the entry may already have been removed and
|
|
87
|
+
* rebuilt by a remount. Deleting by hash alone would then collect the *new*
|
|
88
|
+
* entry — with its observers, its data and its request — because an old
|
|
89
|
+
* timer said so.
|
|
90
|
+
*/
|
|
91
|
+
remove(query: Query<mixed>): void {
|
|
92
|
+
if (this.queries.get(query.hash) === query) {
|
|
93
|
+
this.queries.delete(query.hash);
|
|
94
|
+
}
|
|
95
|
+
query.destroy();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every entry the filter describes, in insertion order. */
|
|
99
|
+
findAll(filters?: QueryFilters): Array<Query<mixed>> {
|
|
100
|
+
const all = Array.from(this.queries.values());
|
|
101
|
+
if (filters == null) {
|
|
102
|
+
return all;
|
|
103
|
+
}
|
|
104
|
+
return all.filter((query) => matches(query, filters));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The first entry the filter describes. */
|
|
108
|
+
find(filters: QueryFilters): Query<mixed> | void {
|
|
109
|
+
return this.findAll(filters)[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Forget everything, cancelling anything in flight. */
|
|
113
|
+
clear(): void {
|
|
114
|
+
for (const query of Array.from(this.queries.values())) {
|
|
115
|
+
this.remove(query);
|
|
116
|
+
}
|
|
117
|
+
this.queries.clear();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matches(query: Query<mixed>, filters: QueryFilters): boolean {
|
|
122
|
+
if (
|
|
123
|
+
filters.queryKey != null &&
|
|
124
|
+
!matchesKey(query.key, filters.queryKey, filters.exact === true)
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const type = filters.type ?? "all";
|
|
129
|
+
if (type === "active" && !query.isActive()) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (type === "inactive" && query.isActive()) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return filters.predicate == null || filters.predicate(query);
|
|
136
|
+
}
|
package/client.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/client`: the cache as something you can talk to.
|
|
4
|
+
//
|
|
5
|
+
// A [`QueryClient`] is the cache plus the two things a cache is useless
|
|
6
|
+
// without: the application's defaults, and a vocabulary for acting on entries
|
|
7
|
+
// from outside React. Everything here is callable from an event handler, a
|
|
8
|
+
// route loader, a test, or a server render — nothing on this object needs a
|
|
9
|
+
// component to exist.
|
|
10
|
+
//
|
|
11
|
+
// # Why defaults live here rather than at the call site
|
|
12
|
+
//
|
|
13
|
+
// `staleTime` is an application-wide decision — how long is an answer good for
|
|
14
|
+
// around here — and repeating it at three hundred call sites means the three
|
|
15
|
+
// hundred and first is different and nobody knows why. So the client holds
|
|
16
|
+
// them and every option falls back to them, one level, with no merging of
|
|
17
|
+
// nested objects to reason about.
|
|
18
|
+
//
|
|
19
|
+
// # Why `setQueryData` takes an updater
|
|
20
|
+
//
|
|
21
|
+
// The value being replaced is the one in the cache *now*, which is not
|
|
22
|
+
// necessarily the one the component rendered — a background refresh may have
|
|
23
|
+
// landed in between. Reading it out, changing it, and writing it back would
|
|
24
|
+
// then silently discard that refresh. The updater form reads and writes in the
|
|
25
|
+
// same step, and returning `undefined` from it means "on reflection, do
|
|
26
|
+
// nothing", which is how an optimistic update declines to guess about an entry
|
|
27
|
+
// that has since been dropped.
|
|
28
|
+
//
|
|
29
|
+
// # Why invalidation refetches only what is on screen
|
|
30
|
+
//
|
|
31
|
+
// `invalidateQueries(["users"])` in an application holding a hundred cached
|
|
32
|
+
// users must not make a hundred requests. Every match is *marked* stale, so
|
|
33
|
+
// whichever of them is looked at next refetches on sight; only the ones with a
|
|
34
|
+
// live observer are refetched now. The distinction is what makes invalidation
|
|
35
|
+
// by prefix safe to use freely, which in turn is what makes it the right way
|
|
36
|
+
// to express "something about users changed".
|
|
37
|
+
|
|
38
|
+
import { QueryCache } from "./cache.js";
|
|
39
|
+
import type { QueryFilters } from "./cache.js";
|
|
40
|
+
import { hashKey } from "./key.js";
|
|
41
|
+
import type { QueryKey } from "./key.js";
|
|
42
|
+
import type { QueryOptions, ResolvedQueryOptions } from "./observer.js";
|
|
43
|
+
import { Presence } from "./presence.js";
|
|
44
|
+
import { DEFAULT_GC_TIME } from "./query.js";
|
|
45
|
+
import type { FetchContext, QueryState } from "./query.js";
|
|
46
|
+
import type { MutationOptions, ResolvedMutationOptions } from "./mutation.js";
|
|
47
|
+
import { backoffDelay } from "./retry.js";
|
|
48
|
+
import type { RetryDelay, RetryPolicy } from "./retry.js";
|
|
49
|
+
|
|
50
|
+
/** What a whole application decides once. */
|
|
51
|
+
export type QueryDefaults = {|
|
|
52
|
+
readonly staleTime?: number,
|
|
53
|
+
readonly gcTime?: number,
|
|
54
|
+
readonly retry?: RetryPolicy,
|
|
55
|
+
readonly retryDelay?: RetryDelay,
|
|
56
|
+
readonly refetchInterval?: number | null,
|
|
57
|
+
readonly refetchOnWindowFocus?: boolean,
|
|
58
|
+
readonly refetchOnReconnect?: boolean,
|
|
59
|
+
|};
|
|
60
|
+
|
|
61
|
+
export type MutationDefaults = {|
|
|
62
|
+
readonly retry?: RetryPolicy,
|
|
63
|
+
readonly retryDelay?: RetryDelay,
|
|
64
|
+
|};
|
|
65
|
+
|
|
66
|
+
export type QueryClientOptions = {|
|
|
67
|
+
readonly queries?: QueryDefaults,
|
|
68
|
+
readonly mutations?: MutationDefaults,
|
|
69
|
+
/** Replaceable so React Native and tests can drive focus themselves. */
|
|
70
|
+
readonly presence?: Presence,
|
|
71
|
+
|};
|
|
72
|
+
|
|
73
|
+
/** What `fetchQuery` needs, which is a query without anything React-shaped. */
|
|
74
|
+
export type FetchQueryOptions<TData> = {|
|
|
75
|
+
readonly queryKey: QueryKey,
|
|
76
|
+
readonly queryFn: (context: FetchContext<TData>) => Promise<TData>,
|
|
77
|
+
readonly staleTime?: number,
|
|
78
|
+
readonly gcTime?: number,
|
|
79
|
+
readonly retry?: RetryPolicy,
|
|
80
|
+
readonly retryDelay?: RetryDelay,
|
|
81
|
+
|};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Three retries with doubling backoff, five minutes of grace, fresh for no
|
|
85
|
+
* time at all.
|
|
86
|
+
*
|
|
87
|
+
* `staleTime: 0` is the conservative default and the surprising one: a mount
|
|
88
|
+
* refetches, showing the cached answer immediately and replacing it when the
|
|
89
|
+
* new one lands. Applications that know better should say so — `staleTime` is
|
|
90
|
+
* the single most valuable option on this object.
|
|
91
|
+
*/
|
|
92
|
+
const QUERY_DEFAULTS = {
|
|
93
|
+
staleTime: 0,
|
|
94
|
+
gcTime: DEFAULT_GC_TIME,
|
|
95
|
+
retry: (3: RetryPolicy),
|
|
96
|
+
retryDelay: (backoffDelay: RetryDelay),
|
|
97
|
+
refetchInterval: null,
|
|
98
|
+
refetchOnWindowFocus: true,
|
|
99
|
+
refetchOnReconnect: true,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Writes are not retried by default.
|
|
104
|
+
*
|
|
105
|
+
* A failed read can be repeated because reading twice is free. A failed write
|
|
106
|
+
* may well have succeeded on the server and lost its answer on the way back,
|
|
107
|
+
* and repeating it creates the second invoice. Retrying a mutation is a
|
|
108
|
+
* decision about idempotency that only the caller can make.
|
|
109
|
+
*/
|
|
110
|
+
const MUTATION_DEFAULTS = {
|
|
111
|
+
retry: (false: RetryPolicy),
|
|
112
|
+
retryDelay: (backoffDelay: RetryDelay),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export class QueryClient {
|
|
116
|
+
readonly cache: QueryCache = new QueryCache();
|
|
117
|
+
readonly presence: Presence;
|
|
118
|
+
readonly queryDefaults: typeof QUERY_DEFAULTS;
|
|
119
|
+
readonly mutationDefaults: typeof MUTATION_DEFAULTS;
|
|
120
|
+
|
|
121
|
+
constructor(options?: QueryClientOptions) {
|
|
122
|
+
this.presence = options?.presence ?? new Presence();
|
|
123
|
+
this.queryDefaults = { ...QUERY_DEFAULTS, ...stripUndefined(options?.queries) };
|
|
124
|
+
this.mutationDefaults = { ...MUTATION_DEFAULTS, ...stripUndefined(options?.mutations) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The options with this client's defaults filled in. A pure function. */
|
|
128
|
+
resolveQuery<TData, TSelected>(
|
|
129
|
+
options: QueryOptions<TData, TSelected>,
|
|
130
|
+
): ResolvedQueryOptions<TData, TSelected> {
|
|
131
|
+
const defaults = this.queryDefaults;
|
|
132
|
+
return {
|
|
133
|
+
queryKey: options.queryKey,
|
|
134
|
+
queryFn: options.queryFn,
|
|
135
|
+
enabled: options.enabled ?? true,
|
|
136
|
+
staleTime: options.staleTime ?? defaults.staleTime,
|
|
137
|
+
gcTime: options.gcTime ?? defaults.gcTime,
|
|
138
|
+
retry: options.retry ?? defaults.retry,
|
|
139
|
+
retryDelay: options.retryDelay ?? defaults.retryDelay,
|
|
140
|
+
select: options.select,
|
|
141
|
+
placeholderData: options.placeholderData,
|
|
142
|
+
refetchInterval: options.refetchInterval ?? defaults.refetchInterval,
|
|
143
|
+
refetchOnWindowFocus: options.refetchOnWindowFocus ?? defaults.refetchOnWindowFocus,
|
|
144
|
+
refetchOnReconnect: options.refetchOnReconnect ?? defaults.refetchOnReconnect,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
resolveMutation<TVariables, TData, TContext>(
|
|
149
|
+
options: MutationOptions<TVariables, TData, TContext>,
|
|
150
|
+
): ResolvedMutationOptions<TVariables, TData, TContext> {
|
|
151
|
+
return {
|
|
152
|
+
...options,
|
|
153
|
+
retry: options.retry ?? this.mutationDefaults.retry,
|
|
154
|
+
retryDelay: options.retryDelay ?? this.mutationDefaults.retryDelay,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What is cached for `key`, without asking for it.
|
|
160
|
+
*
|
|
161
|
+
* `mixed`, not a generic the caller instantiates. A key is an array of
|
|
162
|
+
* strings and numbers; it carries no type, and a signature that pretended
|
|
163
|
+
* otherwise would be an unchecked cast wearing a type parameter. Narrow it
|
|
164
|
+
* where you read it, with the same schema that validated the response.
|
|
165
|
+
*/
|
|
166
|
+
getQueryData(key: QueryKey): mixed {
|
|
167
|
+
return this.cache.get(hashKey(key))?.state.data;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Everything known about `key`, including the timestamps a result omits. */
|
|
171
|
+
getQueryState(key: QueryKey): QueryState<mixed> | void {
|
|
172
|
+
return this.cache.get(hashKey(key))?.state;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Put a value in, creating the entry if there is none.
|
|
177
|
+
*
|
|
178
|
+
* The write goes through structural sharing, so setting data that is deeply
|
|
179
|
+
* equal to what is there changes no identity and re-renders nothing.
|
|
180
|
+
*/
|
|
181
|
+
setQueryData(key: QueryKey, updater: mixed | ((previous: mixed) => mixed)): mixed {
|
|
182
|
+
const query = this.cache.build(key, this.queryDefaults.gcTime);
|
|
183
|
+
const next =
|
|
184
|
+
typeof updater === "function" ? (updater as $FlowFixMe)(query.state.data) : updater;
|
|
185
|
+
if (next === undefined) {
|
|
186
|
+
return query.state.data;
|
|
187
|
+
}
|
|
188
|
+
return query.setData(next);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fetch `key` now unless it is fresh, and hand back the answer.
|
|
193
|
+
*
|
|
194
|
+
* This is the imperative half of `useQuery`: the same cache, the same
|
|
195
|
+
* de-duplication, no component. A route loader that calls this before
|
|
196
|
+
* navigating hands the component a cache that is already warm, and the
|
|
197
|
+
* component's own mount finds nothing to do.
|
|
198
|
+
*/
|
|
199
|
+
fetchQuery<TData>(options: FetchQueryOptions<TData>): Promise<mixed> {
|
|
200
|
+
const resolved = this.resolveQuery(options as $FlowFixMe);
|
|
201
|
+
const query = this.cache.build(resolved.queryKey, resolved.gcTime);
|
|
202
|
+
if (!query.isStale(resolved.staleTime)) {
|
|
203
|
+
return Promise.resolve(query.state.data);
|
|
204
|
+
}
|
|
205
|
+
return query.fetch((context) => resolved.queryFn(context as $FlowFixMe), {
|
|
206
|
+
retry: resolved.retry,
|
|
207
|
+
retryDelay: resolved.retryDelay,
|
|
208
|
+
cancelRefetch: false,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The same thing, for when the answer is not wanted here.
|
|
214
|
+
*
|
|
215
|
+
* Never rejects: a prefetch is an optimisation, and an optimisation that can
|
|
216
|
+
* take down the page that started it is not one. The failure is recorded on
|
|
217
|
+
* the entry, where the component that eventually reads it will find it.
|
|
218
|
+
*/
|
|
219
|
+
prefetchQuery<TData>(options: FetchQueryOptions<TData>): Promise<void> {
|
|
220
|
+
return this.fetchQuery(options).then(ignore, ignore);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Mark matching entries stale, and refetch the ones being watched.
|
|
225
|
+
*
|
|
226
|
+
* The default filter is "everything", which is the right thing after signing
|
|
227
|
+
* in or out.
|
|
228
|
+
*/
|
|
229
|
+
invalidateQueries(filters?: QueryFilters): Promise<void> {
|
|
230
|
+
for (const query of this.cache.findAll(filters)) {
|
|
231
|
+
query.invalidate();
|
|
232
|
+
}
|
|
233
|
+
return this.refetchQueries({ ...(filters ?? {}), type: "active" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Refetch matching entries now. Never rejects. */
|
|
237
|
+
refetchQueries(filters?: QueryFilters): Promise<void> {
|
|
238
|
+
const matched = this.cache.findAll(filters);
|
|
239
|
+
return Promise.all(matched.map((query) => query.refetch())).then(ignore);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Abort matching requests and put their entries back as they were.
|
|
244
|
+
*
|
|
245
|
+
* The call an optimistic update makes first: a refetch already in flight
|
|
246
|
+
* would otherwise land after the optimistic write and replace it with the
|
|
247
|
+
* server's previous answer, which looks exactly like the mutation being
|
|
248
|
+
* undone at random.
|
|
249
|
+
*/
|
|
250
|
+
cancelQueries(filters?: QueryFilters): Promise<void> {
|
|
251
|
+
for (const query of this.cache.findAll(filters)) {
|
|
252
|
+
query.cancel({ revert: true });
|
|
253
|
+
}
|
|
254
|
+
return Promise.resolve();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Drop matching entries entirely, cancelling anything in flight. */
|
|
258
|
+
removeQueries(filters?: QueryFilters): void {
|
|
259
|
+
for (const query of this.cache.findAll(filters)) {
|
|
260
|
+
this.cache.remove(query);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** How many matching requests are in flight, for a global progress bar. */
|
|
265
|
+
isFetching(filters?: QueryFilters): number {
|
|
266
|
+
return this.cache.findAll(filters).filter((query) => query.state.fetchStatus === "fetching")
|
|
267
|
+
.length;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Forget everything. A test's `afterEach`, or a sign-out. */
|
|
271
|
+
clear(): void {
|
|
272
|
+
this.cache.clear();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function stripUndefined<T: { +[string]: mixed }>(source: T | void): { [string]: mixed } {
|
|
277
|
+
const out: { [string]: mixed } = {};
|
|
278
|
+
if (source == null) {
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
for (const name of Object.keys(source)) {
|
|
282
|
+
if (source[name] !== undefined) {
|
|
283
|
+
out[name] = source[name];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function ignore(): void {}
|
package/index.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query`: asking for the same thing twice should cost once.
|
|
4
|
+
//
|
|
5
|
+
// Server state is not application state. It is owned somewhere else, it goes
|
|
6
|
+
// out of date without anybody touching it, several components want the same
|
|
7
|
+
// piece of it at the same moment, and every read can fail. A `useState` beside
|
|
8
|
+
// a `useEffect` models none of that, which is why every application that
|
|
9
|
+
// starts with one ends up rebuilding this package badly.
|
|
10
|
+
//
|
|
11
|
+
// ```js
|
|
12
|
+
// const client = new QueryClient({ queries: { staleTime: 30_000 } });
|
|
13
|
+
//
|
|
14
|
+
// component Profile(id: string) {
|
|
15
|
+
// const { data, isPending, error } = useQuery({
|
|
16
|
+
// queryKey: ["user", id],
|
|
17
|
+
// queryFn: ({ signal }) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
|
|
18
|
+
// });
|
|
19
|
+
// if (isPending) return <Spinner />;
|
|
20
|
+
// if (error != null) return <Failure error={error} />;
|
|
21
|
+
// return <Card user={data} />;
|
|
22
|
+
// }
|
|
23
|
+
// ```
|
|
24
|
+
//
|
|
25
|
+
// A header and a sidebar rendering that make one request. Navigating away and
|
|
26
|
+
// back shows the profile immediately and refreshes it behind. A response that
|
|
27
|
+
// is byte-identical to the cached one re-renders nothing at all.
|
|
28
|
+
//
|
|
29
|
+
// # The five decisions everything else follows from
|
|
30
|
+
//
|
|
31
|
+
// **A key is a value, not a reference.** `["user", id]` written in two files
|
|
32
|
+
// is one entry, because keys are compared by their contents. `key.js`.
|
|
33
|
+
//
|
|
34
|
+
// **An unchanged answer is the same object.** A response is merged into the
|
|
35
|
+
// cached one so that every deeply-equal subtree keeps its identity. That is
|
|
36
|
+
// what makes "did anything change" an `Object.is`, which is what makes a poll
|
|
37
|
+
// over unchanging data free. `structural.js`.
|
|
38
|
+
//
|
|
39
|
+
// **One entry, one request in flight.** Two components mounting in the same
|
|
40
|
+
// tick join one request — not only for the saving, but because two requests
|
|
41
|
+
// can answer in either order and the two components would then disagree.
|
|
42
|
+
// `query.js`.
|
|
43
|
+
//
|
|
44
|
+
// **Stale is not the same as absent.** A cached answer is shown while it is
|
|
45
|
+
// refreshed, so navigating back does not flash a spinner over data that is
|
|
46
|
+
// already on screen. `staleTime` is the whole of the policy. `query.js`.
|
|
47
|
+
//
|
|
48
|
+
// **The snapshot is stable when nothing changed.** A component reads through
|
|
49
|
+
// `useSyncExternalStore`, which re-renders only when the snapshot's identity
|
|
50
|
+
// changes; the observer returns the *same* result object whenever every field
|
|
51
|
+
// in it is unchanged. `observer.js`.
|
|
52
|
+
//
|
|
53
|
+
// # How the package is laid out
|
|
54
|
+
//
|
|
55
|
+
// Eleven modules beside this one, each named after the thing it decides. Nothing
|
|
56
|
+
// is under an `internal/`: every one of them is a reasonable thing to import
|
|
57
|
+
// on purpose, and hiding them would have cost a reader a directory hop to
|
|
58
|
+
// reach the first line of code without making anything safer.
|
|
59
|
+
//
|
|
60
|
+
// The value-level leaves, which have no state and no React in them:
|
|
61
|
+
//
|
|
62
|
+
// - `key.js` — when two requests are the same request, and what a prefix
|
|
63
|
+
// filter matches.
|
|
64
|
+
// - `structural.js` — when two answers are the same answer, and how to keep
|
|
65
|
+
// the old references for the parts that are.
|
|
66
|
+
// - `retry.js` — whether to try again, how long to wait, and how to stop.
|
|
67
|
+
//
|
|
68
|
+
// The cache, which is a plain object graph that works with nothing rendering:
|
|
69
|
+
//
|
|
70
|
+
// - `query.js` — one key: its state machine, its one in-flight request, its
|
|
71
|
+
// abort signal and its collection timer.
|
|
72
|
+
// - `cache.js` — every entry, and the filter vocabulary that says which ones
|
|
73
|
+
// an operation means.
|
|
74
|
+
// - `mutation.js` — the write side: optimistic context, rollback, and why a
|
|
75
|
+
// write is never cancelled.
|
|
76
|
+
// - `presence.js` — the tab came back; the network came back.
|
|
77
|
+
// - `client.js` — the cache with the application's defaults on it, and the
|
|
78
|
+
// imperative surface: `setQueryData`, `invalidateQueries`, `prefetchQuery`,
|
|
79
|
+
// `cancelQueries`.
|
|
80
|
+
//
|
|
81
|
+
// The React edge:
|
|
82
|
+
//
|
|
83
|
+
// - `observer.js` — one component's options over one entry: the narrowing, the
|
|
84
|
+
// placeholder, and the reference-stable snapshot that decides whether a
|
|
85
|
+
// render happens at all.
|
|
86
|
+
// - `infinite.js` — what a paged query changes about an ordinary one, which is
|
|
87
|
+
// only how the entry is filled.
|
|
88
|
+
// - `react.js` — the provider and the four hooks, each a
|
|
89
|
+
// `useSyncExternalStore` and almost nothing else.
|
|
90
|
+
//
|
|
91
|
+
// `observer.js` is the one to read first if something re-renders when it
|
|
92
|
+
// should not, and `query.js` if something fetches when it should not.
|
|
93
|
+
//
|
|
94
|
+
// # What is not here, and where it went instead
|
|
95
|
+
//
|
|
96
|
+
// `initialData` is absent: `client.setQueryData` before rendering is the same
|
|
97
|
+
// thing with fewer rules, and the difference between initial data and
|
|
98
|
+
// placeholder data is a distinction this package would rather not make people
|
|
99
|
+
// learn. `placeholderData` is here, because "show the previous page while the
|
|
100
|
+
// next one loads" has no other spelling.
|
|
101
|
+
//
|
|
102
|
+
// Suspense, offline request queues, and `client.fetchInfiniteQuery` are named
|
|
103
|
+
// as out of scope in the modules that would own them, with the reason in each
|
|
104
|
+
// case. A single request in flight for `useAsync` — one call, no cache — is
|
|
105
|
+
// `@uniflowed/hooks`.
|
|
106
|
+
|
|
107
|
+
export type { QueryFilters } from "./cache.js";
|
|
108
|
+
export type {
|
|
109
|
+
FetchQueryOptions,
|
|
110
|
+
MutationDefaults,
|
|
111
|
+
QueryClientOptions,
|
|
112
|
+
QueryDefaults,
|
|
113
|
+
} from "./client.js";
|
|
114
|
+
export type {
|
|
115
|
+
InfiniteData,
|
|
116
|
+
InfinitePageContext,
|
|
117
|
+
InfiniteQueryOptions,
|
|
118
|
+
InfiniteQueryResult,
|
|
119
|
+
PageParamFn,
|
|
120
|
+
} from "./infinite.js";
|
|
121
|
+
export type { QueryKey } from "./key.js";
|
|
122
|
+
export type {
|
|
123
|
+
MutationCallbacks,
|
|
124
|
+
MutationOptions,
|
|
125
|
+
MutationResult,
|
|
126
|
+
MutationState,
|
|
127
|
+
MutationStatus,
|
|
128
|
+
} from "./mutation.js";
|
|
129
|
+
export type { QueryOptions, QueryResult, ResolvedQueryOptions } from "./observer.js";
|
|
130
|
+
export type { PresenceEvent } from "./presence.js";
|
|
131
|
+
export type {
|
|
132
|
+
FetchContext,
|
|
133
|
+
FetchDirection,
|
|
134
|
+
FetchStatus,
|
|
135
|
+
Fetcher,
|
|
136
|
+
QueryState,
|
|
137
|
+
QueryStatus,
|
|
138
|
+
QueryWatcher,
|
|
139
|
+
} from "./query.js";
|
|
140
|
+
export type { RetryDelay, RetryPolicy } from "./retry.js";
|
|
141
|
+
|
|
142
|
+
export { QueryCache } from "./cache.js";
|
|
143
|
+
export { QueryClient } from "./client.js";
|
|
144
|
+
export { InfiniteQueryObserver, infinitePages } from "./infinite.js";
|
|
145
|
+
export { hashKey, matchesKey } from "./key.js";
|
|
146
|
+
export { Mutation } from "./mutation.js";
|
|
147
|
+
export { QueryObserver } from "./observer.js";
|
|
148
|
+
export { Presence } from "./presence.js";
|
|
149
|
+
export { DEFAULT_GC_TIME, Query } from "./query.js";
|
|
150
|
+
export {
|
|
151
|
+
QueryClientProvider,
|
|
152
|
+
useInfiniteQuery,
|
|
153
|
+
useMutation,
|
|
154
|
+
useQuery,
|
|
155
|
+
useQueryClient,
|
|
156
|
+
} from "./react.js";
|
|
157
|
+
export { CancelledError, backoffDelay } from "./retry.js";
|
|
158
|
+
export { structuralShare } from "./structural.js";
|