@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/state.js ADDED
@@ -0,0 +1,520 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks/state`: 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
+ // # What belongs in this module
12
+ //
13
+ // A `useState` a component would otherwise write out by hand, returned as the
14
+ // operations that make sense on it rather than as a setter: a boolean with
15
+ // `toggle`, a number with `increment` and a clamp, a list with the six edits
16
+ // anyone ever makes to one, a set with `toggle`, a position in a cycle, a value
17
+ // that can be undone, a value that survives a reload. The test is that the hook
18
+ // owns the value and hands back a small API over it.
19
+ //
20
+ // Every operation here produces a new value rather than editing the one it was
21
+ // given, and an operation that would change nothing returns the *same* value —
22
+ // removing an index that is not there, adding a member that is already in the
23
+ // set. That is not thrift: `useState` compares with `Object.is`, so returning
24
+ // the old value is what makes a no-op cost no render.
25
+ //
26
+ // Not here: shared application state. An atom two routes both read is
27
+ // `@uniflowed/state`'s, and a value derived from a server response is
28
+ // `@uniflowed/query`'s. A value that arrives from outside the page — another
29
+ // tab, the system clipboard — is `channels.js`, one file over. Everything in
30
+ // this file is local to one component; `useStorage` reaches outside only to
31
+ // persist, and only under a key the caller named.
32
+
33
+ import { useCallback, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
34
+
35
+ import { browserWindow } from "./browser.js";
36
+ import { useStableCallback } from "./lifecycle.js";
37
+
38
+ /** A boolean and the three things a caller ever does to one. */
39
+ export type UseToggleReturn = {|
40
+ readonly on: boolean,
41
+ readonly toggle: () => void,
42
+ readonly set: (value: boolean) => void,
43
+ |};
44
+
45
+ /** A boolean with the three things a caller ever does to one. */
46
+ export hook useToggle(initial: boolean = false): UseToggleReturn {
47
+ const [on, setOn] = useState(initial);
48
+ const toggle = useCallback(() => setOn((value) => !value), []);
49
+ return useMemo(() => ({ on, toggle, set: setOn }), [on, toggle]);
50
+ }
51
+
52
+ /** A number and the operations that suit one. */
53
+ export type UseCounterReturn = {|
54
+ readonly count: number,
55
+ readonly increment: (by?: number) => void,
56
+ readonly decrement: (by?: number) => void,
57
+ readonly set: (value: number) => void,
58
+ readonly reset: () => void,
59
+ |};
60
+
61
+ /** A number, optionally clamped. */
62
+ export hook useCounter(
63
+ initial: number = 0,
64
+ bounds?: {| readonly min?: number, readonly max?: number |},
65
+ ): UseCounterReturn {
66
+ const min = bounds?.min;
67
+ const max = bounds?.max;
68
+
69
+ const clamp = useCallback(
70
+ (value: number) => {
71
+ const lower = min == null ? value : Math.max(min, value);
72
+ return max == null ? lower : Math.min(max, lower);
73
+ },
74
+ [min, max],
75
+ );
76
+
77
+ const [count, setCount] = useState(() => clamp(initial));
78
+ const move = useCallback((delta: number) => setCount((value) => clamp(value + delta)), [clamp]);
79
+
80
+ return useMemo(
81
+ () => ({
82
+ count,
83
+ increment: (by?: number) => move(by ?? 1),
84
+ decrement: (by?: number) => move(-(by ?? 1)),
85
+ set: (value: number) => setCount(clamp(value)),
86
+ reset: () => setCount(clamp(initial)),
87
+ }),
88
+ [count, move, clamp, initial],
89
+ );
90
+ }
91
+
92
+ /** A list and the edits anyone makes to one. */
93
+ export type UseListReturn<T> = {|
94
+ readonly items: $ReadOnlyArray<T>,
95
+ readonly set: (items: $ReadOnlyArray<T>) => void,
96
+ readonly push: (item: T) => void,
97
+ readonly insertAt: (index: number, item: T) => void,
98
+ readonly replaceAt: (index: number, item: T) => void,
99
+ readonly removeAt: (index: number) => void,
100
+ readonly move: (from: number, to: number) => void,
101
+ readonly clear: () => void,
102
+ |};
103
+
104
+ /**
105
+ * A list, with the six edits anyone ever makes to one.
106
+ *
107
+ * An index outside the list is not an error and not a throw: it leaves the
108
+ * list alone and returns the same array, so a row removed twice by a
109
+ * double-clicked button is removed once. `@uniflowed/form`'s `useFieldArray`
110
+ * is the version of this for form rows, and knows about keys, errors and
111
+ * dirty flags; this one is for a list that is only a list.
112
+ */
113
+ export hook useList<T>(initial: $ReadOnlyArray<T> = []): UseListReturn<T> {
114
+ const [items, setItems] = useState<$ReadOnlyArray<T>>(initial);
115
+
116
+ const set = useStableCallback((next: $ReadOnlyArray<T>) => setItems(next));
117
+
118
+ const push = useStableCallback((item: T) => setItems((current) => [...current, item]));
119
+
120
+ const insertAt = useStableCallback((index: number, item: T) =>
121
+ setItems((current) =>
122
+ index < 0 || index > current.length
123
+ ? current
124
+ : [...current.slice(0, index), item, ...current.slice(index)],
125
+ ),
126
+ );
127
+
128
+ const replaceAt = useStableCallback((index: number, item: T) =>
129
+ setItems((current) =>
130
+ index < 0 || index >= current.length
131
+ ? current
132
+ : current.map((existing, at) => (at === index ? item : existing)),
133
+ ),
134
+ );
135
+
136
+ const removeAt = useStableCallback((index: number) =>
137
+ setItems((current) =>
138
+ index < 0 || index >= current.length ? current : current.filter((_, at) => at !== index),
139
+ ),
140
+ );
141
+
142
+ const move = useStableCallback((from: number, to: number) =>
143
+ setItems((current) => {
144
+ if (from < 0 || from >= current.length || to < 0 || to >= current.length || from === to) {
145
+ return current;
146
+ }
147
+ const next = [...current];
148
+ const [moved] = next.splice(from, 1);
149
+ next.splice(to, 0, moved);
150
+ return next;
151
+ }),
152
+ );
153
+
154
+ const clear = useStableCallback(() =>
155
+ setItems((current) => (current.length === 0 ? current : [])),
156
+ );
157
+
158
+ return useMemo(
159
+ () => ({ items, set, push, insertAt, replaceAt, removeAt, move, clear }),
160
+ [items, set, push, insertAt, replaceAt, removeAt, move, clear],
161
+ );
162
+ }
163
+
164
+ /** A set of members, and the questions asked of one. */
165
+ export type UseSetReturn<T> = {|
166
+ readonly items: $ReadOnlySet<T>,
167
+ readonly has: (item: T) => boolean,
168
+ readonly add: (item: T) => void,
169
+ readonly remove: (item: T) => void,
170
+ readonly toggle: (item: T) => void,
171
+ readonly clear: () => void,
172
+ readonly set: (items: Iterable<T>) => void,
173
+ |};
174
+
175
+ /**
176
+ * A set, which is what a multi-select or a list of expanded rows actually is.
177
+ *
178
+ * The `Set` is replaced rather than mutated on every change, because a `Set`
179
+ * edited in place is the same object and React would not re-render — the bug
180
+ * people meet the first time they put a collection in `useState`.
181
+ */
182
+ export hook useSet<T>(initial?: Iterable<T>): UseSetReturn<T> {
183
+ const [items, setItems] = useState<$ReadOnlySet<T>>(() => new Set(initial));
184
+
185
+ // `useCallback` rather than `useStableCallback`, and the difference is a bug
186
+ // this hook had: a stable callback's body is installed in an insertion
187
+ // effect, which runs *after* the render that produced it — so a render
188
+ // asking `has(item)` about the set it is currently displaying would be
189
+ // answered from the previous one. A stable identity is for a callback that
190
+ // crosses into an effect or an event handler; a question a render asks has
191
+ // to change when the answer does.
192
+ const has = useCallback((item: T) => items.has(item), [items]);
193
+
194
+ const add = useStableCallback((item: T) =>
195
+ setItems((current) => (current.has(item) ? current : new Set(current).add(item))),
196
+ );
197
+
198
+ const remove = useStableCallback((item: T) =>
199
+ setItems((current) => {
200
+ if (!current.has(item)) {
201
+ return current;
202
+ }
203
+ const next = new Set(current);
204
+ next.delete(item);
205
+ return next;
206
+ }),
207
+ );
208
+
209
+ const toggle = useStableCallback((item: T) =>
210
+ setItems((current) => {
211
+ const next = new Set(current);
212
+ if (!next.delete(item)) {
213
+ next.add(item);
214
+ }
215
+ return next;
216
+ }),
217
+ );
218
+
219
+ const clear = useStableCallback(() =>
220
+ setItems((current) => (current.size === 0 ? current : new Set())),
221
+ );
222
+
223
+ const set = useStableCallback((next: Iterable<T>) => setItems(new Set(next)));
224
+
225
+ return useMemo(
226
+ () => ({ items, has, add, remove, toggle, clear, set }),
227
+ [items, has, add, remove, toggle, clear, set],
228
+ );
229
+ }
230
+
231
+ /** A position in a list that wraps. */
232
+ export type UseCycleReturn<T> = {|
233
+ /** The value at the current position, or `null` when the list is empty. */
234
+ readonly value: T | null,
235
+ readonly index: number,
236
+ readonly next: () => void,
237
+ readonly previous: () => void,
238
+ readonly go: (index: number) => void,
239
+ |};
240
+
241
+ /**
242
+ * Step through a list, wrapping at both ends.
243
+ *
244
+ * A theme switcher, a carousel, a sort order that cycles. The counter behind
245
+ * this is unbounded and the position is worked out from it on each render, so
246
+ * `values` may change length between renders without the position becoming
247
+ * invalid — and `values` is deliberately not a dependency of anything, so
248
+ * writing the list inline in the call is free.
249
+ *
250
+ * `value` is `T | null` rather than `T` because an empty list has no current
251
+ * value. Flow's array access would happily have said `T` and handed back an
252
+ * `undefined` at runtime; this package does not claim what it cannot show.
253
+ */
254
+ export hook useCycle<T>(values: $ReadOnlyArray<T>, initialIndex: number = 0): UseCycleReturn<T> {
255
+ const [raw, setRaw] = useState(initialIndex);
256
+
257
+ const next = useStableCallback(() => setRaw((current) => current + 1));
258
+ const previous = useStableCallback(() => setRaw((current) => current - 1));
259
+ const go = useStableCallback((index: number) => setRaw(index));
260
+
261
+ const length = values.length;
262
+ // Two modulos, because JavaScript's `%` keeps the sign of its left operand
263
+ // and `previous()` from position zero would otherwise be -1.
264
+ const index = length === 0 ? -1 : ((raw % length) + length) % length;
265
+ const value = index < 0 ? null : values[index];
266
+
267
+ return useMemo(() => ({ value, index, next, previous, go }), [value, index, next, previous, go]);
268
+ }
269
+
270
+ /** A value with the history behind and ahead of it. */
271
+ export type UseUndoableReturn<T> = {|
272
+ readonly value: T,
273
+ readonly set: (next: T) => void,
274
+ readonly undo: () => void,
275
+ readonly redo: () => void,
276
+ readonly canUndo: boolean,
277
+ readonly canRedo: boolean,
278
+ /** Keep the current value, forget how it got here. */
279
+ readonly clear: () => void,
280
+ /** Back to the value the hook started with, history and all. */
281
+ readonly reset: () => void,
282
+ |};
283
+
284
+ /** The three parts of an undo stack, kept in one state so they cannot disagree. */
285
+ type Timeline<T> = {|
286
+ readonly past: $ReadOnlyArray<T>,
287
+ readonly present: T,
288
+ readonly future: $ReadOnlyArray<T>,
289
+ |};
290
+
291
+ /**
292
+ * A value that can be undone and redone.
293
+ *
294
+ * One `useState` holding all three parts, not three: past, present and future
295
+ * change together, and three separate states would be three renders and a
296
+ * window in which they disagree.
297
+ *
298
+ * `set` clears the future, which is what every editor does — typing after an
299
+ * undo abandons what was undone. `limit` bounds the past so that a long
300
+ * editing session does not hold every version of a large value alive; the
301
+ * oldest entries are dropped, and `canUndo` stops being true when they run
302
+ * out.
303
+ */
304
+ export hook useUndoable<T>(
305
+ initial: T,
306
+ options?: {| readonly limit?: number |},
307
+ ): UseUndoableReturn<T> {
308
+ const limit = options?.limit ?? 100;
309
+ const [timeline, setTimeline] = useState<Timeline<T>>({
310
+ past: [],
311
+ present: initial,
312
+ future: [],
313
+ });
314
+
315
+ const set = useStableCallback((next: T) =>
316
+ setTimeline((current) => {
317
+ if (Object.is(current.present, next)) {
318
+ return current;
319
+ }
320
+ const past = [...current.past, current.present];
321
+ return {
322
+ past: past.length > limit ? past.slice(past.length - limit) : past,
323
+ present: next,
324
+ future: [],
325
+ };
326
+ }),
327
+ );
328
+
329
+ const undo = useStableCallback(() =>
330
+ setTimeline((current) => {
331
+ const previous = current.past[current.past.length - 1];
332
+ if (current.past.length === 0) {
333
+ return current;
334
+ }
335
+ return {
336
+ past: current.past.slice(0, -1),
337
+ present: previous,
338
+ future: [current.present, ...current.future],
339
+ };
340
+ }),
341
+ );
342
+
343
+ const redo = useStableCallback(() =>
344
+ setTimeline((current) => {
345
+ const [ahead, ...rest] = current.future;
346
+ if (current.future.length === 0) {
347
+ return current;
348
+ }
349
+ return { past: [...current.past, current.present], present: ahead, future: rest };
350
+ }),
351
+ );
352
+
353
+ const clear = useStableCallback(() =>
354
+ setTimeline((current) =>
355
+ current.past.length === 0 && current.future.length === 0
356
+ ? current
357
+ : { past: [], present: current.present, future: [] },
358
+ ),
359
+ );
360
+
361
+ const reset = useStableCallback(() => setTimeline({ past: [], present: initial, future: [] }));
362
+
363
+ return useMemo(
364
+ () => ({
365
+ value: timeline.present,
366
+ set,
367
+ undo,
368
+ redo,
369
+ canUndo: timeline.past.length > 0,
370
+ canRedo: timeline.future.length > 0,
371
+ clear,
372
+ reset,
373
+ }),
374
+ [timeline, set, undo, redo, clear, reset],
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Every subscriber of a storage key, so a write is seen by all of them.
380
+ *
381
+ * A `storage` event does not fire in the tab that made the change, so without
382
+ * this two components sharing a key drift apart until one of them re-renders
383
+ * for an unrelated reason.
384
+ */
385
+ const listeners: Map<string, Set<() => void>> = new Map();
386
+
387
+ function announce(key: string): void {
388
+ for (const listener of listeners.get(key) ?? []) {
389
+ listener();
390
+ }
391
+ }
392
+
393
+ /**
394
+ * `localStorage` or `sessionStorage`, or `null` where neither is readable.
395
+ *
396
+ * # Why this guard is written twice
397
+ *
398
+ * `@uniflowed/state`'s `createJSONStorage`
399
+ * (`packages/state/internal/composed.js`) guards the same four hazards — a
400
+ * storage property that throws, a read that throws, a write that throws, and
401
+ * finding the object a `storage` event arrives on — and reaches the same
402
+ * conclusions about each. Merging the two was considered and declined;
403
+ * ubugeeei-prod/uf#318 is the issue, and this is half of the decision. The other half is
404
+ * in `createJSONStorage`, which carries the argument in full.
405
+ *
406
+ * In short: the helper would have to live in a package both may depend on,
407
+ * `@uniflowed/web` is the only candidate, and it is not on npm while this
408
+ * package is — so the edge would make `npm install @uniflowed/hooks` answer
409
+ * `ETARGET`. `tools/ci/publishable.sh` refuses it now.
410
+ *
411
+ * What is *not* shared even in principle is the listener registry above. Two
412
+ * components reading one key in one document have to agree, so a write here
413
+ * announces itself; `createJSONStorage` deliberately does not announce, so
414
+ * that two stores in one process stay two stores. A shared helper would have
415
+ * had to leave that decision to its caller, which is most of what there was
416
+ * to share.
417
+ */
418
+ function area(session: boolean): Storage | null {
419
+ const win = browserWindow();
420
+ if (win == null) {
421
+ return null;
422
+ }
423
+ try {
424
+ return (session ? win.sessionStorage : win.localStorage) ?? null;
425
+ } catch {
426
+ // A browser with site data blocked throws on the property itself.
427
+ return null;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * State kept in `localStorage`, or in `sessionStorage`.
433
+ *
434
+ * `initial` is what a prerender uses and what an unset or unreadable key falls
435
+ * back to, so the first paint is stated rather than accidental. A value that
436
+ * will not parse is treated as absent rather than thrown: storage is shared
437
+ * with older versions of the same application, and refusing to start because
438
+ * of a stale key would be worse than starting fresh.
439
+ */
440
+ export hook useStorage<T>(
441
+ key: string,
442
+ initial: T,
443
+ options?: {| readonly session?: boolean |},
444
+ ): [T, (value: T) => void] {
445
+ const session = options?.session ?? false;
446
+
447
+ const subscribe = useCallback(
448
+ (notify: () => void) => {
449
+ const set = listeners.get(key) ?? new Set();
450
+ set.add(notify);
451
+ listeners.set(key, set);
452
+ const onStorage = (event: StorageEvent) => {
453
+ // A `null` key is the whole area being cleared, which every key is
454
+ // affected by.
455
+ if (event.key == null || event.key === key) {
456
+ notify();
457
+ }
458
+ };
459
+ const win = browserWindow();
460
+ win?.addEventListener("storage", onStorage);
461
+ return () => {
462
+ set.delete(notify);
463
+ win?.removeEventListener("storage", onStorage);
464
+ };
465
+ },
466
+ [key],
467
+ );
468
+
469
+ const raw = useSyncExternalStore(
470
+ subscribe,
471
+ useCallback(() => {
472
+ try {
473
+ return area(session)?.getItem(key) ?? null;
474
+ } catch {
475
+ return null;
476
+ }
477
+ }, [key, session]),
478
+ () => null,
479
+ );
480
+
481
+ const value = useMemo((): T => {
482
+ if (raw == null) {
483
+ return initial;
484
+ }
485
+ try {
486
+ // The one unchecked step in this hook, and the comparison with
487
+ // `createJSONStorage` is what turned it up: that one marks the cast and
488
+ // offers a `revive` to close it, this one used to hand `JSON.parse`'s
489
+ // `any` back as a `T` without saying so. The trade is the same and so is
490
+ // the reason — persistence is a cache, and a cache that refuses to start
491
+ // because an older version of the application wrote the key is worse
492
+ // than one that is occasionally stale — but it is a trade, so it is
493
+ // named.
494
+ return JSON.parse(raw) as $FlowFixMe;
495
+ } catch {
496
+ return initial;
497
+ }
498
+ }, [raw, initial]);
499
+
500
+ const write = useStableCallback((next: T) => {
501
+ try {
502
+ // `JSON.stringify` has no string for `undefined`, and writing the word
503
+ // "undefined" would be a value that parses back as something else.
504
+ // Removing the key is what makes the next read fall back to `initial`,
505
+ // which is what a caller who wrote `undefined` meant.
506
+ const encoded = JSON.stringify(next);
507
+ if (encoded == null) {
508
+ area(session)?.removeItem(key);
509
+ } else {
510
+ area(session)?.setItem(key, encoded);
511
+ }
512
+ } catch {
513
+ // Full, or blocked. The announcement still happens so the components
514
+ // sharing this key agree with each other for this session.
515
+ }
516
+ announce(key);
517
+ });
518
+
519
+ return [value, write];
520
+ }