@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,213 @@
1
+ // @flow
2
+ //
3
+ // What an atom is before any store exists.
4
+ //
5
+ // An atom is a *definition*, not a value. `atom(0)` allocates no state,
6
+ // belongs to nothing, and can be declared at module scope in a file that a
7
+ // server imports. The value lives in a store, and the same definition has a
8
+ // different value in every store that ever instantiates it.
9
+ //
10
+ // # Why definitions and values are separated
11
+ //
12
+ // The alternative — an atom that holds its own value, which is what this
13
+ // package used to ship — is simpler right up to the point where two of
14
+ // something must exist at once, and then it is unfixable:
15
+ //
16
+ // * a server renders two requests concurrently in one process, and module
17
+ // state is shared between them, so one user's cart is the other user's cart;
18
+ // * a test writes an atom and the next test in the same file inherits it;
19
+ // * a component tree wants its own copy — a preview pane, a modal editing a
20
+ // draft of what the page behind it shows — and there is no copy to have.
21
+ //
22
+ // A store is the unit of isolation those three want, and `<Provider>` is how a
23
+ // React subtree picks one. This is also Jotai's model, for the same reasons.
24
+ //
25
+ // # Why the record carries functions rather than a class
26
+ //
27
+ // A definition is a plain frozen-shaped record of the two functions a store
28
+ // needs — how to read it, and what happens when it is written — so the store
29
+ // is a `WeakMap` lookup plus a call, and an atom that is never read costs one
30
+ // object. There is no base class to extend and no registry holding a reference
31
+ // to every atom ever declared, which is what lets an `atomFamily` member be
32
+ // collected once the key is gone.
33
+
34
+ import type { LoadContext, Unsubscribe } from "@uniflowed/cell";
35
+
36
+ /** What a setter accepts: a value, or a reducer over the current one. */
37
+ export type SetAction<T> = T | ((current: T) => T);
38
+
39
+ /**
40
+ * The three states an asynchronous read can be in.
41
+ *
42
+ * A discriminated union rather than `{ loading, data, error }` with three
43
+ * optional fields, because two of those eight combinations are nonsense and
44
+ * `match` can prove this one is exhaustive.
45
+ */
46
+ export type Loadable<T> =
47
+ | { readonly state: "loading" }
48
+ | { readonly state: "hasData", readonly data: T }
49
+ | { readonly state: "hasError", readonly error: mixed };
50
+
51
+ /**
52
+ * What an atom's `onMount` is handed: the atom, bound to the store it was
53
+ * mounted in.
54
+ *
55
+ * `set` goes through the store's ordinary write path, so an atom that mounts a
56
+ * subscription feeds values in exactly the way an event handler would.
57
+ */
58
+ export type AtomMount<T> = {
59
+ readonly get: () => T,
60
+ readonly set: (value: T) => void,
61
+ readonly subscribe: (listener: () => void) => Unsubscribe,
62
+ };
63
+
64
+ /**
65
+ * Reading another atom, from inside a read or a write.
66
+ *
67
+ * Inside a read this is also what records the dependency — which is why a read
68
+ * that branches depends on the branch it took and not on the other one.
69
+ */
70
+ export type AtomGetter = <V>(target: AtomRecord<V, empty>) => V;
71
+
72
+ /** Writing another atom, from inside a write. */
73
+ export type AtomSetter = <V, A>(target: AtomRecord<V, A>, arg: A) => void;
74
+
75
+ /**
76
+ * An atom, as the store sees it.
77
+ *
78
+ * Every field is read-only, which is what makes `AtomRecord<T, A>` a subtype
79
+ * of `AtomRecord<T, empty>`: a writable atom is accepted anywhere a readable
80
+ * one is, and the argument type stays exact at the call site rather than
81
+ * widening to `mixed` the moment an atom is passed somewhere general.
82
+ */
83
+ export type AtomRecord<T, A> = {
84
+ readonly kind: "primitive" | "derived" | "async",
85
+ /** For diagnostics only. Never load-bearing. */
86
+ readonly label: string,
87
+ /** The value before anything is computed: a primitive's initial value, an
88
+ * async atom's `loading`, a write-only atom's `null`. */
89
+ readonly initial: T,
90
+ readonly read: null | ((get: AtomGetter) => T),
91
+ readonly load: null | ((get: AtomGetter, context: LoadContext) => Promise<T>),
92
+ readonly write: null | ((get: AtomGetter, set: AtomSetter, arg: A) => void),
93
+ readonly equals: null | ((previous: T, next: T) => boolean),
94
+ readonly onMount: null | ((mount: AtomMount<T>) => void | (() => void)),
95
+ };
96
+
97
+ /** What every constructor accepts. */
98
+ export type AtomOptions<T> = {
99
+ readonly debugLabel?: string,
100
+ readonly equals?: (previous: T, next: T) => boolean,
101
+ };
102
+
103
+ /** What a primitive atom accepts, which is a mount as well. */
104
+ export type PrimitiveOptions<T> = {
105
+ readonly debugLabel?: string,
106
+ readonly equals?: (previous: T, next: T) => boolean,
107
+ readonly onMount?: (mount: AtomMount<T>) => void | (() => void),
108
+ };
109
+
110
+ /**
111
+ * The fields every kind of atom shares, filled in from whichever options that
112
+ * kind accepts.
113
+ *
114
+ * The options parameter is inexact on purpose: a primitive's options carry an
115
+ * `onMount` that the other kinds do not have, and this is the one place all of
116
+ * them meet.
117
+ */
118
+ function baseRecord<T, A>(
119
+ kind: "primitive" | "derived" | "async",
120
+ label: string,
121
+ initial: T,
122
+ options: void | {
123
+ readonly debugLabel?: string,
124
+ readonly equals?: (previous: T, next: T) => boolean,
125
+ ...
126
+ },
127
+ ): AtomRecord<T, A> {
128
+ return {
129
+ kind,
130
+ label: options?.debugLabel ?? label,
131
+ initial,
132
+ read: null,
133
+ load: null,
134
+ write: null,
135
+ equals: options?.equals ?? null,
136
+ onMount: null,
137
+ };
138
+ }
139
+
140
+ /** A value a store holds directly. */
141
+ export function definePrimitive<T>(
142
+ initial: T,
143
+ options?: PrimitiveOptions<T>,
144
+ ): AtomRecord<T, SetAction<T>> {
145
+ return {
146
+ ...baseRecord("primitive", "atom", initial, options),
147
+ onMount: options?.onMount ?? null,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * A value computed from other atoms, with an optional write of its own.
153
+ *
154
+ * The record's `initial` is never read for one of these — a selector's value
155
+ * comes from running `read` — but the field exists for the kinds that do have
156
+ * one, so the placeholder is cast here, once, rather than at every call site.
157
+ */
158
+ export function defineSelector<T, A>(
159
+ read: (get: AtomGetter) => T,
160
+ write: null | ((get: AtomGetter, set: AtomSetter, argument: A) => void),
161
+ options?: AtomOptions<T>,
162
+ ): AtomRecord<T, A> {
163
+ const unevaluated: T = null as $FlowFixMe;
164
+ return { ...baseRecord("derived", "selector", unevaluated, options), read, write };
165
+ }
166
+
167
+ /**
168
+ * A write with no value: an action.
169
+ *
170
+ * Its value really is `null` rather than a placeholder, so reading one is
171
+ * defined behaviour, and a component that only dispatches never subscribes to
172
+ * anything.
173
+ */
174
+ export function defineAction<A>(
175
+ write: (get: AtomGetter, set: AtomSetter, argument: A) => void,
176
+ options?: AtomOptions<null>,
177
+ ): AtomRecord<null, A> {
178
+ return { ...baseRecord("derived", "action", null, options), write };
179
+ }
180
+
181
+ /**
182
+ * A value that arrives from a promise, projected into a [`Loadable`].
183
+ *
184
+ * The rejection is folded into the value here, at definition time, rather than
185
+ * left for the store: a promise that rejects with nobody attached is an
186
+ * unhandled rejection, and the store attaches its handler one turn later than
187
+ * this does.
188
+ *
189
+ * Which is also why the abandoned-load check cannot live here. Folding turns
190
+ * every rejection into a *resolution* carrying `{ state: "hasError" }`, so by
191
+ * the time a value reaches the cell there is nothing left to recognise an
192
+ * aborted load by. The cell decides whether a settlement still speaks for it,
193
+ * before this projection is ever consulted — see `@uniflowed/cell`'s
194
+ * `internal/resource.js`.
195
+ */
196
+ export function defineAsync<T>(
197
+ load: (get: AtomGetter, context: LoadContext) => Promise<T>,
198
+ options?: AtomOptions<Loadable<T>>,
199
+ ): AtomRecord<Loadable<T>, empty> {
200
+ const pending: Loadable<T> = { state: "loading" };
201
+ return {
202
+ ...baseRecord("async", "asyncAtom", pending, options),
203
+ load: (get, context) => load(get, context).then(asData, asError),
204
+ };
205
+ }
206
+
207
+ function asData<T>(data: T): Loadable<T> {
208
+ return { state: "hasData", data };
209
+ }
210
+
211
+ function asError<T>(error: mixed): Loadable<T> {
212
+ return { state: "hasError", error };
213
+ }