@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.
- package/index.js +691 -0
- package/internal/atom.js +213 -0
- package/internal/composed.js +882 -0
- package/internal/provider.js +64 -0
- package/internal/store.js +359 -0
- package/package.json +25 -0
|
@@ -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
|
+
}
|