@uniflowed/hooks 0.0.0-alpha.4 → 0.0.0-alpha.41
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/async.js +99 -16
- package/browser.js +934 -60
- package/channels.js +224 -0
- package/dom.js +262 -58
- package/events.js +300 -0
- package/index.js +176 -21
- package/keyboard.js +328 -0
- package/lifecycle.js +12 -6
- package/package.json +9 -3
- package/render.js +354 -0
- package/state.js +378 -31
- package/timing.js +332 -17
package/browser.js
CHANGED
|
@@ -12,38 +12,282 @@
|
|
|
12
12
|
//
|
|
13
13
|
// # What belongs in this module
|
|
14
14
|
//
|
|
15
|
-
// A reading of the one browser the page is in: its size, its
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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 fragment in
|
|
17
|
+
// its address bar, the preferences the reader set, the permissions the reader
|
|
18
|
+
// granted. There is exactly one answer at a time, nobody has to pass anything
|
|
19
|
+
// in to ask, and a server has no answer at all — which is why every hook here
|
|
20
|
+
// either takes a server value from the caller or states an honest default.
|
|
21
21
|
//
|
|
22
22
|
// Not here: anything about a specific element, which needs a ref and lives in
|
|
23
23
|
// `dom.js`. `useDocumentVisible` is the closest call in the package and stays
|
|
24
24
|
// here, because the document is the environment rather than a node a caller
|
|
25
25
|
// chose.
|
|
26
|
+
//
|
|
27
|
+
// The path and the query are not here either, and that one is a boundary rather
|
|
28
|
+
// than a filing decision: they are on the request, so `@uniflowed/router` has
|
|
29
|
+
// already resolved them and `useRoute` is where a page reads them. What is here
|
|
30
|
+
// is the fragment, which the browser strips before the request goes out and
|
|
31
|
+
// which therefore nothing on the server has ever seen. See `useHash`.
|
|
32
|
+
//
|
|
33
|
+
// # What the first render produces
|
|
34
|
+
//
|
|
35
|
+
// Every hook below answers twice with the same value — once during the
|
|
36
|
+
// prerender, once during the client's hydrating render — and only then with
|
|
37
|
+
// what the browser actually says. That second column is not a detail: it is the
|
|
38
|
+
// value React compares the two trees at, so a hook whose first client render
|
|
39
|
+
// disagreed with the server's would report a mismatch on a page that did
|
|
40
|
+
// nothing wrong.
|
|
41
|
+
//
|
|
42
|
+
// | Hook | Prerender, and the first client render | Afterwards |
|
|
43
|
+
// | --- | --- | --- |
|
|
44
|
+
// | `useSupported` | `false` | what `probe` says |
|
|
45
|
+
// | `useMediaQuery` | `serverValue`, default `false` | whether it matches |
|
|
46
|
+
// | `usePreferredColorScheme` | `serverValue`, default `"light"` | the reader's setting |
|
|
47
|
+
// | `usePrefersReducedMotion` | `serverValue`, default `false` | the reader's setting |
|
|
48
|
+
// | `useOnline` | `serverValue`, default `true` | `navigator.onLine` |
|
|
49
|
+
// | `useDocumentVisible` | `serverValue`, default `true` | the visibility state |
|
|
50
|
+
// | `useHash` | `""`, and no way to say otherwise | the fragment |
|
|
51
|
+
// | `useWindowSize` | `serverValue`, default `0x0` | the viewport |
|
|
52
|
+
// | `useWindowScroll` | `serverValue`, default `0,0` | the offset |
|
|
53
|
+
// | `useNetwork` | `{ online: serverValue, measured: null }` | what Chromium measured |
|
|
54
|
+
// | `useGeolocation` | `"unsupported"` | `"pending"`, then a fix or a failure |
|
|
55
|
+
// | `usePermission` | `"unknown"` | what the browser answers |
|
|
56
|
+
// | `useScrollLock` | nothing at all — effects do not run in a prerender | the page is held |
|
|
57
|
+
//
|
|
58
|
+
// `packages/hooks/hooks-ssr.test.js` renders every one of them in a process with
|
|
59
|
+
// no document and asserts the markup, so a row of this table that stopped being
|
|
60
|
+
// true would fail there rather than in somebody's browser.
|
|
61
|
+
//
|
|
62
|
+
// # The one that writes
|
|
63
|
+
//
|
|
64
|
+
// `useScrollLock` is the exception to "reading", and it is here rather than in
|
|
65
|
+
// `dom.js` because what it freezes is the page: there is one of it, the caller
|
|
66
|
+
// has no ref to it, and the compensation it has to make — the width of the
|
|
67
|
+
// scrollbar that is about to disappear — is a fact about the window rather
|
|
68
|
+
// than about any element. A lock that took a ref would be a different and
|
|
69
|
+
// rarer hook.
|
|
70
|
+
//
|
|
71
|
+
// # Two kinds of hook, and why the second kind exists
|
|
72
|
+
//
|
|
73
|
+
// Most of these are a `useSyncExternalStore` over an event the browser already
|
|
74
|
+
// fires, which is the shape that survives a prerender and a concurrent render
|
|
75
|
+
// without tearing. Three are not: `useGeolocation` and `usePermission` are
|
|
76
|
+
// subscriptions whose *first* value only arrives asynchronously, so there is
|
|
77
|
+
// nothing for a snapshot to return until it does, and `useScrollLock` writes.
|
|
78
|
+
// Each says so where it is defined.
|
|
79
|
+
//
|
|
80
|
+
// `useHash` is a store with three subscriptions instead of one, because no
|
|
81
|
+
// single event covers the fragment. `hashchange` and `popstate` cover what a
|
|
82
|
+
// reader does; `history.pushState` fires neither, so a registry of its own
|
|
83
|
+
// subscribers covers what the hook itself writes; and `currententrychange`,
|
|
84
|
+
// where the Navigation API exists, covers the case neither of those reaches —
|
|
85
|
+
// a `pushState` made by other code, the router's own included. That is a store
|
|
86
|
+
// whose gap has shrunk to the browsers without the Navigation API, not a
|
|
87
|
+
// fourth kind of hook.
|
|
26
88
|
|
|
27
|
-
import { useCallback, useSyncExternalStore } from "@uniflowed/react";
|
|
89
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
|
|
28
90
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
91
|
+
import { useIsomorphicLayoutEffect } from "./lifecycle.js";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The part of a `navigator` this package reads.
|
|
95
|
+
*
|
|
96
|
+
* Not a declaration of `Navigator` — Flow ships one of those. This is the
|
|
97
|
+
* list of what these hooks actually touch, which is short enough to be worth
|
|
98
|
+
* writing down and is the reason none of them needs an `any`: everything below
|
|
99
|
+
* `browserWindow()` is checked against this.
|
|
100
|
+
*
|
|
101
|
+
* Every field is optional because a hosted document is not required to carry
|
|
102
|
+
* the whole of a browser. `navigator.geolocation` is `null` under happy-dom
|
|
103
|
+
* and absent under a bare Node global, and `navigator.connection` exists
|
|
104
|
+
* nowhere but Chromium.
|
|
105
|
+
*/
|
|
106
|
+
export type BrowserNavigator = {
|
|
107
|
+
readonly onLine?: boolean,
|
|
108
|
+
readonly userAgent?: string,
|
|
109
|
+
readonly clipboard?: ?{
|
|
110
|
+
readonly readText: () => Promise<string>,
|
|
111
|
+
readonly writeText: (text: string) => Promise<void>,
|
|
112
|
+
...
|
|
113
|
+
},
|
|
114
|
+
readonly geolocation?: ?Geolocation,
|
|
115
|
+
readonly permissions?: ?{
|
|
116
|
+
readonly query: (descriptor: { readonly name: string, ... }) => Promise<PermissionStatus>,
|
|
117
|
+
...
|
|
118
|
+
},
|
|
119
|
+
readonly connection?: ?NetworkConnection,
|
|
120
|
+
...
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The part of `location` this module reads.
|
|
125
|
+
*
|
|
126
|
+
* `href` and the fragment, and nothing else. The path and the query are on the
|
|
127
|
+
* request, so `@uniflowed/router` has already resolved them and a second
|
|
128
|
+
* reading of them here would be a second answer to a settled question — see
|
|
129
|
+
* `useHash`.
|
|
130
|
+
*/
|
|
131
|
+
export type BrowserLocation = {
|
|
132
|
+
readonly href: string,
|
|
133
|
+
readonly hash: string,
|
|
134
|
+
...
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The part of `history` this module writes through.
|
|
139
|
+
*
|
|
140
|
+
* `state` is read so that a fragment written from here hands back whatever a
|
|
141
|
+
* router put there, rather than clearing it. The second parameter of both
|
|
142
|
+
* methods has been ignored by every browser since the API shipped, and is
|
|
143
|
+
* typed rather than omitted because it is positional.
|
|
144
|
+
*/
|
|
145
|
+
export type BrowserHistory = {
|
|
146
|
+
readonly state: mixed,
|
|
147
|
+
readonly pushState: (state: mixed, unused: string, url: string) => void,
|
|
148
|
+
readonly replaceState: (state: mixed, unused: string, url: string) => void,
|
|
149
|
+
...
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The part of the Navigation API this module listens to.
|
|
154
|
+
*
|
|
155
|
+
* One event, and deliberately only one. `currententrychange` fires after the
|
|
156
|
+
* current history entry has changed *for any reason* — a link, the back
|
|
157
|
+
* button, and the two calls that fire nothing else, `history.pushState` and
|
|
158
|
+
* `history.replaceState`. It is the only thing the platform offers that hears
|
|
159
|
+
* a fragment written by code other than the writer, which is what makes
|
|
160
|
+
* `useHash` able to see `@uniflowed/router`'s own navigation.
|
|
161
|
+
*
|
|
162
|
+
* `navigate` and `navigateerror` are not here: intercepting a navigation is
|
|
163
|
+
* the router's business, and a hook that reads the fragment has no opinion
|
|
164
|
+
* about whether one should happen.
|
|
165
|
+
*
|
|
166
|
+
* Optional on [`BrowserWindow`], because this is an addition rather than the
|
|
167
|
+
* base — see `useHash` for which browsers have it and what the others get.
|
|
168
|
+
*/
|
|
169
|
+
export type BrowserNavigation = {
|
|
170
|
+
readonly addEventListener: (type: "currententrychange", listener: () => mixed) => void,
|
|
171
|
+
readonly removeEventListener: (type: "currententrychange", listener: () => mixed) => void,
|
|
172
|
+
...
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The Network Information object, which only Chromium has. */
|
|
176
|
+
export type NetworkConnection = {
|
|
177
|
+
readonly downlink?: number,
|
|
178
|
+
readonly effectiveType?: string,
|
|
179
|
+
readonly saveData?: boolean,
|
|
180
|
+
readonly addEventListener?: (type: string, listener: () => mixed) => void,
|
|
181
|
+
readonly removeEventListener?: (type: string, listener: () => mixed) => void,
|
|
182
|
+
...
|
|
183
|
+
};
|
|
33
184
|
|
|
34
185
|
/**
|
|
35
|
-
* The window
|
|
186
|
+
* The part of a `window` this package reads.
|
|
187
|
+
*
|
|
188
|
+
* Same idea as [`BrowserNavigator`], and the same reason: naming what is
|
|
189
|
+
* touched turns every read in the package into a checked one. The observer
|
|
190
|
+
* constructors are optional because a browser old enough to lack one is a
|
|
191
|
+
* browser a hook here has to keep working in — it degrades to reporting
|
|
192
|
+
* nothing rather than throwing during a render.
|
|
193
|
+
*/
|
|
194
|
+
export type BrowserWindow = {
|
|
195
|
+
readonly document: Document,
|
|
196
|
+
readonly navigator: BrowserNavigator,
|
|
197
|
+
readonly location?: ?BrowserLocation,
|
|
198
|
+
readonly history?: ?BrowserHistory,
|
|
199
|
+
readonly navigation?: ?BrowserNavigation,
|
|
200
|
+
readonly localStorage?: ?Storage,
|
|
201
|
+
readonly sessionStorage?: ?Storage,
|
|
202
|
+
readonly innerWidth: number,
|
|
203
|
+
readonly innerHeight: number,
|
|
204
|
+
readonly scrollX: number,
|
|
205
|
+
readonly scrollY: number,
|
|
206
|
+
readonly matchMedia?: (query: string) => MediaQueryList,
|
|
207
|
+
readonly getComputedStyle?: (element: Element) => CSSStyleDeclaration,
|
|
208
|
+
readonly requestAnimationFrame?: (callback: (time: number) => mixed) => AnimationFrameID,
|
|
209
|
+
readonly cancelAnimationFrame?: (handle: AnimationFrameID) => void,
|
|
210
|
+
// Two overloads, the way Flow's own `EventTarget` is declared: a `storage`
|
|
211
|
+
// listener is handed a `StorageEvent` and needs its `key`, and narrowing an
|
|
212
|
+
// `Event` down to one at runtime would mean an `instanceof StorageEvent`
|
|
213
|
+
// against a name that is not defined in every host a uf test runs in.
|
|
214
|
+
readonly addEventListener: ((
|
|
215
|
+
type: "storage",
|
|
216
|
+
listener: (event: StorageEvent) => mixed,
|
|
217
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
218
|
+
) => void) &
|
|
219
|
+
((
|
|
220
|
+
type: string,
|
|
221
|
+
listener: (event: Event) => mixed,
|
|
222
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
223
|
+
) => void),
|
|
224
|
+
readonly removeEventListener: ((
|
|
225
|
+
type: "storage",
|
|
226
|
+
listener: (event: StorageEvent) => mixed,
|
|
227
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
228
|
+
) => void) &
|
|
229
|
+
((
|
|
230
|
+
type: string,
|
|
231
|
+
listener: (event: Event) => mixed,
|
|
232
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
233
|
+
) => void),
|
|
234
|
+
readonly ResizeObserver?: Class<ResizeObserver>,
|
|
235
|
+
readonly IntersectionObserver?: Class<IntersectionObserver>,
|
|
236
|
+
readonly MutationObserver?: Class<MutationObserver>,
|
|
237
|
+
...
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The window these hooks listen to, or `null` where there is no browser.
|
|
36
242
|
*
|
|
37
243
|
* In a browser `globalThis` *is* the window, so `globalThis.addEventListener`
|
|
38
244
|
* looks correct. It is not correct anywhere a document has been installed onto
|
|
39
245
|
* another host's global — which is every uf test process, where `globalThis` is
|
|
40
|
-
* Node's and has no `addEventListener` at all. Ask the
|
|
41
|
-
* methods and both cases work.
|
|
246
|
+
* Node's and has no `addEventListener` at all. Ask the document's own window
|
|
247
|
+
* for its methods and both cases work.
|
|
248
|
+
*
|
|
249
|
+
* Exported because it is the first question every hook in this package asks,
|
|
250
|
+
* and an application writing its own prerender-safe hook has to ask it too.
|
|
251
|
+
* The single `?? globalThis` is this package's only unchecked step: `window` is
|
|
252
|
+
* `any` in Flow's own library definition, and this is where that stops.
|
|
42
253
|
*/
|
|
43
|
-
function
|
|
254
|
+
export function browserWindow(): BrowserWindow | null {
|
|
255
|
+
if (typeof globalThis.document === "undefined") {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
44
258
|
return globalThis.window ?? globalThis;
|
|
45
259
|
}
|
|
46
260
|
|
|
261
|
+
/** A subscription to nothing, for a value that cannot change. */
|
|
262
|
+
function subscribeToNothing(): () => void {
|
|
263
|
+
return () => {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The server's answer to every "is this available" question. */
|
|
267
|
+
function unsupported(): boolean {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether a capability the browser may not have is there.
|
|
273
|
+
*
|
|
274
|
+
* The naive version — `typeof window.BroadcastChannel === "function"` in the
|
|
275
|
+
* render — is a hydration mismatch waiting to happen: the server says one
|
|
276
|
+
* thing, the client's first render says another, and React reports it against
|
|
277
|
+
* whatever markup happened to differ. Asked through `useSyncExternalStore`, the
|
|
278
|
+
* server's answer is `false`, the hydrating render agrees with it, and React
|
|
279
|
+
* re-renders with the truth immediately afterwards.
|
|
280
|
+
*
|
|
281
|
+
* `probe` is called during render, so it must only look — never install, never
|
|
282
|
+
* request. It is passed straight through rather than stabilised: a snapshot is
|
|
283
|
+
* a question the render is asking now, and a stable callback's body is
|
|
284
|
+
* installed in an insertion effect that has not run yet, so it would answer
|
|
285
|
+
* from the render before.
|
|
286
|
+
*/
|
|
287
|
+
export hook useSupported(probe: () => boolean): boolean {
|
|
288
|
+
return useSyncExternalStore(subscribeToNothing, probe, unsupported);
|
|
289
|
+
}
|
|
290
|
+
|
|
47
291
|
/**
|
|
48
292
|
* Whether a media query matches.
|
|
49
293
|
*
|
|
@@ -51,46 +295,44 @@ function windowOf(): any {
|
|
|
51
295
|
* default — a page that hides a sidebar under 48rem wants `false` on the
|
|
52
296
|
* server, and one that renders a mobile menu wants `true`. So the caller says.
|
|
53
297
|
*/
|
|
54
|
-
export
|
|
298
|
+
export hook useMediaQuery(query: string, serverValue: boolean = false): boolean {
|
|
55
299
|
const subscribe = useCallback(
|
|
56
300
|
(notify: () => void) => {
|
|
57
|
-
|
|
301
|
+
const list = browserWindow()?.matchMedia?.(query);
|
|
302
|
+
if (list == null) {
|
|
58
303
|
return () => {};
|
|
59
304
|
}
|
|
60
|
-
const list = windowOf().matchMedia(query);
|
|
61
305
|
list.addEventListener("change", notify);
|
|
62
306
|
return () => list.removeEventListener("change", notify);
|
|
63
307
|
},
|
|
64
308
|
[query],
|
|
65
309
|
);
|
|
66
310
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
inBrowser() && typeof windowOf().matchMedia === "function"
|
|
71
|
-
? windowOf().matchMedia(query).matches
|
|
72
|
-
: serverValue,
|
|
73
|
-
() => serverValue,
|
|
311
|
+
const snapshot = useCallback(
|
|
312
|
+
() => browserWindow()?.matchMedia?.(query)?.matches ?? serverValue,
|
|
313
|
+
[query, serverValue],
|
|
74
314
|
);
|
|
315
|
+
|
|
316
|
+
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
75
317
|
}
|
|
76
318
|
|
|
77
319
|
/** The reader's colour-scheme preference. */
|
|
78
|
-
export
|
|
320
|
+
export hook usePreferredColorScheme(serverValue: "light" | "dark" = "light"): "light" | "dark" {
|
|
79
321
|
return useMediaQuery("(prefers-color-scheme: dark)", serverValue === "dark") ? "dark" : "light";
|
|
80
322
|
}
|
|
81
323
|
|
|
82
324
|
/** Whether the reader has asked for less motion. */
|
|
83
|
-
export
|
|
325
|
+
export hook usePrefersReducedMotion(serverValue: boolean = false): boolean {
|
|
84
326
|
return useMediaQuery("(prefers-reduced-motion: reduce)", serverValue);
|
|
85
327
|
}
|
|
86
328
|
|
|
87
329
|
/** Whether the browser thinks it is online. */
|
|
88
|
-
export
|
|
330
|
+
export hook useOnline(serverValue: boolean = true): boolean {
|
|
89
331
|
const subscribe = useCallback((notify: () => void) => {
|
|
90
|
-
|
|
332
|
+
const win = browserWindow();
|
|
333
|
+
if (win == null) {
|
|
91
334
|
return () => {};
|
|
92
335
|
}
|
|
93
|
-
const win = windowOf();
|
|
94
336
|
win.addEventListener("online", notify);
|
|
95
337
|
win.addEventListener("offline", notify);
|
|
96
338
|
return () => {
|
|
@@ -99,45 +341,231 @@ export function useOnline(serverValue: boolean = true): boolean {
|
|
|
99
341
|
};
|
|
100
342
|
}, []);
|
|
101
343
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
() => serverValue,
|
|
344
|
+
const snapshot = useCallback(
|
|
345
|
+
() => browserWindow()?.navigator.onLine ?? serverValue,
|
|
346
|
+
[serverValue],
|
|
106
347
|
);
|
|
348
|
+
|
|
349
|
+
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
107
350
|
}
|
|
108
351
|
|
|
109
352
|
/** Whether the document is the one the reader is looking at. */
|
|
110
|
-
export
|
|
353
|
+
export hook useDocumentVisible(serverValue: boolean = true): boolean {
|
|
111
354
|
const subscribe = useCallback((notify: () => void) => {
|
|
112
|
-
|
|
355
|
+
const document = browserWindow()?.document;
|
|
356
|
+
if (document == null) {
|
|
113
357
|
return () => {};
|
|
114
358
|
}
|
|
115
|
-
|
|
116
|
-
return () =>
|
|
359
|
+
document.addEventListener("visibilitychange", notify);
|
|
360
|
+
return () => document.removeEventListener("visibilitychange", notify);
|
|
117
361
|
}, []);
|
|
118
362
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
363
|
+
const snapshot = useCallback(() => {
|
|
364
|
+
const document = browserWindow()?.document;
|
|
365
|
+
return document == null ? serverValue : document.visibilityState !== "hidden";
|
|
366
|
+
}, [serverValue]);
|
|
367
|
+
|
|
368
|
+
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Everybody watching the fragment in this tab.
|
|
373
|
+
*
|
|
374
|
+
* Module-level, because neither `history.pushState` nor `history.replaceState`
|
|
375
|
+
* fires anything: a component that writes the fragment has to tell the others
|
|
376
|
+
* itself, and in a browser without the Navigation API there is nothing in the
|
|
377
|
+
* platform that will do it. The same registry shape as `useStorage`'s, and
|
|
378
|
+
* balanced under Strict Mode for the same reason — `subscribe` adds and the
|
|
379
|
+
* cleanup it returns removes.
|
|
380
|
+
*
|
|
381
|
+
* It is kept where `currententrychange` exists rather than being switched off
|
|
382
|
+
* there. The two overlap — a write through this hook is heard twice — and that
|
|
383
|
+
* costs nothing, because `useSyncExternalStore` compares the snapshot and the
|
|
384
|
+
* fragment is a string that has not changed between the two notifications.
|
|
385
|
+
* Switching it off, on the other hand, would make the hook's own writes depend
|
|
386
|
+
* on a feature detection, so a browser that reported a `navigation` object it
|
|
387
|
+
* did not fire events from would silently lose the guarantee that has held
|
|
388
|
+
* since this hook existed.
|
|
389
|
+
*/
|
|
390
|
+
const fragmentListeners: Set<() => void> = new Set();
|
|
391
|
+
|
|
392
|
+
/** Listen for every change to the fragment this tab can hear about. */
|
|
393
|
+
function subscribeToFragment(notify: () => void): () => void {
|
|
394
|
+
const win = browserWindow();
|
|
395
|
+
// Read once and closed over, so the cleanup removes the listener from the
|
|
396
|
+
// object it was added to. A `navigation` that appeared or vanished between
|
|
397
|
+
// the two would otherwise leave a listener behind on a hook that unmounted.
|
|
398
|
+
const navigation = win?.navigation;
|
|
399
|
+
fragmentListeners.add(notify);
|
|
400
|
+
// `hashchange` covers an anchor the reader clicked and an address bar they
|
|
401
|
+
// edited; `popstate` covers back and forward, which fires only the second of
|
|
402
|
+
// the two when the entry it lands on differs by more than the fragment.
|
|
403
|
+
win?.addEventListener("hashchange", notify);
|
|
404
|
+
win?.addEventListener("popstate", notify);
|
|
405
|
+
// And `currententrychange` covers the case the other two and the registry
|
|
406
|
+
// between them still miss: a `pushState` or `replaceState` made by code that
|
|
407
|
+
// is not this hook. `@uniflowed/router` makes exactly that call on every
|
|
408
|
+
// client navigation, which is why the gap was never hypothetical.
|
|
409
|
+
navigation?.addEventListener("currententrychange", notify);
|
|
410
|
+
return () => {
|
|
411
|
+
fragmentListeners.delete(notify);
|
|
412
|
+
win?.removeEventListener("hashchange", notify);
|
|
413
|
+
win?.removeEventListener("popstate", notify);
|
|
414
|
+
navigation?.removeEventListener("currententrychange", notify);
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The fragment, without its `#`, decoded where the escapes are valid. */
|
|
419
|
+
function readFragment(): string {
|
|
420
|
+
const raw = browserWindow()?.location?.hash ?? "";
|
|
421
|
+
const text = raw.startsWith("#") ? raw.slice(1) : raw;
|
|
422
|
+
try {
|
|
423
|
+
return decodeURIComponent(text);
|
|
424
|
+
} catch {
|
|
425
|
+
// A fragment is whatever somebody typed into the address bar, and a lone
|
|
426
|
+
// `%` is not a reason to fail a render. The text as written is closer to
|
|
427
|
+
// what the reader meant than a throw is. `@uniflowed/web`'s cookie parser
|
|
428
|
+
// makes the same trade at the same kind of boundary.
|
|
429
|
+
return text;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** What a server render, and the client's hydrating render, both report. */
|
|
434
|
+
function noFragment(): string {
|
|
435
|
+
return "";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Write the fragment, without moving the page.
|
|
440
|
+
*
|
|
441
|
+
* Through `URL` rather than by concatenation, which is the whole safety
|
|
442
|
+
* argument: the setter percent-escapes what it is handed and can only put it
|
|
443
|
+
* after the `#`, so a caller who passes `?admin=1`, `//elsewhere.example` or a
|
|
444
|
+
* whole second URL writes a fragment that says so rather than a query, an
|
|
445
|
+
* origin or a path. `history.pushState` refuses a cross-origin URL, but that is
|
|
446
|
+
* the second line of defence and not one worth relying on for a same-origin
|
|
447
|
+
* path rewrite, which it permits.
|
|
448
|
+
*
|
|
449
|
+
* Module-level, so it is the same function on every render and nothing has to
|
|
450
|
+
* memoize it.
|
|
451
|
+
*/
|
|
452
|
+
function writeFragment(next: string, options?: {| readonly replace?: boolean |}): void {
|
|
453
|
+
const win = browserWindow();
|
|
454
|
+
const location = win?.location;
|
|
455
|
+
const history = win?.history;
|
|
456
|
+
if (location == null || history == null) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const url = new URL(location.href);
|
|
460
|
+
url.hash = next;
|
|
461
|
+
// `history.state` is handed back rather than cleared: a router put it there,
|
|
462
|
+
// and changing which tab is showing is not a reason to lose it.
|
|
463
|
+
if (options?.replace === true) {
|
|
464
|
+
history.replaceState(history.state, "", url.href);
|
|
465
|
+
} else {
|
|
466
|
+
history.pushState(history.state, "", url.href);
|
|
467
|
+
}
|
|
468
|
+
notifyFragment();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function notifyFragment(): void {
|
|
472
|
+
for (const listener of fragmentListeners) {
|
|
473
|
+
listener();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The fragment in the address bar, and a way to change it.
|
|
479
|
+
*
|
|
480
|
+
* The one part of the URL that needs a hook. A request carries the path and the
|
|
481
|
+
* query, so a server render already knows both and `@uniflowed/router`'s
|
|
482
|
+
* `useRoute` has resolved them; the fragment is never sent — the browser strips
|
|
483
|
+
* it before the request goes out — so there is no value for a server to know
|
|
484
|
+
* and no caller who could supply a better one. That is why this takes no
|
|
485
|
+
* `serverValue` where `useMediaQuery` and `useOnline` do: `""` is not a default
|
|
486
|
+
* chosen for want of a better one, it is the only honest answer, and the
|
|
487
|
+
* client's hydrating render reports it too before re-rendering with the truth.
|
|
488
|
+
*
|
|
489
|
+
* Returned without the `#`, and percent-decoded, so it compares directly
|
|
490
|
+
* against the `id` a section was given.
|
|
491
|
+
*
|
|
492
|
+
* Writing does not scroll, and that is deliberate. Moving to a section and
|
|
493
|
+
* recording which tab is open are two intentions, and the platform couples them
|
|
494
|
+
* only because assigning `location.hash` is the old way to do both at once — so
|
|
495
|
+
* a tab strip that writes `billing` would jump the page. Call
|
|
496
|
+
* `element.scrollIntoView()` where scrolling is what was wanted.
|
|
497
|
+
*
|
|
498
|
+
* `replace` overwrites the current history entry instead of adding one, which
|
|
499
|
+
* is what a tab strip wants: eleven tab clicks should not be eleven presses of
|
|
500
|
+
* the back button.
|
|
501
|
+
*
|
|
502
|
+
* # What it hears, and where
|
|
503
|
+
*
|
|
504
|
+
* `history.pushState` and `history.replaceState` fire no event of any kind —
|
|
505
|
+
* not `hashchange`, not `popstate` — so what a hook can see depends on where
|
|
506
|
+
* the write came from and on what the browser has:
|
|
507
|
+
*
|
|
508
|
+
* | The write | Everywhere | Without the Navigation API |
|
|
509
|
+
* | --- | --- | --- |
|
|
510
|
+
* | the reader: an anchor, the address bar, back and forward | seen | seen |
|
|
511
|
+
* | this hook's own setter | seen | seen |
|
|
512
|
+
* | `pushState` from other code — `@uniflowed/router`'s navigation | seen | **not seen** |
|
|
513
|
+
*
|
|
514
|
+
* The first two are `hashchange`, `popstate` and the module's own registry.
|
|
515
|
+
* The third is `currententrychange`, which fires after the current history
|
|
516
|
+
* entry changes for any reason at all, and which is the only thing the
|
|
517
|
+
* platform offers that hears a write the writer did not announce.
|
|
518
|
+
*
|
|
519
|
+
* "Without the Navigation API" is now a narrow set: Chrome and Edge have had
|
|
520
|
+
* it since 102 (2022), Safari since 26.2 and Firefox since 147 — but a reader
|
|
521
|
+
* on an older Safari or Firefox is a reader this column describes, and there
|
|
522
|
+
* the registry is still the whole answer. A router navigation that changes
|
|
523
|
+
* only the fragment leaves such a page showing the section it was on.
|
|
524
|
+
*
|
|
525
|
+
* The fragment is deliberately not on `RouteInfo` — `useRoute()` cannot answer
|
|
526
|
+
* this question and should not learn to. A `RouteInfo` is what a *request*
|
|
527
|
+
* resolved to, and the browser strips the fragment before the request goes
|
|
528
|
+
* out, so a field for it would be one the server could never fill and the two
|
|
529
|
+
* renders would disagree about. This hook is the one answer, and
|
|
530
|
+
* `currententrychange` is what makes it a complete one on the browsers that
|
|
531
|
+
* have it rather than a second reading of a value the router also holds.
|
|
532
|
+
*/
|
|
533
|
+
export hook useHash(): [
|
|
534
|
+
string,
|
|
535
|
+
(next: string, options?: {| readonly replace?: boolean |}) => void,
|
|
536
|
+
] {
|
|
537
|
+
const fragment = useSyncExternalStore(subscribeToFragment, readFragment, noFragment);
|
|
538
|
+
return [fragment, writeFragment];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* How far something has been scrolled.
|
|
543
|
+
*
|
|
544
|
+
* Defined here rather than in `dom.js` because `dom.js` imports this module
|
|
545
|
+
* and not the other way round; `useScroll` over an element uses the same
|
|
546
|
+
* shape, and one name for one thing is worth the arrow.
|
|
547
|
+
*/
|
|
548
|
+
export type ScrollOffset = {| readonly x: number, readonly y: number |};
|
|
549
|
+
|
|
550
|
+
/** How big something is. Shared with `useElementSize` for the same reason. */
|
|
551
|
+
export type Size = {| readonly width: number, readonly height: number |};
|
|
552
|
+
|
|
553
|
+
/** Read `"12x34"` back into a pair. */
|
|
554
|
+
function unpack(packed: string): ScrollOffset {
|
|
555
|
+
const [x, y] = packed.split("x");
|
|
556
|
+
return { x: Number(x), y: Number(y) };
|
|
124
557
|
}
|
|
125
558
|
|
|
126
559
|
/** The size of the viewport. */
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|}): {|
|
|
131
|
-
readonly width: number,
|
|
132
|
-
readonly height: number,
|
|
133
|
-
|} {
|
|
134
|
-
const fallback = serverValue ?? { width: 0, height: 0 };
|
|
560
|
+
export hook useWindowSize(serverValue?: Size): Size {
|
|
561
|
+
const width = serverValue?.width ?? 0;
|
|
562
|
+
const height = serverValue?.height ?? 0;
|
|
135
563
|
|
|
136
564
|
const subscribe = useCallback((notify: () => void) => {
|
|
137
|
-
|
|
565
|
+
const win = browserWindow();
|
|
566
|
+
if (win == null) {
|
|
138
567
|
return () => {};
|
|
139
568
|
}
|
|
140
|
-
const win = windowOf();
|
|
141
569
|
win.addEventListener("resize", notify);
|
|
142
570
|
return () => win.removeEventListener("resize", notify);
|
|
143
571
|
}, []);
|
|
@@ -147,13 +575,459 @@ export function useWindowSize(serverValue?: {|
|
|
|
147
575
|
// check, which is an infinite loop React reports rather than tolerates.
|
|
148
576
|
const packed = useSyncExternalStore(
|
|
149
577
|
subscribe,
|
|
150
|
-
() =>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
() => `${
|
|
578
|
+
useCallback(() => {
|
|
579
|
+
const win = browserWindow();
|
|
580
|
+
return win == null ? `${width}x${height}` : `${win.innerWidth}x${win.innerHeight}`;
|
|
581
|
+
}, [width, height]),
|
|
582
|
+
useCallback(() => `${width}x${height}`, [width, height]),
|
|
155
583
|
);
|
|
156
584
|
|
|
157
|
-
const
|
|
158
|
-
return { width:
|
|
585
|
+
const size = unpack(packed);
|
|
586
|
+
return { width: size.x, height: size.y };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* How far the page has been scrolled.
|
|
591
|
+
*
|
|
592
|
+
* The same packed-string snapshot as `useWindowSize`, for the same reason, and
|
|
593
|
+
* a passive listener because a scroll handler that could call
|
|
594
|
+
* `preventDefault` blocks scrolling on a touch screen until it has run.
|
|
595
|
+
*/
|
|
596
|
+
export hook useWindowScroll(serverValue?: ScrollOffset): ScrollOffset {
|
|
597
|
+
const x = serverValue?.x ?? 0;
|
|
598
|
+
const y = serverValue?.y ?? 0;
|
|
599
|
+
|
|
600
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
601
|
+
const win = browserWindow();
|
|
602
|
+
if (win == null) {
|
|
603
|
+
return () => {};
|
|
604
|
+
}
|
|
605
|
+
win.addEventListener("scroll", notify, { passive: true });
|
|
606
|
+
return () => win.removeEventListener("scroll", notify);
|
|
607
|
+
}, []);
|
|
608
|
+
|
|
609
|
+
const packed = useSyncExternalStore(
|
|
610
|
+
subscribe,
|
|
611
|
+
useCallback(() => {
|
|
612
|
+
const win = browserWindow();
|
|
613
|
+
return win == null ? `${x}x${y}` : `${win.scrollX}x${win.scrollY}`;
|
|
614
|
+
}, [x, y]),
|
|
615
|
+
useCallback(() => `${x}x${y}`, [x, y]),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
return unpack(packed);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* How many components are currently holding the page still.
|
|
623
|
+
*
|
|
624
|
+
* Module-level rather than per-component, because two dialogs open at once
|
|
625
|
+
* must not have the first one to close put the page back: the page unlocks
|
|
626
|
+
* when the last of them lets go. Strict Mode's mount-unmount-mount is
|
|
627
|
+
* balanced by construction — the effect increments and its cleanup decrements.
|
|
628
|
+
*/
|
|
629
|
+
let scrollLocks = 0;
|
|
630
|
+
|
|
631
|
+
/** What to put back when the last lock is released. */
|
|
632
|
+
let releaseScroll: (() => void) | null = null;
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The widest a scrollbar is allowed to be believed.
|
|
636
|
+
*
|
|
637
|
+
* The compensation is `innerWidth - documentElement.clientWidth`, which is the
|
|
638
|
+
* scrollbar's width in a browser that lays out and is the whole viewport in
|
|
639
|
+
* one that does not — a headless document reports a client width of zero.
|
|
640
|
+
* Padding the page by a viewport pushes it off screen, so a number that could
|
|
641
|
+
* not be a scrollbar is treated as no measurement at all.
|
|
642
|
+
*/
|
|
643
|
+
const WIDEST_SCROLLBAR = 40;
|
|
644
|
+
|
|
645
|
+
function lockScroll(): void {
|
|
646
|
+
scrollLocks += 1;
|
|
647
|
+
if (scrollLocks > 1) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const win = browserWindow();
|
|
651
|
+
const body = win?.document.body;
|
|
652
|
+
if (win == null || body == null) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const previousOverflow = body.style.overflow;
|
|
656
|
+
const previousPadding = body.style.paddingRight;
|
|
657
|
+
const root = win.document.documentElement;
|
|
658
|
+
const gap = root == null ? 0 : win.innerWidth - root.clientWidth;
|
|
659
|
+
body.style.overflow = "hidden";
|
|
660
|
+
if (gap > 0 && gap <= WIDEST_SCROLLBAR) {
|
|
661
|
+
const computed = win.getComputedStyle?.(body).paddingRight ?? "";
|
|
662
|
+
body.style.paddingRight = `${(Number.parseFloat(computed) || 0) + gap}px`;
|
|
663
|
+
}
|
|
664
|
+
releaseScroll = () => {
|
|
665
|
+
body.style.overflow = previousOverflow;
|
|
666
|
+
body.style.paddingRight = previousPadding;
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function unlockScroll(): void {
|
|
671
|
+
scrollLocks = Math.max(0, scrollLocks - 1);
|
|
672
|
+
if (scrollLocks === 0 && releaseScroll != null) {
|
|
673
|
+
releaseScroll();
|
|
674
|
+
releaseScroll = null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Hold the page still while `locked`.
|
|
680
|
+
*
|
|
681
|
+
* A layout effect, so the page is frozen before the frame in which the dialog
|
|
682
|
+
* that asked for it appears — an ordinary effect lets one frame of scrolling
|
|
683
|
+
* through, which reads as a jump.
|
|
684
|
+
*
|
|
685
|
+
* On a server this does nothing at all: effects do not run during a prerender,
|
|
686
|
+
* so a locked dialog rendered into HTML leaves the markup alone.
|
|
687
|
+
*/
|
|
688
|
+
export hook useScrollLock(locked: boolean): void {
|
|
689
|
+
useIsomorphicLayoutEffect(() => {
|
|
690
|
+
if (!locked) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
lockScroll();
|
|
694
|
+
return unlockScroll;
|
|
695
|
+
}, [locked]);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** How good the connection is, as the browser grades it. */
|
|
699
|
+
export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* What Network Information measured, where there is such a thing.
|
|
703
|
+
*
|
|
704
|
+
* Its own type rather than three more fields on [`Network`], because the three
|
|
705
|
+
* of them arrive together or not at all: they come from `navigator.connection`,
|
|
706
|
+
* which is Chromium's alone. A server, and every other browser, has no object
|
|
707
|
+
* to read them off.
|
|
708
|
+
*/
|
|
709
|
+
export type NetworkMeasurement = {|
|
|
710
|
+
/** Estimated bandwidth in megabits per second, where the browser reports it. */
|
|
711
|
+
readonly downlink: number | null,
|
|
712
|
+
readonly effectiveType: EffectiveConnectionType | null,
|
|
713
|
+
/** Whether the reader has asked for less data to be used. */
|
|
714
|
+
readonly saveData: boolean,
|
|
715
|
+
|};
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* What the browser will say about the connection.
|
|
719
|
+
*
|
|
720
|
+
* `measured` used to be three flat fields and a `supported` boolean beside
|
|
721
|
+
* them, which is the shape this package now refuses: a caller had to read one
|
|
722
|
+
* field to learn whether three others meant anything, and `downlink: null` said
|
|
723
|
+
* both "this browser does not measure bandwidth" and "it does, and has not
|
|
724
|
+
* decided yet". A null here says one thing — nothing on this side can answer —
|
|
725
|
+
* and the fields that would have been guesses are not reachable to be read.
|
|
726
|
+
*/
|
|
727
|
+
export type Network = {|
|
|
728
|
+
readonly online: boolean,
|
|
729
|
+
readonly measured: NetworkMeasurement | null,
|
|
730
|
+
|};
|
|
731
|
+
|
|
732
|
+
const EFFECTIVE_TYPES: $ReadOnlyArray<EffectiveConnectionType> = ["slow-2g", "2g", "3g", "4g"];
|
|
733
|
+
|
|
734
|
+
function asEffectiveType(value: string): EffectiveConnectionType | null {
|
|
735
|
+
for (const known of EFFECTIVE_TYPES) {
|
|
736
|
+
if (known === value) {
|
|
737
|
+
return known;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* What the browser will say about the connection.
|
|
745
|
+
*
|
|
746
|
+
* Only Chromium implements Network Information, so a browser that does not
|
|
747
|
+
* reports `measured: null` rather than three fields that are indistinguishable
|
|
748
|
+
* from a slow connection. `online` stays flat and is answered everywhere — it
|
|
749
|
+
* is the field almost every caller wants, and burying it behind a narrowing
|
|
750
|
+
* would have made the common case pay for the rare one.
|
|
751
|
+
*
|
|
752
|
+
* The snapshot is a packed string for the reason `useWindowSize` gives: an
|
|
753
|
+
* object rebuilt on every check never compares equal, and `useSyncExternalStore`
|
|
754
|
+
* would re-render forever.
|
|
755
|
+
*/
|
|
756
|
+
export hook useNetwork(serverValue: boolean = true): Network {
|
|
757
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
758
|
+
const win = browserWindow();
|
|
759
|
+
if (win == null) {
|
|
760
|
+
return () => {};
|
|
761
|
+
}
|
|
762
|
+
const connection = win.navigator.connection;
|
|
763
|
+
win.addEventListener("online", notify);
|
|
764
|
+
win.addEventListener("offline", notify);
|
|
765
|
+
connection?.addEventListener?.("change", notify);
|
|
766
|
+
return () => {
|
|
767
|
+
win.removeEventListener("online", notify);
|
|
768
|
+
win.removeEventListener("offline", notify);
|
|
769
|
+
connection?.removeEventListener?.("change", notify);
|
|
770
|
+
};
|
|
771
|
+
}, []);
|
|
772
|
+
|
|
773
|
+
// Whether there was a connection object to read is its own field, because
|
|
774
|
+
// "this browser does not measure" and "it measures and reported nothing yet"
|
|
775
|
+
// pack to the same three empty values and are not the same answer.
|
|
776
|
+
// `effectiveType` is last so that it is the only field a `|` in browser text
|
|
777
|
+
// could run into.
|
|
778
|
+
const server = useCallback(() => `${serverValue ? "1" : "0"}|0|0||`, [serverValue]);
|
|
779
|
+
|
|
780
|
+
const packed = useSyncExternalStore(
|
|
781
|
+
subscribe,
|
|
782
|
+
useCallback(() => {
|
|
783
|
+
const navigator = browserWindow()?.navigator;
|
|
784
|
+
if (navigator == null) {
|
|
785
|
+
return `${serverValue ? "1" : "0"}|0|0||`;
|
|
786
|
+
}
|
|
787
|
+
const connection = navigator.connection;
|
|
788
|
+
const online = navigator.onLine ?? serverValue;
|
|
789
|
+
if (connection == null) {
|
|
790
|
+
return `${online ? "1" : "0"}|0|0||`;
|
|
791
|
+
}
|
|
792
|
+
const downlink = connection.downlink;
|
|
793
|
+
return [
|
|
794
|
+
online ? "1" : "0",
|
|
795
|
+
"1",
|
|
796
|
+
connection.saveData === true ? "1" : "0",
|
|
797
|
+
downlink == null ? "" : String(downlink),
|
|
798
|
+
connection.effectiveType ?? "",
|
|
799
|
+
].join("|");
|
|
800
|
+
}, [serverValue]),
|
|
801
|
+
server,
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
return useMemo(() => {
|
|
805
|
+
const [online, measured, saveData, downlink, effectiveType] = packed.split("|");
|
|
806
|
+
return {
|
|
807
|
+
online: online === "1",
|
|
808
|
+
measured:
|
|
809
|
+
measured === "1"
|
|
810
|
+
? {
|
|
811
|
+
downlink: downlink === "" ? null : Number(downlink),
|
|
812
|
+
effectiveType: asEffectiveType(effectiveType),
|
|
813
|
+
saveData: saveData === "1",
|
|
814
|
+
}
|
|
815
|
+
: null,
|
|
816
|
+
};
|
|
817
|
+
}, [packed]);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Where the reader is, to the accuracy the browser was willing to give. */
|
|
821
|
+
export type Geoposition = {|
|
|
822
|
+
readonly latitude: number,
|
|
823
|
+
readonly longitude: number,
|
|
824
|
+
/** Radius of a 95% confidence circle, in metres. */
|
|
825
|
+
readonly accuracy: number,
|
|
826
|
+
readonly timestamp: number,
|
|
827
|
+
|};
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* What `useGeolocation` knows so far.
|
|
831
|
+
*
|
|
832
|
+
* A union rather than a record of nullable fields, and the difference is the
|
|
833
|
+
* whole point of the type. The record it replaced —
|
|
834
|
+
* `{| position: Geoposition | null, error: Error | null, supported: boolean |}`
|
|
835
|
+
* — could represent eight states, of which four could never happen, and it
|
|
836
|
+
* asked every caller to work out from three fields which of the four real ones
|
|
837
|
+
* they were in. `position == null` meant "no browser", "not asked", "asked and
|
|
838
|
+
* refused" and "waiting for the reader to decide", and a page that wanted to
|
|
839
|
+
* say something different for each had to reconstruct the distinction the hook
|
|
840
|
+
* had thrown away.
|
|
841
|
+
*
|
|
842
|
+
* Five states, each of which a page does something different about, and Flow
|
|
843
|
+
* refuses to read a field the state does not have.
|
|
844
|
+
*/
|
|
845
|
+
export type GeolocationReading =
|
|
846
|
+
/**
|
|
847
|
+
* Not watching, because the caller passed `enabled: false`. Reported before
|
|
848
|
+
* `"unsupported"` is even considered, so it is the same on both sides of a
|
|
849
|
+
* hydration.
|
|
850
|
+
*/
|
|
851
|
+
| {| readonly status: "idle" |}
|
|
852
|
+
/** No geolocation object here at all: a server render, or a browser without one. */
|
|
853
|
+
| {| readonly status: "unsupported" |}
|
|
854
|
+
/** Watching. The reader has been asked and has not answered yet. */
|
|
855
|
+
| {| readonly status: "pending" |}
|
|
856
|
+
/**
|
|
857
|
+
* A fix. `error` is the failure of a *later* reading, and its presence means
|
|
858
|
+
* the position beside it is the last good one rather than the current one.
|
|
859
|
+
*/
|
|
860
|
+
| {| readonly status: "located", readonly position: Geoposition, readonly error: Error | null |}
|
|
861
|
+
/** Refused, unavailable or timed out, with no earlier fix to fall back on. */
|
|
862
|
+
| {| readonly status: "failed", readonly error: Error |};
|
|
863
|
+
|
|
864
|
+
// The three states with nothing in them, allocated once. A hook that returns a
|
|
865
|
+
// fresh object for "nothing has happened" makes every caller's dependency array
|
|
866
|
+
// change on every render.
|
|
867
|
+
const UNSUPPORTED: GeolocationReading = { status: "unsupported" };
|
|
868
|
+
const IDLE: GeolocationReading = { status: "idle" };
|
|
869
|
+
const PENDING: GeolocationReading = { status: "pending" };
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Watch where the reader is.
|
|
873
|
+
*
|
|
874
|
+
* An effect rather than a `useSyncExternalStore`, and the difference is not
|
|
875
|
+
* stylistic: there is no snapshot to read. The browser has no "current
|
|
876
|
+
* position" property to ask — the first value arrives in a callback, after a
|
|
877
|
+
* permission prompt the reader may take a minute to answer or never answer at
|
|
878
|
+
* all. So there is nothing but `"unsupported"` to report during a prerender and
|
|
879
|
+
* in the client's first render, which is also what makes it hydration-safe: the
|
|
880
|
+
* server writes the markup for a page that does not know where anybody is, and
|
|
881
|
+
* the hydrating render agrees with it.
|
|
882
|
+
*
|
|
883
|
+
* Turning `enabled` off after a fix reports `"idle"` rather than keeping the
|
|
884
|
+
* position, because a reading nobody is watching is a reading nobody should be
|
|
885
|
+
* shown. A caller who wants the last one to stay on screen holds it.
|
|
886
|
+
*
|
|
887
|
+
* Mounting this asks the reader for permission. Mount it on the page that
|
|
888
|
+
* needs a position, not at the top of an application.
|
|
889
|
+
*/
|
|
890
|
+
export hook useGeolocation(options?: {|
|
|
891
|
+
readonly enabled?: boolean,
|
|
892
|
+
readonly highAccuracy?: boolean,
|
|
893
|
+
readonly maximumAge?: number,
|
|
894
|
+
readonly timeout?: number,
|
|
895
|
+
|}): GeolocationReading {
|
|
896
|
+
const enabled = options?.enabled ?? true;
|
|
897
|
+
const highAccuracy = options?.highAccuracy ?? false;
|
|
898
|
+
const maximumAge = options?.maximumAge;
|
|
899
|
+
const timeout = options?.timeout;
|
|
900
|
+
|
|
901
|
+
const supported = useSupported(() => browserWindow()?.navigator.geolocation != null);
|
|
902
|
+
const [reading, setReading] = useState<{|
|
|
903
|
+
position: Geoposition | null,
|
|
904
|
+
error: Error | null,
|
|
905
|
+
|}>({ position: null, error: null });
|
|
906
|
+
|
|
907
|
+
useEffect(() => {
|
|
908
|
+
const geolocation = browserWindow()?.navigator.geolocation;
|
|
909
|
+
if (!enabled || geolocation == null) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const watch = geolocation.watchPosition(
|
|
913
|
+
(position: Position) => {
|
|
914
|
+
setReading({
|
|
915
|
+
position: {
|
|
916
|
+
latitude: position.coords.latitude,
|
|
917
|
+
longitude: position.coords.longitude,
|
|
918
|
+
accuracy: position.coords.accuracy,
|
|
919
|
+
timestamp: position.timestamp,
|
|
920
|
+
},
|
|
921
|
+
error: null,
|
|
922
|
+
});
|
|
923
|
+
},
|
|
924
|
+
(failure: PositionError) => {
|
|
925
|
+
// The position already on screen is kept: a timeout on the third
|
|
926
|
+
// reading does not mean the second one stopped being true.
|
|
927
|
+
setReading((current) => ({ ...current, error: new Error(failure.message) }));
|
|
928
|
+
},
|
|
929
|
+
{ enableHighAccuracy: highAccuracy, maximumAge, timeout },
|
|
930
|
+
);
|
|
931
|
+
return () => geolocation.clearWatch(watch);
|
|
932
|
+
}, [enabled, highAccuracy, maximumAge, timeout]);
|
|
933
|
+
|
|
934
|
+
// Derived during the render from what the effect has recorded, rather than
|
|
935
|
+
// kept as a second copy of it in state: a status that had to be written by
|
|
936
|
+
// the same `setReading` call would be a second thing to keep in step with
|
|
937
|
+
// `enabled` and with `supported`, neither of which the effect can see.
|
|
938
|
+
return useMemo(() => {
|
|
939
|
+
// `enabled` is asked about before `supported`, because it is the caller's
|
|
940
|
+
// own decision and is therefore the same on both sides of a hydration: a
|
|
941
|
+
// watch nobody asked for is idle on a server and idle in a browser, where
|
|
942
|
+
// "unsupported" would have been true on the server and false a moment
|
|
943
|
+
// later.
|
|
944
|
+
if (!enabled) {
|
|
945
|
+
return IDLE;
|
|
946
|
+
}
|
|
947
|
+
if (!supported) {
|
|
948
|
+
return UNSUPPORTED;
|
|
949
|
+
}
|
|
950
|
+
const { position, error } = reading;
|
|
951
|
+
if (position != null) {
|
|
952
|
+
return { status: "located", position, error };
|
|
953
|
+
}
|
|
954
|
+
return error == null ? PENDING : { status: "failed", error };
|
|
955
|
+
}, [supported, enabled, reading]);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** A permission this hook knows how to ask about. */
|
|
959
|
+
export type PermissionName =
|
|
960
|
+
| "geolocation"
|
|
961
|
+
| "notifications"
|
|
962
|
+
| "camera"
|
|
963
|
+
| "microphone"
|
|
964
|
+
| "clipboard-read"
|
|
965
|
+
| "clipboard-write"
|
|
966
|
+
| "persistent-storage"
|
|
967
|
+
| "push"
|
|
968
|
+
| "midi";
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* What the browser says about a permission.
|
|
972
|
+
*
|
|
973
|
+
* `"unknown"` is one value for four situations that a caller treats the same
|
|
974
|
+
* way — no Permissions API, a name this browser does not recognise, an answer
|
|
975
|
+
* that has not arrived yet, and a server render. Splitting them would make
|
|
976
|
+
* every caller write the same four-armed `match` to reach the same conclusion.
|
|
977
|
+
*/
|
|
978
|
+
export type PermissionAnswer = "granted" | "denied" | "prompt" | "unknown";
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Whether the reader has granted a permission, without asking for it.
|
|
982
|
+
*
|
|
983
|
+
* Querying is not prompting: this reports the current state and follows it if
|
|
984
|
+
* the reader changes their mind in browser settings. Asking for the permission
|
|
985
|
+
* is the API's own job — `getUserMedia`, `watchPosition` — and doing it from
|
|
986
|
+
* here would make a hook that reads have a side effect nobody asked for.
|
|
987
|
+
*
|
|
988
|
+
* An effect rather than a store, for the reason `useGeolocation` gives: the
|
|
989
|
+
* answer is a promise, so there is nothing to read synchronously.
|
|
990
|
+
*/
|
|
991
|
+
export hook usePermission(name: PermissionName): PermissionAnswer {
|
|
992
|
+
const [answer, setAnswer] = useState<PermissionAnswer>("unknown");
|
|
993
|
+
|
|
994
|
+
useEffect(() => {
|
|
995
|
+
const permissions = browserWindow()?.navigator.permissions;
|
|
996
|
+
if (permissions == null) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
// Set when the effect is superseded, so an answer that arrives for a name
|
|
1000
|
+
// the caller has stopped asking about is dropped rather than shown.
|
|
1001
|
+
let ignore = false;
|
|
1002
|
+
let status: PermissionStatus | null = null;
|
|
1003
|
+
const onChange = () => {
|
|
1004
|
+
if (!ignore && status != null) {
|
|
1005
|
+
setAnswer(status.state);
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
permissions.query({ name }).then(
|
|
1010
|
+
(result: PermissionStatus) => {
|
|
1011
|
+
if (ignore) {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
status = result;
|
|
1015
|
+
setAnswer(result.state);
|
|
1016
|
+
result.addEventListener("change", onChange);
|
|
1017
|
+
},
|
|
1018
|
+
() => {
|
|
1019
|
+
// A name this browser does not know rejects rather than answering.
|
|
1020
|
+
if (!ignore) {
|
|
1021
|
+
setAnswer("unknown");
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
return () => {
|
|
1027
|
+
ignore = true;
|
|
1028
|
+
status?.removeEventListener("change", onChange);
|
|
1029
|
+
};
|
|
1030
|
+
}, [name]);
|
|
1031
|
+
|
|
1032
|
+
return answer;
|
|
159
1033
|
}
|