@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/observer.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/observer`: turning an entry into something React can read.
|
|
4
|
+
//
|
|
5
|
+
// A [`Query`] holds facts. A component needs a *snapshot*: one immutable
|
|
6
|
+
// object, with the narrowing already applied, that is the same object as last
|
|
7
|
+
// time whenever nothing it contains has changed. The distance between those
|
|
8
|
+
// two is this module, and almost all of it is the word "same".
|
|
9
|
+
//
|
|
10
|
+
// # Why the snapshot has to be identical when nothing changed
|
|
11
|
+
//
|
|
12
|
+
// `useSyncExternalStore` compares the value `getSnapshot` returns with the one
|
|
13
|
+
// it returned before, using `Object.is`, and re-renders only if they differ.
|
|
14
|
+
// So an observer that builds a fresh result object every time it is asked
|
|
15
|
+
// re-renders its component on every notification, including the ones that say
|
|
16
|
+
// nothing changed — and, worse, the loop is not obviously wrong from the
|
|
17
|
+
// component's side. React's own diagnostic for it is "The result of
|
|
18
|
+
// getSnapshot should be cached to avoid an infinite loop".
|
|
19
|
+
//
|
|
20
|
+
// So [`QueryObserver.readResult`] builds the candidate, compares it shallowly
|
|
21
|
+
// with the one it returned last time, and returns the *old* object when they
|
|
22
|
+
// agree. Every field it compares is either a primitive or a value that has
|
|
23
|
+
// already been through structural sharing, so shallow identity is exactly the
|
|
24
|
+
// right question.
|
|
25
|
+
//
|
|
26
|
+
// That single rule is what makes the interesting behaviours fall out:
|
|
27
|
+
//
|
|
28
|
+
// * A poll whose answer has not changed does not re-render anything.
|
|
29
|
+
// * `setQueryData` re-renders the components watching that key and no
|
|
30
|
+
// others, because no other entry's snapshot moved.
|
|
31
|
+
// * A `select` that narrows to a field re-renders only when *that field*
|
|
32
|
+
// changes, because the narrowed value goes through structural sharing too.
|
|
33
|
+
//
|
|
34
|
+
// # Why this object caches, and why that is not "mutation during render"
|
|
35
|
+
//
|
|
36
|
+
// `readResult` runs during render and writes to three private slots: the last
|
|
37
|
+
// result, the last `select` input and output, and the last real data seen (for
|
|
38
|
+
// `placeholderData`). None of that is state — it is a memo, invisible outside
|
|
39
|
+
// the hook that owns this observer, idempotent under React's double
|
|
40
|
+
// invocation, and identical in result whether the render is committed or
|
|
41
|
+
// thrown away. It is the same category as `useMemo`, and `useSyncExternalStore`
|
|
42
|
+
// cannot be used correctly without it.
|
|
43
|
+
//
|
|
44
|
+
// Everything that is *not* a memo — building the cache entry, starting a
|
|
45
|
+
// request, registering a listener, setting a timer — happens in
|
|
46
|
+
// [`QueryObserver.subscribe`], which React calls from an effect, and every one
|
|
47
|
+
// of them is undone by the function it returns.
|
|
48
|
+
//
|
|
49
|
+
// # Why options arrive as an argument during render and as a callback outside it
|
|
50
|
+
//
|
|
51
|
+
// A component passes a new options object on every render, usually with a
|
|
52
|
+
// fresh `queryFn` closure. Two things want it, and they want different
|
|
53
|
+
// versions:
|
|
54
|
+
//
|
|
55
|
+
// * The snapshot wants *this* render's options, so it is passed in. An
|
|
56
|
+
// observer that had cached them would narrow with a `select` from the
|
|
57
|
+
// previous render, which is a stale render, which is a bug React cannot
|
|
58
|
+
// see.
|
|
59
|
+
// * A fetch started later — from an interval, from a refetch button, from
|
|
60
|
+
// the tab regaining focus — wants the *latest* options. So it reads them
|
|
61
|
+
// through a callback whose identity never changes and whose body is always
|
|
62
|
+
// current. That is why a caller writing `queryFn` inline does not
|
|
63
|
+
// resubscribe on every keystroke, and why the request it eventually makes
|
|
64
|
+
// is not the one from three renders ago.
|
|
65
|
+
|
|
66
|
+
import { hashKey } from "./key.js";
|
|
67
|
+
import type { QueryKey } from "./key.js";
|
|
68
|
+
import { EMPTY_STATE } from "./query.js";
|
|
69
|
+
import type {
|
|
70
|
+
FetchContext,
|
|
71
|
+
FetchDirection,
|
|
72
|
+
FetchStatus,
|
|
73
|
+
Fetcher,
|
|
74
|
+
Query,
|
|
75
|
+
QueryState,
|
|
76
|
+
QueryStatus,
|
|
77
|
+
} from "./query.js";
|
|
78
|
+
import type { RetryDelay, RetryPolicy } from "./retry.js";
|
|
79
|
+
import { shallowEqual, structuralShare } from "./structural.js";
|
|
80
|
+
|
|
81
|
+
// Type-only: an observer is handed its client, and never imports one.
|
|
82
|
+
import type { QueryClient } from "./client.js";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How a component asks for a key.
|
|
86
|
+
*
|
|
87
|
+
* Everything except the key and the function has a default on the client, so
|
|
88
|
+
* an application sets `staleTime` once instead of at every call site.
|
|
89
|
+
*/
|
|
90
|
+
export type QueryOptions<TData, TSelected = TData> = {|
|
|
91
|
+
readonly queryKey: QueryKey,
|
|
92
|
+
readonly queryFn: (context: FetchContext<TData>) => Promise<TData>,
|
|
93
|
+
/** `false` means "do not fetch this yet"; a manual refetch still works. */
|
|
94
|
+
readonly enabled?: boolean,
|
|
95
|
+
/** How long an answer counts as fresh. `Infinity` means "until I say so". */
|
|
96
|
+
readonly staleTime?: number,
|
|
97
|
+
/** How long an unwatched entry is kept before it is collected. */
|
|
98
|
+
readonly gcTime?: number,
|
|
99
|
+
readonly retry?: RetryPolicy,
|
|
100
|
+
readonly retryDelay?: RetryDelay,
|
|
101
|
+
/** Narrow the data. See the module docs for what it buys. */
|
|
102
|
+
readonly select?: (data: TData) => TSelected,
|
|
103
|
+
/** Shown while there is nothing yet; never written to the cache. */
|
|
104
|
+
readonly placeholderData?: mixed | ((previous: TData | void) => mixed),
|
|
105
|
+
readonly refetchInterval?: number | null,
|
|
106
|
+
readonly refetchOnWindowFocus?: boolean,
|
|
107
|
+
readonly refetchOnReconnect?: boolean,
|
|
108
|
+
|};
|
|
109
|
+
|
|
110
|
+
/** The same options with the client's defaults filled in. */
|
|
111
|
+
export type ResolvedQueryOptions<TData, TSelected = TData> = {|
|
|
112
|
+
readonly queryKey: QueryKey,
|
|
113
|
+
readonly queryFn: (context: FetchContext<TData>) => Promise<TData>,
|
|
114
|
+
readonly enabled: boolean,
|
|
115
|
+
readonly staleTime: number,
|
|
116
|
+
readonly gcTime: number,
|
|
117
|
+
readonly retry: RetryPolicy,
|
|
118
|
+
readonly retryDelay: RetryDelay,
|
|
119
|
+
readonly select?: (data: TData) => TSelected,
|
|
120
|
+
readonly placeholderData?: mixed | ((previous: TData | void) => mixed),
|
|
121
|
+
readonly refetchInterval: number | null,
|
|
122
|
+
readonly refetchOnWindowFocus: boolean,
|
|
123
|
+
readonly refetchOnReconnect: boolean,
|
|
124
|
+
|};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What a component sees.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately without timestamps. A clock reading in a render-visible
|
|
130
|
+
* snapshot re-renders every observer on every successful refresh, even when
|
|
131
|
+
* the answer is identical — which is exactly what structural sharing exists to
|
|
132
|
+
* prevent, and it also defeats `select`: narrowing to `user.name` is only
|
|
133
|
+
* worth having if the snapshot is insensitive to the fields it discarded, and
|
|
134
|
+
* a `dataUpdatedAt` beside it moves whenever *any* field does.
|
|
135
|
+
*
|
|
136
|
+
* So the snapshot carries decisions rather than readings. `isStale` is the one
|
|
137
|
+
* the clock reaches, because it is the one a component can act on.
|
|
138
|
+
* `client.getQueryState(key)` has `dataUpdatedAt`, `checkedAt` and the rest for
|
|
139
|
+
* a devtool or a "last updated" label that wants them.
|
|
140
|
+
*/
|
|
141
|
+
export type QueryResult<T> = {|
|
|
142
|
+
readonly data: T | void,
|
|
143
|
+
readonly error: Error | null,
|
|
144
|
+
readonly status: QueryStatus,
|
|
145
|
+
readonly fetchStatus: FetchStatus,
|
|
146
|
+
/** There is no answer yet, not even a failed one. */
|
|
147
|
+
readonly isPending: boolean,
|
|
148
|
+
/** The first load: pending *and* a request is in flight. */
|
|
149
|
+
readonly isLoading: boolean,
|
|
150
|
+
readonly isSuccess: boolean,
|
|
151
|
+
readonly isError: boolean,
|
|
152
|
+
/** A request is in flight, first load or refresh. */
|
|
153
|
+
readonly isFetching: boolean,
|
|
154
|
+
/** A request is in flight over data that is already on screen. */
|
|
155
|
+
readonly isRefetching: boolean,
|
|
156
|
+
readonly isStale: boolean,
|
|
157
|
+
readonly isPlaceholderData: boolean,
|
|
158
|
+
/** Failed attempts in the request in flight, for "retrying (2 of 3)". */
|
|
159
|
+
readonly failureCount: number,
|
|
160
|
+
/** Refetch now, superseding anything in flight. Never rejects. */
|
|
161
|
+
readonly refetch: () => Promise<void>,
|
|
162
|
+
|};
|
|
163
|
+
|
|
164
|
+
export class QueryObserver<TData, TSelected = TData> {
|
|
165
|
+
client: QueryClient;
|
|
166
|
+
readonly getOptions: () => QueryOptions<TData, TSelected>;
|
|
167
|
+
/** Stable for the observer's life, so it never changes a snapshot. */
|
|
168
|
+
readonly refetch: () => Promise<void>;
|
|
169
|
+
|
|
170
|
+
listener: (() => void) | null = null;
|
|
171
|
+
query: Query<TData> | null = null;
|
|
172
|
+
|
|
173
|
+
// The three memos. See the module docs: caches, not state.
|
|
174
|
+
result: QueryResult<TSelected> | null = null;
|
|
175
|
+
selection: {| raw: mixed, select: mixed, output: mixed |} | null = null;
|
|
176
|
+
lastData: TData | void = undefined;
|
|
177
|
+
|
|
178
|
+
staleTimer: TimeoutID | null = null;
|
|
179
|
+
intervalTimer: IntervalID | null = null;
|
|
180
|
+
stopPresence: (() => void) | null = null;
|
|
181
|
+
|
|
182
|
+
constructor(client: QueryClient, getOptions: () => QueryOptions<TData, TSelected>) {
|
|
183
|
+
this.client = client;
|
|
184
|
+
this.getOptions = getOptions;
|
|
185
|
+
this.refetch = () => this.fetch({ cancelRefetch: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The snapshot for this render.
|
|
190
|
+
*
|
|
191
|
+
* Reads the cache without touching it: an entry that does not exist yet
|
|
192
|
+
* stays not existing, and the component is told there is nothing yet. The
|
|
193
|
+
* entry is built by [`subscribe`] one effect later.
|
|
194
|
+
*/
|
|
195
|
+
readResult(
|
|
196
|
+
client: QueryClient,
|
|
197
|
+
options: ResolvedQueryOptions<TData, TSelected>,
|
|
198
|
+
): QueryResult<TSelected> {
|
|
199
|
+
const query = client.cache.get(hashKey(options.queryKey)) as $FlowFixMe;
|
|
200
|
+
const candidate = this.buildResult(query, query?.state ?? EMPTY_STATE, options);
|
|
201
|
+
const previous = this.result;
|
|
202
|
+
if (previous != null && shallowEqual(previous, candidate)) {
|
|
203
|
+
return previous;
|
|
204
|
+
}
|
|
205
|
+
this.result = candidate;
|
|
206
|
+
return candidate;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Start watching, and keep the entry fresh while anybody is.
|
|
211
|
+
*
|
|
212
|
+
* Subscribing *is* the statement that a value is wanted, so the fetch
|
|
213
|
+
* belongs here rather than in an effect beside it. That is what lets the
|
|
214
|
+
* React binding be a `useSyncExternalStore` and nothing else: no effect to
|
|
215
|
+
* trigger the request, no ref holding the latest query function, no
|
|
216
|
+
* dependency array to argue with.
|
|
217
|
+
*/
|
|
218
|
+
subscribe(client: QueryClient, listener: () => void): () => void {
|
|
219
|
+
this.client = client;
|
|
220
|
+
this.listener = listener;
|
|
221
|
+
|
|
222
|
+
const options = this.resolved();
|
|
223
|
+
const query = this.attach(options);
|
|
224
|
+
|
|
225
|
+
if (options.enabled && query.isStale(options.staleTime)) {
|
|
226
|
+
void this.fetch({ cancelRefetch: false });
|
|
227
|
+
}
|
|
228
|
+
this.updateStaleTimer(options);
|
|
229
|
+
this.startInterval(options);
|
|
230
|
+
this.watchPresence(client, options);
|
|
231
|
+
|
|
232
|
+
return () => {
|
|
233
|
+
this.stopTimers();
|
|
234
|
+
this.stopPresence?.();
|
|
235
|
+
this.stopPresence = null;
|
|
236
|
+
this.listener = null;
|
|
237
|
+
// Whatever it is watching *now*, which is not necessarily the entry it
|
|
238
|
+
// started on: `attach` may have moved it since.
|
|
239
|
+
const held = this.query;
|
|
240
|
+
this.query = null;
|
|
241
|
+
held?.removeObserver(this);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The query changed. Whether that is a render is React's decision. */
|
|
246
|
+
onQueryUpdate(): void {
|
|
247
|
+
const options = this.resolved();
|
|
248
|
+
this.attach(options, true);
|
|
249
|
+
this.updateStaleTimer(options);
|
|
250
|
+
this.listener?.();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Make sure this observer is registered on the entry its key resolves to now.
|
|
255
|
+
*
|
|
256
|
+
* Usually a no-op. It is not one after `removeQueries` on a key somebody is
|
|
257
|
+
* watching: the entry that was dropped is not coming back, and an observer
|
|
258
|
+
* left holding it would be listening to an object nothing writes to any more
|
|
259
|
+
* while the next fetch quietly filled its replacement. The dropped entry
|
|
260
|
+
* announces its own destruction, which is what brings this path here, and
|
|
261
|
+
* the component finds itself watching a fresh entry that is refetching.
|
|
262
|
+
*
|
|
263
|
+
* Only ever reached from an effect, a timer or an event — never from a
|
|
264
|
+
* render, where building an entry would be a mutation.
|
|
265
|
+
*
|
|
266
|
+
* `refill` is what separates "make sure the registration is right before I
|
|
267
|
+
* fetch" from "I have just been moved to an empty entry and somebody is
|
|
268
|
+
* looking at it".
|
|
269
|
+
*
|
|
270
|
+
* The check is against the held entry's own hash rather than the options',
|
|
271
|
+
* because this runs on every notification and hashing a key is a
|
|
272
|
+
* `JSON.stringify`. It is the same question: while an observer is
|
|
273
|
+
* subscribed, the entry it holds is the one its key hashes to, because the
|
|
274
|
+
* key's hash is part of what makes a subscription different — a key that
|
|
275
|
+
* changes tears this subscription down and builds another.
|
|
276
|
+
*/
|
|
277
|
+
attach(options: ResolvedQueryOptions<TData, TSelected>, refill: boolean = false): Query<TData> {
|
|
278
|
+
const held = this.query;
|
|
279
|
+
if (held != null && this.client.cache.get(held.hash) === held) {
|
|
280
|
+
return held;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const current = this.client.cache.build(options.queryKey, options.gcTime) as $FlowFixMe;
|
|
284
|
+
if (this.listener == null) {
|
|
285
|
+
return current;
|
|
286
|
+
}
|
|
287
|
+
held?.removeObserver(this);
|
|
288
|
+
this.query = current;
|
|
289
|
+
current.addObserver(this, options.gcTime);
|
|
290
|
+
if (refill && options.enabled && current.isStale(options.staleTime)) {
|
|
291
|
+
void this.fetch({ cancelRefetch: false });
|
|
292
|
+
}
|
|
293
|
+
return current;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
isEnabled(): boolean {
|
|
297
|
+
return this.listener != null && this.resolved().enabled;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Refetch on behalf of invalidation. Never rejects; errors land in state. */
|
|
301
|
+
refetchNow(): Promise<void> {
|
|
302
|
+
return this.fetch({ cancelRefetch: true });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Ask for the key again.
|
|
307
|
+
*
|
|
308
|
+
* Never rejects. A failed request is a state a component renders, not an
|
|
309
|
+
* exception a click handler has to catch — and a rejected promise nobody
|
|
310
|
+
* awaited is an unhandled rejection in the console for something the UI is
|
|
311
|
+
* already showing.
|
|
312
|
+
*/
|
|
313
|
+
fetch(options: {|
|
|
314
|
+
readonly cancelRefetch: boolean,
|
|
315
|
+
readonly direction?: FetchDirection | null,
|
|
316
|
+
|}): Promise<void> {
|
|
317
|
+
const resolved = this.resolved();
|
|
318
|
+
const direction = options.direction ?? null;
|
|
319
|
+
// Through `attach`, so a fetch can never fill an entry this observer is
|
|
320
|
+
// not registered on and then wonder why nothing re-rendered.
|
|
321
|
+
const query = this.attach(resolved);
|
|
322
|
+
return query
|
|
323
|
+
.fetch(this.buildFetcher(resolved, direction), {
|
|
324
|
+
retry: resolved.retry,
|
|
325
|
+
retryDelay: resolved.retryDelay,
|
|
326
|
+
cancelRefetch: options.cancelRefetch,
|
|
327
|
+
direction,
|
|
328
|
+
})
|
|
329
|
+
.then(ignore, ignore);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* How the entry is refilled.
|
|
334
|
+
*
|
|
335
|
+
* The context is passed straight through rather than destructured, because
|
|
336
|
+
* `signal` is a getter and reading it is what tells the cache the request
|
|
337
|
+
* can be aborted. Unpacking it here would opt every query in, including the
|
|
338
|
+
* ones that cannot honour it.
|
|
339
|
+
*/
|
|
340
|
+
buildFetcher(
|
|
341
|
+
options: ResolvedQueryOptions<TData, TSelected>,
|
|
342
|
+
_direction: FetchDirection | null,
|
|
343
|
+
): Fetcher<TData> {
|
|
344
|
+
return (context) => options.queryFn(context);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
buildResult(
|
|
348
|
+
query: Query<TData> | void,
|
|
349
|
+
state: QueryState<TData>,
|
|
350
|
+
options: ResolvedQueryOptions<TData, TSelected>,
|
|
351
|
+
): QueryResult<TSelected> {
|
|
352
|
+
let raw: mixed = state.data;
|
|
353
|
+
let status = state.status;
|
|
354
|
+
let isPlaceholderData = false;
|
|
355
|
+
|
|
356
|
+
// Remembered so `placeholderData` can be given the previous answer: that
|
|
357
|
+
// is how a paged list keeps the page it is showing while the next one
|
|
358
|
+
// loads, instead of blanking between pages.
|
|
359
|
+
if (status === "success" && state.data !== undefined) {
|
|
360
|
+
this.lastData = state.data;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (status === "pending" && options.placeholderData !== undefined) {
|
|
364
|
+
const placeholder =
|
|
365
|
+
typeof options.placeholderData === "function"
|
|
366
|
+
? options.placeholderData(this.lastData)
|
|
367
|
+
: options.placeholderData;
|
|
368
|
+
if (placeholder !== undefined) {
|
|
369
|
+
raw = placeholder;
|
|
370
|
+
status = "success";
|
|
371
|
+
isPlaceholderData = true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const isFetching = state.fetchStatus === "fetching";
|
|
376
|
+
const isPending = status === "pending";
|
|
377
|
+
return {
|
|
378
|
+
data: raw === undefined ? undefined : (this.narrow(raw, options.select) as $FlowFixMe),
|
|
379
|
+
error: state.error,
|
|
380
|
+
status,
|
|
381
|
+
fetchStatus: state.fetchStatus,
|
|
382
|
+
isPending,
|
|
383
|
+
isLoading: isPending && isFetching,
|
|
384
|
+
isSuccess: status === "success",
|
|
385
|
+
isError: status === "error",
|
|
386
|
+
isFetching,
|
|
387
|
+
isRefetching: isFetching && !isPending,
|
|
388
|
+
isStale: query == null || query.isStale(options.staleTime),
|
|
389
|
+
isPlaceholderData,
|
|
390
|
+
failureCount: state.failureCount,
|
|
391
|
+
refetch: this.refetch,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Apply `select`, keeping the narrowed value's identity when it can.
|
|
397
|
+
*
|
|
398
|
+
* Two layers, because callers write `select` inline and a fresh closure each
|
|
399
|
+
* render defeats a memo keyed on identity alone. So: reuse the output when
|
|
400
|
+
* both the data and the function are the same, and otherwise recompute and
|
|
401
|
+
* hand the result through structural sharing — which returns the previous
|
|
402
|
+
* narrowed value when the new one is deeply equal to it. The first layer
|
|
403
|
+
* makes it cheap; the second makes it *correct*, and correctness here is
|
|
404
|
+
* what stops a component from re-rendering on data it does not read.
|
|
405
|
+
*/
|
|
406
|
+
narrow(raw: mixed, select: ((data: TData) => TSelected) | void): mixed {
|
|
407
|
+
if (select == null) {
|
|
408
|
+
return raw;
|
|
409
|
+
}
|
|
410
|
+
const memo = this.selection;
|
|
411
|
+
if (memo != null && memo.raw === raw && memo.select === select) {
|
|
412
|
+
return memo.output;
|
|
413
|
+
}
|
|
414
|
+
const computed = select(raw as $FlowFixMe);
|
|
415
|
+
const output = memo == null ? computed : structuralShare(memo.output, computed);
|
|
416
|
+
this.selection = { raw, select, output };
|
|
417
|
+
return output;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
resolved(): ResolvedQueryOptions<TData, TSelected> {
|
|
421
|
+
return this.client.resolveQuery(this.getOptions());
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Wake up when the entry turns stale.
|
|
426
|
+
*
|
|
427
|
+
* Staleness is a fact about the clock, and nothing else in this package ever
|
|
428
|
+
* looks at the clock on its own. Without this timer a component that shows
|
|
429
|
+
* "this may be out of date" would be told only by the next unrelated
|
|
430
|
+
* re-render, which may never come.
|
|
431
|
+
*/
|
|
432
|
+
updateStaleTimer(options: ResolvedQueryOptions<TData, TSelected>): void {
|
|
433
|
+
if (this.staleTimer != null) {
|
|
434
|
+
clearTimeout(this.staleTimer);
|
|
435
|
+
this.staleTimer = null;
|
|
436
|
+
}
|
|
437
|
+
const query = this.query;
|
|
438
|
+
if (query == null || this.listener == null) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const { staleTime } = options;
|
|
442
|
+
if (staleTime <= 0 || staleTime === Number.POSITIVE_INFINITY) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const remaining = query.state.checkedAt + staleTime - Date.now();
|
|
446
|
+
if (query.state.checkedAt === 0 || remaining <= 0) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.staleTimer = setTimeout(() => {
|
|
450
|
+
this.staleTimer = null;
|
|
451
|
+
this.listener?.();
|
|
452
|
+
}, remaining + 1);
|
|
453
|
+
(this.staleTimer as $FlowFixMe)?.unref?.();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
startInterval(options: ResolvedQueryOptions<TData, TSelected>): void {
|
|
457
|
+
const interval = options.refetchInterval;
|
|
458
|
+
if (interval == null || interval <= 0 || !options.enabled) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.intervalTimer = setInterval(() => {
|
|
462
|
+
void this.fetch({ cancelRefetch: false });
|
|
463
|
+
}, interval);
|
|
464
|
+
(this.intervalTimer as $FlowFixMe)?.unref?.();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Refresh when the reader comes back, or the network does.
|
|
469
|
+
*
|
|
470
|
+
* Only when the entry is actually stale: coming back to a tab that was
|
|
471
|
+
* hidden for two seconds should not refetch anything, and `staleTime` is
|
|
472
|
+
* already the application's statement about how long an answer is good for.
|
|
473
|
+
*/
|
|
474
|
+
watchPresence(client: QueryClient, options: ResolvedQueryOptions<TData, TSelected>): void {
|
|
475
|
+
if (!options.refetchOnWindowFocus && !options.refetchOnReconnect) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.stopPresence = client.presence.subscribe((event) => {
|
|
479
|
+
const current = this.resolved();
|
|
480
|
+
const wanted = event === "focus" ? current.refetchOnWindowFocus : current.refetchOnReconnect;
|
|
481
|
+
const query = this.query;
|
|
482
|
+
if (!wanted || !current.enabled || query == null || !query.isStale(current.staleTime)) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
void this.fetch({ cancelRefetch: false });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
stopTimers(): void {
|
|
490
|
+
if (this.staleTimer != null) {
|
|
491
|
+
clearTimeout(this.staleTimer);
|
|
492
|
+
this.staleTimer = null;
|
|
493
|
+
}
|
|
494
|
+
if (this.intervalTimer != null) {
|
|
495
|
+
clearInterval(this.intervalTimer);
|
|
496
|
+
this.intervalTimer = null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function ignore(): void {}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/query",
|
|
3
|
+
"version": "0.0.0-alpha.10",
|
|
4
|
+
"description": "A query cache with de-duplication, structural sharing 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
|
+
"./cache": "./cache.js",
|
|
16
|
+
"./client": "./client.js",
|
|
17
|
+
"./infinite": "./infinite.js",
|
|
18
|
+
"./key": "./key.js",
|
|
19
|
+
"./mutation": "./mutation.js",
|
|
20
|
+
"./observer": "./observer.js",
|
|
21
|
+
"./presence": "./presence.js",
|
|
22
|
+
"./query": "./query.js",
|
|
23
|
+
"./react": "./react.js",
|
|
24
|
+
"./retry": "./retry.js",
|
|
25
|
+
"./structural": "./structural.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"*.js"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@uniflowed/hooks": "0.0.0-alpha.10",
|
|
32
|
+
"@uniflowed/react": "0.0.0-alpha.10"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": ">=19"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/presence.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/query/presence`: the world moved while nobody was looking.
|
|
4
|
+
//
|
|
5
|
+
// Two facts sit outside every cache and invalidate all of it at once: the
|
|
6
|
+
// reader came back to the tab, and the network came back. Both mean the same
|
|
7
|
+
// thing — time passed during which this application could not have learned
|
|
8
|
+
// anything — and both are the reason a query library feels different from a
|
|
9
|
+
// `useEffect` that fetches once. A dashboard left open over lunch should not
|
|
10
|
+
// still be showing lunchtime.
|
|
11
|
+
//
|
|
12
|
+
// # Why the two live in one module
|
|
13
|
+
//
|
|
14
|
+
// They are the same five lines twice: a boolean, listeners, a browser event
|
|
15
|
+
// that sets it, and an override for the environments where the browser event
|
|
16
|
+
// does not exist. Splitting them would produce two files that differ only in
|
|
17
|
+
// the name of the event, and a reader who found one would have to be told
|
|
18
|
+
// about the other anyway.
|
|
19
|
+
//
|
|
20
|
+
// # Why the listeners are attached lazily and released
|
|
21
|
+
//
|
|
22
|
+
// A package may not do work while it is being imported: an application that
|
|
23
|
+
// imports `useQuery` and renders nothing must not have registered a
|
|
24
|
+
// `visibilitychange` handler, and a server that imports it has no `document`
|
|
25
|
+
// to register one on. So the handlers go on when the first watcher arrives and
|
|
26
|
+
// come off when the last one leaves, which also means a test that renders and
|
|
27
|
+
// unmounts leaves the process exactly as it found it.
|
|
28
|
+
//
|
|
29
|
+
// # Why `setFocused` and `setOnline` are public
|
|
30
|
+
//
|
|
31
|
+
// React Native has no `document` and no `window`; its equivalents are
|
|
32
|
+
// `AppState` and NetInfo, and an application there is expected to drive this
|
|
33
|
+
// object from them. Tests need the same door for the same reason. Providing it
|
|
34
|
+
// is what keeps the browser bindings from being the only way in — and what
|
|
35
|
+
// keeps this module honest about the fact that "focused" is an assumption, not
|
|
36
|
+
// an observation, wherever the platform does not report it.
|
|
37
|
+
//
|
|
38
|
+
// Nothing here pauses a request when the network is gone. A fetch that fails
|
|
39
|
+
// offline is a failure like any other, and [`../retry`] already knows what to
|
|
40
|
+
// do with one; a second mechanism that holds requests in a queue would have to
|
|
41
|
+
// agree with the first about ordering, cancellation and timeouts, and the
|
|
42
|
+
// disagreement is where the bugs would be.
|
|
43
|
+
|
|
44
|
+
/** Which of the two ambient facts changed. */
|
|
45
|
+
export type PresenceEvent = "focus" | "online";
|
|
46
|
+
|
|
47
|
+
export class Presence {
|
|
48
|
+
focused: boolean;
|
|
49
|
+
online: boolean;
|
|
50
|
+
|
|
51
|
+
listeners: Set<(event: PresenceEvent) => void> = new Set();
|
|
52
|
+
detach: (() => void) | null = null;
|
|
53
|
+
|
|
54
|
+
constructor() {
|
|
55
|
+
// Read once at construction rather than at import: the answer is the
|
|
56
|
+
// environment's, and the environment does not exist yet while modules are
|
|
57
|
+
// being evaluated on a server.
|
|
58
|
+
const document = (globalThis as $FlowFixMe).document;
|
|
59
|
+
const navigator = (globalThis as $FlowFixMe).navigator;
|
|
60
|
+
this.focused = document == null || document.visibilityState !== "hidden";
|
|
61
|
+
this.online = navigator == null || navigator.onLine !== false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isFocused(): boolean {
|
|
65
|
+
return this.focused;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isOnline(): boolean {
|
|
69
|
+
return this.online;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Report that the reader came back, or left.
|
|
74
|
+
*
|
|
75
|
+
* Only a change is announced. A platform that fires its focus event on every
|
|
76
|
+
* window activation — including the one the reader never left — would
|
|
77
|
+
* otherwise turn a click on the page into a refetch of everything.
|
|
78
|
+
*/
|
|
79
|
+
setFocused(value: boolean): void {
|
|
80
|
+
if (this.focused === value) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.focused = value;
|
|
84
|
+
if (value) {
|
|
85
|
+
this.announce("focus");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Report that the network came back, or went away. */
|
|
90
|
+
setOnline(value: boolean): void {
|
|
91
|
+
if (this.online === value) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.online = value;
|
|
95
|
+
if (value) {
|
|
96
|
+
this.announce("online");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Watch for either event. Returns the unsubscribe. */
|
|
101
|
+
subscribe(listener: (event: PresenceEvent) => void): () => void {
|
|
102
|
+
this.listeners.add(listener);
|
|
103
|
+
if (this.listeners.size === 1) {
|
|
104
|
+
this.attach();
|
|
105
|
+
}
|
|
106
|
+
return () => {
|
|
107
|
+
this.listeners.delete(listener);
|
|
108
|
+
if (this.listeners.size === 0) {
|
|
109
|
+
this.release();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
announce(event: PresenceEvent): void {
|
|
115
|
+
for (const listener of Array.from(this.listeners)) {
|
|
116
|
+
listener(event);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
attach(): void {
|
|
121
|
+
const document = (globalThis as $FlowFixMe).document;
|
|
122
|
+
const window = (globalThis as $FlowFixMe).window;
|
|
123
|
+
if (document?.addEventListener == null && window?.addEventListener == null) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const onVisibility = () => this.setFocused(document.visibilityState !== "hidden");
|
|
128
|
+
const onOnline = () => this.setOnline(true);
|
|
129
|
+
const onOffline = () => this.setOnline(false);
|
|
130
|
+
|
|
131
|
+
document?.addEventListener?.("visibilitychange", onVisibility);
|
|
132
|
+
window?.addEventListener?.("online", onOnline);
|
|
133
|
+
window?.addEventListener?.("offline", onOffline);
|
|
134
|
+
|
|
135
|
+
this.detach = () => {
|
|
136
|
+
document?.removeEventListener?.("visibilitychange", onVisibility);
|
|
137
|
+
window?.removeEventListener?.("online", onOnline);
|
|
138
|
+
window?.removeEventListener?.("offline", onOffline);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
release(): void {
|
|
143
|
+
this.detach?.();
|
|
144
|
+
this.detach = null;
|
|
145
|
+
}
|
|
146
|
+
}
|