@uniflowed/hooks 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,206 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks`: the hooks a React application writes anyway.
4
+ //
5
+ // Every hook here is one that people write by hand in every project and get
6
+ // subtly wrong in the same way each time — a timer that calls a stale closure,
7
+ // a subscription re-established on every keystroke, a slow request overwriting
8
+ // a fast one, persisted state that differs between the server render and the
9
+ // first paint, a shortcut that fires while somebody is typing, a dialog that
10
+ // unlocks the page while a second dialog is still open.
11
+ //
12
+ // VueUse is the benchmark, and most of what it offers is not here on purpose.
13
+ // Its `Reactivity`, `Watch` and `Array` categories exist because Vue's `ref`
14
+ // is a mutable box that has to be wrapped, unwrapped, synced and derived;
15
+ // React has no box, and `useMemo` over a plain array is the whole of
16
+ // `useArrayFilter`. Its `Component` category is Vue's template machinery —
17
+ // `templateRef`, `unrefElement`, `useVModel` — which is `ref` and props here.
18
+ // Porting either would have produced hooks whose only purpose was to look
19
+ // familiar. The "Readiness" section below says what is here, what is
20
+ // deliberately elsewhere in uf, and what is simply not built.
21
+ //
22
+ // # Prerendering is the constraint that shapes the surface
23
+ //
24
+ // uf prerenders every static route, so each of these runs once where there is
25
+ // no `window`. The browser hooks are built on `useSyncExternalStore`, which
26
+ // takes the server's value as a separate argument — so what a prerender sees is
27
+ // *stated* rather than being whatever a `typeof window` check fell through to,
28
+ // and React reads the value when it commits rather than when it renders, which
29
+ // is what stops a media query that changes mid-render from tearing.
30
+ //
31
+ // Where there is no honest default, the caller supplies one: a page that hides
32
+ // its sidebar under 48rem wants `false` on the server and one that renders a
33
+ // mobile menu wants `true`, and a library cannot know which.
34
+ //
35
+ // The hooks built on effects rather than stores — everything in `dom.js`, and
36
+ // the three in `browser.js` whose first value only arrives in a callback — do
37
+ // nothing at all before hydration, and report the same starting value on both
38
+ // sides for that reason. Each module's header says which it is and why.
39
+ //
40
+ // # React's rules are the design, not a constraint on it
41
+ //
42
+ // Nothing here writes during a render, reads a ref during a render, or depends
43
+ // on a render having happened exactly once. Callbacks that cross into effects
44
+ // go through `useStableCallback`, whose ref is written in an insertion effect
45
+ // rather than in the body, so Strict Mode's double render and a render the
46
+ // React Compiler skips are both correct. Every effect's cleanup removes
47
+ // exactly what its body added, which is what makes Strict Mode's
48
+ // mount-unmount-mount balanced — including the module-level counters behind
49
+ // `useScrollLock` and `useStorage`.
50
+ //
51
+ // # How the package is laid out
52
+ //
53
+ // Eight modules beside this one, split by what a hook's subject is — because
54
+ // that is the question a reader looking for one actually asks:
55
+ //
56
+ // - `lifecycle.js` — the component itself: mounted, previous, run once.
57
+ // - `state.js` — a value the component owns, with the operations that suit it:
58
+ // toggle, counter, list, set, cycle, undo/redo, storage.
59
+ // - `timing.js` — when something runs: intervals, timeouts, debounce,
60
+ // throttle, frames, idleness, and the clock behind "3 minutes ago".
61
+ // - `async.js` — one promise: its states, its abort signal, its retries.
62
+ // - `browser.js` — the ambient environment: viewport, scroll, connection,
63
+ // position, permissions, preferences.
64
+ // - `dom.js` — one element the caller holds a ref to: listen, measure,
65
+ // observe, press.
66
+ // - `keyboard.js` — what is being pressed: a chord, and a held key.
67
+ // - `channels.js` — a value that came from outside the page: another tab, the
68
+ // system clipboard.
69
+ //
70
+ // The two that are easiest to confuse are `browser.js` and `dom.js`, so each
71
+ // says so in its own header: `browser.js` needs no ref because there is one
72
+ // browser, and `dom.js` needs one because there are as many answers as there
73
+ // are elements. `keyboard.js` is neither, which is why it is a third file
74
+ // rather than a corner of one of them, and its header says what the four hard
75
+ // parts of a shortcut are.
76
+ //
77
+ // They sit here rather than under an `internal/`, and each has its own
78
+ // subpath. Every name in them is exported from this file, so calling them
79
+ // internal would have described nothing true, and it cost a reader a directory
80
+ // hop to reach the first line of code. `internal/` is for a module consumers
81
+ // must not reach; this package has none.
82
+ //
83
+ // `lifecycle.js` and `browser.js` are the two the others import —
84
+ // `useStableCallback` and `browserWindow` — and both are still subjects rather
85
+ // than bags of shared helpers. A hook goes in `lifecycle.js` because it is
86
+ // about the component's life, never because more than one file wanted it.
87
+ //
88
+ // # Readiness
89
+ //
90
+ // **Implemented and tested.** The component's life; the state shapes; every
91
+ // timer, including the adaptive schedule behind `useTimeAgo`; `useAsync` with
92
+ // abort and retry; media queries, colour scheme, reduced motion, online,
93
+ // document visibility, window size and scroll, scroll lock; element size,
94
+ // intersection, mutations, hover, focus-within, click-outside, long press,
95
+ // element scroll, the element as state; key chords and held keys; storage with
96
+ // cross-tab sync;
97
+ // broadcast channels; the clipboard. `tests/library/hooks.test.js` covers
98
+ // behaviour and cleanup, and `tests/library/hooks-ssr.test.js` renders the
99
+ // whole surface in a process that has no DOM at all.
100
+ //
101
+ // **Experimental.** `useGeolocation`, `useNetwork` and `usePermission`. The
102
+ // shapes are settled and the cleanup is right, but the browsers disagree about
103
+ // them more than the rest of this package does: Network Information is
104
+ // Chromium's alone, the Permissions API rejects rather than answers for names
105
+ // it does not know, and geolocation cannot be exercised end to end in a
106
+ // headless document — the tests cover the unsupported path, the subscription
107
+ // and its teardown, not a real fix. Treat the fields as advisory.
108
+ //
109
+ // **Not implemented, and not planned here.** Everything whose subject is
110
+ // somewhere else in uf: a request with a cache is `@uniflowed/query`, a form
111
+ // field is `@uniflowed/form`, an atom two routes read is `@uniflowed/state`, a
112
+ // rendered instant is `@uniflowed/web`'s `Time`, styling and dark mode are
113
+ // `@uniflowed/stylex`, and a virtual list or an infinite scroller is
114
+ // `@uniflowed/ui`. Beyond those: the device APIs VueUse wraps that a general
115
+ // application does not reach for — Bluetooth, gamepads, USB, speech, wake
116
+ // lock, screen capture, web workers, battery, vibration — are absent rather
117
+ // than shallow. A wrapper over one of those is three lines and a `supported`
118
+ // flag; what makes it worth shipping is knowing the failure modes, and this
119
+ // package does not yet.
120
+
121
+ export type { Async, AsyncOptions } from "./async.js";
122
+ export type {
123
+ BrowserNavigator,
124
+ BrowserWindow,
125
+ EffectiveConnectionType,
126
+ GeolocationReading,
127
+ Geoposition,
128
+ Network,
129
+ NetworkConnection,
130
+ PermissionAnswer,
131
+ PermissionName,
132
+ ScrollOffset,
133
+ Size,
134
+ } from "./browser.js";
135
+ export type { UseBroadcastReturn, UseClipboardReturn } from "./channels.js";
136
+ export type { ListenerOptions, ListenerTarget, MutationOptions, Ref } from "./dom.js";
137
+ export type { KeyComboOptions } from "./keyboard.js";
138
+ export type {
139
+ UseCounterReturn,
140
+ UseCycleReturn,
141
+ UseListReturn,
142
+ UseSetReturn,
143
+ UseToggleReturn,
144
+ UseUndoableReturn,
145
+ } from "./state.js";
146
+
147
+ export { useAsync } from "./async.js";
148
+ export {
149
+ useIsomorphicLayoutEffect,
150
+ useMount,
151
+ useMounted,
152
+ usePrevious,
153
+ useRerender,
154
+ useStableCallback,
155
+ useUnmount,
156
+ } from "./lifecycle.js";
157
+ export {
158
+ useAnimationFrame,
159
+ useDebouncedCallback,
160
+ useDebouncedValue,
161
+ useIdle,
162
+ useInterval,
163
+ useNow,
164
+ useThrottledCallback,
165
+ useTimeAgo,
166
+ useTimeout,
167
+ } from "./timing.js";
168
+ export {
169
+ browserWindow,
170
+ useDocumentVisible,
171
+ useGeolocation,
172
+ useMediaQuery,
173
+ useNetwork,
174
+ useOnline,
175
+ usePermission,
176
+ usePreferredColorScheme,
177
+ usePrefersReducedMotion,
178
+ useScrollLock,
179
+ useSupported,
180
+ useWindowScroll,
181
+ useWindowSize,
182
+ } from "./browser.js";
183
+ export {
184
+ useClickOutside,
185
+ useElementRef,
186
+ useElementSize,
187
+ useElementState,
188
+ useEventListener,
189
+ useFocusWithin,
190
+ useHover,
191
+ useIntersecting,
192
+ useLongPress,
193
+ useMutationObserver,
194
+ useScroll,
195
+ } from "./dom.js";
196
+ export { useKeyCombo, useKeyHeld } from "./keyboard.js";
197
+ export { useBroadcast, useClipboard } from "./channels.js";
198
+ export {
199
+ useCounter,
200
+ useCycle,
201
+ useList,
202
+ useSet,
203
+ useStorage,
204
+ useToggle,
205
+ useUndoable,
206
+ } from "./state.js";
package/keyboard.js ADDED
@@ -0,0 +1,328 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks/keyboard`: a chord is not an event.
4
+ //
5
+ // This is the third subject in the package that is neither one element nor the
6
+ // ambient environment, and it earns a file because none of the difficulty is
7
+ // in the listening. `document.addEventListener("keydown", …)` is one line; the
8
+ // four things after it are what people get wrong, and they get them wrong the
9
+ // same way in every project:
10
+ //
11
+ // **A combination is not a key.** `⌘K` is a `keydown` whose `key` is `"k"` and
12
+ // whose `metaKey` is true, and a handler that checks only the first fires on a
13
+ // plain `k` — so a search box opens while somebody is typing a name. Checking
14
+ // the modifiers that *are* named is half of it; refusing the ones that are not
15
+ // is the other half, and it is the half that gets left out.
16
+ //
17
+ // **`mod` is not `ctrl`.** The shortcut is ⌘K on a Mac and Ctrl+K everywhere
18
+ // else. A library that makes the caller write two bindings makes every caller
19
+ // write the same platform test.
20
+ //
21
+ // **A shortcut inside a text field is a keystroke.** `?` should open help,
22
+ // except while somebody is typing a question mark into a comment. The default
23
+ // here is that a chord does not fire while the reader is typing, and a caller
24
+ // who means it to says so.
25
+ //
26
+ // **A held key repeats.** Holding a key sends `keydown` twenty times a second.
27
+ // A shortcut that opens a dialog should open it once.
28
+ //
29
+ // # What belongs in this module
30
+ //
31
+ // A hook whose subject is what is being pressed: a chord, and whether a key is
32
+ // down. Not the key events of one element — `useEventListener(ref, "keydown",
33
+ // …)` in `dom.js` is that, and it is the right tool when the subject really is
34
+ // the element. These listen on the document by default, because a shortcut
35
+ // belongs to the page rather than to whatever happens to have focus.
36
+ //
37
+ // # Before hydration
38
+ //
39
+ // Nothing. Both hooks do their work in effects, `useKeyHeld` reports `false`
40
+ // on a server and in the first client render, and the platform test that
41
+ // decides what `mod` means is made inside the effect rather than during a
42
+ // render — so nothing here can differ between the two passes React compares.
43
+
44
+ import { useEffect, useState } from "@uniflowed/react";
45
+
46
+ import { browserWindow } from "./browser.js";
47
+ import type { Ref } from "./dom.js";
48
+ import { useStableCallback } from "./lifecycle.js";
49
+
50
+ /** A combination, once its spelling has been resolved. */
51
+ type Chord = {|
52
+ readonly key: string,
53
+ readonly ctrl: boolean,
54
+ readonly meta: boolean,
55
+ readonly alt: boolean,
56
+ readonly shift: boolean,
57
+ /** Ctrl, or Command on Apple platforms. */
58
+ readonly mod: boolean,
59
+ |};
60
+
61
+ /**
62
+ * The spellings people actually write, mapped to the one `KeyboardEvent.key`
63
+ * uses.
64
+ *
65
+ * `key` reports the character produced, so the arrows are `"ArrowUp"` and the
66
+ * space bar is a literal space — neither of which anybody writes in a
67
+ * shortcut.
68
+ */
69
+ const KEY_ALIASES: { readonly [string]: string } = {
70
+ esc: "escape",
71
+ space: " ",
72
+ spacebar: " ",
73
+ ret: "enter",
74
+ return: "enter",
75
+ up: "arrowup",
76
+ down: "arrowdown",
77
+ left: "arrowleft",
78
+ right: "arrowright",
79
+ del: "delete",
80
+ plus: "+",
81
+ };
82
+
83
+ /**
84
+ * Read `"mod+shift+k"` into a chord.
85
+ *
86
+ * Unknown modifier names are treated as the key rather than rejected: a typo
87
+ * produces a shortcut that never fires, which the author notices, and a throw
88
+ * during a render would take the page down instead.
89
+ */
90
+ function parseChord(combo: string): Chord {
91
+ let ctrl = false;
92
+ let meta = false;
93
+ let alt = false;
94
+ let shift = false;
95
+ let mod = false;
96
+ let key = "";
97
+
98
+ for (const raw of combo.split("+")) {
99
+ const part = raw.trim().toLowerCase();
100
+ if (part === "") {
101
+ // `"shift++"` is shift and the plus key, and splitting leaves a hole.
102
+ key = "+";
103
+ } else if (part === "ctrl" || part === "control") {
104
+ ctrl = true;
105
+ } else if (part === "cmd" || part === "command" || part === "meta" || part === "super") {
106
+ meta = true;
107
+ } else if (part === "alt" || part === "opt" || part === "option") {
108
+ alt = true;
109
+ } else if (part === "shift") {
110
+ shift = true;
111
+ } else if (part === "mod") {
112
+ mod = true;
113
+ } else {
114
+ key = KEY_ALIASES[part] ?? part;
115
+ }
116
+ }
117
+
118
+ return { key, ctrl, meta, alt, shift, mod };
119
+ }
120
+
121
+ /**
122
+ * Whether `mod` means Command here.
123
+ *
124
+ * Read from the user agent because that is the only thing every browser
125
+ * agrees on: `navigator.platform` is deprecated and frozen, and
126
+ * `userAgentData` exists in Chromium alone. Called from inside an effect, so a
127
+ * server render never asks.
128
+ */
129
+ function onApple(): boolean {
130
+ const agent = browserWindow()?.navigator.userAgent ?? "";
131
+ return /mac|iphone|ipad|ipod/i.test(agent);
132
+ }
133
+
134
+ /** Whether the event landed in something the reader is typing into. */
135
+ function typing(target: EventTarget): boolean {
136
+ if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) {
137
+ return false;
138
+ }
139
+ if (target.isContentEditable) {
140
+ return true;
141
+ }
142
+ const tag = target.tagName.toLowerCase();
143
+ return tag === "input" || tag === "textarea" || tag === "select";
144
+ }
145
+
146
+ /**
147
+ * Whether a key is one that shift is needed to type.
148
+ *
149
+ * A single character that is neither a letter nor a digit: `"?"`, `"+"`,
150
+ * `"!"`. `KeyboardEvent.key` reports the character *produced*, so those arrive
151
+ * with `shiftKey` true on a US layout and false on layouts where they have
152
+ * their own key.
153
+ */
154
+ function shiftedSymbol(key: string): boolean {
155
+ return key.length === 1 && !/[a-z0-9]/.test(key);
156
+ }
157
+
158
+ /**
159
+ * Whether `event` is this chord.
160
+ *
161
+ * The modifiers named must be down and the ones not named must be up. That
162
+ * second half is what makes `"mod+k"` refuse Ctrl+Shift+K, which is a
163
+ * different shortcut somebody else has probably bound.
164
+ *
165
+ * Shift is the one exception, and only for a key that shift is needed to
166
+ * type: `"?"` is the most common single-key shortcut on the web and arrives
167
+ * with `shiftKey` true on a US layout and false on a German one, so requiring
168
+ * either would make it unbindable on half the keyboards in the world. For a
169
+ * letter, a digit or a named key it is checked like the rest.
170
+ */
171
+ function isChord(chord: Chord, event: KeyboardEvent, apple: boolean): boolean {
172
+ if (event.key.toLowerCase() !== chord.key) {
173
+ return false;
174
+ }
175
+ const meta = chord.meta || (chord.mod && apple);
176
+ const ctrl = chord.ctrl || (chord.mod && !apple);
177
+ if (event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== chord.alt) {
178
+ return false;
179
+ }
180
+ if (chord.shift) {
181
+ return event.shiftKey;
182
+ }
183
+ return shiftedSymbol(chord.key) || !event.shiftKey;
184
+ }
185
+
186
+ /** `event` as a keyboard event, or `null` for anything else. */
187
+ function asKeyboardEvent(event: Event): KeyboardEvent | null {
188
+ if (typeof KeyboardEvent === "undefined" || !(event instanceof KeyboardEvent)) {
189
+ return null;
190
+ }
191
+ return event;
192
+ }
193
+
194
+ /** How a chord is listened for. */
195
+ export type KeyComboOptions = {|
196
+ /** Listen on this element instead of the document. */
197
+ readonly target?: Ref<HTMLElement> | null,
198
+ /** Fire even while the reader is typing into a field. Off by default. */
199
+ readonly whileTyping?: boolean,
200
+ /** Fire again while the key is held down. Off by default. */
201
+ readonly repeat?: boolean,
202
+ /** Call `preventDefault` when it fires. On by default, since ⌘K is the browser's too. */
203
+ readonly preventDefault?: boolean,
204
+ /** Turn the binding off without changing where the hook is called. */
205
+ readonly enabled?: boolean,
206
+ |};
207
+
208
+ /**
209
+ * Call `handler` when a key combination is pressed.
210
+ *
211
+ * ```js
212
+ * useKeyCombo("mod+k", () => setSearchOpen(true));
213
+ * useKeyCombo("escape", close, { whileTyping: true });
214
+ * ```
215
+ *
216
+ * `preventDefault` defaults to on because the combinations worth binding are
217
+ * the ones the browser also wants — ⌘K is the address bar in Chrome, ⌘S is
218
+ * Save Page — and a shortcut that fires *and* opens a browser dialog is worse
219
+ * than either alone. A chord that is only the application's, like `escape`,
220
+ * loses nothing by it.
221
+ */
222
+ export hook useKeyCombo(
223
+ combo: string,
224
+ handler: (event: KeyboardEvent) => mixed,
225
+ options?: KeyComboOptions,
226
+ ): void {
227
+ const stable = useStableCallback(handler);
228
+ const target = options?.target ?? null;
229
+ const whileTyping = options?.whileTyping ?? false;
230
+ const repeat = options?.repeat ?? false;
231
+ const preventDefault = options?.preventDefault ?? true;
232
+ const enabled = options?.enabled ?? true;
233
+
234
+ useEffect(() => {
235
+ const win = browserWindow();
236
+ if (!enabled || win == null) {
237
+ return;
238
+ }
239
+ // A ref that is given and empty means the element is not there yet, which
240
+ // is not the same as "no element was asked for": falling back to the
241
+ // document would bind the shortcut to the whole page by accident.
242
+ const node = target == null ? win.document : target.current;
243
+ if (node == null) {
244
+ return;
245
+ }
246
+
247
+ const chord = parseChord(combo);
248
+ const apple = onApple();
249
+
250
+ const listener = (event: Event) => {
251
+ const key = asKeyboardEvent(event);
252
+ if (key == null || (!repeat && key.repeat)) {
253
+ return;
254
+ }
255
+ if (!whileTyping && typing(key.target)) {
256
+ return;
257
+ }
258
+ if (!isChord(chord, key, apple)) {
259
+ return;
260
+ }
261
+ if (preventDefault) {
262
+ key.preventDefault();
263
+ }
264
+ stable(key);
265
+ };
266
+
267
+ node.addEventListener("keydown", listener);
268
+ return () => node.removeEventListener("keydown", listener);
269
+ }, [combo, enabled, target, whileTyping, repeat, preventDefault, stable]);
270
+ }
271
+
272
+ /**
273
+ * Whether a key is being held down.
274
+ *
275
+ * For the case a chord cannot express: a modifier that changes what a drag
276
+ * does while it is held, a space bar that pans a canvas. `key` is matched
277
+ * against `KeyboardEvent.key`, case-insensitively, so `"shift"`, `"escape"`
278
+ * and `" "` all work.
279
+ *
280
+ * The `blur` reset is the reason this is a hook rather than two listeners.
281
+ * Holding a key and switching windows sends the `keyup` to the other window,
282
+ * so a hand-written version leaves the key held forever — the canvas stays
283
+ * panning after the reader comes back. Losing focus releases everything.
284
+ */
285
+ export hook useKeyHeld(
286
+ key: string,
287
+ options?: {| readonly target?: Ref<HTMLElement> | null |},
288
+ ): boolean {
289
+ const [held, setHeld] = useState(false);
290
+ const target = options?.target ?? null;
291
+ const wanted = key.toLowerCase();
292
+
293
+ useEffect(() => {
294
+ const win = browserWindow();
295
+ if (win == null) {
296
+ return;
297
+ }
298
+ const node = target == null ? win.document : target.current;
299
+ if (node == null) {
300
+ return;
301
+ }
302
+
303
+ const down = (event: Event) => {
304
+ const pressed = asKeyboardEvent(event);
305
+ if (pressed != null && pressed.key.toLowerCase() === wanted) {
306
+ setHeld(true);
307
+ }
308
+ };
309
+ const up = (event: Event) => {
310
+ const released = asKeyboardEvent(event);
311
+ if (released != null && released.key.toLowerCase() === wanted) {
312
+ setHeld(false);
313
+ }
314
+ };
315
+ const release = () => setHeld(false);
316
+
317
+ node.addEventListener("keydown", down);
318
+ node.addEventListener("keyup", up);
319
+ win.addEventListener("blur", release);
320
+ return () => {
321
+ node.removeEventListener("keydown", down);
322
+ node.removeEventListener("keyup", up);
323
+ win.removeEventListener("blur", release);
324
+ };
325
+ }, [wanted, target]);
326
+
327
+ return held;
328
+ }
package/lifecycle.js ADDED
@@ -0,0 +1,114 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks/lifecycle`: where a component is in its life.
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
+ // # What belongs in this module
13
+ //
14
+ // A hook whose subject is the component itself: has it mounted, is it still
15
+ // mounted, what did it render last time, run this once, run this on the way
16
+ // out, and the two effect-shaped primitives the rest of the package needs
17
+ // (`useIsomorphicLayoutEffect`, `useStableCallback`). None of them reads
18
+ // anything outside React.
19
+ //
20
+ // It is also the one module here the others import, which is a consequence and
21
+ // not the reason: `timing.js`, `state.js` and `dom.js` all need a stable
22
+ // callback. That does not make this a "utils" or a "common" — it has a subject
23
+ // of its own, and a hook lands here because it is about the component's life,
24
+ // never because two other files happened to want it.
25
+
26
+ import {
27
+ useCallback,
28
+ useEffect,
29
+ useInsertionEffect,
30
+ useLayoutEffect,
31
+ useRef,
32
+ useState,
33
+ } from "@uniflowed/react";
34
+
35
+ /**
36
+ * `useLayoutEffect` in the browser, `useEffect` on the server.
37
+ *
38
+ * uf prerenders every static route, and React warns that `useLayoutEffect`
39
+ * does nothing during a server render — correctly, because there is no layout
40
+ * to read. Every hook here that measures or subscribes uses this, so a hook is
41
+ * not a reason a page cannot be prerendered.
42
+ */
43
+ export const useIsomorphicLayoutEffect: typeof useLayoutEffect =
44
+ typeof globalThis.document === "undefined" ? useEffect : useLayoutEffect;
45
+
46
+ /**
47
+ * A callback whose identity never changes and whose body is always the latest.
48
+ *
49
+ * This is the `useEvent` shape from React's own RFC. The ref is written in an
50
+ * insertion effect rather than in the render, because writing it during render
51
+ * makes the callback's behaviour depend on whether that render was thrown away
52
+ * — and it is written before any layout effect runs, so a subscription set up
53
+ * in one already sees the current body.
54
+ */
55
+ export hook useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
56
+ callback: (...args: TArgs) => TReturn,
57
+ ): (...args: TArgs) => TReturn {
58
+ const latest = useRef(callback);
59
+
60
+ useInsertionEffect(() => {
61
+ latest.current = callback;
62
+ }, [callback]);
63
+
64
+ return useCallback((...args: TArgs) => latest.current(...args), []);
65
+ }
66
+
67
+ /** The value from the previous render, or `undefined` on the first. */
68
+ export hook usePrevious<T>(value: T): T | void {
69
+ const previous = useRef<T | void>(undefined);
70
+ useEffect(() => {
71
+ previous.current = value;
72
+ }, [value]);
73
+ return previous.current;
74
+ }
75
+
76
+ /**
77
+ * Whether the component has mounted.
78
+ *
79
+ * For the case where a value differs between server and client and rendering
80
+ * the client's on the first pass would be a hydration mismatch: render the
81
+ * server's, then switch.
82
+ */
83
+ export hook useMounted(): boolean {
84
+ const [mounted, setMounted] = useState(false);
85
+ useEffect(() => {
86
+ setMounted(true);
87
+ }, []);
88
+ return mounted;
89
+ }
90
+
91
+ /** Run `body` once, after mount. */
92
+ export hook useMount(body: () => mixed): void {
93
+ const stable = useStableCallback(body);
94
+ useEffect(() => {
95
+ stable();
96
+ }, [stable]);
97
+ }
98
+
99
+ /** Run `body` once, at unmount. */
100
+ export hook useUnmount(body: () => mixed): void {
101
+ const stable = useStableCallback(body);
102
+ useEffect(() => () => void stable(), [stable]);
103
+ }
104
+
105
+ /**
106
+ * Force a re-render.
107
+ *
108
+ * A counter rather than a boolean, because two renders in a row must both
109
+ * change the state or React drops the second.
110
+ */
111
+ export hook useRerender(): () => void {
112
+ const [, setTick] = useState(0);
113
+ return useCallback(() => setTick((tick) => tick + 1), []);
114
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@uniflowed/hooks",
3
+ "version": "0.0.0-alpha.10",
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
+ "./async": "./async.js",
16
+ "./browser": "./browser.js",
17
+ "./channels": "./channels.js",
18
+ "./dom": "./dom.js",
19
+ "./keyboard": "./keyboard.js",
20
+ "./lifecycle": "./lifecycle.js",
21
+ "./state": "./state.js",
22
+ "./timing": "./timing.js"
23
+ },
24
+ "files": [
25
+ "*.js"
26
+ ],
27
+ "dependencies": {
28
+ "@uniflowed/react": "0.0.0-alpha.10"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=19"
32
+ }
33
+ }