@uniflowed/hooks 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/browser.js ADDED
@@ -0,0 +1,675 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks/browser`: reading the environment, safely on a server.
4
+ //
5
+ // uf prerenders every static route, so each of these runs once where there is
6
+ // no `window`. `useSyncExternalStore` is what makes that correct rather than
7
+ // guarded: it takes a server snapshot as a separate argument, so the value
8
+ // used during prerender is stated rather than being whatever a `typeof window`
9
+ // check happened to fall through to. It also means React reads the value at
10
+ // the moment it commits, which is what stops a media query changing between
11
+ // render and paint from tearing.
12
+ //
13
+ // # What belongs in this module
14
+ //
15
+ // A reading of the one browser the page is in: its size, its scroll offset,
16
+ // its connection, its visibility, its position on the earth, the preferences
17
+ // the reader set, the permissions the reader granted. There is exactly one
18
+ // answer at a time, nobody has to pass anything in to ask, and a server has no
19
+ // answer at all — which is why every hook here either takes a server value
20
+ // from the caller or states an honest default.
21
+ //
22
+ // Not here: anything about a specific element, which needs a ref and lives in
23
+ // `dom.js`. `useDocumentVisible` is the closest call in the package and stays
24
+ // here, because the document is the environment rather than a node a caller
25
+ // chose.
26
+ //
27
+ // # The one that writes
28
+ //
29
+ // `useScrollLock` is the exception to "reading", and it is here rather than in
30
+ // `dom.js` because what it freezes is the page: there is one of it, the caller
31
+ // has no ref to it, and the compensation it has to make — the width of the
32
+ // scrollbar that is about to disappear — is a fact about the window rather
33
+ // than about any element. A lock that took a ref would be a different and
34
+ // rarer hook.
35
+ //
36
+ // # Two kinds of hook, and why the second kind exists
37
+ //
38
+ // Most of these are a `useSyncExternalStore` over an event the browser already
39
+ // fires, which is the shape that survives a prerender and a concurrent render
40
+ // without tearing. Three are not: `useGeolocation` and `usePermission` are
41
+ // subscriptions whose *first* value only arrives asynchronously, so there is
42
+ // nothing for a snapshot to return until it does, and `useScrollLock` writes.
43
+ // Each says so where it is defined.
44
+
45
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
46
+
47
+ import { useIsomorphicLayoutEffect } from "./lifecycle.js";
48
+
49
+ /**
50
+ * The part of a `navigator` this package reads.
51
+ *
52
+ * Not a declaration of `Navigator` — Flow ships one of those. This is the
53
+ * list of what these hooks actually touch, which is short enough to be worth
54
+ * writing down and is the reason none of them needs an `any`: everything below
55
+ * `browserWindow()` is checked against this.
56
+ *
57
+ * Every field is optional because a hosted document is not required to carry
58
+ * the whole of a browser. `navigator.geolocation` is `null` under happy-dom
59
+ * and absent under a bare Node global, and `navigator.connection` exists
60
+ * nowhere but Chromium.
61
+ */
62
+ export type BrowserNavigator = {
63
+ readonly onLine?: boolean,
64
+ readonly userAgent?: string,
65
+ readonly clipboard?: ?{
66
+ readonly readText: () => Promise<string>,
67
+ readonly writeText: (text: string) => Promise<void>,
68
+ ...
69
+ },
70
+ readonly geolocation?: ?Geolocation,
71
+ readonly permissions?: ?{
72
+ readonly query: (descriptor: { readonly name: string, ... }) => Promise<PermissionStatus>,
73
+ ...
74
+ },
75
+ readonly connection?: ?NetworkConnection,
76
+ ...
77
+ };
78
+
79
+ /** The Network Information object, which only Chromium has. */
80
+ export type NetworkConnection = {
81
+ readonly downlink?: number,
82
+ readonly effectiveType?: string,
83
+ readonly saveData?: boolean,
84
+ readonly addEventListener?: (type: string, listener: () => mixed) => void,
85
+ readonly removeEventListener?: (type: string, listener: () => mixed) => void,
86
+ ...
87
+ };
88
+
89
+ /**
90
+ * The part of a `window` this package reads.
91
+ *
92
+ * Same idea as [`BrowserNavigator`], and the same reason: naming what is
93
+ * touched turns every read in the package into a checked one. The observer
94
+ * constructors are optional because a browser old enough to lack one is a
95
+ * browser a hook here has to keep working in — it degrades to reporting
96
+ * nothing rather than throwing during a render.
97
+ */
98
+ export type BrowserWindow = {
99
+ readonly document: Document,
100
+ readonly navigator: BrowserNavigator,
101
+ readonly localStorage?: ?Storage,
102
+ readonly sessionStorage?: ?Storage,
103
+ readonly innerWidth: number,
104
+ readonly innerHeight: number,
105
+ readonly scrollX: number,
106
+ readonly scrollY: number,
107
+ readonly matchMedia?: (query: string) => MediaQueryList,
108
+ readonly getComputedStyle?: (element: Element) => CSSStyleDeclaration,
109
+ readonly requestAnimationFrame?: (callback: (time: number) => mixed) => AnimationFrameID,
110
+ readonly cancelAnimationFrame?: (handle: AnimationFrameID) => void,
111
+ // Two overloads, the way Flow's own `EventTarget` is declared: a `storage`
112
+ // listener is handed a `StorageEvent` and needs its `key`, and narrowing an
113
+ // `Event` down to one at runtime would mean an `instanceof StorageEvent`
114
+ // against a name that is not defined in every host a uf test runs in.
115
+ readonly addEventListener: ((
116
+ type: "storage",
117
+ listener: (event: StorageEvent) => mixed,
118
+ options?: EventListenerOptionsOrUseCapture,
119
+ ) => void) &
120
+ ((
121
+ type: string,
122
+ listener: (event: Event) => mixed,
123
+ options?: EventListenerOptionsOrUseCapture,
124
+ ) => void),
125
+ readonly removeEventListener: ((
126
+ type: "storage",
127
+ listener: (event: StorageEvent) => mixed,
128
+ options?: EventListenerOptionsOrUseCapture,
129
+ ) => void) &
130
+ ((
131
+ type: string,
132
+ listener: (event: Event) => mixed,
133
+ options?: EventListenerOptionsOrUseCapture,
134
+ ) => void),
135
+ readonly ResizeObserver?: Class<ResizeObserver>,
136
+ readonly IntersectionObserver?: Class<IntersectionObserver>,
137
+ readonly MutationObserver?: Class<MutationObserver>,
138
+ ...
139
+ };
140
+
141
+ /**
142
+ * The window these hooks listen to, or `null` where there is no browser.
143
+ *
144
+ * In a browser `globalThis` *is* the window, so `globalThis.addEventListener`
145
+ * looks correct. It is not correct anywhere a document has been installed onto
146
+ * another host's global — which is every uf test process, where `globalThis` is
147
+ * Node's and has no `addEventListener` at all. Ask the document's own window
148
+ * for its methods and both cases work.
149
+ *
150
+ * Exported because it is the first question every hook in this package asks,
151
+ * and an application writing its own prerender-safe hook has to ask it too.
152
+ * The single `?? globalThis` is this package's only unchecked step: `window` is
153
+ * `any` in Flow's own library definition, and this is where that stops.
154
+ */
155
+ export function browserWindow(): BrowserWindow | null {
156
+ if (typeof globalThis.document === "undefined") {
157
+ return null;
158
+ }
159
+ return globalThis.window ?? globalThis;
160
+ }
161
+
162
+ /** A subscription to nothing, for a value that cannot change. */
163
+ function subscribeToNothing(): () => void {
164
+ return () => {};
165
+ }
166
+
167
+ /** The server's answer to every "is this available" question. */
168
+ function unsupported(): boolean {
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * Whether a capability the browser may not have is there.
174
+ *
175
+ * The naive version — `typeof window.BroadcastChannel === "function"` in the
176
+ * render — is a hydration mismatch waiting to happen: the server says one
177
+ * thing, the client's first render says another, and React reports it against
178
+ * whatever markup happened to differ. Asked through `useSyncExternalStore`, the
179
+ * server's answer is `false`, the hydrating render agrees with it, and React
180
+ * re-renders with the truth immediately afterwards.
181
+ *
182
+ * `probe` is called during render, so it must only look — never install, never
183
+ * request. It is passed straight through rather than stabilised: a snapshot is
184
+ * a question the render is asking now, and a stable callback's body is
185
+ * installed in an insertion effect that has not run yet, so it would answer
186
+ * from the render before.
187
+ */
188
+ export hook useSupported(probe: () => boolean): boolean {
189
+ return useSyncExternalStore(subscribeToNothing, probe, unsupported);
190
+ }
191
+
192
+ /**
193
+ * Whether a media query matches.
194
+ *
195
+ * `serverValue` is what a prerender should assume, and it has no honest
196
+ * default — a page that hides a sidebar under 48rem wants `false` on the
197
+ * server, and one that renders a mobile menu wants `true`. So the caller says.
198
+ */
199
+ export hook useMediaQuery(query: string, serverValue: boolean = false): boolean {
200
+ const subscribe = useCallback(
201
+ (notify: () => void) => {
202
+ const list = browserWindow()?.matchMedia?.(query);
203
+ if (list == null) {
204
+ return () => {};
205
+ }
206
+ list.addEventListener("change", notify);
207
+ return () => list.removeEventListener("change", notify);
208
+ },
209
+ [query],
210
+ );
211
+
212
+ const snapshot = useCallback(
213
+ () => browserWindow()?.matchMedia?.(query)?.matches ?? serverValue,
214
+ [query, serverValue],
215
+ );
216
+
217
+ return useSyncExternalStore(subscribe, snapshot, () => serverValue);
218
+ }
219
+
220
+ /** The reader's colour-scheme preference. */
221
+ export hook usePreferredColorScheme(serverValue: "light" | "dark" = "light"): "light" | "dark" {
222
+ return useMediaQuery("(prefers-color-scheme: dark)", serverValue === "dark") ? "dark" : "light";
223
+ }
224
+
225
+ /** Whether the reader has asked for less motion. */
226
+ export hook usePrefersReducedMotion(serverValue: boolean = false): boolean {
227
+ return useMediaQuery("(prefers-reduced-motion: reduce)", serverValue);
228
+ }
229
+
230
+ /** Whether the browser thinks it is online. */
231
+ export hook useOnline(serverValue: boolean = true): boolean {
232
+ const subscribe = useCallback((notify: () => void) => {
233
+ const win = browserWindow();
234
+ if (win == null) {
235
+ return () => {};
236
+ }
237
+ win.addEventListener("online", notify);
238
+ win.addEventListener("offline", notify);
239
+ return () => {
240
+ win.removeEventListener("online", notify);
241
+ win.removeEventListener("offline", notify);
242
+ };
243
+ }, []);
244
+
245
+ const snapshot = useCallback(
246
+ () => browserWindow()?.navigator.onLine ?? serverValue,
247
+ [serverValue],
248
+ );
249
+
250
+ return useSyncExternalStore(subscribe, snapshot, () => serverValue);
251
+ }
252
+
253
+ /** Whether the document is the one the reader is looking at. */
254
+ export hook useDocumentVisible(serverValue: boolean = true): boolean {
255
+ const subscribe = useCallback((notify: () => void) => {
256
+ const document = browserWindow()?.document;
257
+ if (document == null) {
258
+ return () => {};
259
+ }
260
+ document.addEventListener("visibilitychange", notify);
261
+ return () => document.removeEventListener("visibilitychange", notify);
262
+ }, []);
263
+
264
+ const snapshot = useCallback(() => {
265
+ const document = browserWindow()?.document;
266
+ return document == null ? serverValue : document.visibilityState !== "hidden";
267
+ }, [serverValue]);
268
+
269
+ return useSyncExternalStore(subscribe, snapshot, () => serverValue);
270
+ }
271
+
272
+ /**
273
+ * How far something has been scrolled.
274
+ *
275
+ * Defined here rather than in `dom.js` because `dom.js` imports this module
276
+ * and not the other way round; `useScroll` over an element uses the same
277
+ * shape, and one name for one thing is worth the arrow.
278
+ */
279
+ export type ScrollOffset = {| readonly x: number, readonly y: number |};
280
+
281
+ /** How big something is. Shared with `useElementSize` for the same reason. */
282
+ export type Size = {| readonly width: number, readonly height: number |};
283
+
284
+ /** Read `"12x34"` back into a pair. */
285
+ function unpack(packed: string): ScrollOffset {
286
+ const [x, y] = packed.split("x");
287
+ return { x: Number(x), y: Number(y) };
288
+ }
289
+
290
+ /** The size of the viewport. */
291
+ export hook useWindowSize(serverValue?: Size): Size {
292
+ const width = serverValue?.width ?? 0;
293
+ const height = serverValue?.height ?? 0;
294
+
295
+ const subscribe = useCallback((notify: () => void) => {
296
+ const win = browserWindow();
297
+ if (win == null) {
298
+ return () => {};
299
+ }
300
+ win.addEventListener("resize", notify);
301
+ return () => win.removeEventListener("resize", notify);
302
+ }, []);
303
+
304
+ // A string snapshot, because `useSyncExternalStore` compares snapshots by
305
+ // identity: returning a fresh object every time would re-render on every
306
+ // check, which is an infinite loop React reports rather than tolerates.
307
+ const packed = useSyncExternalStore(
308
+ subscribe,
309
+ useCallback(() => {
310
+ const win = browserWindow();
311
+ return win == null ? `${width}x${height}` : `${win.innerWidth}x${win.innerHeight}`;
312
+ }, [width, height]),
313
+ useCallback(() => `${width}x${height}`, [width, height]),
314
+ );
315
+
316
+ const size = unpack(packed);
317
+ return { width: size.x, height: size.y };
318
+ }
319
+
320
+ /**
321
+ * How far the page has been scrolled.
322
+ *
323
+ * The same packed-string snapshot as `useWindowSize`, for the same reason, and
324
+ * a passive listener because a scroll handler that could call
325
+ * `preventDefault` blocks scrolling on a touch screen until it has run.
326
+ */
327
+ export hook useWindowScroll(serverValue?: ScrollOffset): ScrollOffset {
328
+ const x = serverValue?.x ?? 0;
329
+ const y = serverValue?.y ?? 0;
330
+
331
+ const subscribe = useCallback((notify: () => void) => {
332
+ const win = browserWindow();
333
+ if (win == null) {
334
+ return () => {};
335
+ }
336
+ win.addEventListener("scroll", notify, { passive: true });
337
+ return () => win.removeEventListener("scroll", notify);
338
+ }, []);
339
+
340
+ const packed = useSyncExternalStore(
341
+ subscribe,
342
+ useCallback(() => {
343
+ const win = browserWindow();
344
+ return win == null ? `${x}x${y}` : `${win.scrollX}x${win.scrollY}`;
345
+ }, [x, y]),
346
+ useCallback(() => `${x}x${y}`, [x, y]),
347
+ );
348
+
349
+ return unpack(packed);
350
+ }
351
+
352
+ /**
353
+ * How many components are currently holding the page still.
354
+ *
355
+ * Module-level rather than per-component, because two dialogs open at once
356
+ * must not have the first one to close put the page back: the page unlocks
357
+ * when the last of them lets go. Strict Mode's mount-unmount-mount is
358
+ * balanced by construction — the effect increments and its cleanup decrements.
359
+ */
360
+ let scrollLocks = 0;
361
+
362
+ /** What to put back when the last lock is released. */
363
+ let releaseScroll: (() => void) | null = null;
364
+
365
+ /**
366
+ * The widest a scrollbar is allowed to be believed.
367
+ *
368
+ * The compensation is `innerWidth - documentElement.clientWidth`, which is the
369
+ * scrollbar's width in a browser that lays out and is the whole viewport in
370
+ * one that does not — a headless document reports a client width of zero.
371
+ * Padding the page by a viewport pushes it off screen, so a number that could
372
+ * not be a scrollbar is treated as no measurement at all.
373
+ */
374
+ const WIDEST_SCROLLBAR = 40;
375
+
376
+ function lockScroll(): void {
377
+ scrollLocks += 1;
378
+ if (scrollLocks > 1) {
379
+ return;
380
+ }
381
+ const win = browserWindow();
382
+ const body = win?.document.body;
383
+ if (win == null || body == null) {
384
+ return;
385
+ }
386
+ const previousOverflow = body.style.overflow;
387
+ const previousPadding = body.style.paddingRight;
388
+ const root = win.document.documentElement;
389
+ const gap = root == null ? 0 : win.innerWidth - root.clientWidth;
390
+ body.style.overflow = "hidden";
391
+ if (gap > 0 && gap <= WIDEST_SCROLLBAR) {
392
+ const computed = win.getComputedStyle?.(body).paddingRight ?? "";
393
+ body.style.paddingRight = `${(Number.parseFloat(computed) || 0) + gap}px`;
394
+ }
395
+ releaseScroll = () => {
396
+ body.style.overflow = previousOverflow;
397
+ body.style.paddingRight = previousPadding;
398
+ };
399
+ }
400
+
401
+ function unlockScroll(): void {
402
+ scrollLocks = Math.max(0, scrollLocks - 1);
403
+ if (scrollLocks === 0 && releaseScroll != null) {
404
+ releaseScroll();
405
+ releaseScroll = null;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Hold the page still while `locked`.
411
+ *
412
+ * A layout effect, so the page is frozen before the frame in which the dialog
413
+ * that asked for it appears — an ordinary effect lets one frame of scrolling
414
+ * through, which reads as a jump.
415
+ *
416
+ * On a server this does nothing at all: effects do not run during a prerender,
417
+ * so a locked dialog rendered into HTML leaves the markup alone.
418
+ */
419
+ export hook useScrollLock(locked: boolean): void {
420
+ useIsomorphicLayoutEffect(() => {
421
+ if (!locked) {
422
+ return;
423
+ }
424
+ lockScroll();
425
+ return unlockScroll;
426
+ }, [locked]);
427
+ }
428
+
429
+ /** How good the connection is, as the browser grades it. */
430
+ export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
431
+
432
+ /** What the browser will say about the connection. */
433
+ export type Network = {|
434
+ readonly online: boolean,
435
+ /** Estimated bandwidth in megabits per second, where the browser reports it. */
436
+ readonly downlink: number | null,
437
+ readonly effectiveType: EffectiveConnectionType | null,
438
+ /** Whether the reader has asked for less data to be used. */
439
+ readonly saveData: boolean,
440
+ /** Whether anything beyond `online` was actually measured. */
441
+ readonly supported: boolean,
442
+ |};
443
+
444
+ const EFFECTIVE_TYPES: $ReadOnlyArray<EffectiveConnectionType> = ["slow-2g", "2g", "3g", "4g"];
445
+
446
+ function asEffectiveType(value: string): EffectiveConnectionType | null {
447
+ for (const known of EFFECTIVE_TYPES) {
448
+ if (known === value) {
449
+ return known;
450
+ }
451
+ }
452
+ return null;
453
+ }
454
+
455
+ /**
456
+ * What the browser will say about the connection.
457
+ *
458
+ * Only Chromium implements Network Information, so `supported` is part of the
459
+ * answer rather than something a caller has to find out by seeing nulls. The
460
+ * `online` half is everywhere, and stays true even where the rest is unknown —
461
+ * which is the field almost every caller wants.
462
+ *
463
+ * The snapshot is a packed string for the reason `useWindowSize` gives: an
464
+ * object rebuilt on every check never compares equal, and `useSyncExternalStore`
465
+ * would re-render forever.
466
+ */
467
+ export hook useNetwork(serverValue: boolean = true): Network {
468
+ const subscribe = useCallback((notify: () => void) => {
469
+ const win = browserWindow();
470
+ if (win == null) {
471
+ return () => {};
472
+ }
473
+ const connection = win.navigator.connection;
474
+ win.addEventListener("online", notify);
475
+ win.addEventListener("offline", notify);
476
+ connection?.addEventListener?.("change", notify);
477
+ return () => {
478
+ win.removeEventListener("online", notify);
479
+ win.removeEventListener("offline", notify);
480
+ connection?.removeEventListener?.("change", notify);
481
+ };
482
+ }, []);
483
+
484
+ const server = useCallback(() => `${serverValue ? "1" : "0"}|||0`, [serverValue]);
485
+
486
+ const packed = useSyncExternalStore(
487
+ subscribe,
488
+ useCallback(() => {
489
+ const navigator = browserWindow()?.navigator;
490
+ if (navigator == null) {
491
+ return `${serverValue ? "1" : "0"}|||0`;
492
+ }
493
+ const connection = navigator.connection;
494
+ const online = navigator.onLine ?? serverValue;
495
+ if (connection == null) {
496
+ return `${online ? "1" : "0"}|||0`;
497
+ }
498
+ const downlink = connection.downlink;
499
+ return [
500
+ online ? "1" : "0",
501
+ downlink == null ? "" : String(downlink),
502
+ connection.effectiveType ?? "",
503
+ connection.saveData === true ? "1" : "0",
504
+ ].join("|");
505
+ }, [serverValue]),
506
+ server,
507
+ );
508
+
509
+ return useMemo(() => {
510
+ const [online, downlink, effectiveType, saveData] = packed.split("|");
511
+ return {
512
+ online: online === "1",
513
+ downlink: downlink === "" ? null : Number(downlink),
514
+ effectiveType: asEffectiveType(effectiveType),
515
+ saveData: saveData === "1",
516
+ supported: downlink !== "" || effectiveType !== "",
517
+ };
518
+ }, [packed]);
519
+ }
520
+
521
+ /** Where the reader is, to the accuracy the browser was willing to give. */
522
+ export type Geoposition = {|
523
+ readonly latitude: number,
524
+ readonly longitude: number,
525
+ /** Radius of a 95% confidence circle, in metres. */
526
+ readonly accuracy: number,
527
+ readonly timestamp: number,
528
+ |};
529
+
530
+ /** What `useGeolocation` knows so far. */
531
+ export type GeolocationReading = {|
532
+ readonly position: Geoposition | null,
533
+ readonly error: Error | null,
534
+ readonly supported: boolean,
535
+ |};
536
+
537
+ /**
538
+ * Watch where the reader is.
539
+ *
540
+ * An effect rather than a `useSyncExternalStore`, and the difference is not
541
+ * stylistic: there is no snapshot to read. The browser has no "current
542
+ * position" property to ask — the first value arrives in a callback, after a
543
+ * permission prompt the reader may take a minute to answer or never answer at
544
+ * all. So `position` is `null` until one arrives, on a server and in a browser
545
+ * alike, which is also what makes it hydration-safe.
546
+ *
547
+ * Mounting this asks the reader for permission. Mount it on the page that
548
+ * needs a position, not at the top of an application.
549
+ */
550
+ export hook useGeolocation(options?: {|
551
+ readonly enabled?: boolean,
552
+ readonly highAccuracy?: boolean,
553
+ readonly maximumAge?: number,
554
+ readonly timeout?: number,
555
+ |}): GeolocationReading {
556
+ const enabled = options?.enabled ?? true;
557
+ const highAccuracy = options?.highAccuracy ?? false;
558
+ const maximumAge = options?.maximumAge;
559
+ const timeout = options?.timeout;
560
+
561
+ const supported = useSupported(() => browserWindow()?.navigator.geolocation != null);
562
+ const [reading, setReading] = useState<{|
563
+ position: Geoposition | null,
564
+ error: Error | null,
565
+ |}>({ position: null, error: null });
566
+
567
+ useEffect(() => {
568
+ const geolocation = browserWindow()?.navigator.geolocation;
569
+ if (!enabled || geolocation == null) {
570
+ return;
571
+ }
572
+ const watch = geolocation.watchPosition(
573
+ (position: Position) => {
574
+ setReading({
575
+ position: {
576
+ latitude: position.coords.latitude,
577
+ longitude: position.coords.longitude,
578
+ accuracy: position.coords.accuracy,
579
+ timestamp: position.timestamp,
580
+ },
581
+ error: null,
582
+ });
583
+ },
584
+ (failure: PositionError) => {
585
+ // The position already on screen is kept: a timeout on the third
586
+ // reading does not mean the second one stopped being true.
587
+ setReading((current) => ({ ...current, error: new Error(failure.message) }));
588
+ },
589
+ { enableHighAccuracy: highAccuracy, maximumAge, timeout },
590
+ );
591
+ return () => geolocation.clearWatch(watch);
592
+ }, [enabled, highAccuracy, maximumAge, timeout]);
593
+
594
+ return useMemo(
595
+ () => ({ position: reading.position, error: reading.error, supported }),
596
+ [reading, supported],
597
+ );
598
+ }
599
+
600
+ /** A permission this hook knows how to ask about. */
601
+ export type PermissionName =
602
+ | "geolocation"
603
+ | "notifications"
604
+ | "camera"
605
+ | "microphone"
606
+ | "clipboard-read"
607
+ | "clipboard-write"
608
+ | "persistent-storage"
609
+ | "push"
610
+ | "midi";
611
+
612
+ /**
613
+ * What the browser says about a permission.
614
+ *
615
+ * `"unknown"` is one value for four situations that a caller treats the same
616
+ * way — no Permissions API, a name this browser does not recognise, an answer
617
+ * that has not arrived yet, and a server render. Splitting them would make
618
+ * every caller write the same four-armed `match` to reach the same conclusion.
619
+ */
620
+ export type PermissionAnswer = "granted" | "denied" | "prompt" | "unknown";
621
+
622
+ /**
623
+ * Whether the reader has granted a permission, without asking for it.
624
+ *
625
+ * Querying is not prompting: this reports the current state and follows it if
626
+ * the reader changes their mind in browser settings. Asking for the permission
627
+ * is the API's own job — `getUserMedia`, `watchPosition` — and doing it from
628
+ * here would make a hook that reads have a side effect nobody asked for.
629
+ *
630
+ * An effect rather than a store, for the reason `useGeolocation` gives: the
631
+ * answer is a promise, so there is nothing to read synchronously.
632
+ */
633
+ export hook usePermission(name: PermissionName): PermissionAnswer {
634
+ const [answer, setAnswer] = useState<PermissionAnswer>("unknown");
635
+
636
+ useEffect(() => {
637
+ const permissions = browserWindow()?.navigator.permissions;
638
+ if (permissions == null) {
639
+ return;
640
+ }
641
+ // Set when the effect is superseded, so an answer that arrives for a name
642
+ // the caller has stopped asking about is dropped rather than shown.
643
+ let ignore = false;
644
+ let status: PermissionStatus | null = null;
645
+ const onChange = () => {
646
+ if (!ignore && status != null) {
647
+ setAnswer(status.state);
648
+ }
649
+ };
650
+
651
+ permissions.query({ name }).then(
652
+ (result: PermissionStatus) => {
653
+ if (ignore) {
654
+ return;
655
+ }
656
+ status = result;
657
+ setAnswer(result.state);
658
+ result.addEventListener("change", onChange);
659
+ },
660
+ () => {
661
+ // A name this browser does not know rejects rather than answering.
662
+ if (!ignore) {
663
+ setAnswer("unknown");
664
+ }
665
+ },
666
+ );
667
+
668
+ return () => {
669
+ ignore = true;
670
+ status?.removeEventListener("change", onChange);
671
+ };
672
+ }, [name]);
673
+
674
+ return answer;
675
+ }