@uniflowed/hooks 0.0.0-alpha.2

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/index.js ADDED
@@ -0,0 +1,60 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks`: the hooks a React application writes anyway.
4
+ //
5
+ // Not a large collection. Every hook here is one that people write by hand in
6
+ // every project and get subtly wrong in the same way each time — a timer that
7
+ // calls a stale closure, a subscription re-established on every keystroke, a
8
+ // slow request overwriting a fast one, persisted state that differs between the
9
+ // server render and the first paint.
10
+ //
11
+ // # Prerendering is the constraint that shapes the surface
12
+ //
13
+ // uf prerenders every static route, so each of these runs once where there is
14
+ // no `window`. The browser hooks are built on `useSyncExternalStore`, which
15
+ // takes the server's value as a separate argument — so what a prerender sees is
16
+ // *stated* rather than being whatever a `typeof window` check fell through to,
17
+ // and React reads the value when it commits rather than when it renders, which
18
+ // is what stops a media query that changes mid-render from tearing.
19
+ //
20
+ // Where there is no honest default, the caller supplies one: a page that hides
21
+ // its sidebar under 48rem wants `false` on the server and one that renders a
22
+ // mobile menu wants `true`, and a library cannot know which.
23
+
24
+ export type { Async } from "./internal/async.js";
25
+
26
+ export { useAsync } from "./internal/async.js";
27
+ export {
28
+ useIsomorphicLayoutEffect,
29
+ useMount,
30
+ useMounted,
31
+ usePrevious,
32
+ useRerender,
33
+ useStableCallback,
34
+ useUnmount,
35
+ } from "./internal/lifecycle.js";
36
+ export {
37
+ useDebouncedCallback,
38
+ useDebouncedValue,
39
+ useInterval,
40
+ useThrottledCallback,
41
+ useTimeout,
42
+ } from "./internal/timing.js";
43
+ export {
44
+ useDocumentVisible,
45
+ useMediaQuery,
46
+ useOnline,
47
+ usePrefersReducedMotion,
48
+ usePreferredColorScheme,
49
+ useWindowSize,
50
+ } from "./internal/browser.js";
51
+ export {
52
+ useClickOutside,
53
+ useElementRef,
54
+ useElementSize,
55
+ useEventListener,
56
+ useFocusWithin,
57
+ useHover,
58
+ useIntersecting,
59
+ } from "./internal/element.js";
60
+ export { useCounter, useStorage, useToggle } from "./internal/state.js";
@@ -0,0 +1,77 @@
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
+ }
@@ -0,0 +1,145 @@
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
+ }
@@ -0,0 +1,186 @@
1
+ // @flow
2
+ //
3
+ // Watching a node.
4
+ //
5
+ // Each of these takes a ref rather than returning one, so a component can put
6
+ // several on the same element and can hand the ref to something else as well.
7
+ // The listener is attached in a layout effect, so it is in place before the
8
+ // browser paints — a click that lands in the same frame as the mount is a real
9
+ // case on a touch screen.
10
+
11
+ import { useEffect, useRef, useState } from "@uniflowed/react";
12
+
13
+ import { useIsomorphicLayoutEffect, useStableCallback } from "./lifecycle.js";
14
+
15
+ type Ref<T> = { current: T | null };
16
+
17
+ /**
18
+ * The window these hooks read constructors from.
19
+ *
20
+ * In a browser `globalThis` is the window; anywhere a document was installed
21
+ * onto another host's global it is not, and `globalThis.ResizeObserver` is
22
+ * undefined while `window.ResizeObserver` is there.
23
+ */
24
+ function windowOf(): any {
25
+ return globalThis.window ?? globalThis;
26
+ }
27
+
28
+ /**
29
+ * Listen to an event on a target, cleaning up after itself.
30
+ *
31
+ * `target` may be a ref, an element, or a function returning either, so this
32
+ * covers the window, the document, and a node that does not exist yet at the
33
+ * time the hook is called.
34
+ */
35
+ export function useEventListener<TEvent extends Event>(
36
+ target: Ref<EventTarget> | EventTarget | (() => EventTarget | null) | null,
37
+ name: string,
38
+ handler: (event: TEvent) => mixed,
39
+ options?: AddEventListenerOptions,
40
+ ): void {
41
+ const stable = useStableCallback(handler);
42
+ const capture = options?.capture ?? false;
43
+ const passive = options?.passive;
44
+ const once = options?.once ?? false;
45
+
46
+ useIsomorphicLayoutEffect(() => {
47
+ const node = resolve(target);
48
+ if (node == null) {
49
+ return;
50
+ }
51
+ const listener = (event: Event) => stable(event as any);
52
+ node.addEventListener(name, listener, { capture, passive, once });
53
+ return () => node.removeEventListener(name, listener, { capture });
54
+ }, [target, name, stable, capture, passive, once]);
55
+ }
56
+
57
+ /**
58
+ * Call `handler` when a press lands outside `ref`.
59
+ *
60
+ * `pointerdown` rather than `click`, because a menu that closes on click stays
61
+ * open for the whole press — and because a click whose press started inside
62
+ * the menu and ended outside it should not close it.
63
+ */
64
+ export function useClickOutside(ref: Ref<HTMLElement>, handler: (event: Event) => mixed): void {
65
+ const stable = useStableCallback(handler);
66
+
67
+ useEffect(() => {
68
+ if (typeof globalThis.document === "undefined") {
69
+ return;
70
+ }
71
+ const listener = (event: Event) => {
72
+ const node = ref.current;
73
+ const target: any = event.target;
74
+ if (node != null && target != null && !node.contains(target)) {
75
+ stable(event);
76
+ }
77
+ };
78
+ globalThis.document.addEventListener("pointerdown", listener);
79
+ return () => globalThis.document.removeEventListener("pointerdown", listener);
80
+ }, [ref, stable]);
81
+ }
82
+
83
+ /** Whether the pointer is over the element. */
84
+ export function useHover(ref: Ref<HTMLElement>): boolean {
85
+ const [hovered, setHovered] = useState(false);
86
+ useEventListener(ref, "pointerenter", () => setHovered(true));
87
+ useEventListener(ref, "pointerleave", () => setHovered(false));
88
+ return hovered;
89
+ }
90
+
91
+ /** Whether focus is inside the element. */
92
+ export function useFocusWithin(ref: Ref<HTMLElement>): boolean {
93
+ const [within, setWithin] = useState(false);
94
+ useEventListener(ref, "focusin", () => setWithin(true));
95
+ useEventListener(ref, "focusout", () => setWithin(false));
96
+ return within;
97
+ }
98
+
99
+ /**
100
+ * The element's size, as the browser measures it.
101
+ *
102
+ * A `ResizeObserver` rather than a window resize listener, because an element
103
+ * changes size when its content changes, when a sibling grows, and when a
104
+ * container query fires — none of which resizes the window.
105
+ */
106
+ export function useElementSize(ref: Ref<HTMLElement>): {|
107
+ readonly width: number,
108
+ readonly height: number,
109
+ |} {
110
+ const [size, setSize] = useState({ width: 0, height: 0 });
111
+
112
+ useIsomorphicLayoutEffect(() => {
113
+ const node = ref.current;
114
+ if (node == null || typeof windowOf().ResizeObserver !== "function") {
115
+ return;
116
+ }
117
+ const observer = new (windowOf().ResizeObserver)((entries) => {
118
+ const entry = entries[0];
119
+ if (entry == null) {
120
+ return;
121
+ }
122
+ const box = entry.contentRect;
123
+ // Only on a real change: an observer that fires with the same numbers
124
+ // would re-render forever.
125
+ setSize((current) =>
126
+ current.width === box.width && current.height === box.height
127
+ ? current
128
+ : { width: box.width, height: box.height },
129
+ );
130
+ });
131
+ observer.observe(node);
132
+ return () => observer.disconnect();
133
+ }, [ref]);
134
+
135
+ return size;
136
+ }
137
+
138
+ /** Whether the element is in the viewport. */
139
+ export function useIntersecting(
140
+ ref: Ref<HTMLElement>,
141
+ options?: {| readonly rootMargin?: string, readonly threshold?: number |},
142
+ ): boolean {
143
+ const [intersecting, setIntersecting] = useState(false);
144
+ const rootMargin = options?.rootMargin;
145
+ const threshold = options?.threshold;
146
+
147
+ useEffect(() => {
148
+ const node = ref.current;
149
+ if (node == null || typeof windowOf().IntersectionObserver !== "function") {
150
+ return;
151
+ }
152
+ const observer = new (windowOf().IntersectionObserver)(
153
+ (entries) => {
154
+ const entry = entries[0];
155
+ if (entry != null) {
156
+ setIntersecting(entry.isIntersecting);
157
+ }
158
+ },
159
+ { rootMargin, threshold },
160
+ );
161
+ observer.observe(node);
162
+ return () => observer.disconnect();
163
+ }, [ref, rootMargin, threshold]);
164
+
165
+ return intersecting;
166
+ }
167
+
168
+ function resolve(
169
+ target: Ref<EventTarget> | EventTarget | (() => EventTarget | null) | null,
170
+ ): EventTarget | null {
171
+ if (target == null) {
172
+ return null;
173
+ }
174
+ if (typeof target === "function") {
175
+ return target();
176
+ }
177
+ if (typeof (target as any).addEventListener === "function") {
178
+ return target as any;
179
+ }
180
+ return (target as any).current ?? null;
181
+ }
182
+
183
+ /** A ref for one of the hooks above, typed for the element you will attach it to. */
184
+ export function useElementRef<T extends HTMLElement>(): Ref<T> {
185
+ return useRef<T | null>(null);
186
+ }
@@ -0,0 +1,100 @@
1
+ // @flow
2
+ //
3
+ // The hooks everything else is built out of.
4
+ //
5
+ // The one that matters most is `useStableCallback`. A callback recreated every
6
+ // render is the single most common cause of a React performance problem and of
7
+ // a subscription that tears itself down and sets itself up on every keystroke —
8
+ // and the usual fix, listing the callback in a dependency array, spreads the
9
+ // problem to every hook that takes it. A stable identity that always calls the
10
+ // latest closure fixes it once.
11
+
12
+ import {
13
+ useCallback,
14
+ useEffect,
15
+ useInsertionEffect,
16
+ useLayoutEffect,
17
+ useRef,
18
+ useState,
19
+ } from "@uniflowed/react";
20
+
21
+ /**
22
+ * `useLayoutEffect` in the browser, `useEffect` on the server.
23
+ *
24
+ * uf prerenders every static route, and React warns that `useLayoutEffect`
25
+ * does nothing during a server render — correctly, because there is no layout
26
+ * to read. Every hook here that measures or subscribes uses this, so a hook is
27
+ * not a reason a page cannot be prerendered.
28
+ */
29
+ export const useIsomorphicLayoutEffect: typeof useLayoutEffect =
30
+ typeof globalThis.document === "undefined" ? useEffect : useLayoutEffect;
31
+
32
+ /**
33
+ * A callback whose identity never changes and whose body is always the latest.
34
+ *
35
+ * This is the `useEvent` shape from React's own RFC. The ref is written in an
36
+ * insertion effect rather than in the render, because writing it during render
37
+ * makes the callback's behaviour depend on whether that render was thrown away
38
+ * — and it is written before any layout effect runs, so a subscription set up
39
+ * in one already sees the current body.
40
+ */
41
+ export function useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
42
+ callback: (...args: TArgs) => TReturn,
43
+ ): (...args: TArgs) => TReturn {
44
+ const latest = useRef(callback);
45
+
46
+ useInsertionEffect(() => {
47
+ latest.current = callback;
48
+ }, [callback]);
49
+
50
+ return useCallback((...args: TArgs) => latest.current(...args), []);
51
+ }
52
+
53
+ /** The value from the previous render, or `undefined` on the first. */
54
+ export function usePrevious<T>(value: T): T | void {
55
+ const previous = useRef<T | void>(undefined);
56
+ useEffect(() => {
57
+ previous.current = value;
58
+ }, [value]);
59
+ return previous.current;
60
+ }
61
+
62
+ /**
63
+ * Whether the component has mounted.
64
+ *
65
+ * For the case where a value differs between server and client and rendering
66
+ * the client's on the first pass would be a hydration mismatch: render the
67
+ * server's, then switch.
68
+ */
69
+ export function useMounted(): boolean {
70
+ const [mounted, setMounted] = useState(false);
71
+ useEffect(() => {
72
+ setMounted(true);
73
+ }, []);
74
+ return mounted;
75
+ }
76
+
77
+ /** Run `body` once, after mount. */
78
+ export function useMount(body: () => mixed): void {
79
+ const stable = useStableCallback(body);
80
+ useEffect(() => {
81
+ stable();
82
+ }, [stable]);
83
+ }
84
+
85
+ /** Run `body` once, at unmount. */
86
+ export function useUnmount(body: () => mixed): void {
87
+ const stable = useStableCallback(body);
88
+ useEffect(() => () => void stable(), [stable]);
89
+ }
90
+
91
+ /**
92
+ * Force a re-render.
93
+ *
94
+ * A counter rather than a boolean, because two renders in a row must both
95
+ * change the state or React drops the second.
96
+ */
97
+ export function useRerender(): () => void {
98
+ const [, setTick] = useState(0);
99
+ return useCallback(() => setTick((tick) => tick + 1), []);
100
+ }
@@ -0,0 +1,159 @@
1
+ // @flow
2
+ //
3
+ // State with a shape.
4
+ //
5
+ // `useStorage` is the one worth reading. Persisted state has three problems a
6
+ // `useState` plus a `useEffect` does not solve: the first render on a
7
+ // prerendered page has no storage to read, two components using the same key
8
+ // must agree, and another tab writing the key should be seen. All three are
9
+ // what `useSyncExternalStore` is for.
10
+
11
+ import { useCallback, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
12
+
13
+ import { useStableCallback } from "./lifecycle.js";
14
+
15
+ /** A boolean with the three things a caller ever does to one. */
16
+ export function useToggle(initial: boolean = false): {|
17
+ readonly on: boolean,
18
+ readonly toggle: () => void,
19
+ readonly set: (value: boolean) => void,
20
+ |} {
21
+ const [on, setOn] = useState(initial);
22
+ const toggle = useCallback(() => setOn((value) => !value), []);
23
+ return useMemo(() => ({ on, toggle, set: setOn }), [on, toggle]);
24
+ }
25
+
26
+ /** A number, optionally clamped. */
27
+ export function useCounter(
28
+ initial: number = 0,
29
+ bounds?: {| readonly min?: number, readonly max?: number |},
30
+ ): {|
31
+ readonly count: number,
32
+ readonly increment: (by?: number) => void,
33
+ readonly decrement: (by?: number) => void,
34
+ readonly set: (value: number) => void,
35
+ readonly reset: () => void,
36
+ |} {
37
+ const min = bounds?.min;
38
+ const max = bounds?.max;
39
+
40
+ const clamp = useCallback(
41
+ (value: number) => {
42
+ const lower = min == null ? value : Math.max(min, value);
43
+ return max == null ? lower : Math.min(max, lower);
44
+ },
45
+ [min, max],
46
+ );
47
+
48
+ const [count, setCount] = useState(() => clamp(initial));
49
+ const move = useCallback((delta: number) => setCount((value) => clamp(value + delta)), [clamp]);
50
+
51
+ return useMemo(
52
+ () => ({
53
+ count,
54
+ increment: (by?: number) => move(by ?? 1),
55
+ decrement: (by?: number) => move(-(by ?? 1)),
56
+ set: (value: number) => setCount(clamp(value)),
57
+ reset: () => setCount(clamp(initial)),
58
+ }),
59
+ [count, move, clamp, initial],
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Every subscriber of a storage key, so a write is seen by all of them.
65
+ *
66
+ * A `storage` event does not fire in the tab that made the change, so without
67
+ * this two components sharing a key drift apart until one of them re-renders
68
+ * for an unrelated reason.
69
+ */
70
+ const listeners: Map<string, Set<() => void>> = new Map();
71
+
72
+ function announce(key: string): void {
73
+ for (const listener of listeners.get(key) ?? []) {
74
+ listener();
75
+ }
76
+ }
77
+
78
+ function area(session: boolean): mixed {
79
+ try {
80
+ return session ? globalThis.sessionStorage : globalThis.localStorage;
81
+ } catch {
82
+ // A browser with site data blocked throws on the property itself.
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * State kept in `localStorage`, or in `sessionStorage`.
89
+ *
90
+ * `initial` is what a prerender uses and what an unset or unreadable key falls
91
+ * back to, so the first paint is stated rather than accidental. A value that
92
+ * will not parse is treated as absent rather than thrown: storage is shared
93
+ * with older versions of the same application, and refusing to start because
94
+ * of a stale key would be worse than starting fresh.
95
+ */
96
+ export function useStorage<T>(
97
+ key: string,
98
+ initial: T,
99
+ options?: {| readonly session?: boolean |},
100
+ ): [T, (value: T) => void] {
101
+ const session = options?.session ?? false;
102
+
103
+ const subscribe = useCallback(
104
+ (notify: () => void) => {
105
+ const set = listeners.get(key) ?? new Set();
106
+ set.add(notify);
107
+ listeners.set(key, set);
108
+ const onStorage = (event: mixed) => {
109
+ if ((event as any)?.key === key) {
110
+ notify();
111
+ }
112
+ };
113
+ const win: any = globalThis.window ?? globalThis;
114
+ win.addEventListener?.("storage", onStorage);
115
+ return () => {
116
+ set.delete(notify);
117
+ win.removeEventListener?.("storage", onStorage);
118
+ };
119
+ },
120
+ [key],
121
+ );
122
+
123
+ const raw = useSyncExternalStore(
124
+ subscribe,
125
+ () => {
126
+ const store: any = area(session);
127
+ try {
128
+ return store?.getItem(key) ?? null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ },
133
+ () => null,
134
+ );
135
+
136
+ const value = useMemo(() => {
137
+ if (raw == null) {
138
+ return initial;
139
+ }
140
+ try {
141
+ return JSON.parse(raw);
142
+ } catch {
143
+ return initial;
144
+ }
145
+ }, [raw, initial]);
146
+
147
+ const write = useStableCallback((next: T) => {
148
+ const store: any = area(session);
149
+ try {
150
+ store?.setItem(key, JSON.stringify(next));
151
+ } catch {
152
+ // Full, or blocked. The announcement still happens so the components
153
+ // sharing this key agree with each other for this session.
154
+ }
155
+ announce(key);
156
+ });
157
+
158
+ return [value, write];
159
+ }
@@ -0,0 +1,116 @@
1
+ // @flow
2
+ //
3
+ // 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
+ import { useEffect, useRef, useState } from "@uniflowed/react";
12
+
13
+ import { useStableCallback } from "./lifecycle.js";
14
+
15
+ /**
16
+ * Call `body` every `millis`, or not at all when `millis` is null.
17
+ *
18
+ * Null rather than a separate `enabled` flag because "no interval" and "an
19
+ * interval of nothing" are the same thing, and one argument cannot disagree
20
+ * with itself.
21
+ */
22
+ export function useInterval(body: () => mixed, millis: number | null): void {
23
+ const stable = useStableCallback(body);
24
+ useEffect(() => {
25
+ if (millis == null) {
26
+ return;
27
+ }
28
+ const id = setInterval(stable, millis);
29
+ return () => clearInterval(id);
30
+ }, [stable, millis]);
31
+ }
32
+
33
+ /** Call `body` once after `millis`, or not at all when `millis` is null. */
34
+ export function useTimeout(body: () => mixed, millis: number | null): void {
35
+ const stable = useStableCallback(body);
36
+ useEffect(() => {
37
+ if (millis == null) {
38
+ return;
39
+ }
40
+ const id = setTimeout(stable, millis);
41
+ return () => clearTimeout(id);
42
+ }, [stable, millis]);
43
+ }
44
+
45
+ /**
46
+ * `value`, but only after it has stopped changing for `millis`.
47
+ *
48
+ * The classic use is a search box: the query updates on every keystroke and
49
+ * the request should not.
50
+ */
51
+ export function useDebouncedValue<T>(value: T, millis: number): T {
52
+ const [settled, setSettled] = useState(value);
53
+
54
+ useEffect(() => {
55
+ const id = setTimeout(() => setSettled(value), millis);
56
+ return () => clearTimeout(id);
57
+ }, [value, millis]);
58
+
59
+ return settled;
60
+ }
61
+
62
+ /**
63
+ * A callback that runs at most once per `millis`.
64
+ *
65
+ * Leading edge: the first call goes through immediately and later ones inside
66
+ * the window are dropped, which is what a scroll or resize handler wants —
67
+ * the trailing-edge version would make the first paint late.
68
+ */
69
+ export function useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
70
+ body: (...args: TArgs) => mixed,
71
+ millis: number,
72
+ ): (...args: TArgs) => void {
73
+ const stable = useStableCallback(body);
74
+ const last = useRef(0);
75
+
76
+ return useStableCallback((...args: TArgs) => {
77
+ const now = Date.now();
78
+ if (now - last.current >= millis) {
79
+ last.current = now;
80
+ stable(...args);
81
+ }
82
+ });
83
+ }
84
+
85
+ /**
86
+ * A callback that runs `millis` after the last time it was asked to.
87
+ *
88
+ * Trailing edge, and it cancels itself at unmount — the version people write
89
+ * calls `setState` on a component that is gone.
90
+ */
91
+ export function useDebouncedCallback<TArgs extends $ReadOnlyArray<mixed>>(
92
+ body: (...args: TArgs) => mixed,
93
+ millis: number,
94
+ ): (...args: TArgs) => void {
95
+ const stable = useStableCallback(body);
96
+ const timer = useRef<TimeoutID | null>(null);
97
+
98
+ useEffect(
99
+ () => () => {
100
+ if (timer.current != null) {
101
+ clearTimeout(timer.current);
102
+ }
103
+ },
104
+ [],
105
+ );
106
+
107
+ return useStableCallback((...args: TArgs) => {
108
+ if (timer.current != null) {
109
+ clearTimeout(timer.current);
110
+ }
111
+ timer.current = setTimeout(() => {
112
+ timer.current = null;
113
+ stable(...args);
114
+ }, millis);
115
+ });
116
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@uniflowed/hooks",
3
+ "version": "0.0.0-alpha.2",
4
+ "description": "The React hooks an application writes anyway, prerender-safe, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/hooks"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "internal"
19
+ ],
20
+ "dependencies": {
21
+ "@uniflowed/react": "0.0.0-alpha.2"
22
+ },
23
+ "peerDependencies": {
24
+ "react": ">=19"
25
+ }
26
+ }