@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,64 @@
1
+ // @flow
2
+ //
3
+ // Which store a React subtree uses.
4
+ //
5
+ // One context and one component. It is separate from the hooks because it
6
+ // answers a different question: the hooks ask "what is this atom's value
7
+ // here", and this decides what "here" means.
8
+ //
9
+ // # Why there is a default store at all
10
+ //
11
+ // Most applications have exactly one, and making every one of them wrap its
12
+ // tree in a provider — and every test, and every story — to get it is
13
+ // ceremony that buys nothing. So an unwrapped tree reads the default store,
14
+ // which is created on first use.
15
+ //
16
+ // A provider is what you reach for when one is not enough: a server rendering
17
+ // two requests in one process, a preview pane holding a draft of the state the
18
+ // page behind it shows, a test that wants a clean slate without reloading the
19
+ // module. Those are real, which is why the scoping exists, and they are not
20
+ // the common case, which is why it is optional.
21
+ //
22
+ // # Why the fallback store is made with useState
23
+ //
24
+ // `<Provider>` with no `store` prop owns one. Creating it in the render body
25
+ // would make a new store every render, and every re-render would throw the
26
+ // tree's state away; `useState` with a lazy initialiser creates it once and
27
+ // keeps it for the life of the component, without an effect that would leave
28
+ // the first render with no store to read.
29
+
30
+ import * as React from "@uniflowed/react";
31
+ import { createContext, useContext, useState } from "@uniflowed/react";
32
+
33
+ import type { StoreInstance } from "./store.js";
34
+ import { createStore, defaultStore } from "./store.js";
35
+
36
+ /**
37
+ * `null` means "nobody scoped one", which is different from a store: it is
38
+ * what makes an unwrapped tree fall through to the default rather than throw.
39
+ */
40
+ const StoreContext: React.Context<StoreInstance | null> = createContext(null);
41
+
42
+ /**
43
+ * Give a subtree its own store.
44
+ *
45
+ * Passing `null` asks for a fresh one owned by this component, which is what
46
+ * the public `Provider` does when it is given no store.
47
+ */
48
+ export component StoreScope(store: StoreInstance | null, children: React.Node) {
49
+ const [owned] = useState(createStore);
50
+ return <StoreContext.Provider value={store ?? owned}>{children}</StoreContext.Provider>;
51
+ }
52
+
53
+ /**
54
+ * The store a hook should use: the one it was handed, then the one the tree
55
+ * was scoped to, then the default.
56
+ *
57
+ * `useContext` is called unconditionally even when an override was passed,
58
+ * because the alternative is a conditional hook, and because a component that
59
+ * sometimes takes a store prop must not change its hook order when it does.
60
+ */
61
+ export hook useStoreInstance(override?: StoreInstance): StoreInstance {
62
+ const scoped = useContext(StoreContext);
63
+ return override ?? scoped ?? defaultStore();
64
+ }
@@ -0,0 +1,359 @@
1
+ // @flow
2
+ //
3
+ // A store: where an atom's value actually lives.
4
+ //
5
+ // One `WeakMap` from an atom definition to the cell holding its value in this
6
+ // store, and the four operations everything else in the package is built from
7
+ // — get, set, subscribe, and the React binding. There is no graph here. A
8
+ // derived atom's read is handed a `get` that resolves through this store, and
9
+ // the cell it is instantiated into does the tracking, the memoisation, the
10
+ // glitch-free propagation and the batching. That is the whole reason
11
+ // `@uniflowed/cell` is a separate package: two implementations of dependency
12
+ // tracking in one product is one too many.
13
+ //
14
+ // # Why a WeakMap
15
+ //
16
+ // An `atomFamily` can be keyed by anything — a row id, a date, a filter — and
17
+ // a long-lived store must not accumulate a cell per key ever asked for. Keying
18
+ // weakly means a family member that nothing references any more takes its
19
+ // value with it. It also means the store never has to be told an atom exists:
20
+ // instantiation happens on first contact, so importing a module full of atoms
21
+ // costs nothing until one is read.
22
+ //
23
+ // # Why instantiation during a React render is safe
24
+ //
25
+ // `useAtomValue` reads through `getSnapshot`, which React calls during render,
26
+ // and that read may create the cell. It is a mutation, and it is the one kind
27
+ // React permits: it is idempotent, keyed by an identity the caller already
28
+ // holds, and unobservable — two renders that race produce the same cell, and
29
+ // the second finds the first's. Nothing outside the store can tell whether the
30
+ // cell existed before the render.
31
+ //
32
+ // What is *not* done during render is mounting: `onMount` runs from
33
+ // `subscribe`, which React calls after commit. A render that is thrown away
34
+ // therefore starts nothing that would need stopping.
35
+
36
+ import type { Cell, CellOptions, Unsubscribe } from "@uniflowed/cell";
37
+ import {
38
+ batch,
39
+ derived,
40
+ peek,
41
+ read,
42
+ refresh,
43
+ resource,
44
+ state,
45
+ status,
46
+ subscribe,
47
+ untracked,
48
+ update,
49
+ write,
50
+ } from "@uniflowed/cell";
51
+
52
+ import type { AtomGetter, AtomRecord, AtomSetter, Loadable } from "./atom.js";
53
+
54
+ /**
55
+ * The three callbacks React needs for one atom, cached so their identity never
56
+ * changes.
57
+ *
58
+ * `useSyncExternalStore` re-subscribes whenever the identity of `subscribe`
59
+ * changes, and a memoised child re-renders whenever the identity of a setter
60
+ * changes. A `useCallback` would keep them stable within one component;
61
+ * caching them on the store keeps them stable across every component reading
62
+ * that atom, so mounting the thousandth reader allocates nothing.
63
+ */
64
+ export type Binding<T, A> = {
65
+ readonly subscribe: (listener: () => void) => Unsubscribe,
66
+ readonly snapshot: () => T,
67
+ readonly setter: (arg: A) => void,
68
+ };
69
+
70
+ export type StoreInstance = {
71
+ readonly get: AtomGetter,
72
+ readonly set: AtomSetter,
73
+ readonly sub: <V>(target: AtomRecord<V, empty>, listener: () => void) => Unsubscribe,
74
+ readonly bind: <V, A>(target: AtomRecord<V, A>) => Binding<V, A>,
75
+ readonly reload: <V>(target: AtomRecord<V, empty>) => void,
76
+ };
77
+
78
+ /**
79
+ * An atom, as the thing a store's maps are keyed by.
80
+ *
81
+ * A key is an identity and nothing else: the maps are looked up by the atom
82
+ * the caller already holds, and no property of one is ever read through this
83
+ * type. So the honest key type is not "an atom record of some value type" —
84
+ * which is `AtomRecord<any, any>`, and is a claim that every field of one may
85
+ * be read and will answer `any` — it is "something with an atom's name on it".
86
+ * An interface is how Flow says that: `AtomRecord<V, A>` satisfies it for
87
+ * every `V` and `A`, and nothing that comes back out of a map has this type.
88
+ */
89
+ type AtomIdentity = interface { readonly label: string };
90
+
91
+ /**
92
+ * A cell or a binding whose value type is not known here.
93
+ *
94
+ * This is the existential the key type escaped, and it does not escape: a
95
+ * store's maps hold every atom in the application at once, and the lookup that
96
+ * comes back out has to be a `Cell<V>` for the `V` of the atom it was found
97
+ * under. That is a *correspondence* between a key's type and its value's, and
98
+ * Flow has no way to write one — not an existential (`some T` loses which
99
+ * `T`), not variance (`Cell` is invariant, deliberately: a `Cell<Dog>` is not
100
+ * a `Cell<Animal>` because anything holding the second may write a `Cat`), and
101
+ * not `mixed` with a cast at the read, which is this same unsoundness spelled
102
+ * three times instead of twice.
103
+ *
104
+ * `Binding` is the near miss worth recording. Its `T` is only ever returned
105
+ * and its `A` only ever taken, so with the sigils that say so —
106
+ * `Binding<out T, in A>` — every binding really would be a
107
+ * `Binding<mixed, empty>` and could be *stored* as one. It is reading it back
108
+ * as the caller's `Binding<V, A>` that has nowhere to go, so the sigils would
109
+ * buy nothing here and are not added for the look of it.
110
+ *
111
+ * Every function that reaches a value is generic in its type, so nothing
112
+ * outside this file sees either of these.
113
+ */
114
+ // Suppressed rather than left to fail `check:lib`: the reason above is the
115
+ // whole argument, and it does not end in a change anyone can make to this file.
116
+ // uf-lint-disable flow/unclear-type
117
+ type AnyCell = Cell<any>;
118
+ type AnyBinding = Binding<any, any>;
119
+ // uf-lint-enable flow/unclear-type
120
+
121
+ export function createStore(): StoreInstance {
122
+ const cells: WeakMap<AtomIdentity, AnyCell> = new WeakMap();
123
+ const bindings: WeakMap<AtomIdentity, AnyBinding> = new WeakMap();
124
+ /**
125
+ * How to make an asynchronous atom load again, per atom instantiated here.
126
+ *
127
+ * An async atom is two cells — a resource and the projection over it — and
128
+ * `cells` holds the projection, because that is the one an application
129
+ * reads. Refreshing the projection would do nothing: it is a `derived` cell,
130
+ * and recomputing it reads a resource that is perfectly up to date. So the
131
+ * resource is recorded here as it is built, next to the map that hides it.
132
+ */
133
+ const reloads: WeakMap<AtomIdentity, () => void> = new WeakMap();
134
+
135
+ function cellFor<V, A>(target: AtomRecord<V, A>): Cell<V> {
136
+ const existing = cells.get(target);
137
+ if (existing != null) {
138
+ return existing;
139
+ }
140
+ const created = instantiate<V, A>(target);
141
+ cells.set(target, created);
142
+ return created;
143
+ }
144
+
145
+ function instantiate<V, A>(target: AtomRecord<V, A>): Cell<V> {
146
+ const options = cellOptions<V, A>(target);
147
+ return match (target.kind) {
148
+ "primitive" => state(target.initial, options),
149
+ "async" => loadable<V, A>(target, options),
150
+ _ => derivedAtom<V, A>(target, options),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * The cell for an atom of kind `"derived"`.
156
+ *
157
+ * The atom kind and the cell constructor carry the same name one layer
158
+ * apart, so the local one takes the suffix: bare `derived` here is
159
+ * `@uniflowed/cell`'s.
160
+ */
161
+ function derivedAtom<V, A>(target: AtomRecord<V, A>, options: CellOptions<V>): Cell<V> {
162
+ const reader = target.read;
163
+ if (reader === null) {
164
+ // A write-only atom. It still gets a cell, so that `useSetAtom` on one
165
+ // works the same way as on any other atom, but its value is a constant
166
+ // and nothing ever recomputes it.
167
+ return state(target.initial, options);
168
+ }
169
+ return derived(() => reader(get), options);
170
+ }
171
+
172
+ /**
173
+ * An asynchronous atom: a resource that reloads when its inputs change,
174
+ * projected into the [`Loadable`] the atom's readers see.
175
+ *
176
+ * The projection is a separate cell rather than logic inside the resource
177
+ * because the two have different equality: the resource's value changes once
178
+ * per settlement, while the loadable also has to change when the *status*
179
+ * does — a reload that returns to `loading` is a render, even though the
180
+ * data it holds has not changed yet.
181
+ */
182
+ function loadable<V, A>(target: AtomRecord<V, A>, options: CellOptions<V>): Cell<V> {
183
+ const loader = target.load;
184
+ if (loader === null) {
185
+ return state(target.initial, options);
186
+ }
187
+ // The load's own context is forwarded rather than rebuilt: the cell owns
188
+ // the signal, because the cell is what abandons the load.
189
+ const pending = resource((context) => loader(get, context));
190
+ reloads.set(target, () => {
191
+ refresh(pending);
192
+ });
193
+ return derived(() => {
194
+ try {
195
+ // Read first, and unconditionally: this is what makes the projection
196
+ // depend on the resource. A load in flight reads as `null`, and a
197
+ // reload that has not settled reads as the status rather than as the
198
+ // value it still holds — a refetch is a loading state, not stale data
199
+ // presented as current.
200
+ const settled = read(pending);
201
+ return status(pending) === "success" && settled != null ? settled : target.initial;
202
+ } catch (error) {
203
+ // Only a `load` that threw synchronously reaches here: a rejection is
204
+ // folded into the value where the atom was defined.
205
+ const failure: Loadable<mixed> = { state: "hasError", error };
206
+ return failure as $FlowFixMe;
207
+ }
208
+ }, options);
209
+ }
210
+
211
+ function cellOptions<V, A>(target: AtomRecord<V, A>): CellOptions<V> {
212
+ const equals = target.equals;
213
+ const onMount = target.onMount;
214
+ if (onMount === null) {
215
+ return equals === null ? {} : { equals };
216
+ }
217
+ // The mount is handed the atom bound to *this* store, so a subscription it
218
+ // starts feeds this store's value and no other's.
219
+ const mounted = (self: Cell<V>) =>
220
+ onMount({
221
+ get: () => peek(self),
222
+ set: (value: V) => {
223
+ set(target, value as $FlowFixMe);
224
+ },
225
+ subscribe: (listener) => subscribe(self, listener),
226
+ });
227
+ return equals === null ? { onMount: mounted } : { equals, onMount: mounted };
228
+ }
229
+
230
+ const get: AtomGetter = (target) => read(cellFor(target));
231
+
232
+ /**
233
+ * Apply one write.
234
+ *
235
+ * A writable atom's own `write` runs untracked: a writer is not a derive,
236
+ * and a `get` inside one that recorded a dependency would make the atom
237
+ * recompute because of something a *handler* looked at.
238
+ */
239
+ function apply<V, A>(target: AtomRecord<V, A>, argument: A): void {
240
+ const writer = target.write;
241
+ if (writer !== null) {
242
+ untracked(() => {
243
+ writer(get, set, argument);
244
+ });
245
+ return;
246
+ }
247
+ if (target.kind !== "primitive") {
248
+ throw Error(`@uniflowed/state ${target.label} is read-only`);
249
+ }
250
+ const node = cellFor<V, A>(target);
251
+ // A function argument is a reducer, exactly as `useState` reads one — with
252
+ // the same consequence, that an atom holding a function must be written
253
+ // through a reducer returning it.
254
+ if (typeof argument === "function") {
255
+ update(node, argument as $FlowFixMe);
256
+ return;
257
+ }
258
+ write(node, argument as $FlowFixMe);
259
+ }
260
+
261
+ /**
262
+ * Every write is a batch, so an atom whose writer sets three others wakes
263
+ * each subscriber once rather than three times, and no subscriber ever runs
264
+ * against a store that is halfway through one logical change.
265
+ */
266
+ const set: AtomSetter = (target, argument) => {
267
+ batch(() => {
268
+ apply(target, argument);
269
+ });
270
+ };
271
+
272
+ const sub = <V>(target: AtomRecord<V, empty>, listener: () => void): Unsubscribe =>
273
+ subscribe(cellFor(target), listener);
274
+
275
+ /**
276
+ * Start an asynchronous atom's load again with the dependencies it already
277
+ * has.
278
+ *
279
+ * Instantiating first is what makes this work on an atom this store has
280
+ * never been asked for: the resource is built as the atom is instantiated,
281
+ * so the entry exists by the time it is looked up.
282
+ *
283
+ * The throw is a runtime guard behind a static one. `refresh` takes an
284
+ * `AsyncAtom`, which only `asyncAtom` produces, so a `selector` that happens
285
+ * to return a `Loadable` is rejected by Flow before it gets here — but the
286
+ * type is erased at run time and an untyped caller deserves the name of the
287
+ * atom rather than a silent no-op.
288
+ */
289
+ const reload = <V>(target: AtomRecord<V, empty>): void => {
290
+ cellFor(target);
291
+ const again = reloads.get(target);
292
+ if (again === undefined) {
293
+ throw Error(`@uniflowed/state ${target.label} is not an asynchronous atom`);
294
+ }
295
+ again();
296
+ };
297
+
298
+ function bind<V, A>(target: AtomRecord<V, A>): Binding<V, A> {
299
+ const existing = bindings.get(target);
300
+ if (existing != null) {
301
+ return existing;
302
+ }
303
+ const created: Binding<V, A> = {
304
+ subscribe: (listener) => subscribe(cellFor(target), listener),
305
+ // `peek`, not `read`: a snapshot taken during a React render must not
306
+ // become a dependency of whatever happens to be evaluating.
307
+ snapshot: () => peek(cellFor(target)),
308
+ setter: (argument) => {
309
+ set(target, argument);
310
+ },
311
+ };
312
+ bindings.set(target, created);
313
+ return created;
314
+ }
315
+
316
+ return { get, set, sub, bind, reload };
317
+ }
318
+
319
+ /**
320
+ * The same three callbacks for a cell that is not an atom.
321
+ *
322
+ * `@uniflowed/cell` is the layer below this one and applications do reach it
323
+ * — a route loader hands out cells — so the React binding accepts one
324
+ * directly. There is no store involved: a cell already holds its own value,
325
+ * which is precisely the difference between a cell and an atom.
326
+ */
327
+ const cellBindings: WeakMap<AnyCell, AnyBinding> = new WeakMap();
328
+
329
+ export function bindCell<T>(source: Cell<T>): Binding<T, T> {
330
+ const existing = cellBindings.get(source);
331
+ if (existing != null) {
332
+ return existing;
333
+ }
334
+ const created: Binding<T, T> = {
335
+ subscribe: (listener) => subscribe(source, listener),
336
+ snapshot: () => peek(source),
337
+ setter: (value) => {
338
+ write(source, value);
339
+ },
340
+ };
341
+ cellBindings.set(source, created);
342
+ return created;
343
+ }
344
+
345
+ /**
346
+ * The store used by anything that does not name one.
347
+ *
348
+ * Created on first use rather than at import, so a module that imports this
349
+ * package and only declares atoms allocates nothing — and so the cost lands in
350
+ * a stack the profiler can attribute.
351
+ */
352
+ let fallback: null | StoreInstance = null;
353
+
354
+ export function defaultStore(): StoreInstance {
355
+ if (fallback === null) {
356
+ fallback = createStore();
357
+ }
358
+ return fallback;
359
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@uniflowed/state",
3
+ "version": "0.0.0-alpha.18",
4
+ "description": "Atoms and the React binding for @uniflowed/state, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/state"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "internal",
19
+ "!*.test.js"
20
+ ],
21
+ "dependencies": {
22
+ "@uniflowed/cell": "0.0.0-alpha.18",
23
+ "@uniflowed/react": "0.0.0-alpha.18"
24
+ }
25
+ }