@uniflowed/state 0.0.0-alpha.18

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.
@@ -0,0 +1,882 @@
1
+ // @flow
2
+ //
3
+ // Atoms assembled out of other atoms.
4
+ //
5
+ // Everything here is written with the same constructors an application has,
6
+ // and none of it reaches into a store: a family is a memoised factory, a
7
+ // default is a selector over a hidden primitive, persisted state is a writable
8
+ // selector whose write also touches storage, and an atom persisted to
9
+ // something asynchronous is that selector over an `asyncAtom`. That is the
10
+ // point of keeping them in one module — they are worked examples of the public
11
+ // API, and if one of them needed a private hook the API would be missing
12
+ // something.
13
+ //
14
+ // The parity work in ubugeeei-prod/uf#292 is the strongest evidence that claim is true:
15
+ // `selectAtom`, `atomWithReducer`, `atomWithReset` and `freezeAtom` are four
16
+ // of `jotai/utils`' names, they are between one and six lines each here, and
17
+ // not one of them needed anything the four constructors do not already offer.
18
+ //
19
+ // # Why a hidden primitive rather than a flag on the store
20
+ //
21
+ // `atomWithDefault` needs to remember "nobody has set this yet", and the
22
+ // obvious home for that is a field on the store's entry for the atom. It would
23
+ // also be invisible to the dependency graph: the atom would not recompute when
24
+ // the flag changed. Keeping the flag in an ordinary atom means the machinery
25
+ // that already tracks values tracks this too — and it is what makes the
26
+ // dependency on the *default* disappear the moment a value is set, because the
27
+ // selector stops reading it.
28
+ //
29
+ // # Why persisted state is read on mount and not when it is declared
30
+ //
31
+ // `atomWithStorage` used to read storage as an argument to the atom it was
32
+ // building, so the read happened while the module was being evaluated. Every
33
+ // other constructor here can be called at module scope in a file a server
34
+ // imports; this one did I/O there, and it produced the one bug a state library
35
+ // must not have.
36
+ //
37
+ // The server renders the page with `initial`. The browser downloads the
38
+ // module, evaluates it, reads `"dark"` out of `localStorage` — and the first
39
+ // client render disagrees with the HTML that is already on screen. That is a
40
+ // hydration mismatch, and no amount of care in the React binding can prevent
41
+ // it, because the value was already wrong before a hook was called. It also
42
+ // gave every store in the process the same read and the same key, which is the
43
+ // case `internal/atom.js` says stores exist for.
44
+ //
45
+ // So the read moved into the hidden primitive's `onMount`, which the store
46
+ // binds to itself and React runs after commit. The first client render is
47
+ // `initial`, exactly like the server's; the stored value arrives immediately
48
+ // afterwards, in the store that mounted. The same hook is where a `subscribe`
49
+ // belongs, so a second tab's write reaches every mounted store and no
50
+ // unmounted one.
51
+ //
52
+ // The cost is honest and worth naming: an atom nothing has mounted reads
53
+ // `initial` even when storage holds something else, so a route handler that
54
+ // only reads sees the default. `getOnInit` is for that caller — it moves the
55
+ // read to first use rather than to mount, which is still per store and still
56
+ // not at import.
57
+
58
+ import type { LoadContext } from "@uniflowed/cell";
59
+
60
+ import type { AtomGetter, AtomOptions, AtomRecord, Loadable, SetAction } from "./atom.js";
61
+ import { defineAsync, definePrimitive, defineSelector } from "./atom.js";
62
+
63
+ /**
64
+ * The argument that puts an atom back the way it was.
65
+ *
66
+ * Opaque so it cannot be confused with a value: an atom of `symbol` would
67
+ * otherwise have a value that silently means "reset".
68
+ */
69
+ export opaque type Reset = symbol;
70
+
71
+ export const RESET: Reset = Symbol("@uniflowed/state RESET");
72
+
73
+ /**
74
+ * Where [`atomWithStorage`] keeps a value.
75
+ *
76
+ * Typed in the value rather than in strings, which is the difference between
77
+ * this and the string store underneath it: a cookie jar, a React Native
78
+ * `AsyncStorage`, an in-memory map in a test and `localStorage` do not agree on
79
+ * a serialisation, and the one they would agree on is JSON — which is
80
+ * [`createJSONStorage`]'s job, not this type's.
81
+ *
82
+ * `getItem` takes the initial value so that "nothing is stored" and "the
83
+ * stored value will not parse" have one answer in one place rather than an
84
+ * `?T` every caller unwraps the same way.
85
+ *
86
+ * `removeItem` is required, not optional. A persistence layer that cannot
87
+ * delete is not one: state that can be stored and never un-stored has no
88
+ * expression for `RESET`, and an application that offers "clear my
89
+ * preferences" would have to reach past this type to do it.
90
+ *
91
+ * `subscribe` is optional because most storages have no way to tell anyone
92
+ * they changed. One that does — the browser's `storage` event — is how a
93
+ * second tab reaches this one. It is given the initial value to report when
94
+ * the key is removed elsewhere.
95
+ */
96
+ export type StorageAdapter<T> = {
97
+ readonly getItem: (key: string, initial: T) => T,
98
+ readonly setItem: (key: string, value: T) => void,
99
+ readonly removeItem: (key: string) => void,
100
+ readonly subscribe?: (key: string, onChange: (value: T) => void, initial: T) => () => void,
101
+ };
102
+
103
+ /**
104
+ * The part of the Web Storage API [`createJSONStorage`] needs.
105
+ *
106
+ * Named rather than taken as `Storage`, because a server has no `Storage` and
107
+ * a test should not need one: anything with these three methods will do.
108
+ * Inexact, so a real `localStorage` — which has `length`, `key` and `clear`
109
+ * as well — is one of these.
110
+ */
111
+ export type StringStorage = {
112
+ readonly getItem: (key: string) => null | string,
113
+ readonly setItem: (key: string, value: string) => void,
114
+ readonly removeItem: (key: string) => void,
115
+ ...
116
+ };
117
+
118
+ /** What [`createJSONStorage`] can be told about the values it reads. */
119
+ export type JSONStorageOptions<T> = {
120
+ /**
121
+ * Turn what `JSON.parse` produced into a `T`, or throw to fall back to the
122
+ * initial value.
123
+ *
124
+ * This is the package's one unchecked step, and the option exists so that a
125
+ * caller who minds can check it: `JSON.parse` answers `any`, and what comes
126
+ * back out of storage was written by an older version of the application, by
127
+ * another tab, or by a person editing their own `localStorage`. The default
128
+ * trusts it, because persistence is a cache and a cache that refuses to
129
+ * start is worse than one that is occasionally stale. `revive: (raw) =>
130
+ * schema.parse(raw)` with `@uniflowed/validator` is the version that does
131
+ * not trust it, and it needs no support from here — a `revive` that throws
132
+ * is a value that will not parse.
133
+ */
134
+ readonly revive?: (raw: mixed) => T,
135
+ };
136
+
137
+ /**
138
+ * A keyed collection of atoms, created on first use.
139
+ *
140
+ * `remove` matters more than it looks: a family keyed by something unbounded —
141
+ * a search term, a date — otherwise keeps one atom per key ever asked for, and
142
+ * the atoms are reachable from the family, so nothing collects them.
143
+ */
144
+ export type AtomFamily<Key, Member> = {
145
+ (key: Key): Member,
146
+ readonly remove: (key: Key) => void,
147
+ readonly size: () => number,
148
+ ...
149
+ };
150
+
151
+ /**
152
+ * One atom per key, created on first use and the same one thereafter.
153
+ *
154
+ * The alternative — one atom holding a map — re-renders every reader when any
155
+ * entry changes, because the map is one value. A family gives each key its own
156
+ * atom, so a list of a thousand rows re-renders one row.
157
+ */
158
+ export function atomFamily<Key, Member>(create: (key: Key) => Member): AtomFamily<Key, Member> {
159
+ const members: Map<Key, Member> = new Map();
160
+ const family = (key: Key): Member => {
161
+ const existing = members.get(key);
162
+ if (existing !== undefined) {
163
+ return existing;
164
+ }
165
+ const created = create(key);
166
+ members.set(key, created);
167
+ return created;
168
+ };
169
+ family.remove = (key: Key) => {
170
+ members.delete(key);
171
+ };
172
+ family.size = () => members.size;
173
+ return family;
174
+ }
175
+
176
+ /**
177
+ * One part of another atom, recomputed only when that part changes.
178
+ *
179
+ * Jotai's `selectAtom(anAtom, selector, equalityFn)`. The point is the
180
+ * equality cutoff rather than the projection: a component reading
181
+ * `selectAtom(user, (u) => u.name)` re-renders when the name changes and not
182
+ * when the avatar does, even though both live in one atom.
183
+ *
184
+ * One difference from Jotai, kept rather than papered over. Its selector is
185
+ * called with `(value, previousSlice)`, and this one is called with the value
186
+ * alone. A read that can see its own previous output is not a pure function of
187
+ * its dependencies, which is the property `@uniflowed/cell`'s equality cutoff
188
+ * is built on, so the parameter has nowhere to come from. It also has nothing
189
+ * left to do: `prevSlice` exists so that a selector building a fresh array
190
+ * each time can return the previous one and avoid a re-render, and `equals` is
191
+ * the direct way to say that.
192
+ */
193
+ export function selectAtom<T, Slice>(
194
+ source: AtomRecord<T, empty>,
195
+ select: (value: T) => Slice,
196
+ equals?: (previous: Slice, next: Slice) => boolean,
197
+ ): AtomRecord<Slice, empty> {
198
+ return defineSelector(
199
+ (get) => select(get(source)),
200
+ null,
201
+ equals == null ? undefined : { equals },
202
+ );
203
+ }
204
+
205
+ /**
206
+ * A value and the actions that change it: `useReducer`, as an atom.
207
+ *
208
+ * Jotai's `atomWithReducer(initialValue, reducer)`, built the way
209
+ * [`atomWithDefault`] is — a hidden primitive holding the value, and a
210
+ * writable selector over it whose write is the only way in.
211
+ *
212
+ * The reason to reach for this rather than an [`atom`] is in the type. It is a
213
+ * `WritableAtom<State, Action>` where the two parameters differ, so a state
214
+ * cannot be handed to it by mistake, and `useSetAtom` gives a component a
215
+ * `dispatch` whose argument Flow checks against the actions the reducer names
216
+ * rather than against `State`.
217
+ */
218
+ export function atomWithReducer<State, Action>(
219
+ initial: State,
220
+ reduce: (state: State, action: Action) => State,
221
+ options?: AtomOptions<State>,
222
+ ): AtomRecord<State, Action> {
223
+ const value = definePrimitive<State>(initial, {
224
+ debugLabel: `${options?.debugLabel ?? "atomWithReducer"} value`,
225
+ });
226
+
227
+ return defineSelector(
228
+ (get) => get(value),
229
+ (get, set, action) => {
230
+ // `get` inside a write records no dependency, so reading the current
231
+ // state to reduce it does not make the atom recompute for it.
232
+ set(value, reduce(get(value), action));
233
+ },
234
+ options,
235
+ );
236
+ }
237
+
238
+ /** Whether an override has been written, and what it is. */
239
+ type Slot<T> = { readonly filled: false } | { readonly filled: true, readonly value: T };
240
+
241
+ /**
242
+ * An atom whose value is computed until someone writes one.
243
+ *
244
+ * The interesting property is that the dependency on the default is dynamic:
245
+ * while nothing has been written, the atom depends on everything `getDefault`
246
+ * read; after a write it depends on the override alone, and changes to what
247
+ * the default would have read recompute nothing.
248
+ *
249
+ * Writing [`RESET`] puts it back, and the dependency on the default with it.
250
+ */
251
+ export function atomWithDefault<T>(
252
+ getDefault: (get: AtomGetter) => T,
253
+ options?: AtomOptions<T>,
254
+ ): AtomRecord<T, SetAction<T> | Reset> {
255
+ const empty: Slot<T> = { filled: false };
256
+ const override = definePrimitive<Slot<T>>(empty, {
257
+ debugLabel: `${options?.debugLabel ?? "atomWithDefault"} override`,
258
+ });
259
+
260
+ const resolve = (get: AtomGetter): T => {
261
+ const slot = get(override);
262
+ return slot.filled ? slot.value : getDefault(get);
263
+ };
264
+
265
+ return defineSelector(
266
+ resolve,
267
+ (get, set, argument) => {
268
+ if (argument === RESET) {
269
+ set(override, empty);
270
+ return;
271
+ }
272
+ const next =
273
+ typeof argument === "function"
274
+ ? (argument as $FlowFixMe)(resolve(get))
275
+ : (argument as $FlowFixMe);
276
+ set(override, { filled: true, value: next });
277
+ },
278
+ options,
279
+ );
280
+ }
281
+
282
+ /**
283
+ * A piece of state that also answers to [`RESET`].
284
+ *
285
+ * Jotai's `atomWithReset(initialValue)`. It is [`atomWithDefault`] with the
286
+ * signature a caller who has a value rather than a derivation actually wants,
287
+ * and that is the whole of it — but it is worth the name, because `atom(0)`
288
+ * cannot take `RESET` and "the resettable one is the one called
289
+ * `atomWithDefault`" is not something anybody guesses.
290
+ *
291
+ * Jotai's `atomWithLazy` is deliberately *not* here for the mirror-image
292
+ * reason: its argument is already the function `atomWithDefault` takes, so it
293
+ * would be a second name for one thing rather than a second signature for it.
294
+ */
295
+ export function atomWithReset<T>(
296
+ initial: T,
297
+ options?: AtomOptions<T>,
298
+ ): AtomRecord<T, SetAction<T> | Reset> {
299
+ return atomWithDefault(() => initial, options);
300
+ }
301
+
302
+ /** What this store knows about the value behind a storage key. */
303
+ type Stored<T> = { readonly known: false } | { readonly known: true, readonly value: T };
304
+
305
+ /** What [`atomWithStorage`] accepts on top of what every atom does. */
306
+ export type StorageOptions<T> = {
307
+ readonly debugLabel?: string,
308
+ readonly equals?: (previous: T, next: T) => boolean,
309
+ /**
310
+ * Read the stored value the first time the atom is used in a store, rather
311
+ * than when it is mounted there. `false` by default.
312
+ *
313
+ * The default is what makes a server-rendered page hydrate: the first client
314
+ * render has to be the one the server already sent, and it cannot be if the
315
+ * value has been read out of `localStorage` before React runs. Turning this
316
+ * on says "there is no server render to agree with" — an application that is
317
+ * only ever a browser tab, where a first paint holding `initial` is a flash
318
+ * of the wrong theme and nothing else is at stake.
319
+ *
320
+ * Even on, the read is per store and happens on demand; it never happens
321
+ * while the module is being evaluated.
322
+ */
323
+ readonly getOnInit?: boolean,
324
+ };
325
+
326
+ /**
327
+ * An atom mirrored into a key-value store.
328
+ *
329
+ * Persistence happens in the *write*, not in a subscription, so it does not
330
+ * depend on the atom being mounted: state written from a route handler or a
331
+ * test is stored the same as state written from a component.
332
+ *
333
+ * The read is the other direction and does depend on it — see the note at the
334
+ * top of this file. The stored value arrives on mount, in the store that
335
+ * mounted, so that the first client render is the one the server rendered.
336
+ * `getOnInit` is for the application that has no server render to agree with.
337
+ *
338
+ * Unreadable or malformed stored data falls back to `initial` rather than
339
+ * throwing. Persistence is a cache, and a cache that can brick an application
340
+ * on a schema change — or on a user editing their own `localStorage` — is
341
+ * worse than no cache.
342
+ *
343
+ * Writing [`RESET`] removes the key, which is the only way to stop persisting
344
+ * something: setting it back to `initial` stores `initial`.
345
+ *
346
+ * Passing no `storage` yields an atom that behaves the same in every other
347
+ * way, including `RESET`, which is what a test and a server want.
348
+ */
349
+ export function atomWithStorage<T>(
350
+ key: string,
351
+ initial: T,
352
+ storage?: StorageAdapter<T>,
353
+ options?: StorageOptions<T>,
354
+ ): AtomRecord<T, SetAction<T> | Reset> {
355
+ const label = options?.debugLabel ?? key;
356
+ const equals = options?.equals;
357
+ if (storage == null) {
358
+ // Not a degenerate case worth a different shape: this is the form a test
359
+ // and a prerender use, and `RESET` has to mean the same thing in it.
360
+ return atomWithDefault<T>(() => initial, { debugLabel: label, equals });
361
+ }
362
+
363
+ const eager = options?.getOnInit ?? false;
364
+ const unknown: Stored<T> = { known: false };
365
+
366
+ const stored = definePrimitive<Stored<T>>(unknown, {
367
+ debugLabel: `${label} stored`,
368
+ onMount: (mount) => {
369
+ if (!eager) {
370
+ // Storage is the source of truth as this store joins it. A value
371
+ // written before the mount was written to storage too, so adopting
372
+ // what is there cannot lose one.
373
+ mount.set({ known: true, value: storage.getItem(key, initial) });
374
+ }
375
+ const listen = storage.subscribe;
376
+ if (listen == null) {
377
+ return undefined;
378
+ }
379
+ return listen(
380
+ key,
381
+ (value) => {
382
+ mount.set({ known: true, value });
383
+ },
384
+ initial,
385
+ );
386
+ },
387
+ });
388
+
389
+ const resolve = (get: AtomGetter): T => {
390
+ const current = get(stored);
391
+ if (current.known) {
392
+ return current.value;
393
+ }
394
+ // Nothing has mounted this atom in this store, or `RESET` put it back.
395
+ // `initial` is the answer a server gives and therefore the answer the
396
+ // first client render has to give; `getOnInit` is the caller saying there
397
+ // is no server.
398
+ return eager ? storage.getItem(key, initial) : initial;
399
+ };
400
+
401
+ return defineSelector(
402
+ resolve,
403
+ (get, set, argument) => {
404
+ if (argument === RESET) {
405
+ set(stored, unknown);
406
+ storage.removeItem(key);
407
+ return;
408
+ }
409
+ const next =
410
+ typeof argument === "function"
411
+ ? (argument as $FlowFixMe)(resolve(get))
412
+ : (argument as $FlowFixMe);
413
+ set(stored, { known: true, value: next });
414
+ storage.setItem(key, next);
415
+ },
416
+ { debugLabel: label, equals },
417
+ );
418
+ }
419
+
420
+ /**
421
+ * A [`StorageAdapter`] over `localStorage`, `sessionStorage`, or anything else
422
+ * that holds strings.
423
+ *
424
+ * ```
425
+ * const theme = atomWithStorage("theme", "light", createJSONStorage(() => localStorage));
426
+ * ```
427
+ *
428
+ * The thunk is what makes that line safe in a file a server imports, and the
429
+ * reason is more specific than "the storage might be missing": on a runtime
430
+ * without Web Storage the *identifier* `localStorage` is not defined, so
431
+ * evaluating it throws a `ReferenceError` rather than producing `undefined`.
432
+ * The thunk is called inside a `try` every time, so a module that names a
433
+ * storage no runtime here has still imports, and every operation on it becomes
434
+ * a no-op returning the initial value.
435
+ *
436
+ * That is the whole portability story, and it was checked against four
437
+ * runtimes: Node has Web Storage from 22 and only with a flag, Deno has
438
+ * `localStorage` for a page it can name an origin for, Bun has it since 1.2,
439
+ * and an edge runtime has neither it nor a `window`. Every one of those is a
440
+ * `try` around the thunk and a `null` check afterwards.
441
+ *
442
+ * `getItem` and `setItem` are also wrapped: a browser with site data blocked
443
+ * throws on the property, and Safari in private mode throws on a write once
444
+ * its quota is reached. A storage that cannot be written to degrades to an
445
+ * atom that is not persisted, which is the behaviour every one of those
446
+ * applications wants and none of them would write by hand.
447
+ *
448
+ * `subscribe` listens for the browser's `storage` event, which is how another
449
+ * tab reaches this one. It deliberately does *not* announce writes made in
450
+ * this process: two stores in one process are two stores, and keeping them in
451
+ * step would undo the isolation they exist for.
452
+ *
453
+ * # Why this guard is written twice
454
+ *
455
+ * `@uniflowed/hooks`'s `useStorage` (`packages/hooks/state.js`) guards the
456
+ * same four hazards — a storage property that throws, a read that throws, a
457
+ * write that throws, and finding the object a `storage` event arrives on —
458
+ * and was written from the same reasoning by somebody who had not read this.
459
+ * Merging the two was considered and declined; ubugeeei-prod/uf#318 is the issue, and this
460
+ * is half of the decision. The other half is in `useStorage`.
461
+ *
462
+ * A shared helper has to live somewhere both packages may depend on, and
463
+ * there is no such place. `@uniflowed/web` is the obvious home — it already
464
+ * owns the platform bindings, and `cookie.js` reaches for `globalThis` the
465
+ * same way — but `@uniflowed/hooks` is on npm and `@uniflowed/web` is not
466
+ * (`tools/release/pending-packages.txt`). That edge would make
467
+ * `npm install @uniflowed/hooks` answer `ETARGET`: the tarball would name a
468
+ * version of `@uniflowed/web` the registry does not have, and it would
469
+ * install perfectly from this workspace, which is exactly how #409 stayed
470
+ * hidden. `tools/ci/publishable.sh` now refuses that edge, so the reason this
471
+ * decision rests on is a check rather than a paragraph.
472
+ *
473
+ * The two are also less alike than the list of hazards suggests. This one is
474
+ * defined over a thunk the caller supplies and answers a `T`; that one picks
475
+ * its area from a boolean and answers a string. That one keeps a registry of
476
+ * same-tab listeners so two components sharing a key agree, which this one
477
+ * must not have — see the paragraph above. What is common once those are
478
+ * taken out is four `try` blocks and a `JSON.parse` with a fallback, and the
479
+ * shared thing worth extracting from that is the *reasoning*, which is why
480
+ * each copy now names the other.
481
+ *
482
+ * What would change the answer, precisely: `@uniflowed/web` reaching npm
483
+ * (#210) removes the blocking reason, and a *third* caller writing these
484
+ * guards a third time removes the other one. Neither has happened, and a
485
+ * helper built before either is a dependency edge bought on speculation.
486
+ */
487
+ export function createJSONStorage<T>(
488
+ getStringStorage: () => StringStorage | null | void,
489
+ options?: JSONStorageOptions<T>,
490
+ ): StorageAdapter<T> {
491
+ const revive = options?.revive;
492
+
493
+ const resolve = (): StringStorage | null => {
494
+ try {
495
+ return getStringStorage() ?? null;
496
+ } catch {
497
+ return null;
498
+ }
499
+ };
500
+
501
+ const parse = (raw: null | string, initial: T): T => {
502
+ if (raw == null) {
503
+ return initial;
504
+ }
505
+ try {
506
+ const decoded = JSON.parse(raw);
507
+ // The one unchecked step in the package, and it is one line rather than
508
+ // one per call site. `revive` is how a caller closes it.
509
+ return revive == null ? (decoded as $FlowFixMe) : revive(decoded);
510
+ } catch {
511
+ return initial;
512
+ }
513
+ };
514
+
515
+ return {
516
+ getItem: (key, initial) => {
517
+ const store = resolve();
518
+ if (store === null) {
519
+ return initial;
520
+ }
521
+ try {
522
+ return parse(store.getItem(key), initial);
523
+ } catch {
524
+ return initial;
525
+ }
526
+ },
527
+ setItem: (key, value) => {
528
+ const store = resolve();
529
+ if (store === null) {
530
+ return;
531
+ }
532
+ try {
533
+ store.setItem(key, JSON.stringify(value));
534
+ } catch {
535
+ // Out of quota, or site data blocked. The value is still the atom's;
536
+ // it simply will not outlive the tab.
537
+ }
538
+ },
539
+ removeItem: (key) => {
540
+ const store = resolve();
541
+ if (store === null) {
542
+ return;
543
+ }
544
+ try {
545
+ store.removeItem(key);
546
+ } catch {
547
+ // As above.
548
+ }
549
+ },
550
+ subscribe: (key, onChange, initial) => {
551
+ const host = storageEvents();
552
+ if (host === null) {
553
+ return () => {};
554
+ }
555
+ const listener = (event: StorageEvent) => {
556
+ // A `null` key is the whole area being cleared, which every key in it
557
+ // is affected by.
558
+ if (event.key != null && event.key !== key) {
559
+ return;
560
+ }
561
+ onChange(parse(event.key == null ? null : event.newValue, initial));
562
+ };
563
+ host.addEventListener("storage", listener);
564
+ return () => {
565
+ host.removeEventListener("storage", listener);
566
+ };
567
+ },
568
+ };
569
+ }
570
+
571
+ /** What a `storage` event is dispatched on, where there is one. */
572
+ type StorageEvents = {
573
+ readonly addEventListener: (type: "storage", listener: (event: StorageEvent) => mixed) => void,
574
+ readonly removeEventListener: (type: "storage", listener: (event: StorageEvent) => mixed) => void,
575
+ ...
576
+ };
577
+
578
+ /**
579
+ * The object a `storage` event will arrive on, or `null` where none will.
580
+ *
581
+ * In a browser `globalThis` *is* the window, so `globalThis.addEventListener`
582
+ * looks right. It is wrong anywhere a document has been installed onto another
583
+ * host's global — every uf test process, where `globalThis` is Node's and has
584
+ * no `addEventListener` at all. Asking the document's own window first and
585
+ * falling back covers both, and the `typeof` check covers the servers where
586
+ * neither answers.
587
+ */
588
+ function storageEvents(): null | StorageEvents {
589
+ const host = globalThis.window ?? globalThis;
590
+ return typeof host?.addEventListener === "function" ? host : null;
591
+ }
592
+
593
+ /**
594
+ * Where [`atomWithAsyncStorage`] keeps a value: IndexedDB, a React Native
595
+ * `AsyncStorage`, a server-backed preference store.
596
+ *
597
+ * The same shape as [`StorageAdapter`] with the read made a promise, and it is
598
+ * a separate type rather than a widening of that one because the difference is
599
+ * not the adapter's — it is the *atom's value type*, which becomes a
600
+ * [`Loadable`]. See [`atomWithAsyncStorage`].
601
+ *
602
+ * `getItem` is handed the load's `LoadContext`, so an adapter that can stop an
603
+ * IndexedDB request or a fetch has the signal to stop it with. Ignoring the
604
+ * third parameter is fine and is what a `Map`-backed adapter in a test does:
605
+ * whether a settled read is still the one the atom is waiting for is decided
606
+ * by `@uniflowed/cell` either way, and the signal only decides whether the
607
+ * work carries on in the meantime.
608
+ *
609
+ * `setItem` and `removeItem` answer a promise or nothing, and the atom does
610
+ * not wait for either — see the note about failed writes on
611
+ * [`atomWithAsyncStorage`].
612
+ */
613
+ export type AsyncStorageAdapter<T> = {
614
+ readonly getItem: (key: string, initial: T, context: LoadContext) => Promise<T>,
615
+ readonly setItem: (key: string, value: T) => Promise<mixed> | void,
616
+ readonly removeItem: (key: string) => Promise<mixed> | void,
617
+ readonly subscribe?: (key: string, onChange: (value: T) => void, initial: T) => () => void,
618
+ };
619
+
620
+ /**
621
+ * What [`atomWithAsyncStorage`]'s setter accepts.
622
+ *
623
+ * A value, or a function of what the atom currently holds — which is a
624
+ * [`Loadable`] rather than a `T`, and that is not a wrinkle to be smoothed
625
+ * over. A write can happen before the first read has settled, so there may be
626
+ * no current value to reduce; a reducer typed `(current: T) => T` would be a
627
+ * promise the atom cannot keep. Handed the loadable, a caller who wants to
628
+ * increment a persisted counter has to say what "increment" means before the
629
+ * count has arrived, which is a question they have to answer anyway.
630
+ */
631
+ export type AsyncSetAction<T> = T | ((current: Loadable<T>) => T);
632
+
633
+ /** What [`atomWithAsyncStorage`] accepts on top of what every atom does. */
634
+ export type AsyncStorageOptions<T> = {
635
+ readonly debugLabel?: string,
636
+ /**
637
+ * When two values of `T` are the same value.
638
+ *
639
+ * Over `T` rather than over `Loadable<T>`, because `T` is what a caller has
640
+ * an opinion about; the constructor lifts it to the loadable, where
641
+ * `loading` equals `loading` and an error equals itself.
642
+ */
643
+ readonly equals?: (previous: T, next: T) => boolean,
644
+ };
645
+
646
+ /**
647
+ * A storage write whose outcome is nobody's value.
648
+ *
649
+ * The rejection has to be attached — a promise that rejects with no handler is
650
+ * an unhandled rejection, and on Node that is a process that exits — but there
651
+ * is nothing to attach it *to*. See the note on [`atomWithAsyncStorage`].
652
+ */
653
+ function discardOutcome(result: Promise<mixed> | void): void {
654
+ if (result != null) {
655
+ result.then(doNothing, doNothing);
656
+ }
657
+ }
658
+
659
+ function doNothing(): void {}
660
+
661
+ /**
662
+ * Lift an equality over `T` to one over `Loadable<T>`.
663
+ *
664
+ * Without this the atom would re-render every reader on every write, including
665
+ * one that stored the value already showing: the loadable is built fresh by
666
+ * the read, so `Object.is` on it is never true. The synchronous
667
+ * [`atomWithStorage`] gets this for nothing, because the value it returns *is*
668
+ * the `T`.
669
+ */
670
+ function sameLoadable<T>(
671
+ equals: void | ((previous: T, next: T) => boolean),
672
+ ): (previous: Loadable<T>, next: Loadable<T>) => boolean {
673
+ const sameValue = equals ?? Object.is;
674
+ return (previous, next) => {
675
+ if (previous.state !== next.state) {
676
+ return false;
677
+ }
678
+ if (previous.state === "hasData" && next.state === "hasData") {
679
+ return sameValue(previous.data, next.data);
680
+ }
681
+ if (previous.state === "hasError" && next.state === "hasError") {
682
+ return Object.is(previous.error, next.error);
683
+ }
684
+ return true;
685
+ };
686
+ }
687
+
688
+ /**
689
+ * An atom persisted to a storage whose read is asynchronous.
690
+ *
691
+ * ```
692
+ * const draft = atomWithAsyncStorage("draft", "", indexedDbStorage);
693
+ * // read(draft) is { state: "loading" }, then { state: "hasData", data: … }
694
+ * ```
695
+ *
696
+ * A second constructor rather than an option on [`atomWithStorage`], because
697
+ * the value type is different: it is a [`Loadable`] until the first read
698
+ * settles, and it stays one afterwards so that a component renders the same
699
+ * three cases it renders for any other asynchronous value. Jotai's
700
+ * `atomWithStorage` makes the value `T | Promise<T>` and leans on Suspense to
701
+ * render it; this package has declined Suspense (see `asyncAtom`), and taking
702
+ * the signature without it would leave the type saying `T` for a value that is
703
+ * not there yet. ubugeeei-prod/uf#317 is where that was worked out.
704
+ *
705
+ * Three things had to be decided rather than typed, and each is a property
706
+ * `tests/library/state.test.js` holds this to.
707
+ *
708
+ * **A write while the first read is in flight wins, and the read is dropped.**
709
+ * Not by a flag counting generations — `@uniflowed/cell` already has one, and
710
+ * a second answer to one question is how two of them come to disagree. A write
711
+ * fills a hidden override, the read stops looking at the load, and the load
712
+ * loses its last reader: the cell abandons it, aborts its signal, and never
713
+ * delivers. It is the mechanism [`atomWithDefault`] uses to stop depending on
714
+ * its default, applied to a load instead of a derivation.
715
+ *
716
+ * **A read that fails is `hasError`, not `initial`.** This is the one place
717
+ * where the two constructors part company on purpose. The synchronous one
718
+ * falls back, because persistence is a cache and it has nowhere to put the
719
+ * failure; here there is somewhere to put it, and "the database is locked" is
720
+ * not the same fact as "nobody has set a preference". An *absent* key is still
721
+ * the adapter's business and still answers `initial`, so the two stay
722
+ * distinguishable.
723
+ *
724
+ * **A write that fails is silent**, which is the opposite choice and the same
725
+ * reasoning: the value the caller wrote is the atom's value whatever storage
726
+ * did with it, so a failed write has no value to be. It will simply not
727
+ * outlive the session. The promise is still attached, because an unhandled
728
+ * rejection ends a Node process.
729
+ *
730
+ * `RESET` removes the key and puts the atom back to `{ hasData, initial }`
731
+ * rather than forgetting and reading again. Reading again would race the
732
+ * removal — the read is asynchronous and the removal has not finished — and
733
+ * would answer with the value that was just deleted. The synchronous version
734
+ * can afford to forget precisely because its removal is already done by the
735
+ * time anything reads.
736
+ *
737
+ * The one habit that does not carry over from [`atomWithStorage`]: the read
738
+ * starts as soon as a store is asked for the atom, mounted or not. That one
739
+ * waits for a mount because the first client render has to be the one the
740
+ * server already sent; here both sides render `loading`, so there is nothing
741
+ * to disagree with and nothing to wait for.
742
+ */
743
+ export function atomWithAsyncStorage<T>(
744
+ key: string,
745
+ initial: T,
746
+ storage: AsyncStorageAdapter<T>,
747
+ options?: AsyncStorageOptions<T>,
748
+ ): AtomRecord<Loadable<T>, AsyncSetAction<T> | Reset> {
749
+ const label = options?.debugLabel ?? key;
750
+ const empty: Slot<T> = { filled: false };
751
+
752
+ // Everything this store knows that the load does not: what was written here,
753
+ // and what another tab told this store while it was mounted. Filling it is
754
+ // what takes the load out of the atom's dependencies.
755
+ const override = definePrimitive<Slot<T>>(empty, {
756
+ debugLabel: `${label} override`,
757
+ onMount: (mount) => {
758
+ const listen = storage.subscribe;
759
+ if (listen == null) {
760
+ return undefined;
761
+ }
762
+ return listen(
763
+ key,
764
+ (value) => {
765
+ mount.set({ filled: true, value });
766
+ },
767
+ initial,
768
+ );
769
+ },
770
+ });
771
+
772
+ const loaded = defineAsync<T>((_get, context) => storage.getItem(key, initial, context), {
773
+ debugLabel: `${label} loaded`,
774
+ });
775
+
776
+ const resolve = (get: AtomGetter): Loadable<T> => {
777
+ const slot = get(override);
778
+ // Reading `loaded` only in this branch is the whole of the write-wins
779
+ // behaviour: once the override is filled the load is not a dependency, so
780
+ // nothing recomputes when it settles and the cell stops it.
781
+ return slot.filled ? { state: "hasData", data: slot.value } : get(loaded);
782
+ };
783
+
784
+ return defineSelector(
785
+ resolve,
786
+ (get, set, argument) => {
787
+ if (argument === RESET) {
788
+ set(override, { filled: true, value: initial });
789
+ discardOutcome(storage.removeItem(key));
790
+ return;
791
+ }
792
+ const next =
793
+ typeof argument === "function"
794
+ ? (argument as $FlowFixMe)(resolve(get))
795
+ : (argument as $FlowFixMe);
796
+ set(override, { filled: true, value: next });
797
+ discardOutcome(storage.setItem(key, next));
798
+ },
799
+ { debugLabel: label, equals: sameLoadable(options?.equals) },
800
+ );
801
+ }
802
+
803
+ /**
804
+ * The data an asynchronous atom is holding, or `fallback` until it has some.
805
+ *
806
+ * For the component that has nothing useful to render while a load is in
807
+ * flight and does not want to say so three times in one file. It keeps the
808
+ * failure quiet, which is the trade: a screen that shows the fallback forever
809
+ * is the price of not handling the error, and `useAtomValue` on the loadable
810
+ * itself is the version that makes the caller look at it.
811
+ */
812
+ export function unwrap<T>(
813
+ target: AtomRecord<Loadable<T>, empty>,
814
+ fallback: T,
815
+ ): AtomRecord<T, empty> {
816
+ return defineSelector((get) => {
817
+ const settled = get(target);
818
+ return match (settled) {
819
+ {state: "hasData", data: const data, ...} => data,
820
+ _ => fallback,
821
+ };
822
+ }, null);
823
+ }
824
+
825
+ /**
826
+ * Every object this process has already walked.
827
+ *
828
+ * Module scope rather than per atom, and that is not a shortcut: freezing is
829
+ * idempotent and global — an object frozen for one atom is frozen for every
830
+ * other — so the only thing a per-atom set would buy is walking the same
831
+ * structure again. It is weak, so nothing here keeps a value alive.
832
+ */
833
+ const walked: WeakSet<interface {}> = new WeakSet();
834
+
835
+ /**
836
+ * The same atom, with its value frozen so that nothing can change it in place.
837
+ *
838
+ * Jotai's `freezeAtom(anAtom)`. The bug it exists for is the first one anybody
839
+ * meets: `state.items.push(row)` changes the value without replacing it, the
840
+ * equality cutoff correctly sees no change, and nothing re-renders. Frozen,
841
+ * that line throws in a module and fails silently outside one — either way it
842
+ * is at the mutation rather than three components away.
843
+ *
844
+ * The freeze is deep, and the source's value is frozen with it. There is only
845
+ * one object: this returns what it was given rather than a copy, so freezing
846
+ * the derived atom freezes the atom it derives from too. That is the point —
847
+ * a guard that only covered the copy would leave the mutation that matters
848
+ * unguarded — and it is worth knowing before wrapping an atom somebody else
849
+ * writes to.
850
+ *
851
+ * The cost is one walk of the structure the first time it is seen. A value
852
+ * that shares most of its objects with the previous one — which is what
853
+ * immutable updates produce — is walked only where it is new.
854
+ *
855
+ * What it cannot guard, said here rather than discovered: `Object.freeze` is
856
+ * about properties, so a `Map` or a `Set` in the value is frozen as an object
857
+ * and still accepts `set` and `add`. That is `Object.freeze`'s limit rather
858
+ * than this function's, and it is the same limit Jotai's `freezeAtom` has;
859
+ * state built out of plain objects and arrays is fully covered.
860
+ */
861
+ export function freezeAtom<T>(source: AtomRecord<T, empty>): AtomRecord<T, empty> {
862
+ return defineSelector((get) => {
863
+ const value = get(source);
864
+ freezeDeeply(value);
865
+ return value;
866
+ }, null);
867
+ }
868
+
869
+ function freezeDeeply(value: mixed): void {
870
+ if (value === null || typeof value !== "object" || walked.has(value)) {
871
+ return;
872
+ }
873
+ // Added before the walk rather than after it, so a structure that refers to
874
+ // itself — a tree with parent links, a graph — terminates.
875
+ walked.add(value);
876
+ Object.freeze(value);
877
+ // `Object.values` covers an array's elements as well as an object's
878
+ // properties, so there is no second branch for one.
879
+ for (const entry of Object.values(value)) {
880
+ freezeDeeply(entry);
881
+ }
882
+ }