@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/async.js +170 -0
- package/browser.js +675 -0
- package/channels.js +224 -0
- package/dom.js +407 -0
- package/index.js +206 -0
- package/keyboard.js +328 -0
- package/lifecycle.js +114 -0
- package/package.json +33 -0
- package/state.js +520 -0
- package/timing.js +408 -0
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
|
+
}
|