@uniflowed/hooks 0.0.0-alpha.2 → 0.0.0-alpha.20

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/timing.js ADDED
@@ -0,0 +1,437 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks/timing`: timers that stop when the component does.
4
+ //
5
+ // Every one of these exists because the hand-written version leaks: a
6
+ // `setInterval` in a `useEffect` whose dependency array includes the callback
7
+ // is torn down and restarted on every render, and one without the callback in
8
+ // the array calls a stale closure forever. `useStableCallback` removes the
9
+ // choice — the timer is set once and always calls the current body.
10
+ //
11
+ // # What belongs in this module
12
+ //
13
+ // A hook whose subject is *when* something runs: on a schedule, after a wait,
14
+ // no more often than some rate, on the next frame, once the reader has stopped
15
+ // touching anything. Every one of them owns a handle that has to be cleared,
16
+ // and the cleanup is the reason the hook exists rather than a detail of it.
17
+ //
18
+ // `useNow` and `useTimeAgo` belong here for the same reason, which is easy to
19
+ // miss: what is difficult about "3 minutes ago" is not the words, it is
20
+ // deciding how often the words have to be worked out again. A label a minute
21
+ // old must be redrawn every second and one a week old must not be redrawn at
22
+ // all, and getting that wrong is either a wrong label or a component that
23
+ // re-renders sixty times a second forever.
24
+ //
25
+ // Not here: `useMount` and `useUnmount`, which are about the component's life
26
+ // rather than a clock, and live in `lifecycle.js`; and rendering a time, which
27
+ // is `@uniflowed/web`'s `Time`. The split between that component and
28
+ // `useTimeAgo` is the split between markup and schedule — `Time` decides what
29
+ // a `<time>` element contains and how it survives hydration, and works out its
30
+ // relative text exactly once; `useTimeAgo` is for a label that has to stay
31
+ // true while the reader looks at it. This package does not depend on
32
+ // `@uniflowed/web` to get there, because a hook library that pulled in a
33
+ // component library would be the wrong direction for the one arrow between
34
+ // them.
35
+ //
36
+ // # Where the time comes from
37
+ //
38
+ // Not from `Date.now()`. Every read in this module goes through
39
+ // `@uniflowed/core/clock`, which is a seam a test, a server or a runtime can
40
+ // put its own clock behind — so "3 minutes ago" is a value a test can assert
41
+ // rather than a value it has to wait three minutes for, and a server render is
42
+ // reproducible rather than being stamped with whenever it happened to run.
43
+ //
44
+ // A throttle measured against an installed clock is a throttle that does not
45
+ // elapse while that clock is stopped. That is not a defect to work around: a
46
+ // fixed clock means time is not passing, and a rate limit that fired anyway
47
+ // would be measuring something other than the time the caller said it was.
48
+ // `manualClock` is the one to install when a test wants the window to close.
49
+
50
+ import { useEffect, useMemo, useRef, useState } from "@uniflowed/react";
51
+ import { currentClock } from "@uniflowed/core/clock";
52
+
53
+ import { browserWindow } from "./browser.js";
54
+ import { useMounted, useStableCallback } from "./lifecycle.js";
55
+ import { useRenderEnvelope } from "./render.js";
56
+
57
+ /**
58
+ * Call `body` every `millis`, or not at all when `millis` is null.
59
+ *
60
+ * Null rather than a separate `enabled` flag because "no interval" and "an
61
+ * interval of nothing" are the same thing, and one argument cannot disagree
62
+ * with itself.
63
+ */
64
+ export hook useInterval(body: () => mixed, millis: number | null): void {
65
+ const stable = useStableCallback(body);
66
+ useEffect(() => {
67
+ if (millis == null) {
68
+ return;
69
+ }
70
+ const id = setInterval(stable, millis);
71
+ return () => clearInterval(id);
72
+ }, [stable, millis]);
73
+ }
74
+
75
+ /** Call `body` once after `millis`, or not at all when `millis` is null. */
76
+ export hook useTimeout(body: () => mixed, millis: number | null): void {
77
+ const stable = useStableCallback(body);
78
+ useEffect(() => {
79
+ if (millis == null) {
80
+ return;
81
+ }
82
+ const id = setTimeout(stable, millis);
83
+ return () => clearTimeout(id);
84
+ }, [stable, millis]);
85
+ }
86
+
87
+ /**
88
+ * `value`, but only after it has stopped changing for `millis`.
89
+ *
90
+ * The classic use is a search box: the query updates on every keystroke and
91
+ * the request should not.
92
+ */
93
+ export hook useDebouncedValue<T>(value: T, millis: number): T {
94
+ const [settled, setSettled] = useState(value);
95
+
96
+ useEffect(() => {
97
+ const id = setTimeout(() => setSettled(value), millis);
98
+ return () => clearTimeout(id);
99
+ }, [value, millis]);
100
+
101
+ return settled;
102
+ }
103
+
104
+ /**
105
+ * A callback that runs at most once per `millis`.
106
+ *
107
+ * Leading edge: the first call goes through immediately and later ones inside
108
+ * the window are dropped, which is what a scroll or resize handler wants —
109
+ * the trailing-edge version would make the first paint late.
110
+ */
111
+ export hook useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
112
+ body: (...args: TArgs) => mixed,
113
+ millis: number,
114
+ ): (...args: TArgs) => void {
115
+ // Written out rather than inferred: Flow cannot instantiate one function's
116
+ // rest-parameter type variable from another's, so the type arguments are
117
+ // given here and at every other call in this file.
118
+ const stable = useStableCallback<TArgs, mixed>(body);
119
+ const last = useRef(0);
120
+
121
+ return useStableCallback<TArgs, void>((...args: TArgs) => {
122
+ const now = currentClock().now();
123
+ if (now - last.current >= millis) {
124
+ last.current = now;
125
+ stable(...args);
126
+ }
127
+ });
128
+ }
129
+
130
+ /**
131
+ * A callback that runs `millis` after the last time it was asked to.
132
+ *
133
+ * Trailing edge, and it cancels itself at unmount — the version people write
134
+ * calls `setState` on a component that is gone.
135
+ */
136
+ export hook useDebouncedCallback<TArgs extends $ReadOnlyArray<mixed>>(
137
+ body: (...args: TArgs) => mixed,
138
+ millis: number,
139
+ ): (...args: TArgs) => void {
140
+ const stable = useStableCallback<TArgs, mixed>(body);
141
+ const timer = useRef<TimeoutID | null>(null);
142
+
143
+ useEffect(
144
+ () => () => {
145
+ if (timer.current != null) {
146
+ clearTimeout(timer.current);
147
+ }
148
+ },
149
+ [],
150
+ );
151
+
152
+ return useStableCallback<TArgs, void>((...args: TArgs) => {
153
+ if (timer.current != null) {
154
+ clearTimeout(timer.current);
155
+ }
156
+ timer.current = setTimeout(() => {
157
+ timer.current = null;
158
+ stable(...args);
159
+ }, millis);
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Run `body` before every frame the browser paints, while `active`.
165
+ *
166
+ * `delta` is the milliseconds since the previous frame and is zero on the
167
+ * first, which is what an animation integrates against: a frame that took 32ms
168
+ * because the tab was busy has to move twice as far as one that took 16ms, and
169
+ * a hand-written loop that assumes sixty a second runs at half speed on a
170
+ * hundred-and-twenty-hertz display.
171
+ *
172
+ * Nothing runs before hydration: there is no frame to paint during a prerender,
173
+ * and the effect that would ask for one does not run there.
174
+ */
175
+ export hook useAnimationFrame(
176
+ body: (frame: {| readonly delta: number, readonly time: number |}) => mixed,
177
+ active: boolean = true,
178
+ ): void {
179
+ const stable = useStableCallback(body);
180
+
181
+ useEffect(() => {
182
+ const win = browserWindow();
183
+ if (!active || win == null || win.requestAnimationFrame == null) {
184
+ return;
185
+ }
186
+ let handle: AnimationFrameID | null = null;
187
+ let previous: number | null = null;
188
+
189
+ const step = (time: number) => {
190
+ const delta = previous == null ? 0 : time - previous;
191
+ previous = time;
192
+ // Asked for before the body runs, so a body that throws does not stop
193
+ // the loop silently — it stops it loudly, on the next frame, having
194
+ // already reported the throw to the browser.
195
+ handle = win.requestAnimationFrame?.(step) ?? null;
196
+ stable({ delta, time });
197
+ };
198
+
199
+ handle = win.requestAnimationFrame(step);
200
+ return () => {
201
+ if (handle != null) {
202
+ win.cancelAnimationFrame?.(handle);
203
+ }
204
+ };
205
+ }, [active, stable]);
206
+ }
207
+
208
+ /** What counts as the reader still being there. */
209
+ const ACTIVITY: $ReadOnlyArray<string> = [
210
+ "pointermove",
211
+ "pointerdown",
212
+ "keydown",
213
+ "wheel",
214
+ "touchstart",
215
+ "scroll",
216
+ "visibilitychange",
217
+ ];
218
+
219
+ /**
220
+ * Whether the reader has stopped doing anything for `millis`.
221
+ *
222
+ * `false` on a server and on the first client render, which is the answer that
223
+ * cannot be wrong: nobody is idle before the page exists, and starting at
224
+ * `true` would flash whatever the page shows an idle reader.
225
+ *
226
+ * The listeners are passive and on the window rather than on any element, so
227
+ * this costs nothing on a touch screen and sees activity anywhere on the page.
228
+ */
229
+ export hook useIdle(
230
+ millis: number = 60_000,
231
+ options?: {| readonly events?: $ReadOnlyArray<string> |},
232
+ ): boolean {
233
+ const [idle, setIdle] = useState(false);
234
+ const events = options?.events ?? ACTIVITY;
235
+ // Compared by contents: an array written inline in the call is a new array
236
+ // every render, and depending on its identity would re-listen every render.
237
+ const key = events.join(",");
238
+
239
+ useEffect(() => {
240
+ const win = browserWindow();
241
+ if (win == null) {
242
+ return;
243
+ }
244
+ const names = key.split(",");
245
+ let timer: TimeoutID | null = null;
246
+
247
+ const wake = () => {
248
+ setIdle(false);
249
+ if (timer != null) {
250
+ clearTimeout(timer);
251
+ }
252
+ timer = setTimeout(() => setIdle(true), millis);
253
+ };
254
+
255
+ wake();
256
+ for (const name of names) {
257
+ win.addEventListener(name, wake, { passive: true });
258
+ }
259
+ return () => {
260
+ if (timer != null) {
261
+ clearTimeout(timer);
262
+ }
263
+ for (const name of names) {
264
+ win.removeEventListener(name, wake);
265
+ }
266
+ };
267
+ }, [millis, key]);
268
+
269
+ return idle;
270
+ }
271
+
272
+ /**
273
+ * The current time, re-read every `millis`.
274
+ *
275
+ * The clock is read in the initial state rather than in an effect, so a
276
+ * client-only page has the right time on its first paint instead of a frame of
277
+ * something else. That read is the one impure thing in this package, and it is
278
+ * bounded: it happens once, the value is never re-read during a render, and a
279
+ * render React throws away is replaced by another whose clock is just as valid.
280
+ *
281
+ * On a prerendered page the two renders are at two different instants, so the
282
+ * first one on each side has to be the *same* instant or React reports a
283
+ * mismatch. Under a `RenderProvider` that happens by itself — the anchor the
284
+ * server fixed travels in the markup, both sides start from it, and the real
285
+ * time arrives with the first effect. `serverValue` is the same choice made by
286
+ * hand, for a caller who has the instant from somewhere else and for a tree
287
+ * with no provider above it; it wins over the anchor when both are there,
288
+ * because an argument at the call site is a decision and a context is a
289
+ * default.
290
+ *
291
+ * A `Date` rather than a `Temporal.Instant`, and deliberately: this value's
292
+ * consumers subtract it from another one to decide when to run again, which is
293
+ * millisecond arithmetic on a number. The Temporal-shaped reading of the same
294
+ * anchor is `useRenderedAt` in `render.js`, and rendering an instant is
295
+ * `@uniflowed/web`'s `Time`.
296
+ */
297
+ export hook useNow(millis: number | null = 1000, serverValue: Date | null = null): Date {
298
+ // The instant rather than the object: a caller writing `new Date(...)` in
299
+ // the call passes a different object every render, and a dependency on it
300
+ // would re-run the effect forever.
301
+ const anchored = useRenderEnvelope()?.at ?? null;
302
+ const since = serverValue == null ? anchored : serverValue.getTime();
303
+ const [now, setNow] = useState<Date>(
304
+ () => new Date(since == null ? currentClock().now() : since),
305
+ );
306
+
307
+ useEffect(() => {
308
+ if (since != null) {
309
+ setNow(new Date(currentClock().now()));
310
+ }
311
+ }, [since]);
312
+
313
+ useInterval(() => setNow(new Date(currentClock().now())), millis);
314
+ return now;
315
+ }
316
+
317
+ /**
318
+ * `Intl.RelativeTimeFormat`, which Flow's own library definition does not have.
319
+ *
320
+ * The vendored `intl.js` declares `Collator`, `DateTimeFormat`, `Locale`,
321
+ * `NumberFormat`, `PluralRules` and `Segmenter` and stops there, so
322
+ * `Intl.RelativeTimeFormat` is a missing property and `Intl$RelativeTimeFormatUnit`
323
+ * is an unresolvable name. Declaring the shape here is how this file names a
324
+ * global its checker has not caught up with — narrow, exactly as wide as what
325
+ * is called, and optional so that a runtime without the constructor is a
326
+ * branch rather than a crash.
327
+ */
328
+ declare class RelativeTimeFormat {
329
+ constructor(locale?: string, options?: { numeric?: "always" | "auto", ... }): void;
330
+ format(value: number, unit: RelativeUnit): string;
331
+ }
332
+
333
+ declare var Intl: {
334
+ RelativeTimeFormat?: Class<RelativeTimeFormat>,
335
+ ...
336
+ };
337
+
338
+ /** The units `ago` is willing to describe a gap in. */
339
+ type RelativeUnit = "year" | "month" | "day" | "hour" | "minute" | "second";
340
+
341
+ /** Milliseconds in each unit, largest first. */
342
+ const UNITS: $ReadOnlyArray<[RelativeUnit, number]> = [
343
+ ["year", 31_536_000_000],
344
+ ["month", 2_592_000_000],
345
+ ["day", 86_400_000],
346
+ ["hour", 3_600_000],
347
+ ["minute", 60_000],
348
+ ["second", 1_000],
349
+ ];
350
+
351
+ /** How often a label this far from now has to be worked out again. */
352
+ function cadence(distance: number): number {
353
+ if (distance < 60_000) {
354
+ return 1_000;
355
+ }
356
+ if (distance < 3_600_000) {
357
+ return 30_000;
358
+ }
359
+ return 60_000;
360
+ }
361
+
362
+ /**
363
+ * "3 minutes ago", or "in 3 minutes".
364
+ *
365
+ * Falls back to the instant itself where the browser has no
366
+ * `Intl.RelativeTimeFormat`, because a wrong-language string invented here
367
+ * would be worse than the unambiguous one.
368
+ */
369
+ function ago(at: Date, from: Date, locale: string | void): string {
370
+ const Formatter = Intl.RelativeTimeFormat;
371
+ if (Formatter == null) {
372
+ return at.toISOString();
373
+ }
374
+ const difference = at.getTime() - from.getTime();
375
+ const formatter = new Formatter(locale, { numeric: "auto" });
376
+ for (const [unit, span] of UNITS) {
377
+ if (Math.abs(difference) >= span) {
378
+ return formatter.format(Math.round(difference / span), unit);
379
+ }
380
+ }
381
+ // Under a second in either direction is "now", not "in 0 seconds".
382
+ return formatter.format(0, "second");
383
+ }
384
+
385
+ /**
386
+ * "3 minutes ago", kept true while the reader looks at it.
387
+ *
388
+ * Before hydration and on the first client render this is `serverValue`,
389
+ * defaulting to the instant's UTC ISO string — the same choice
390
+ * `@uniflowed/web`'s `Time` makes, and for the same reason: the relative form
391
+ * depends on a clock and a locale that the server does not have, so rendering
392
+ * it on both sides would be a hydration mismatch by construction. The text is
393
+ * in the markup for a crawler and for a reader with no JavaScript, and becomes
394
+ * relative once the page is alive.
395
+ *
396
+ * The update rate follows the distance rather than being fixed: a label from
397
+ * this minute is redrawn every second, one from this hour every thirty, and an
398
+ * older one every minute. That is why this is not "call `useNow` and format
399
+ * it" — a fixed one-second clock re-renders a week-old timestamp 604,800 times
400
+ * to no effect.
401
+ */
402
+ export hook useTimeAgo(
403
+ value: Date | string | number,
404
+ options?: {|
405
+ readonly serverValue?: string,
406
+ /** Override the schedule. `null` works it out once and leaves it. */
407
+ readonly interval?: number | null,
408
+ readonly locale?: string,
409
+ |},
410
+ ): string {
411
+ const serverValue = options?.serverValue;
412
+ const override = options?.interval;
413
+ const locale = options?.locale;
414
+
415
+ const instant = value instanceof Date ? value.getTime() : new Date(value).getTime();
416
+ const at = useMemo(() => new Date(instant), [instant]);
417
+
418
+ const mounted = useMounted();
419
+ // The schedule itself is the state, not the gap it was chosen from. Holding
420
+ // the gap would mean a render every time the clock moved *and* a second one
421
+ // to record the new gap; holding the schedule means `setSchedule` is handed
422
+ // the same number on all but the few ticks that cross a threshold, and React
423
+ // bails out of those renders entirely.
424
+ const [schedule, setSchedule] = useState(1_000);
425
+ const tick = override === undefined ? schedule : override;
426
+ const now = useNow(mounted ? tick : null);
427
+
428
+ const wanted = cadence(Math.abs(now.getTime() - instant));
429
+ useEffect(() => {
430
+ setSchedule(wanted);
431
+ }, [wanted]);
432
+
433
+ if (!mounted) {
434
+ return serverValue ?? at.toISOString();
435
+ }
436
+ return ago(at, now, locale);
437
+ }
package/internal/async.js DELETED
@@ -1,77 +0,0 @@
1
- // @flow
2
- //
3
- // Running a promise from a component.
4
- //
5
- // Two bugs a hand-written version has, and only one of them is a warning:
6
- // setting state after the component has gone, and a slow first request
7
- // overwriting a fast second one. The second is the dangerous one — it puts a
8
- // wrong answer on screen and nothing says so.
9
- //
10
- // Both are fixed by the effect's own cleanup rather than by a ref: the effect
11
- // that started a request is the thing that knows it has been superseded,
12
- // because React runs its cleanup before running it again. That is the shape
13
- // React's own documentation uses, and it means there is no "latest" anything
14
- // to keep in a ref and no generation counter to keep in step.
15
-
16
- import { useCallback, useEffect, useState } from "@uniflowed/react";
17
-
18
- /** What an in-flight, settled or failed call looks like. */
19
- export type Async<T> = {|
20
- readonly value: T | null,
21
- readonly error: Error | null,
22
- readonly pending: boolean,
23
- /** Run it again, keeping whatever is on screen until the new value lands. */
24
- readonly reload: () => void,
25
- |};
26
-
27
- /**
28
- * Call `body` when `deps` change, and report what happened.
29
- *
30
- * The previous value stays on screen while a reload is in flight, because
31
- * blanking the page to show a spinner every time a filter changes is worse
32
- * than showing slightly stale data for a moment. `pending` says which it is.
33
- */
34
- export function useAsync<T>(body: () => Promise<T>, deps: $ReadOnlyArray<mixed>): Async<T> {
35
- const [state, setState] = useState<{|
36
- value: T | null,
37
- error: Error | null,
38
- pending: boolean,
39
- |}>({ value: null, error: null, pending: true });
40
-
41
- // Changing this is what re-runs the effect, so `reload` is a state change
42
- // rather than a function the effect has to be told about.
43
- const [attempt, setAttempt] = useState(0);
44
- const reload = useCallback(() => setAttempt((current) => current + 1), []);
45
-
46
- useEffect(() => {
47
- // Set when this effect is superseded — by a dependency change, a reload,
48
- // or an unmount. React runs the cleanup before the next run, so the
49
- // request that is no longer wanted knows not to write.
50
- let ignore = false;
51
- setState((current) => ({ ...current, pending: true }));
52
-
53
- body().then(
54
- (value) => {
55
- if (!ignore) {
56
- setState({ value, error: null, pending: false });
57
- }
58
- },
59
- (thrown) => {
60
- if (!ignore) {
61
- setState({
62
- value: null,
63
- error: thrown instanceof Error ? thrown : new Error(String(thrown)),
64
- pending: false,
65
- });
66
- }
67
- },
68
- );
69
-
70
- return () => {
71
- ignore = true;
72
- };
73
- // eslint-disable-next-line react-hooks/exhaustive-deps
74
- }, [...deps, attempt]);
75
-
76
- return { ...state, reload };
77
- }
@@ -1,145 +0,0 @@
1
- // @flow
2
- //
3
- // Reading the browser, 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
- import { useCallback, useSyncExternalStore } from "@uniflowed/react";
14
-
15
- /** Whether there is a document to read at all. */
16
- function inBrowser(): boolean {
17
- return typeof globalThis.document !== "undefined";
18
- }
19
-
20
- /**
21
- * The window these hooks listen to.
22
- *
23
- * In a browser `globalThis` *is* the window, so `globalThis.addEventListener`
24
- * looks correct. It is not correct anywhere a document has been installed onto
25
- * another host's global — which is every uf test process, where `globalThis` is
26
- * Node's and has no `addEventListener` at all. Ask the window for its own
27
- * methods and both cases work.
28
- */
29
- function windowOf(): any {
30
- return globalThis.window ?? globalThis;
31
- }
32
-
33
- /**
34
- * Whether a media query matches.
35
- *
36
- * `serverValue` is what a prerender should assume, and it has no honest
37
- * default — a page that hides a sidebar under 48rem wants `false` on the
38
- * server, and one that renders a mobile menu wants `true`. So the caller says.
39
- */
40
- export function useMediaQuery(query: string, serverValue: boolean = false): boolean {
41
- const subscribe = useCallback(
42
- (notify: () => void) => {
43
- if (!inBrowser() || typeof windowOf().matchMedia !== "function") {
44
- return () => {};
45
- }
46
- const list = windowOf().matchMedia(query);
47
- list.addEventListener("change", notify);
48
- return () => list.removeEventListener("change", notify);
49
- },
50
- [query],
51
- );
52
-
53
- return useSyncExternalStore(
54
- subscribe,
55
- () =>
56
- inBrowser() && typeof windowOf().matchMedia === "function"
57
- ? windowOf().matchMedia(query).matches
58
- : serverValue,
59
- () => serverValue,
60
- );
61
- }
62
-
63
- /** The reader's colour-scheme preference. */
64
- export function usePreferredColorScheme(serverValue: "light" | "dark" = "light"): "light" | "dark" {
65
- return useMediaQuery("(prefers-color-scheme: dark)", serverValue === "dark") ? "dark" : "light";
66
- }
67
-
68
- /** Whether the reader has asked for less motion. */
69
- export function usePrefersReducedMotion(serverValue: boolean = false): boolean {
70
- return useMediaQuery("(prefers-reduced-motion: reduce)", serverValue);
71
- }
72
-
73
- /** Whether the browser thinks it is online. */
74
- export function useOnline(serverValue: boolean = true): boolean {
75
- const subscribe = useCallback((notify: () => void) => {
76
- if (!inBrowser()) {
77
- return () => {};
78
- }
79
- const win = windowOf();
80
- win.addEventListener("online", notify);
81
- win.addEventListener("offline", notify);
82
- return () => {
83
- win.removeEventListener("online", notify);
84
- win.removeEventListener("offline", notify);
85
- };
86
- }, []);
87
-
88
- return useSyncExternalStore(
89
- subscribe,
90
- () => (inBrowser() ? (windowOf().navigator?.onLine ?? true) : serverValue),
91
- () => serverValue,
92
- );
93
- }
94
-
95
- /** Whether the document is the one the reader is looking at. */
96
- export function useDocumentVisible(serverValue: boolean = true): boolean {
97
- const subscribe = useCallback((notify: () => void) => {
98
- if (!inBrowser()) {
99
- return () => {};
100
- }
101
- globalThis.document.addEventListener("visibilitychange", notify);
102
- return () => globalThis.document.removeEventListener("visibilitychange", notify);
103
- }, []);
104
-
105
- return useSyncExternalStore(
106
- subscribe,
107
- () => (inBrowser() ? globalThis.document.visibilityState !== "hidden" : serverValue),
108
- () => serverValue,
109
- );
110
- }
111
-
112
- /** The size of the viewport. */
113
- export function useWindowSize(serverValue?: {|
114
- readonly width: number,
115
- readonly height: number,
116
- |}): {|
117
- readonly width: number,
118
- readonly height: number,
119
- |} {
120
- const fallback = serverValue ?? { width: 0, height: 0 };
121
-
122
- const subscribe = useCallback((notify: () => void) => {
123
- if (!inBrowser()) {
124
- return () => {};
125
- }
126
- const win = windowOf();
127
- win.addEventListener("resize", notify);
128
- return () => win.removeEventListener("resize", notify);
129
- }, []);
130
-
131
- // A string snapshot, because `useSyncExternalStore` compares snapshots by
132
- // identity: returning a fresh object every time would re-render on every
133
- // check, which is an infinite loop React reports rather than tolerates.
134
- const packed = useSyncExternalStore(
135
- subscribe,
136
- () =>
137
- inBrowser()
138
- ? `${windowOf().innerWidth}x${windowOf().innerHeight}`
139
- : `${fallback.width}x${fallback.height}`,
140
- () => `${fallback.width}x${fallback.height}`,
141
- );
142
-
143
- const [width, height] = packed.split("x");
144
- return { width: Number(width), height: Number(height) };
145
- }