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

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,408 @@
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
+ import { useEffect, useMemo, useRef, useState } from "@uniflowed/react";
37
+
38
+ import { browserWindow } from "./browser.js";
39
+ import { useMounted, useStableCallback } from "./lifecycle.js";
40
+
41
+ /**
42
+ * Call `body` every `millis`, or not at all when `millis` is null.
43
+ *
44
+ * Null rather than a separate `enabled` flag because "no interval" and "an
45
+ * interval of nothing" are the same thing, and one argument cannot disagree
46
+ * with itself.
47
+ */
48
+ export hook useInterval(body: () => mixed, millis: number | null): void {
49
+ const stable = useStableCallback(body);
50
+ useEffect(() => {
51
+ if (millis == null) {
52
+ return;
53
+ }
54
+ const id = setInterval(stable, millis);
55
+ return () => clearInterval(id);
56
+ }, [stable, millis]);
57
+ }
58
+
59
+ /** Call `body` once after `millis`, or not at all when `millis` is null. */
60
+ export hook useTimeout(body: () => mixed, millis: number | null): void {
61
+ const stable = useStableCallback(body);
62
+ useEffect(() => {
63
+ if (millis == null) {
64
+ return;
65
+ }
66
+ const id = setTimeout(stable, millis);
67
+ return () => clearTimeout(id);
68
+ }, [stable, millis]);
69
+ }
70
+
71
+ /**
72
+ * `value`, but only after it has stopped changing for `millis`.
73
+ *
74
+ * The classic use is a search box: the query updates on every keystroke and
75
+ * the request should not.
76
+ */
77
+ export hook useDebouncedValue<T>(value: T, millis: number): T {
78
+ const [settled, setSettled] = useState(value);
79
+
80
+ useEffect(() => {
81
+ const id = setTimeout(() => setSettled(value), millis);
82
+ return () => clearTimeout(id);
83
+ }, [value, millis]);
84
+
85
+ return settled;
86
+ }
87
+
88
+ /**
89
+ * A callback that runs at most once per `millis`.
90
+ *
91
+ * Leading edge: the first call goes through immediately and later ones inside
92
+ * the window are dropped, which is what a scroll or resize handler wants —
93
+ * the trailing-edge version would make the first paint late.
94
+ */
95
+ export hook useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
96
+ body: (...args: TArgs) => mixed,
97
+ millis: number,
98
+ ): (...args: TArgs) => void {
99
+ // Written out rather than inferred: Flow cannot instantiate one function's
100
+ // rest-parameter type variable from another's, so the type arguments are
101
+ // given here and at every other call in this file.
102
+ const stable = useStableCallback<TArgs, mixed>(body);
103
+ const last = useRef(0);
104
+
105
+ return useStableCallback<TArgs, void>((...args: TArgs) => {
106
+ const now = Date.now();
107
+ if (now - last.current >= millis) {
108
+ last.current = now;
109
+ stable(...args);
110
+ }
111
+ });
112
+ }
113
+
114
+ /**
115
+ * A callback that runs `millis` after the last time it was asked to.
116
+ *
117
+ * Trailing edge, and it cancels itself at unmount — the version people write
118
+ * calls `setState` on a component that is gone.
119
+ */
120
+ export hook useDebouncedCallback<TArgs extends $ReadOnlyArray<mixed>>(
121
+ body: (...args: TArgs) => mixed,
122
+ millis: number,
123
+ ): (...args: TArgs) => void {
124
+ const stable = useStableCallback<TArgs, mixed>(body);
125
+ const timer = useRef<TimeoutID | null>(null);
126
+
127
+ useEffect(
128
+ () => () => {
129
+ if (timer.current != null) {
130
+ clearTimeout(timer.current);
131
+ }
132
+ },
133
+ [],
134
+ );
135
+
136
+ return useStableCallback<TArgs, void>((...args: TArgs) => {
137
+ if (timer.current != null) {
138
+ clearTimeout(timer.current);
139
+ }
140
+ timer.current = setTimeout(() => {
141
+ timer.current = null;
142
+ stable(...args);
143
+ }, millis);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Run `body` before every frame the browser paints, while `active`.
149
+ *
150
+ * `delta` is the milliseconds since the previous frame and is zero on the
151
+ * first, which is what an animation integrates against: a frame that took 32ms
152
+ * because the tab was busy has to move twice as far as one that took 16ms, and
153
+ * a hand-written loop that assumes sixty a second runs at half speed on a
154
+ * hundred-and-twenty-hertz display.
155
+ *
156
+ * Nothing runs before hydration: there is no frame to paint during a prerender,
157
+ * and the effect that would ask for one does not run there.
158
+ */
159
+ export hook useAnimationFrame(
160
+ body: (frame: {| readonly delta: number, readonly time: number |}) => mixed,
161
+ active: boolean = true,
162
+ ): void {
163
+ const stable = useStableCallback(body);
164
+
165
+ useEffect(() => {
166
+ const win = browserWindow();
167
+ if (!active || win == null || win.requestAnimationFrame == null) {
168
+ return;
169
+ }
170
+ let handle: AnimationFrameID | null = null;
171
+ let previous: number | null = null;
172
+
173
+ const step = (time: number) => {
174
+ const delta = previous == null ? 0 : time - previous;
175
+ previous = time;
176
+ // Asked for before the body runs, so a body that throws does not stop
177
+ // the loop silently — it stops it loudly, on the next frame, having
178
+ // already reported the throw to the browser.
179
+ handle = win.requestAnimationFrame?.(step) ?? null;
180
+ stable({ delta, time });
181
+ };
182
+
183
+ handle = win.requestAnimationFrame(step);
184
+ return () => {
185
+ if (handle != null) {
186
+ win.cancelAnimationFrame?.(handle);
187
+ }
188
+ };
189
+ }, [active, stable]);
190
+ }
191
+
192
+ /** What counts as the reader still being there. */
193
+ const ACTIVITY: $ReadOnlyArray<string> = [
194
+ "pointermove",
195
+ "pointerdown",
196
+ "keydown",
197
+ "wheel",
198
+ "touchstart",
199
+ "scroll",
200
+ "visibilitychange",
201
+ ];
202
+
203
+ /**
204
+ * Whether the reader has stopped doing anything for `millis`.
205
+ *
206
+ * `false` on a server and on the first client render, which is the answer that
207
+ * cannot be wrong: nobody is idle before the page exists, and starting at
208
+ * `true` would flash whatever the page shows an idle reader.
209
+ *
210
+ * The listeners are passive and on the window rather than on any element, so
211
+ * this costs nothing on a touch screen and sees activity anywhere on the page.
212
+ */
213
+ export hook useIdle(
214
+ millis: number = 60_000,
215
+ options?: {| readonly events?: $ReadOnlyArray<string> |},
216
+ ): boolean {
217
+ const [idle, setIdle] = useState(false);
218
+ const events = options?.events ?? ACTIVITY;
219
+ // Compared by contents: an array written inline in the call is a new array
220
+ // every render, and depending on its identity would re-listen every render.
221
+ const key = events.join(",");
222
+
223
+ useEffect(() => {
224
+ const win = browserWindow();
225
+ if (win == null) {
226
+ return;
227
+ }
228
+ const names = key.split(",");
229
+ let timer: TimeoutID | null = null;
230
+
231
+ const wake = () => {
232
+ setIdle(false);
233
+ if (timer != null) {
234
+ clearTimeout(timer);
235
+ }
236
+ timer = setTimeout(() => setIdle(true), millis);
237
+ };
238
+
239
+ wake();
240
+ for (const name of names) {
241
+ win.addEventListener(name, wake, { passive: true });
242
+ }
243
+ return () => {
244
+ if (timer != null) {
245
+ clearTimeout(timer);
246
+ }
247
+ for (const name of names) {
248
+ win.removeEventListener(name, wake);
249
+ }
250
+ };
251
+ }, [millis, key]);
252
+
253
+ return idle;
254
+ }
255
+
256
+ /**
257
+ * The current time, re-read every `millis`.
258
+ *
259
+ * The clock is read in the initial state rather than in an effect, so a
260
+ * client-only page has the right time on its first paint instead of a frame of
261
+ * something else. That read is the one impure thing in this package, and it is
262
+ * bounded: it happens once, the value is never re-read during a render, and a
263
+ * render React throws away is replaced by another whose clock is just as valid.
264
+ *
265
+ * A prerendered page that puts this on screen needs `serverValue`, because the
266
+ * server's clock and the reader's are not the same number and React compares
267
+ * the text. Given one, the first render on both sides is that value and the
268
+ * real time arrives with the first effect. `useTimeAgo` has already made this
269
+ * choice; prefer it for a label.
270
+ */
271
+ export hook useNow(millis: number | null = 1000, serverValue: Date | null = null): Date {
272
+ // The instant rather than the object: a caller writing `new Date(...)` in
273
+ // the call passes a different object every render, and a dependency on it
274
+ // would re-run the effect forever.
275
+ const since = serverValue == null ? null : serverValue.getTime();
276
+ const [now, setNow] = useState<Date>(() => (since == null ? new Date() : new Date(since)));
277
+
278
+ useEffect(() => {
279
+ if (since != null) {
280
+ setNow(new Date());
281
+ }
282
+ }, [since]);
283
+
284
+ useInterval(() => setNow(new Date()), millis);
285
+ return now;
286
+ }
287
+
288
+ /**
289
+ * `Intl.RelativeTimeFormat`, which Flow's own library definition does not have.
290
+ *
291
+ * The vendored `intl.js` declares `Collator`, `DateTimeFormat`, `Locale`,
292
+ * `NumberFormat`, `PluralRules` and `Segmenter` and stops there, so
293
+ * `Intl.RelativeTimeFormat` is a missing property and `Intl$RelativeTimeFormatUnit`
294
+ * is an unresolvable name. Declaring the shape here is how this file names a
295
+ * global its checker has not caught up with — narrow, exactly as wide as what
296
+ * is called, and optional so that a runtime without the constructor is a
297
+ * branch rather than a crash.
298
+ */
299
+ declare class RelativeTimeFormat {
300
+ constructor(locale?: string, options?: { numeric?: "always" | "auto", ... }): void;
301
+ format(value: number, unit: RelativeUnit): string;
302
+ }
303
+
304
+ declare var Intl: {
305
+ RelativeTimeFormat?: Class<RelativeTimeFormat>,
306
+ ...
307
+ };
308
+
309
+ /** The units `ago` is willing to describe a gap in. */
310
+ type RelativeUnit = "year" | "month" | "day" | "hour" | "minute" | "second";
311
+
312
+ /** Milliseconds in each unit, largest first. */
313
+ const UNITS: $ReadOnlyArray<[RelativeUnit, number]> = [
314
+ ["year", 31_536_000_000],
315
+ ["month", 2_592_000_000],
316
+ ["day", 86_400_000],
317
+ ["hour", 3_600_000],
318
+ ["minute", 60_000],
319
+ ["second", 1_000],
320
+ ];
321
+
322
+ /** How often a label this far from now has to be worked out again. */
323
+ function cadence(distance: number): number {
324
+ if (distance < 60_000) {
325
+ return 1_000;
326
+ }
327
+ if (distance < 3_600_000) {
328
+ return 30_000;
329
+ }
330
+ return 60_000;
331
+ }
332
+
333
+ /**
334
+ * "3 minutes ago", or "in 3 minutes".
335
+ *
336
+ * Falls back to the instant itself where the browser has no
337
+ * `Intl.RelativeTimeFormat`, because a wrong-language string invented here
338
+ * would be worse than the unambiguous one.
339
+ */
340
+ function ago(at: Date, from: Date, locale: string | void): string {
341
+ const Formatter = Intl.RelativeTimeFormat;
342
+ if (Formatter == null) {
343
+ return at.toISOString();
344
+ }
345
+ const difference = at.getTime() - from.getTime();
346
+ const formatter = new Formatter(locale, { numeric: "auto" });
347
+ for (const [unit, span] of UNITS) {
348
+ if (Math.abs(difference) >= span) {
349
+ return formatter.format(Math.round(difference / span), unit);
350
+ }
351
+ }
352
+ // Under a second in either direction is "now", not "in 0 seconds".
353
+ return formatter.format(0, "second");
354
+ }
355
+
356
+ /**
357
+ * "3 minutes ago", kept true while the reader looks at it.
358
+ *
359
+ * Before hydration and on the first client render this is `serverValue`,
360
+ * defaulting to the instant's UTC ISO string — the same choice
361
+ * `@uniflowed/web`'s `Time` makes, and for the same reason: the relative form
362
+ * depends on a clock and a locale that the server does not have, so rendering
363
+ * it on both sides would be a hydration mismatch by construction. The text is
364
+ * in the markup for a crawler and for a reader with no JavaScript, and becomes
365
+ * relative once the page is alive.
366
+ *
367
+ * The update rate follows the distance rather than being fixed: a label from
368
+ * this minute is redrawn every second, one from this hour every thirty, and an
369
+ * older one every minute. That is why this is not "call `useNow` and format
370
+ * it" — a fixed one-second clock re-renders a week-old timestamp 604,800 times
371
+ * to no effect.
372
+ */
373
+ export hook useTimeAgo(
374
+ value: Date | string | number,
375
+ options?: {|
376
+ readonly serverValue?: string,
377
+ /** Override the schedule. `null` works it out once and leaves it. */
378
+ readonly interval?: number | null,
379
+ readonly locale?: string,
380
+ |},
381
+ ): string {
382
+ const serverValue = options?.serverValue;
383
+ const override = options?.interval;
384
+ const locale = options?.locale;
385
+
386
+ const instant = value instanceof Date ? value.getTime() : new Date(value).getTime();
387
+ const at = useMemo(() => new Date(instant), [instant]);
388
+
389
+ const mounted = useMounted();
390
+ // The schedule itself is the state, not the gap it was chosen from. Holding
391
+ // the gap would mean a render every time the clock moved *and* a second one
392
+ // to record the new gap; holding the schedule means `setSchedule` is handed
393
+ // the same number on all but the few ticks that cross a threshold, and React
394
+ // bails out of those renders entirely.
395
+ const [schedule, setSchedule] = useState(1_000);
396
+ const tick = override === undefined ? schedule : override;
397
+ const now = useNow(mounted ? tick : null);
398
+
399
+ const wanted = cadence(Math.abs(now.getTime() - instant));
400
+ useEffect(() => {
401
+ setSchedule(wanted);
402
+ }, [wanted]);
403
+
404
+ if (!mounted) {
405
+ return serverValue ?? at.toISOString();
406
+ }
407
+ return ago(at, now, locale);
408
+ }
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
- }