@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/state.js ADDED
@@ -0,0 +1,487 @@
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
+ function area(session: boolean): Storage | null {
394
+ const win = browserWindow();
395
+ if (win == null) {
396
+ return null;
397
+ }
398
+ try {
399
+ return (session ? win.sessionStorage : win.localStorage) ?? null;
400
+ } catch {
401
+ // A browser with site data blocked throws on the property itself.
402
+ return null;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * State kept in `localStorage`, or in `sessionStorage`.
408
+ *
409
+ * `initial` is what a prerender uses and what an unset or unreadable key falls
410
+ * back to, so the first paint is stated rather than accidental. A value that
411
+ * will not parse is treated as absent rather than thrown: storage is shared
412
+ * with older versions of the same application, and refusing to start because
413
+ * of a stale key would be worse than starting fresh.
414
+ */
415
+ export hook useStorage<T>(
416
+ key: string,
417
+ initial: T,
418
+ options?: {| readonly session?: boolean |},
419
+ ): [T, (value: T) => void] {
420
+ const session = options?.session ?? false;
421
+
422
+ const subscribe = useCallback(
423
+ (notify: () => void) => {
424
+ const set = listeners.get(key) ?? new Set();
425
+ set.add(notify);
426
+ listeners.set(key, set);
427
+ const onStorage = (event: StorageEvent) => {
428
+ // A `null` key is the whole area being cleared, which every key is
429
+ // affected by.
430
+ if (event.key == null || event.key === key) {
431
+ notify();
432
+ }
433
+ };
434
+ const win = browserWindow();
435
+ win?.addEventListener("storage", onStorage);
436
+ return () => {
437
+ set.delete(notify);
438
+ win?.removeEventListener("storage", onStorage);
439
+ };
440
+ },
441
+ [key],
442
+ );
443
+
444
+ const raw = useSyncExternalStore(
445
+ subscribe,
446
+ useCallback(() => {
447
+ try {
448
+ return area(session)?.getItem(key) ?? null;
449
+ } catch {
450
+ return null;
451
+ }
452
+ }, [key, session]),
453
+ () => null,
454
+ );
455
+
456
+ const value = useMemo(() => {
457
+ if (raw == null) {
458
+ return initial;
459
+ }
460
+ try {
461
+ return JSON.parse(raw);
462
+ } catch {
463
+ return initial;
464
+ }
465
+ }, [raw, initial]);
466
+
467
+ const write = useStableCallback((next: T) => {
468
+ try {
469
+ // `JSON.stringify` has no string for `undefined`, and writing the word
470
+ // "undefined" would be a value that parses back as something else.
471
+ // Removing the key is what makes the next read fall back to `initial`,
472
+ // which is what a caller who wrote `undefined` meant.
473
+ const encoded = JSON.stringify(next);
474
+ if (encoded == null) {
475
+ area(session)?.removeItem(key);
476
+ } else {
477
+ area(session)?.setItem(key, encoded);
478
+ }
479
+ } catch {
480
+ // Full, or blocked. The announcement still happens so the components
481
+ // sharing this key agree with each other for this session.
482
+ }
483
+ announce(key);
484
+ });
485
+
486
+ return [value, write];
487
+ }