@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/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
+ }