gesso-framework 0.1.0
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/FunctionComponent-DAyf5HQ6.d.ts +716 -0
- package/dist/createComponent-3n1ZSql8.js +254 -0
- package/dist/createComponent-3n1ZSql8.js.map +1 -0
- package/dist/index-BkYPVXSJ.d.ts +7272 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8720 -0
- package/dist/index.js.map +1 -0
- package/dist/jsx/jsx-dev-runtime.d.ts +2 -0
- package/dist/jsx/jsx-dev-runtime.js +2 -0
- package/dist/jsx/jsx-runtime.d.ts +117 -0
- package/dist/jsx/jsx-runtime.js +153 -0
- package/dist/jsx/jsx-runtime.js.map +1 -0
- package/dist/persisted-Ddb51avc.js +2648 -0
- package/dist/persisted-Ddb51avc.js.map +1 -0
- package/dist/worker/index.d.ts +32 -0
- package/dist/worker/index.js +2 -0
- package/package.json +71 -0
|
@@ -0,0 +1,2648 @@
|
|
|
1
|
+
import { BehaviorSubject, Observable, Subject, Subscription, combineLatest, debounceTime, distinctUntilChanged, map, of, skip, tap, throttleTime } from "rxjs";
|
|
2
|
+
//#region src/Input.ts
|
|
3
|
+
/**
|
|
4
|
+
* Reactive cell holding a component input.
|
|
5
|
+
*
|
|
6
|
+
* Inputs must be cells because render() runs exactly once. A component
|
|
7
|
+
* that read a plain input value during render would capture it for the
|
|
8
|
+
* life of the instance, so a parent supplying new props under a
|
|
9
|
+
* dynamic subtree would update the field while the rendered tree kept
|
|
10
|
+
* showing the original value.
|
|
11
|
+
*
|
|
12
|
+
* The cell is written by the component host only: it accepts whatever
|
|
13
|
+
* the parent passed, subscribing it first when the parent passed an
|
|
14
|
+
* Observable. `value` is deliberately read-only, so `this.label.value =
|
|
15
|
+
* x` inside a component is a compile error rather than a silent
|
|
16
|
+
* violation of the one-way data flow.
|
|
17
|
+
*/
|
|
18
|
+
var InputCell = class extends BehaviorSubject {
|
|
19
|
+
/**
|
|
20
|
+
* What to call this cell in a warning: `Component.prop`, or
|
|
21
|
+
* `channel.key`. Set by whoever creates it; a cell without one is
|
|
22
|
+
* reported as "a cell".
|
|
23
|
+
*/
|
|
24
|
+
label;
|
|
25
|
+
/** The component whose body read `.value`, for the stale-read warning below. */
|
|
26
|
+
snapshotBy = null;
|
|
27
|
+
warnedStale = false;
|
|
28
|
+
/** Everything emitted through `emit`, created on the first `events` read. */
|
|
29
|
+
emitted = null;
|
|
30
|
+
constructor(initialValue) {
|
|
31
|
+
super(initialValue);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Fires the output this cell stands for.
|
|
35
|
+
*
|
|
36
|
+
* An output is a cell whose value is what the parent gave to be
|
|
37
|
+
* called: a function, or a target made by `into(subject)`. `emit`
|
|
38
|
+
* calls it with the arguments, and also pushes the first argument
|
|
39
|
+
* through `events`, so the component can fire from three places
|
|
40
|
+
* without passing the cell around and a parent that wants a stream
|
|
41
|
+
* can have one. A parent that passed nothing is fine: the call
|
|
42
|
+
* simply reaches nobody.
|
|
43
|
+
*/
|
|
44
|
+
emit(...args) {
|
|
45
|
+
const handler = super.getValue();
|
|
46
|
+
if (typeof handler === "function") handler(...args);
|
|
47
|
+
this.emitted?.next(args[0]);
|
|
48
|
+
}
|
|
49
|
+
/** What `emit` has fired, as a stream: the first argument of each call. */
|
|
50
|
+
get events() {
|
|
51
|
+
if (this.emitted === null) this.emitted = new Subject();
|
|
52
|
+
return this.emitted.asObservable();
|
|
53
|
+
}
|
|
54
|
+
get value() {
|
|
55
|
+
trackRead(this);
|
|
56
|
+
if (bodyOf !== null && tracking === null && this.snapshotBy === null) this.snapshotBy = bodyOf;
|
|
57
|
+
return super.getValue();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The one place the run-once model goes quietly wrong is a body that
|
|
61
|
+
* reads `props.x.value`, uses the value to build the tree, and never
|
|
62
|
+
* hears that it changed. Nothing crashes; the screen is simply stale.
|
|
63
|
+
* So a cell remembers being read while a body ran, and if it later
|
|
64
|
+
* changes with nobody subscribed to it, it says so once. A cell that
|
|
65
|
+
* something is following is fine: the follower carries the change.
|
|
66
|
+
*/
|
|
67
|
+
next(value) {
|
|
68
|
+
if (this.snapshotBy !== null && !this.warnedStale && !this.observed && !Object.is(value, super.getValue())) {
|
|
69
|
+
this.warnedStale = true;
|
|
70
|
+
warnStaleRead(this.snapshotBy, this.label, super.getValue(), value);
|
|
71
|
+
}
|
|
72
|
+
super.next(value);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** The component whose function body is running, while one is. */
|
|
76
|
+
let bodyOf = null;
|
|
77
|
+
/** The component whose body is running, for a cell that wants to remember being read there. */
|
|
78
|
+
function currentBody() {
|
|
79
|
+
return bodyOf;
|
|
80
|
+
}
|
|
81
|
+
/** The set a running `computed` is collecting its reads into, while one is. */
|
|
82
|
+
let tracking = null;
|
|
83
|
+
/** Records a `.value` read for whatever `computed` is running, if one is. */
|
|
84
|
+
function trackRead(cell) {
|
|
85
|
+
tracking?.add(cell);
|
|
86
|
+
}
|
|
87
|
+
/** Runs `run` with every `.value` read on the way recorded into `into`. */
|
|
88
|
+
function withTracking(into, run) {
|
|
89
|
+
const previous = tracking;
|
|
90
|
+
tracking = into;
|
|
91
|
+
try {
|
|
92
|
+
return run();
|
|
93
|
+
} finally {
|
|
94
|
+
tracking = previous;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Runs a component's body with its name on record, so a `.value` read
|
|
99
|
+
* inside it can be told apart from one in an event handler later, which
|
|
100
|
+
* is the ordinary way to read the current value and warns about nothing.
|
|
101
|
+
*/
|
|
102
|
+
function withBodyOf(tag, run) {
|
|
103
|
+
const previous = bodyOf;
|
|
104
|
+
bodyOf = tag;
|
|
105
|
+
try {
|
|
106
|
+
return run();
|
|
107
|
+
} finally {
|
|
108
|
+
bodyOf = previous;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function warnStaleRead(tag, label, from, to) {
|
|
112
|
+
const what = label === void 0 ? "a cell" : `\`${label}\``;
|
|
113
|
+
const change = describeChange(from, to);
|
|
114
|
+
console.warn(`Component '${tag}' read ${what} with .value while its body ran, and nothing is following that cell. It has since changed ${change}, and whatever was built from the first value still shows it. A component body runs once: bind the cell instead (pass it, or pipe it, into the prop it feeds), or give the component a key so a new value builds a new one.`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* "from X to Y", or for two plain objects the first field that differs,
|
|
118
|
+
* because two objects that print alike for sixty characters say nothing
|
|
119
|
+
* about what actually moved.
|
|
120
|
+
*/
|
|
121
|
+
function describeChange(from, to) {
|
|
122
|
+
if (isPlainObject$2(from) && isPlainObject$2(to)) {
|
|
123
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(from), ...Object.keys(to)])) {
|
|
124
|
+
const before = from[key];
|
|
125
|
+
const after = to[key];
|
|
126
|
+
if (!Object.is(before, after) && safeJson(before) !== safeJson(after)) return `at .${key}, from ${describe(before)} to ${describe(after)}`;
|
|
127
|
+
}
|
|
128
|
+
return "to an equal-looking object";
|
|
129
|
+
}
|
|
130
|
+
return `from ${describe(from)} to ${describe(to)}`;
|
|
131
|
+
}
|
|
132
|
+
function isPlainObject$2(value) {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
function safeJson(value) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.stringify(value) ?? String(value);
|
|
138
|
+
} catch {
|
|
139
|
+
return String(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function describe(value) {
|
|
143
|
+
let text;
|
|
144
|
+
try {
|
|
145
|
+
text = typeof value === "function" ? "a function" : JSON.stringify(value) ?? String(value);
|
|
146
|
+
} catch {
|
|
147
|
+
text = String(value);
|
|
148
|
+
}
|
|
149
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
150
|
+
}
|
|
151
|
+
function input(first, fallback) {
|
|
152
|
+
if (arguments.length < 2 || !(first instanceof InputCell)) return new InputCell(first);
|
|
153
|
+
const source = first;
|
|
154
|
+
const withFallback = (value) => value === void 0 ? fallback : value;
|
|
155
|
+
const derived = new InputCell(withFallback(source.value));
|
|
156
|
+
derived.label = source.label;
|
|
157
|
+
source.subscribe({
|
|
158
|
+
next: (value) => derived.next(withFallback(value)),
|
|
159
|
+
complete: () => derived.complete()
|
|
160
|
+
});
|
|
161
|
+
return derived;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* An output a class component declares as a field:
|
|
165
|
+
*
|
|
166
|
+
* @Output() changed = output<[value: number]>();
|
|
167
|
+
*
|
|
168
|
+
* The host wires the parent's handler into it exactly as it wires an
|
|
169
|
+
* input, and `this.changed.emit(next)` fires it.
|
|
170
|
+
*/
|
|
171
|
+
function output() {
|
|
172
|
+
return new InputCell(void 0);
|
|
173
|
+
}
|
|
174
|
+
const OUTPUT_TARGET = Symbol("gesso:output-target");
|
|
175
|
+
function into(target) {
|
|
176
|
+
return { [OUTPUT_TARGET]: target };
|
|
177
|
+
}
|
|
178
|
+
function isOutputTarget(value) {
|
|
179
|
+
return typeof value === "object" && value !== null && OUTPUT_TARGET in value;
|
|
180
|
+
}
|
|
181
|
+
/** The receiver an `into()` target wraps. */
|
|
182
|
+
function outputTargetOf(target) {
|
|
183
|
+
return target[OUTPUT_TARGET];
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/InternalState.ts
|
|
187
|
+
/**
|
|
188
|
+
* A component's own state: originated here, and never crossing the
|
|
189
|
+
* barrier.
|
|
190
|
+
*
|
|
191
|
+
* The writable counterpart to `InputCell`. The two are the same
|
|
192
|
+
* `BehaviorSubject` and differ by one accessor — this one has a
|
|
193
|
+
* `.value` setter — and that difference is the whole semantics:
|
|
194
|
+
*
|
|
195
|
+
* internalState() never crosses I write it
|
|
196
|
+
* input() crosses inward someone else writes it
|
|
197
|
+
*
|
|
198
|
+
* Named on that axis deliberately. It used to be `state()`, which
|
|
199
|
+
* described *what* a thing was while `input()` described *where it
|
|
200
|
+
* came from*; two names on two axes made neither of them tell you
|
|
201
|
+
* anything about the other. `internalState(products)` reads as a
|
|
202
|
+
* mistake at the call site in a way `state(products)` never did.
|
|
203
|
+
*
|
|
204
|
+
* For values that originate on this thread and die with the component:
|
|
205
|
+
* a tooltip's open flag, a caret, a scroll offset, the active tab.
|
|
206
|
+
* Anything that survives a reload, or that another screen cares about,
|
|
207
|
+
* is application state and belongs on a channel. Anything derived from
|
|
208
|
+
* other cells is a `computed`.
|
|
209
|
+
*
|
|
210
|
+
* It is not only for components. The thread that owns a channel's data
|
|
211
|
+
* writes cells too, and wrote them as a `BehaviorSubject` mirrored
|
|
212
|
+
* into an `asObservable()` because this was reachable only through the
|
|
213
|
+
* framework's main entry. `gesso-framework/worker` is the same cell
|
|
214
|
+
* with none of the renderer behind it, so an application worker holds
|
|
215
|
+
* one cell rather than a subject and a copy of it, and reads it with
|
|
216
|
+
* `.value` in a `computed` rather than listing it in a
|
|
217
|
+
* `combineLatest`. "Internal" still means what it says there: written
|
|
218
|
+
* here, and crossing the barrier only as the plain data a view key
|
|
219
|
+
* publishes.
|
|
220
|
+
*/
|
|
221
|
+
var InternalState = class extends BehaviorSubject {
|
|
222
|
+
/** What to call this cell in a warning or the inspector; optional. */
|
|
223
|
+
label;
|
|
224
|
+
constructor(initialValue) {
|
|
225
|
+
super(initialValue);
|
|
226
|
+
}
|
|
227
|
+
get value() {
|
|
228
|
+
trackRead(this);
|
|
229
|
+
return super.getValue();
|
|
230
|
+
}
|
|
231
|
+
set value(next) {
|
|
232
|
+
this.next(next);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Creates a reactive state cell.
|
|
237
|
+
*
|
|
238
|
+
* Usage inside a component:
|
|
239
|
+
*
|
|
240
|
+
* private readonly count = state(0);
|
|
241
|
+
*
|
|
242
|
+
* increment() {
|
|
243
|
+
* this.count.value++;
|
|
244
|
+
* }
|
|
245
|
+
*/
|
|
246
|
+
function internalState(initialValue, label) {
|
|
247
|
+
const state = new InternalState(initialValue);
|
|
248
|
+
if (label !== void 0) state.label = label;
|
|
249
|
+
return state;
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/channel/structuralEquals.ts
|
|
253
|
+
/**
|
|
254
|
+
* Deep comparison for store read models.
|
|
255
|
+
*
|
|
256
|
+
* Projections and selectors allocate a fresh value on every
|
|
257
|
+
* evaluation, so reference equality reports a change on every
|
|
258
|
+
* unrelated state emission. That invalidates bindings and dirties
|
|
259
|
+
* nodes across the whole tree for state the view never read.
|
|
260
|
+
*
|
|
261
|
+
* Scope is deliberately narrow: primitives, arrays, and plain objects
|
|
262
|
+
* — what a projection is allowed to return. Anything else (class
|
|
263
|
+
* instances, Date, Map, Set, functions) compares by reference, which
|
|
264
|
+
* is conservative: it reports a change, so the UI updates when it did
|
|
265
|
+
* not need to rather than failing to update when it did.
|
|
266
|
+
*
|
|
267
|
+
* The same comparison becomes the equality half of the patch differ
|
|
268
|
+
* in Phase E, so a store behaves identically local or remote.
|
|
269
|
+
*/
|
|
270
|
+
const MAX_DEPTH$1 = 100;
|
|
271
|
+
function structurallyEqual(a, b) {
|
|
272
|
+
return compare(a, b, 0);
|
|
273
|
+
}
|
|
274
|
+
function compare(a, b, depth) {
|
|
275
|
+
if (Object.is(a, b)) return true;
|
|
276
|
+
if (depth > MAX_DEPTH$1) return false;
|
|
277
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
278
|
+
const aIsArray = Array.isArray(a);
|
|
279
|
+
if (aIsArray !== Array.isArray(b)) return false;
|
|
280
|
+
if (aIsArray) {
|
|
281
|
+
const left = a;
|
|
282
|
+
const right = b;
|
|
283
|
+
if (left.length !== right.length) return false;
|
|
284
|
+
for (let i = 0; i < left.length; i++) if (!compare(left[i], right[i], depth + 1)) return false;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if (!isPlainObject$1(a) || !isPlainObject$1(b)) return false;
|
|
288
|
+
const left = a;
|
|
289
|
+
const right = b;
|
|
290
|
+
const leftKeys = Object.keys(left);
|
|
291
|
+
if (leftKeys.length !== Object.keys(right).length) return false;
|
|
292
|
+
for (const key of leftKeys) {
|
|
293
|
+
if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
|
|
294
|
+
if (!compare(left[key], right[key], depth + 1)) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Objects created from an object literal or a null prototype. Class
|
|
300
|
+
* instances are excluded so they keep reference semantics.
|
|
301
|
+
*/
|
|
302
|
+
function isPlainObject$1(value) {
|
|
303
|
+
const prototype = Object.getPrototypeOf(value);
|
|
304
|
+
return prototype === Object.prototype || prototype === null;
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/derive.ts
|
|
308
|
+
/**
|
|
309
|
+
* One value from several, kept equal to `project` of the latest of each
|
|
310
|
+
* source, and emitted only when it changes.
|
|
311
|
+
*
|
|
312
|
+
* This is `combineLatest(...).pipe(map(...), distinctUntilChanged())`,
|
|
313
|
+
* which is what nearly every derived binding in a component body wants
|
|
314
|
+
* and what nearly every one had to write out. Sources are cells or any
|
|
315
|
+
* Observables; the result is what a prop takes.
|
|
316
|
+
*
|
|
317
|
+
* const playing = derive([queue.view.playlistId, audio.state], (id, state) =>
|
|
318
|
+
* id === card.id && state.status === 'playing'
|
|
319
|
+
* );
|
|
320
|
+
*
|
|
321
|
+
* @deprecated Write it as a `computed`, which is the one derivation the
|
|
322
|
+
* documentation teaches. A cell is read with
|
|
323
|
+
* `.value` and anything else through the `read` the function is handed,
|
|
324
|
+
* so the sources are the reads themselves and there is no list beside
|
|
325
|
+
* the expression to keep in step with it:
|
|
326
|
+
*
|
|
327
|
+
* const playing = computed(read =>
|
|
328
|
+
* queue.view.playlistId.value === card.id && read(audio.state).status === 'playing'
|
|
329
|
+
* );
|
|
330
|
+
*
|
|
331
|
+
* It still works and nothing that uses it needs changing today.
|
|
332
|
+
*/
|
|
333
|
+
function derive(sources, project, options = {}) {
|
|
334
|
+
const equal = equalityOf(options.equal ?? "reference");
|
|
335
|
+
return combineLatest(sources).pipe(map((values) => project(...values)), distinctUntilChanged(equal));
|
|
336
|
+
}
|
|
337
|
+
/** The comparison an `Equality` names. Shared with `computed`. */
|
|
338
|
+
function equalityOf(equal) {
|
|
339
|
+
if (equal === "reference") return Object.is;
|
|
340
|
+
if (equal === "structural") return (a, b) => structurallyEqual(a, b);
|
|
341
|
+
return equal;
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/computed.ts
|
|
345
|
+
/**
|
|
346
|
+
* A cell whose value is a function of other cells.
|
|
347
|
+
*
|
|
348
|
+
* `computed(() => quantity.value * price.value)` reads like the value it
|
|
349
|
+
* is. The cells its function reads through `.value` are its sources,
|
|
350
|
+
* found by running the function and watching what it touches, so there
|
|
351
|
+
* is no list to keep in step with the expression; a read the function
|
|
352
|
+
* did not make this time is a source it no longer has.
|
|
353
|
+
*
|
|
354
|
+
* A stream that is not a cell is read through the `read` the function
|
|
355
|
+
* is handed: `computed(read => read(stream).status)` follows the stream
|
|
356
|
+
* as it follows a cell. That is what makes this the only derivation an
|
|
357
|
+
* application needs, whether or not the thing it derives from happens
|
|
358
|
+
* to have a current value of its own.
|
|
359
|
+
*
|
|
360
|
+
* It is a cell and nothing else. Bound to a prop it is an Observable
|
|
361
|
+
* like every other cell, so a component written with it and one written
|
|
362
|
+
* with `pipe` compose without translation. Read in a handler with
|
|
363
|
+
* `.value` it is the current result, computed on the spot if nothing is
|
|
364
|
+
* following it. RxJS is underneath and nothing here replaces it.
|
|
365
|
+
*
|
|
366
|
+
* Nothing runs until someone asks. With no subscriber the function runs
|
|
367
|
+
* only when `.value` is read; with one, the cell follows its sources and
|
|
368
|
+
* emits a result when it differs from the last by `equal`. When the last
|
|
369
|
+
* subscriber leaves it lets go of its sources, so a computed made in a
|
|
370
|
+
* component body dies with the component's bindings and needs no
|
|
371
|
+
* disposal of its own.
|
|
372
|
+
*
|
|
373
|
+
* Reading it with `.value` while a component body runs is the same
|
|
374
|
+
* snapshot an input read there is, and it warns the same way: once, if
|
|
375
|
+
* it later changes with nobody following.
|
|
376
|
+
*/
|
|
377
|
+
var ComputedCell = class extends Observable {
|
|
378
|
+
compute;
|
|
379
|
+
label;
|
|
380
|
+
equal;
|
|
381
|
+
changes = new Subject();
|
|
382
|
+
sources = /* @__PURE__ */ new Set();
|
|
383
|
+
cached;
|
|
384
|
+
hasValue = false;
|
|
385
|
+
upstream = null;
|
|
386
|
+
subscribers = 0;
|
|
387
|
+
attaching = false;
|
|
388
|
+
snapshotBy = null;
|
|
389
|
+
warnedStale = false;
|
|
390
|
+
/** Follows the sources after a body read, only to notice the change the body will not. */
|
|
391
|
+
staleWatch = null;
|
|
392
|
+
constructor(compute, options = {}) {
|
|
393
|
+
super((subscriber) => {
|
|
394
|
+
this.subscribers++;
|
|
395
|
+
this.staleWatch?.unsubscribe();
|
|
396
|
+
this.staleWatch = null;
|
|
397
|
+
if (this.upstream === null) this.attach();
|
|
398
|
+
subscriber.next(this.cached);
|
|
399
|
+
const following = this.changes.subscribe(subscriber);
|
|
400
|
+
return () => {
|
|
401
|
+
following.unsubscribe();
|
|
402
|
+
this.subscribers--;
|
|
403
|
+
if (this.subscribers === 0) this.detach();
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
this.compute = compute;
|
|
407
|
+
this.equal = equalityOf(options.equal ?? "reference");
|
|
408
|
+
this.label = options.label;
|
|
409
|
+
}
|
|
410
|
+
/** The current result: kept by the sources while followed, computed now when not. */
|
|
411
|
+
get value() {
|
|
412
|
+
trackRead(this);
|
|
413
|
+
if (this.upstream === null) this.recompute();
|
|
414
|
+
const body = currentBody();
|
|
415
|
+
if (body !== null && this.snapshotBy === null) {
|
|
416
|
+
this.snapshotBy = body;
|
|
417
|
+
this.watchForStaleRead();
|
|
418
|
+
}
|
|
419
|
+
return this.cached;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* A body read the value once; if a source now changes with nothing
|
|
423
|
+
* following this cell, the screen built from that read is stale and
|
|
424
|
+
* nobody would know. So the sources are watched for exactly that, and
|
|
425
|
+
* the watch ends with the warning or with a real subscriber arriving.
|
|
426
|
+
*/
|
|
427
|
+
watchForStaleRead() {
|
|
428
|
+
this.staleWatch = new Subscription();
|
|
429
|
+
let settling = true;
|
|
430
|
+
for (const source of this.sources) this.staleWatch.add(source.subscribe(() => {
|
|
431
|
+
if (settling || this.observed) return;
|
|
432
|
+
if (this.recompute()) {
|
|
433
|
+
this.warnStale();
|
|
434
|
+
this.staleWatch?.unsubscribe();
|
|
435
|
+
this.staleWatch = null;
|
|
436
|
+
}
|
|
437
|
+
}));
|
|
438
|
+
settling = false;
|
|
439
|
+
}
|
|
440
|
+
warnStale() {
|
|
441
|
+
if (this.snapshotBy === null || this.warnedStale) return;
|
|
442
|
+
this.warnedStale = true;
|
|
443
|
+
console.warn(`Component '${this.snapshotBy}' read ${this.label === void 0 ? "a computed cell" : `\`${this.label}\``} with .value while its body ran, and nothing is following that cell. It has since changed, and whatever was built from the first value still shows it. A component body runs once: bind the cell instead.`);
|
|
444
|
+
}
|
|
445
|
+
/** Whether anything is following this cell; the stale-read warning's question. */
|
|
446
|
+
get observed() {
|
|
447
|
+
return this.subscribers > 0;
|
|
448
|
+
}
|
|
449
|
+
/** Runs the function, watching what it reads; true when the result changed. */
|
|
450
|
+
recompute() {
|
|
451
|
+
const touched = /* @__PURE__ */ new Set();
|
|
452
|
+
const next = withTracking(touched, () => this.compute(readSource));
|
|
453
|
+
this.sources = touched;
|
|
454
|
+
const changed = !this.hasValue || !this.equal(this.cached, next);
|
|
455
|
+
this.hasValue = true;
|
|
456
|
+
this.cached = next;
|
|
457
|
+
return changed;
|
|
458
|
+
}
|
|
459
|
+
/** Follows the current sources, re-running on any change to them. */
|
|
460
|
+
attach() {
|
|
461
|
+
this.attaching = true;
|
|
462
|
+
this.recompute();
|
|
463
|
+
this.upstream = new Subscription();
|
|
464
|
+
for (const source of this.sources) this.upstream.add(source.subscribe(() => this.onSourceChanged()));
|
|
465
|
+
this.attaching = false;
|
|
466
|
+
}
|
|
467
|
+
detach() {
|
|
468
|
+
this.upstream?.unsubscribe();
|
|
469
|
+
this.upstream = null;
|
|
470
|
+
}
|
|
471
|
+
onSourceChanged() {
|
|
472
|
+
if (this.attaching) return;
|
|
473
|
+
const before = new Set(this.sources);
|
|
474
|
+
const changed = this.recompute();
|
|
475
|
+
if (!sameSet(before, this.sources)) {
|
|
476
|
+
this.detach();
|
|
477
|
+
this.attaching = true;
|
|
478
|
+
this.upstream = new Subscription();
|
|
479
|
+
for (const source of this.sources) this.upstream.add(source.subscribe(() => this.onSourceChanged()));
|
|
480
|
+
this.attaching = false;
|
|
481
|
+
}
|
|
482
|
+
if (changed) this.changes.next(this.cached);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* A cell computed from what its function reads. See `ComputedCell`.
|
|
487
|
+
*
|
|
488
|
+
* const total = computed(() => quantity.value * PRICE * RATES[currency.value]);
|
|
489
|
+
* <text text={computed(() => String(count.value))} />
|
|
490
|
+
*
|
|
491
|
+
* Cells are read with `.value`; anything else is read through the
|
|
492
|
+
* `read` the function is given, which follows it the same way:
|
|
493
|
+
*
|
|
494
|
+
* const late = computed(read => read(clock) > deadline);
|
|
495
|
+
*/
|
|
496
|
+
function computed(compute, options = {}) {
|
|
497
|
+
return new ComputedCell(compute, options);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The cell standing for a stream, one per stream.
|
|
501
|
+
*
|
|
502
|
+
* Anything that already has a current value is its own cell, so a
|
|
503
|
+
* `read` of an input, an internal state or another computed costs a
|
|
504
|
+
* property access and nothing more. Everything else gets a `StreamCell`
|
|
505
|
+
* held against it here, so several computeds reading one stream share a
|
|
506
|
+
* single subscription to it rather than opening one each.
|
|
507
|
+
*
|
|
508
|
+
* Weak on purpose: the entry is reachable only while the stream is, so
|
|
509
|
+
* a stream made in a component body is collected with the component.
|
|
510
|
+
*/
|
|
511
|
+
const streamCells = /* @__PURE__ */ new WeakMap();
|
|
512
|
+
/**
|
|
513
|
+
* Whether a source's `.value` announces itself to the running computed,
|
|
514
|
+
* decided once per source and remembered.
|
|
515
|
+
*
|
|
516
|
+
* Having a `value` is not enough. A framework cell records its reads
|
|
517
|
+
* through `trackRead`, which is what lets a computed learn what it
|
|
518
|
+
* depends on; a plain `BehaviorSubject` has a `value` too and records
|
|
519
|
+
* nothing, so a computed that trusted the property would read it once
|
|
520
|
+
* and never hear it change. That is exactly what happened to three
|
|
521
|
+
* data-layer specs that fed a raw subject where the application feeds
|
|
522
|
+
* a channel view. The probe reads `.value` once under a tracking set
|
|
523
|
+
* of its own and asks whether the source turned up in it.
|
|
524
|
+
*/
|
|
525
|
+
const tracksReads = /* @__PURE__ */ new WeakMap();
|
|
526
|
+
function announcesItsReads(source) {
|
|
527
|
+
let known = tracksReads.get(source);
|
|
528
|
+
if (known === void 0) {
|
|
529
|
+
const seen = /* @__PURE__ */ new Set();
|
|
530
|
+
withTracking(seen, () => void source.value);
|
|
531
|
+
known = seen.has(source);
|
|
532
|
+
tracksReads.set(source, known);
|
|
533
|
+
}
|
|
534
|
+
return known;
|
|
535
|
+
}
|
|
536
|
+
function cellFor(source) {
|
|
537
|
+
if ("value" in source && announcesItsReads(source)) return source;
|
|
538
|
+
let cell = streamCells.get(source);
|
|
539
|
+
if (cell === void 0) {
|
|
540
|
+
cell = new StreamCell(source);
|
|
541
|
+
streamCells.set(source, cell);
|
|
542
|
+
}
|
|
543
|
+
return cell;
|
|
544
|
+
}
|
|
545
|
+
/** The `read` every computed's function is handed. */
|
|
546
|
+
const readSource = (source) => cellFor(source).value;
|
|
547
|
+
/**
|
|
548
|
+
* A plain stream, seen as a cell.
|
|
549
|
+
*
|
|
550
|
+
* It holds the last value it saw and hands it to whoever asks, which is
|
|
551
|
+
* the one thing a cell has and an Observable does not. While something
|
|
552
|
+
* follows it, it follows the stream; when the last follower leaves it
|
|
553
|
+
* lets go, so it costs nothing between uses and needs no disposal, on
|
|
554
|
+
* the same terms as `ComputedCell`.
|
|
555
|
+
*
|
|
556
|
+
* A `.value` read with nothing following takes one synchronous
|
|
557
|
+
* subscription and drops it again, which is how a `BehaviorSubject`
|
|
558
|
+
* behind an `asObservable()`, or a `combineLatest` over such subjects,
|
|
559
|
+
* answers with what it already holds. A stream that has nothing to say
|
|
560
|
+
* synchronously answers `undefined` until its first emission arrives,
|
|
561
|
+
* which is the honest answer: there is no value yet.
|
|
562
|
+
*/
|
|
563
|
+
var StreamCell = class extends Observable {
|
|
564
|
+
stream;
|
|
565
|
+
last;
|
|
566
|
+
followers = 0;
|
|
567
|
+
upstream = null;
|
|
568
|
+
changes = new Subject();
|
|
569
|
+
constructor(stream) {
|
|
570
|
+
super((subscriber) => {
|
|
571
|
+
this.followers++;
|
|
572
|
+
if (this.upstream === null) this.attach();
|
|
573
|
+
subscriber.next(this.last);
|
|
574
|
+
const following = this.changes.subscribe(subscriber);
|
|
575
|
+
return () => {
|
|
576
|
+
following.unsubscribe();
|
|
577
|
+
this.followers--;
|
|
578
|
+
if (this.followers === 0) {
|
|
579
|
+
this.upstream?.unsubscribe();
|
|
580
|
+
this.upstream = null;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
});
|
|
584
|
+
this.stream = stream;
|
|
585
|
+
}
|
|
586
|
+
get value() {
|
|
587
|
+
trackRead(this);
|
|
588
|
+
if (this.upstream === null) this.stream.subscribe((value) => {
|
|
589
|
+
this.last = value;
|
|
590
|
+
}).unsubscribe();
|
|
591
|
+
return this.last;
|
|
592
|
+
}
|
|
593
|
+
attach() {
|
|
594
|
+
this.upstream = this.stream.subscribe((value) => {
|
|
595
|
+
this.last = value;
|
|
596
|
+
this.changes.next(value);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
function sameSet(a, b) {
|
|
601
|
+
if (a.size !== b.size) return false;
|
|
602
|
+
for (const item of a) if (!b.has(item)) return false;
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/select.ts
|
|
607
|
+
function select(source, keyOrProject, options = {}) {
|
|
608
|
+
const project = typeof keyOrProject === "function" ? keyOrProject : (value) => value === null || value === void 0 ? void 0 : value[keyOrProject];
|
|
609
|
+
return computed((read) => project(read(source)), {
|
|
610
|
+
equal: options.equal ?? "structural",
|
|
611
|
+
...options.label === void 0 ? {} : { label: options.label }
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/resource.ts
|
|
616
|
+
/**
|
|
617
|
+
* A request, keyed, so a stale answer cannot win.
|
|
618
|
+
*
|
|
619
|
+
* The key says what to fetch, and every value the key source emits is
|
|
620
|
+
* a request. An answer is published only if its request is still the
|
|
621
|
+
* current one, which is the generation counter every screen that loads
|
|
622
|
+
* anything was writing by hand, and the reason opening a page, going
|
|
623
|
+
* back and opening another before the first answers does not end with
|
|
624
|
+
* the first answer on screen.
|
|
625
|
+
*
|
|
626
|
+
* private readonly ref = internalState<PageRef | null>(null);
|
|
627
|
+
* readonly page = resource(this.ref, ref => api.trackPage(ref));
|
|
628
|
+
*
|
|
629
|
+
* show(ref: PageRef | null): Promise<void> {
|
|
630
|
+
* this.ref.value = ref;
|
|
631
|
+
* return this.page.settled;
|
|
632
|
+
* }
|
|
633
|
+
*
|
|
634
|
+
* A `null` key is "nothing is being asked for": the status is `idle`,
|
|
635
|
+
* the value is `null`, and no fetch runs. That is what a screen showing
|
|
636
|
+
* nothing yet actually means, and it saves every caller a branch.
|
|
637
|
+
*
|
|
638
|
+
* Unlike `computed`, a resource is eager: it follows its key from the
|
|
639
|
+
* moment it is made, because a request is an effect and an effect that
|
|
640
|
+
* waits for a subscriber is a request that never happens. It takes one
|
|
641
|
+
* subscription to the key source for its whole life, however many
|
|
642
|
+
* requests run through it, and `dispose()` gives that back.
|
|
643
|
+
*
|
|
644
|
+
* It is a helper and not a data layer. Nothing in the framework
|
|
645
|
+
* requires one, a channel is reached exactly as it was, and an
|
|
646
|
+
* application that would rather write its own is writing against the
|
|
647
|
+
* same barrier this is written against.
|
|
648
|
+
*/
|
|
649
|
+
var Resource = class {
|
|
650
|
+
fetch;
|
|
651
|
+
options;
|
|
652
|
+
/** The one cell everything else here is a projection of. */
|
|
653
|
+
cell;
|
|
654
|
+
/** Bumped per request; an answer from an older one is dropped. */
|
|
655
|
+
requests = 0;
|
|
656
|
+
asked = null;
|
|
657
|
+
settling = Promise.resolve();
|
|
658
|
+
following;
|
|
659
|
+
/** Status, value and error as one record: what a channel view key takes. */
|
|
660
|
+
state;
|
|
661
|
+
status;
|
|
662
|
+
/** What is loaded, or `null` while there is nothing to show. */
|
|
663
|
+
value;
|
|
664
|
+
/** Why the last request failed, as its message; `null` when it did not. */
|
|
665
|
+
error;
|
|
666
|
+
constructor(key, fetch, options = {}) {
|
|
667
|
+
this.fetch = fetch;
|
|
668
|
+
this.options = options;
|
|
669
|
+
this.cell = internalState({
|
|
670
|
+
status: "idle",
|
|
671
|
+
value: null,
|
|
672
|
+
error: null
|
|
673
|
+
}, options.label);
|
|
674
|
+
this.state = this.cell;
|
|
675
|
+
this.status = select(this.cell, "status", named(options.label, "status"));
|
|
676
|
+
this.value = select(this.cell, "value", {
|
|
677
|
+
...named(options.label, "value"),
|
|
678
|
+
equal: "reference"
|
|
679
|
+
});
|
|
680
|
+
this.error = select(this.cell, "error", named(options.label, "error"));
|
|
681
|
+
this.following = key.subscribe((next) => this.request(next ?? null));
|
|
682
|
+
}
|
|
683
|
+
/** What is being asked for, for a caller that needs to guard on it. */
|
|
684
|
+
get requested() {
|
|
685
|
+
return this.asked;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* The request in the air, as a promise that resolves when it settles.
|
|
689
|
+
*
|
|
690
|
+
* Already resolved when nothing is in flight, so a caller that sets
|
|
691
|
+
* the key and returns this reads as an ordinary async method. Each
|
|
692
|
+
* request keeps its own promise, so a dropped one still resolves for
|
|
693
|
+
* whoever is awaiting it; it simply changes nothing on the way.
|
|
694
|
+
*/
|
|
695
|
+
get settled() {
|
|
696
|
+
return this.settling;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Asks again for the same key, keeping what is on screen.
|
|
700
|
+
*
|
|
701
|
+
* This is the retry button. It does not clear the value the way a
|
|
702
|
+
* new key does, because a person pressing retry is asking for the
|
|
703
|
+
* thing they can already see to be brought up to date, and blanking
|
|
704
|
+
* it first would be a worse answer than the stale one.
|
|
705
|
+
*/
|
|
706
|
+
retry() {
|
|
707
|
+
if (this.asked === null) return this.settling;
|
|
708
|
+
const generation = ++this.requests;
|
|
709
|
+
const held = this.cell.value;
|
|
710
|
+
if (held.value === null && held.status !== "loading") this.write({
|
|
711
|
+
status: "loading",
|
|
712
|
+
value: null,
|
|
713
|
+
error: null
|
|
714
|
+
});
|
|
715
|
+
this.settling = this.run(this.asked, generation);
|
|
716
|
+
return this.settling;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Replaces what is loaded, for a change made here rather than
|
|
720
|
+
* fetched.
|
|
721
|
+
*
|
|
722
|
+
* A page of comments appended to the answer already held, an
|
|
723
|
+
* optimistic edit: the resource holds the value, so something has to
|
|
724
|
+
* be able to write it. It does not touch the request in flight, so a
|
|
725
|
+
* refresh that lands afterwards still wins, which is what it should
|
|
726
|
+
* do: it is the newer truth.
|
|
727
|
+
*/
|
|
728
|
+
set(value) {
|
|
729
|
+
this.write({
|
|
730
|
+
status: "ready",
|
|
731
|
+
value,
|
|
732
|
+
error: null
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/** Gives back the subscription to the key source. */
|
|
736
|
+
dispose() {
|
|
737
|
+
this.following.unsubscribe();
|
|
738
|
+
}
|
|
739
|
+
request(key) {
|
|
740
|
+
this.asked = key;
|
|
741
|
+
const generation = ++this.requests;
|
|
742
|
+
if (key === null) {
|
|
743
|
+
this.write({
|
|
744
|
+
status: "idle",
|
|
745
|
+
value: null,
|
|
746
|
+
error: null
|
|
747
|
+
});
|
|
748
|
+
this.settling = Promise.resolve();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const held = this.options.peek?.(key) ?? null;
|
|
752
|
+
this.write({
|
|
753
|
+
status: held === null ? "loading" : "ready",
|
|
754
|
+
value: held,
|
|
755
|
+
error: null
|
|
756
|
+
});
|
|
757
|
+
this.settling = this.run(key, generation);
|
|
758
|
+
}
|
|
759
|
+
run(key, generation) {
|
|
760
|
+
return this.fetch(key).then((answer) => this.answered(generation, answer), (error) => this.refused(generation, error));
|
|
761
|
+
}
|
|
762
|
+
answered(generation, answer) {
|
|
763
|
+
if (generation !== this.requests) return;
|
|
764
|
+
if (answer === null) {
|
|
765
|
+
const held = this.cell.value.value;
|
|
766
|
+
this.write({
|
|
767
|
+
status: held === null ? "missing" : "ready",
|
|
768
|
+
value: held,
|
|
769
|
+
error: null
|
|
770
|
+
});
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
this.write({
|
|
774
|
+
status: "ready",
|
|
775
|
+
value: answer,
|
|
776
|
+
error: null
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
refused(generation, error) {
|
|
780
|
+
if (generation !== this.requests) return;
|
|
781
|
+
const held = this.cell.value.value;
|
|
782
|
+
this.write({
|
|
783
|
+
status: held === null ? "failed" : "ready",
|
|
784
|
+
value: held,
|
|
785
|
+
error: messageOf(error)
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
write(next) {
|
|
789
|
+
this.cell.value = next;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* A keyed request with a status, a value, an error and a retry.
|
|
794
|
+
*
|
|
795
|
+
* const page = resource(ref, key => api.page(key), { peek: key => store.get(key) });
|
|
796
|
+
* <Show when={computed(() => page.status.value === 'loading')}>{() => <Spinner />}</Show>
|
|
797
|
+
*
|
|
798
|
+
* See `Resource` for what each status means and when a stale value is
|
|
799
|
+
* kept. The key is an Observable so that setting a cell is what asks
|
|
800
|
+
* for something: `computed` and `internalState` both work, and so does
|
|
801
|
+
* a channel view key or a router match.
|
|
802
|
+
*/
|
|
803
|
+
function resource(key, fetch, options = {}) {
|
|
804
|
+
return new Resource(key, fetch, options);
|
|
805
|
+
}
|
|
806
|
+
function named(base, part) {
|
|
807
|
+
return base === void 0 ? {} : { label: `${base}.${part}` };
|
|
808
|
+
}
|
|
809
|
+
function messageOf(error) {
|
|
810
|
+
return error instanceof Error ? error.message : String(error);
|
|
811
|
+
}
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/mutate.ts
|
|
814
|
+
/**
|
|
815
|
+
* An optimistic change to a cell, with a rollback that does not fight
|
|
816
|
+
* the person.
|
|
817
|
+
*
|
|
818
|
+
* private readonly favourites = internalState<readonly string[]>([]);
|
|
819
|
+
* private readonly like = mutate(this.favourites, toggled, id => api.favourite(id));
|
|
820
|
+
*
|
|
821
|
+
* toggle(id: string): void {
|
|
822
|
+
* void this.like.run(id);
|
|
823
|
+
* }
|
|
824
|
+
*
|
|
825
|
+
* Three things happen and the order is the whole point. `apply` runs
|
|
826
|
+
* first and writes the cell, so the screen changes on the press rather
|
|
827
|
+
* than a round trip later. `commit` then does the real write. If it
|
|
828
|
+
* rejects, or resolves `false`, the cell goes back to what it held
|
|
829
|
+
* before.
|
|
830
|
+
*
|
|
831
|
+
* **The rollback is guarded.** It happens only while the cell still
|
|
832
|
+
* holds exactly what `apply` wrote. Without that, a slow rejection
|
|
833
|
+
* would fight a fast second press and the cell would end up saying the
|
|
834
|
+
* opposite of the last thing anyone did, which is the guard every
|
|
835
|
+
* optimistic screen writes by hand and half of them get wrong.
|
|
836
|
+
*
|
|
837
|
+
* The cell is named first, and not because it was first written
|
|
838
|
+
* `mutate(apply, commit)`: the cell is what makes the guard possible.
|
|
839
|
+
* A mutation handed only two functions can undo its own change but
|
|
840
|
+
* cannot tell whether undoing it is still the right thing to do.
|
|
841
|
+
*
|
|
842
|
+
* A helper and not a data layer: nothing in the framework requires
|
|
843
|
+
* one, and an application that would rather write the four lines out
|
|
844
|
+
* is writing the same four lines this does.
|
|
845
|
+
*/
|
|
846
|
+
function mutate(cell, apply, commit, options = {}) {
|
|
847
|
+
const equal = equalityOf(options.equal ?? "structural");
|
|
848
|
+
const inFlight = internalState(0, options.label);
|
|
849
|
+
const revert = (before, applied) => {
|
|
850
|
+
if (!equal(cell.value, applied)) return;
|
|
851
|
+
cell.value = before;
|
|
852
|
+
};
|
|
853
|
+
return {
|
|
854
|
+
pending: inFlight,
|
|
855
|
+
async run(argument) {
|
|
856
|
+
const before = cell.value;
|
|
857
|
+
const applied = apply(before, argument);
|
|
858
|
+
cell.value = applied;
|
|
859
|
+
inFlight.value = inFlight.value + 1;
|
|
860
|
+
try {
|
|
861
|
+
if (await commit(argument, applied) === false) {
|
|
862
|
+
revert(before, applied);
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
return true;
|
|
866
|
+
} catch {
|
|
867
|
+
revert(before, applied);
|
|
868
|
+
return false;
|
|
869
|
+
} finally {
|
|
870
|
+
inFlight.value = Math.max(0, inFlight.value - 1);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region src/debounce.ts
|
|
877
|
+
/**
|
|
878
|
+
* A cell that lets its source through on a timer.
|
|
879
|
+
*
|
|
880
|
+
* The two operators below are the same machinery with a different
|
|
881
|
+
* gate, and both are cells rather than plain streams on purpose: a
|
|
882
|
+
* `computed` reads a cell with `.value` and follows it, so a debounced
|
|
883
|
+
* search term composes with everything else in the dialect instead of
|
|
884
|
+
* being the one value in a screen that has to be piped.
|
|
885
|
+
*
|
|
886
|
+
* It holds the last value the gate let through, which is what `.value`
|
|
887
|
+
* answers while something is following it. With nothing following
|
|
888
|
+
* there is no timer running to hold anything back, so `.value` reads
|
|
889
|
+
* the source directly, which is the honest answer rather than a value
|
|
890
|
+
* frozen at whatever moment the last follower left.
|
|
891
|
+
*
|
|
892
|
+
* A value equal to the one it already holds is not a change and is not
|
|
893
|
+
* emitted, the same rule `computed` follows. Without it the first pass
|
|
894
|
+
* of the gate after a subscription would repeat the value the
|
|
895
|
+
* subscriber had just been handed.
|
|
896
|
+
*
|
|
897
|
+
* One subscription upstream however many followers it has, given back
|
|
898
|
+
* when the last of them leaves, on the same terms as `ComputedCell`.
|
|
899
|
+
*/
|
|
900
|
+
var TimedCell = class extends Observable {
|
|
901
|
+
stream;
|
|
902
|
+
gate;
|
|
903
|
+
current;
|
|
904
|
+
hasCurrent = false;
|
|
905
|
+
followers = 0;
|
|
906
|
+
seeding = false;
|
|
907
|
+
upstream = null;
|
|
908
|
+
changes = new Subject();
|
|
909
|
+
constructor(stream, gate) {
|
|
910
|
+
super((subscriber) => {
|
|
911
|
+
this.followers++;
|
|
912
|
+
if (this.upstream === null) this.attach();
|
|
913
|
+
subscriber.next(this.current);
|
|
914
|
+
const following = this.changes.subscribe(subscriber);
|
|
915
|
+
return () => {
|
|
916
|
+
following.unsubscribe();
|
|
917
|
+
this.followers--;
|
|
918
|
+
if (this.followers === 0) {
|
|
919
|
+
this.upstream?.unsubscribe();
|
|
920
|
+
this.upstream = null;
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
this.stream = stream;
|
|
925
|
+
this.gate = gate;
|
|
926
|
+
}
|
|
927
|
+
get value() {
|
|
928
|
+
trackRead(this);
|
|
929
|
+
if (this.upstream === null) this.stream.subscribe((value) => {
|
|
930
|
+
this.current = value;
|
|
931
|
+
this.hasCurrent = true;
|
|
932
|
+
}).unsubscribe();
|
|
933
|
+
return this.current;
|
|
934
|
+
}
|
|
935
|
+
attach() {
|
|
936
|
+
this.seeding = true;
|
|
937
|
+
this.upstream = this.stream.pipe(tap((value) => {
|
|
938
|
+
if (this.seeding && !this.hasCurrent) {
|
|
939
|
+
this.current = value;
|
|
940
|
+
this.hasCurrent = true;
|
|
941
|
+
}
|
|
942
|
+
}), this.gate).subscribe((value) => {
|
|
943
|
+
if (this.hasCurrent && Object.is(this.current, value)) return;
|
|
944
|
+
this.current = value;
|
|
945
|
+
this.hasCurrent = true;
|
|
946
|
+
this.changes.next(value);
|
|
947
|
+
});
|
|
948
|
+
this.seeding = false;
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* A cell that follows its source once it has stopped moving.
|
|
953
|
+
*
|
|
954
|
+
* const query = internalState('');
|
|
955
|
+
* const term = debounced(query, 200);
|
|
956
|
+
* const results = computed(() => index.search(term.value));
|
|
957
|
+
*
|
|
958
|
+
* Nothing is emitted while values keep arriving; `ms` after the last
|
|
959
|
+
* one, the last one is. This is what a search field wants and what
|
|
960
|
+
* every search field in the tree was reaching the router without: a
|
|
961
|
+
* keystroke is not a question, and a pause is.
|
|
962
|
+
*
|
|
963
|
+
* The value the source already held is there immediately, so a screen
|
|
964
|
+
* built from this draws on the first frame rather than `ms` later.
|
|
965
|
+
*/
|
|
966
|
+
function debounced(source, ms) {
|
|
967
|
+
return new TimedCell(source, debounceTime(ms));
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* A cell that follows its source at most once every `ms`.
|
|
971
|
+
*
|
|
972
|
+
* const position = throttled(scrollOffset, 100);
|
|
973
|
+
*
|
|
974
|
+
* The first value goes straight through and the last of a burst
|
|
975
|
+
* follows at the end of the window, so a value that arrives while the
|
|
976
|
+
* window is open is late rather than lost. That pairing is what makes
|
|
977
|
+
* this usable for a position or a progress reading, where the
|
|
978
|
+
* beginning and the end of a movement both matter and the middle does
|
|
979
|
+
* not.
|
|
980
|
+
*
|
|
981
|
+
* Use this for something that is continuously true, and `debounced`
|
|
982
|
+
* for something a person has finished saying.
|
|
983
|
+
*/
|
|
984
|
+
function throttled(source, ms) {
|
|
985
|
+
return new TimedCell(source, throttleTime(ms, void 0, {
|
|
986
|
+
leading: true,
|
|
987
|
+
trailing: true
|
|
988
|
+
}));
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/channel/ChannelToken.ts
|
|
992
|
+
/**
|
|
993
|
+
* Declares a channel.
|
|
994
|
+
*
|
|
995
|
+
* The name identifies it across the thread boundary and must be
|
|
996
|
+
* stable; unlike a class name it survives minification, which is why
|
|
997
|
+
* it is written out rather than derived.
|
|
998
|
+
*/
|
|
999
|
+
function channel(name, initial) {
|
|
1000
|
+
if (name.length === 0) throw new Error("A channel needs a name: it is how the two threads agree on which one this is.");
|
|
1001
|
+
return {
|
|
1002
|
+
name,
|
|
1003
|
+
initial
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Declares a channel from one object.
|
|
1008
|
+
*
|
|
1009
|
+
* export const Catalog = defineChannel('catalog', {
|
|
1010
|
+
* view: {
|
|
1011
|
+
* products: [] as readonly ProductRow[],
|
|
1012
|
+
* status: 'loading' as ShelfStatus
|
|
1013
|
+
* },
|
|
1014
|
+
* commands: {} as {
|
|
1015
|
+
* addToCart(id: string, quantity: number): void;
|
|
1016
|
+
* move(from: number, to: number): void;
|
|
1017
|
+
* }
|
|
1018
|
+
* });
|
|
1019
|
+
*
|
|
1020
|
+
* export type CatalogView = ViewOf<typeof Catalog>;
|
|
1021
|
+
*
|
|
1022
|
+
* The same token `channel()` returns, declared once instead of three
|
|
1023
|
+
* times. `channel<View, Commands>(name, initial)` wrote the view as an
|
|
1024
|
+
* interface, then as an initial literal that had to agree with it, and
|
|
1025
|
+
* a key added to one and forgotten in the other was a type error in a
|
|
1026
|
+
* third file. Here the object is the type.
|
|
1027
|
+
*
|
|
1028
|
+
* A field whose initial value is narrower than the type it holds is
|
|
1029
|
+
* given the type it holds: `[]` is `never[]` and `'loading'` is
|
|
1030
|
+
* `string` unless it is said, which is what the `as` clauses above are
|
|
1031
|
+
* for. `ViewOf` and `CommandsOf` name the resulting types wherever the
|
|
1032
|
+
* application used to name its own interface.
|
|
1033
|
+
*
|
|
1034
|
+
* `channel()` is not deprecated and keeps working exactly as it did.
|
|
1035
|
+
* An application with interfaces it wants to keep, because they are
|
|
1036
|
+
* shared with something else or because the initial values are built
|
|
1037
|
+
* elsewhere, has nothing to migrate.
|
|
1038
|
+
*/
|
|
1039
|
+
function defineChannel(name, spec) {
|
|
1040
|
+
return channel(name, spec.view);
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* The keys a channel publishes.
|
|
1044
|
+
*
|
|
1045
|
+
* Structural in its parameter rather than generic over the token, so
|
|
1046
|
+
* it does not have to agree with any particular command type to read
|
|
1047
|
+
* what is only ever the initial value's shape.
|
|
1048
|
+
*/
|
|
1049
|
+
function viewKeys(token) {
|
|
1050
|
+
return Object.keys(token.initial);
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/channel/ChannelProtocol.ts
|
|
1054
|
+
function isChannelClientMessage(value) {
|
|
1055
|
+
const type = value?.type;
|
|
1056
|
+
return type === "channel:sync" || type === "channel:command";
|
|
1057
|
+
}
|
|
1058
|
+
function isChannelHostMessage(value) {
|
|
1059
|
+
const type = value?.type;
|
|
1060
|
+
return type === "channel:patch" || type === "channel:error";
|
|
1061
|
+
}
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region src/channel/StorePatch.ts
|
|
1064
|
+
/**
|
|
1065
|
+
* Describes how to turn `previous` into `current` for one projection.
|
|
1066
|
+
*
|
|
1067
|
+
* Returns an empty list when nothing changed, which is the common case
|
|
1068
|
+
* and the reason this exists: the point of a projection is that most
|
|
1069
|
+
* state changes do not alter it, and the ones that do usually alter a
|
|
1070
|
+
* small part.
|
|
1071
|
+
*/
|
|
1072
|
+
function diffProjection(projection, previous, current) {
|
|
1073
|
+
const patches = [];
|
|
1074
|
+
diff(projection, [], previous, current, patches);
|
|
1075
|
+
return patches;
|
|
1076
|
+
}
|
|
1077
|
+
function diff(projection, path, previous, current, out) {
|
|
1078
|
+
if (structurallyEqual(previous, current)) return;
|
|
1079
|
+
if (Array.isArray(previous) && Array.isArray(current)) {
|
|
1080
|
+
diffArray(projection, path, previous, current, out);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (isPlainObject(previous) && isPlainObject(current)) {
|
|
1084
|
+
for (const key of Object.keys(current)) if (Object.prototype.hasOwnProperty.call(previous, key)) diff(projection, [...path, key], previous[key], current[key], out);
|
|
1085
|
+
else out.push({
|
|
1086
|
+
op: "set",
|
|
1087
|
+
projection,
|
|
1088
|
+
path: [...path, key],
|
|
1089
|
+
value: current[key]
|
|
1090
|
+
});
|
|
1091
|
+
for (const key of Object.keys(previous)) if (!Object.prototype.hasOwnProperty.call(current, key)) out.push({
|
|
1092
|
+
op: "delete",
|
|
1093
|
+
projection,
|
|
1094
|
+
path: [...path, key]
|
|
1095
|
+
});
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
out.push({
|
|
1099
|
+
op: "set",
|
|
1100
|
+
projection,
|
|
1101
|
+
path,
|
|
1102
|
+
value: current
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Diffs two arrays by trimming the common prefix and suffix.
|
|
1107
|
+
*
|
|
1108
|
+
* This is not a minimal edit script — a shuffle degrades to replacing
|
|
1109
|
+
* the middle wholesale. It is chosen because the operations lists
|
|
1110
|
+
* actually undergo (append, prepend, remove one, edit in place) all
|
|
1111
|
+
* reduce to a single small patch, and computing a true LCS on every
|
|
1112
|
+
* store change would cost more than it saves.
|
|
1113
|
+
*/
|
|
1114
|
+
function diffArray(projection, path, previous, current, out) {
|
|
1115
|
+
let start = 0;
|
|
1116
|
+
while (start < previous.length && start < current.length && structurallyEqual(previous[start], current[start])) start++;
|
|
1117
|
+
let previousEnd = previous.length - 1;
|
|
1118
|
+
let currentEnd = current.length - 1;
|
|
1119
|
+
while (previousEnd >= start && currentEnd >= start && structurallyEqual(previous[previousEnd], current[currentEnd])) {
|
|
1120
|
+
previousEnd--;
|
|
1121
|
+
currentEnd--;
|
|
1122
|
+
}
|
|
1123
|
+
const previousCount = previousEnd - start + 1;
|
|
1124
|
+
const currentCount = currentEnd - start + 1;
|
|
1125
|
+
if (previousCount === 0 && currentCount === 0) return;
|
|
1126
|
+
if (previousCount === currentCount) {
|
|
1127
|
+
for (let offset = 0; offset < previousCount; offset++) {
|
|
1128
|
+
const index = start + offset;
|
|
1129
|
+
diff(projection, [...path, index], previous[index], current[index], out);
|
|
1130
|
+
}
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
out.push({
|
|
1134
|
+
op: "splice",
|
|
1135
|
+
projection,
|
|
1136
|
+
path,
|
|
1137
|
+
index: start,
|
|
1138
|
+
deleteCount: previousCount,
|
|
1139
|
+
items: current.slice(start, currentEnd + 1)
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Applies patches to a projection value, sharing structure with the
|
|
1144
|
+
* original everywhere the patch did not reach.
|
|
1145
|
+
*
|
|
1146
|
+
* Nothing is mutated: bindings hold onto emitted values, so a replica
|
|
1147
|
+
* that edited in place would change data a component already rendered.
|
|
1148
|
+
*/
|
|
1149
|
+
function applyPatches(root, patches) {
|
|
1150
|
+
let next = root;
|
|
1151
|
+
for (const patch of patches) next = applyPatch(next, patch);
|
|
1152
|
+
return next;
|
|
1153
|
+
}
|
|
1154
|
+
function applyPatch(root, patch) {
|
|
1155
|
+
switch (patch.op) {
|
|
1156
|
+
case "set": return setIn(root, patch.path, 0, patch.value);
|
|
1157
|
+
case "delete":
|
|
1158
|
+
if (patch.path.length === 0) return;
|
|
1159
|
+
return deleteIn(root, patch.path, 0);
|
|
1160
|
+
case "splice": return updateIn(root, patch.path, 0, (node) => {
|
|
1161
|
+
const array = Array.isArray(node) ? node : [];
|
|
1162
|
+
return array.slice(0, patch.index).concat(patch.items, array.slice(patch.index + patch.deleteCount));
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
function setIn(node, path, index, value) {
|
|
1167
|
+
if (index === path.length) return value;
|
|
1168
|
+
const key = path[index];
|
|
1169
|
+
const copy = cloneContainer(node, key);
|
|
1170
|
+
setKey(copy, key, setIn(readKey(node, key), path, index + 1, value));
|
|
1171
|
+
return copy;
|
|
1172
|
+
}
|
|
1173
|
+
function deleteIn(node, path, index) {
|
|
1174
|
+
const key = path[index];
|
|
1175
|
+
const copy = cloneContainer(node, key);
|
|
1176
|
+
if (index === path.length - 1) {
|
|
1177
|
+
if (Array.isArray(copy)) copy.splice(Number(key), 1);
|
|
1178
|
+
else delete copy[String(key)];
|
|
1179
|
+
return copy;
|
|
1180
|
+
}
|
|
1181
|
+
setKey(copy, key, deleteIn(readKey(node, key), path, index + 1));
|
|
1182
|
+
return copy;
|
|
1183
|
+
}
|
|
1184
|
+
function updateIn(node, path, index, update) {
|
|
1185
|
+
if (index === path.length) return update(node);
|
|
1186
|
+
const key = path[index];
|
|
1187
|
+
const copy = cloneContainer(node, key);
|
|
1188
|
+
setKey(copy, key, updateIn(readKey(node, key), path, index + 1, update));
|
|
1189
|
+
return copy;
|
|
1190
|
+
}
|
|
1191
|
+
function cloneContainer(node, key) {
|
|
1192
|
+
if (Array.isArray(node)) return node.slice();
|
|
1193
|
+
if (isPlainObject(node)) return { ...node };
|
|
1194
|
+
return typeof key === "number" ? [] : {};
|
|
1195
|
+
}
|
|
1196
|
+
function readKey(node, key) {
|
|
1197
|
+
if (node === null || node === void 0) return;
|
|
1198
|
+
return node[key];
|
|
1199
|
+
}
|
|
1200
|
+
function setKey(container, key, value) {
|
|
1201
|
+
container[key] = value;
|
|
1202
|
+
}
|
|
1203
|
+
function isPlainObject(value) {
|
|
1204
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1205
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1206
|
+
return prototype === Object.prototype || prototype === null;
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/channel/plainData.ts
|
|
1210
|
+
/**
|
|
1211
|
+
* Checks that a value can cross the barrier.
|
|
1212
|
+
*
|
|
1213
|
+
* `structurallyEqual` understands primitives, arrays and plain
|
|
1214
|
+
* objects, and falls back to reference equality for everything else —
|
|
1215
|
+
* which for a freshly built value reports "changed" every single time.
|
|
1216
|
+
* A view key holding a `Date`, a `Map` or a domain object therefore
|
|
1217
|
+
* re-emits on every unrelated update and dirties the subtree bound to
|
|
1218
|
+
* it, forever, while looking perfectly correct.
|
|
1219
|
+
*
|
|
1220
|
+
* That failure is invisible in a test and shows up as a vague slowness
|
|
1221
|
+
* much later, so `provide` checks each key's first emission and throws
|
|
1222
|
+
* naming the path. The view-model layer is where rich objects become
|
|
1223
|
+
* flat data; this is what makes that a rule rather than a convention.
|
|
1224
|
+
*
|
|
1225
|
+
* The check runs once per key, on the first value only. It is a
|
|
1226
|
+
* development guard against a design mistake, not a validator on the
|
|
1227
|
+
* hot path.
|
|
1228
|
+
*/
|
|
1229
|
+
const MAX_DEPTH = 100;
|
|
1230
|
+
/**
|
|
1231
|
+
* Returns the path to the first value that cannot cross, or null when
|
|
1232
|
+
* the whole tree is plain data.
|
|
1233
|
+
*/
|
|
1234
|
+
function findUnplainPath(value, path = []) {
|
|
1235
|
+
if (path.length > MAX_DEPTH) return format(path);
|
|
1236
|
+
if (value === null) return null;
|
|
1237
|
+
const type = typeof value;
|
|
1238
|
+
if (type === "string" || type === "number" || type === "boolean" || type === "undefined") return null;
|
|
1239
|
+
if (type === "function" || type === "symbol" || type === "bigint") return format(path);
|
|
1240
|
+
if (Array.isArray(value)) {
|
|
1241
|
+
for (let index = 0; index < value.length; index++) {
|
|
1242
|
+
const found = findUnplainPath(value[index], [...path, index]);
|
|
1243
|
+
if (found !== null) return found;
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1248
|
+
if (prototype !== Object.prototype && prototype !== null) return format(path);
|
|
1249
|
+
for (const [key, member] of Object.entries(value)) {
|
|
1250
|
+
const found = findUnplainPath(member, [...path, key]);
|
|
1251
|
+
if (found !== null) return found;
|
|
1252
|
+
}
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Throws when `value` cannot cross the barrier, naming the channel,
|
|
1257
|
+
* the key and the path within it.
|
|
1258
|
+
*/
|
|
1259
|
+
function requirePlainData(channelName, key, value) {
|
|
1260
|
+
const path = findUnplainPath(value);
|
|
1261
|
+
if (path === null) return;
|
|
1262
|
+
const where = path === "" ? `'${key}'` : `'${key}'${path}`;
|
|
1263
|
+
throw new Error(`Channel '${channelName}' published ${where}, which is not plain data. Only primitives, arrays and plain objects cross the barrier: a Date, Map, Set, class instance or function compares by reference, so it would report a change on every update and rebuild the subtree bound to it. Flatten it in the view model.`);
|
|
1264
|
+
}
|
|
1265
|
+
function format(path) {
|
|
1266
|
+
return path.map((step) => typeof step === "number" ? `[${step}]` : `.${step}`).join("");
|
|
1267
|
+
}
|
|
1268
|
+
//#endregion
|
|
1269
|
+
//#region src/channel/provide.ts
|
|
1270
|
+
/**
|
|
1271
|
+
* Publishes a channel from the thread that owns its data.
|
|
1272
|
+
*
|
|
1273
|
+
* Each view key is subscribed, diffed against what the other side last
|
|
1274
|
+
* saw, and sent as patches. Whatever produced the observable — a bare
|
|
1275
|
+
* subject or a stack of layers — stays here; only plain data crosses.
|
|
1276
|
+
*
|
|
1277
|
+
* Keys are subscribed on the first sync request, so a channel nobody
|
|
1278
|
+
* is watching costs nothing.
|
|
1279
|
+
*/
|
|
1280
|
+
function provide(token, source, port) {
|
|
1281
|
+
return new ProvidedChannel(token, source, port);
|
|
1282
|
+
}
|
|
1283
|
+
var ProvidedChannel = class {
|
|
1284
|
+
token;
|
|
1285
|
+
source;
|
|
1286
|
+
port;
|
|
1287
|
+
subscriptions = new Subscription();
|
|
1288
|
+
/**
|
|
1289
|
+
* What the other side is known to hold, seeded from the token's
|
|
1290
|
+
* initial value — which the replica also starts from, so an app
|
|
1291
|
+
* whose first emission equals the initial sends nothing at all.
|
|
1292
|
+
*/
|
|
1293
|
+
previous = /* @__PURE__ */ new Map();
|
|
1294
|
+
checked = /* @__PURE__ */ new Set();
|
|
1295
|
+
synced = false;
|
|
1296
|
+
constructor(token, source, port) {
|
|
1297
|
+
this.token = token;
|
|
1298
|
+
this.source = source;
|
|
1299
|
+
this.port = port;
|
|
1300
|
+
for (const key of viewKeys(token)) this.previous.set(key, token.initial[key]);
|
|
1301
|
+
this.port.onmessage = (event) => this.receive(event.data);
|
|
1302
|
+
}
|
|
1303
|
+
receive(data) {
|
|
1304
|
+
if (!isChannelClientMessage(data)) return;
|
|
1305
|
+
try {
|
|
1306
|
+
if (data.type === "channel:sync") {
|
|
1307
|
+
this.sync();
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
this.runCommand(data.command, data.payload, data.rest);
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
this.post({
|
|
1313
|
+
type: "channel:error",
|
|
1314
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1315
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
runCommand(name, payload, rest) {
|
|
1320
|
+
const handler = this.source.commands?.[name];
|
|
1321
|
+
if (handler === void 0) {
|
|
1322
|
+
const names = Object.keys(this.source.commands ?? {}).sort().join(", ");
|
|
1323
|
+
throw new Error(`Channel '${this.token.name}' has no command '${name}'. Declared commands: ${names.length > 0 ? names : "(none)"}.`);
|
|
1324
|
+
}
|
|
1325
|
+
handler(payload, ...rest ?? []);
|
|
1326
|
+
}
|
|
1327
|
+
sync() {
|
|
1328
|
+
if (this.synced) {
|
|
1329
|
+
this.resend();
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
this.synced = true;
|
|
1333
|
+
for (const key of viewKeys(this.token)) {
|
|
1334
|
+
const observable = this.source.view[key];
|
|
1335
|
+
if (observable === void 0) {
|
|
1336
|
+
this.post({
|
|
1337
|
+
type: "channel:error",
|
|
1338
|
+
message: `Channel '${this.token.name}' declares view key '${key}' but nothing was provided for it.`
|
|
1339
|
+
});
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
this.subscriptions.add(observable.subscribe({
|
|
1343
|
+
next: (value) => this.publish(key, value),
|
|
1344
|
+
error: (error) => this.post({
|
|
1345
|
+
type: "channel:error",
|
|
1346
|
+
message: `Channel '${this.token.name}' view key '${key}' errored: ${error instanceof Error ? error.message : String(error)}`,
|
|
1347
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
1348
|
+
})
|
|
1349
|
+
}));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
publish(key, value) {
|
|
1353
|
+
if (!this.checked.has(key)) {
|
|
1354
|
+
this.checked.add(key);
|
|
1355
|
+
try {
|
|
1356
|
+
requirePlainData(this.token.name, key, value);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
this.post({
|
|
1359
|
+
type: "channel:error",
|
|
1360
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1361
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
1362
|
+
});
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const patches = diffProjection(key, this.previous.get(key), value);
|
|
1367
|
+
this.previous.set(key, value);
|
|
1368
|
+
if (patches.length > 0) this.post({
|
|
1369
|
+
type: "channel:patch",
|
|
1370
|
+
patches
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
/** Re-sends every key in full, for a client that reattached. */
|
|
1374
|
+
resend() {
|
|
1375
|
+
const patches = [];
|
|
1376
|
+
for (const [key, value] of this.previous) patches.push({
|
|
1377
|
+
op: "set",
|
|
1378
|
+
projection: key,
|
|
1379
|
+
path: [],
|
|
1380
|
+
value
|
|
1381
|
+
});
|
|
1382
|
+
if (patches.length > 0) this.post({
|
|
1383
|
+
type: "channel:patch",
|
|
1384
|
+
patches
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
post(message) {
|
|
1388
|
+
this.port.postMessage(message);
|
|
1389
|
+
}
|
|
1390
|
+
dispose() {
|
|
1391
|
+
this.subscriptions.unsubscribe();
|
|
1392
|
+
this.port.onmessage = null;
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
//#endregion
|
|
1396
|
+
//#region src/app/NodeReport.ts
|
|
1397
|
+
/**
|
|
1398
|
+
* A node as a path somebody can read: `App > TrackScreen > ActionRow >
|
|
1399
|
+
* Button "Like"`.
|
|
1400
|
+
*
|
|
1401
|
+
* Written for error messages rather than for the inspector, and the
|
|
1402
|
+
* difference decides the shape. A report is read beside the tree it
|
|
1403
|
+
* came from, so an id is a handle; an error is read in a console or an
|
|
1404
|
+
* overlay with no tree beside it, and `node-4821` there is a fact
|
|
1405
|
+
* about nothing. The owner chain is what a person recognises, because
|
|
1406
|
+
* it is the components they wrote.
|
|
1407
|
+
*
|
|
1408
|
+
* Owners arrive nearest-first, as `UiNodeReport.owners` computes them,
|
|
1409
|
+
* and read outermost-first, as a path does. The accessible name is the
|
|
1410
|
+
* leaf when there is one: two `Button`s in the same row are only told
|
|
1411
|
+
* apart by what they say.
|
|
1412
|
+
*/
|
|
1413
|
+
function formatNodePath(owners, node) {
|
|
1414
|
+
const names = owners.map((owner) => owner.name).reverse();
|
|
1415
|
+
if (names.length === 0) return node.label === void 0 || node.label === "" ? `${node.type} ${node.id}` : `${node.type} "${node.label}"`;
|
|
1416
|
+
const path = names.join(" > ");
|
|
1417
|
+
return node.label === void 0 || node.label === "" ? path : `${path} "${node.label}"`;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Describes the stream driving a property.
|
|
1421
|
+
*
|
|
1422
|
+
* `timeOrigin` turns the binding's own reading into an epoch stamp:
|
|
1423
|
+
* `performance.timeOrigin` on a thread that has one, and zero where
|
|
1424
|
+
* the reading is already an epoch.
|
|
1425
|
+
*/
|
|
1426
|
+
function describeStream(binding, timeOrigin) {
|
|
1427
|
+
const label = binding.observable?.label;
|
|
1428
|
+
const labelled = typeof label === "string" && label !== "";
|
|
1429
|
+
const emittedAt = binding.emittedAt();
|
|
1430
|
+
return {
|
|
1431
|
+
source: labelled ? label : `observable #${binding.id}`,
|
|
1432
|
+
kind: labelled ? "cell" : "observable",
|
|
1433
|
+
value: printPropValue(binding.value()),
|
|
1434
|
+
emissions: binding.emissionCount(),
|
|
1435
|
+
emittedAt: emittedAt === null ? null : timeOrigin + emittedAt,
|
|
1436
|
+
connected: binding.connected()
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
/** A stream as one line: what feeds the prop, and how long ago it said so. */
|
|
1440
|
+
function formatStream(stream, now = Date.now()) {
|
|
1441
|
+
const when = stream.emittedAt === null ? "no value yet" : `${formatAge(Math.max(0, now - stream.emittedAt))} ago`;
|
|
1442
|
+
const emissions = `${stream.emissions} emission${stream.emissions === 1 ? "" : "s"}`;
|
|
1443
|
+
return `${stream.source} · ${emissions} · ${when}${stream.connected ? "" : " · disconnected"}`;
|
|
1444
|
+
}
|
|
1445
|
+
/** An age in the largest unit that stays readable. */
|
|
1446
|
+
function formatAge(ms) {
|
|
1447
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
1448
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
1449
|
+
return `${Math.round(ms / 6e4)}m`;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* A value as one line a person can read.
|
|
1453
|
+
*
|
|
1454
|
+
* `JSON.stringify` alone is not enough: the two values a canvas UI
|
|
1455
|
+
* puts in a property that it cannot handle are a `Set` (a node's
|
|
1456
|
+
* visual states) and a function (an event handler), and it prints both
|
|
1457
|
+
* as `{}`, which in an inspector reads as a bug in the application
|
|
1458
|
+
* rather than one in the inspector.
|
|
1459
|
+
*/
|
|
1460
|
+
function printPropValue(value) {
|
|
1461
|
+
if (value === void 0) return "undefined";
|
|
1462
|
+
if (value === null) return "null";
|
|
1463
|
+
if (typeof value === "function") return `ƒ ${value.name === "" ? "(anonymous)" : value.name}`;
|
|
1464
|
+
if (typeof value === "symbol") return value.toString();
|
|
1465
|
+
if (typeof value === "string") return value;
|
|
1466
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
1467
|
+
if (value instanceof Set) return `Set { ${[...value].map(printPropValue).join(", ")} }`;
|
|
1468
|
+
if (value instanceof Map) return `Map { ${[...value].map(([key, entry]) => `${printPropValue(key)}: ${printPropValue(entry)}`).join(", ")} }`;
|
|
1469
|
+
if (Array.isArray(value)) return `[${value.map(printPropValue).join(", ")}]`;
|
|
1470
|
+
try {
|
|
1471
|
+
return JSON.stringify(value) ?? String(value);
|
|
1472
|
+
} catch {
|
|
1473
|
+
return String(value);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/** The report as text, for a console or a test failure. */
|
|
1477
|
+
function formatNodeReport(report) {
|
|
1478
|
+
const lines = [`${report.type} '${report.id}'`];
|
|
1479
|
+
if (report.owners.length > 0) lines.push(`rendered by ${report.owners.map((owner) => owner.name).join(" inside ")}`);
|
|
1480
|
+
if (report.modifiers.length > 0) lines.push(`modifiers: ${report.modifiers.join(", ")}`);
|
|
1481
|
+
if (report.listens.length > 0) lines.push(`listens: ${report.listens.join(", ")}`);
|
|
1482
|
+
if (report.beneath.length > 0) {
|
|
1483
|
+
lines.push("beneath, at the pointer:");
|
|
1484
|
+
for (const under of report.beneath) {
|
|
1485
|
+
const owner = under.owner === void 0 ? "" : ` (${under.owner})`;
|
|
1486
|
+
const listens = under.listens.length === 0 ? "" : `, listens: ${under.listens.join(", ")}`;
|
|
1487
|
+
lines.push(` ${under.type} ${under.id}${owner}${listens}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (report.props.length > 0) {
|
|
1491
|
+
lines.push("props:");
|
|
1492
|
+
for (const prop of report.props) {
|
|
1493
|
+
lines.push(` ${prop.name} = ${prop.value}${prop.source === void 0 ? "" : ` (${prop.source})`}`);
|
|
1494
|
+
if (prop.stream !== void 0) lines.push(` ${formatStream(prop.stream)}`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (report.environment.length > 0) {
|
|
1498
|
+
lines.push("environment:");
|
|
1499
|
+
for (const entry of report.environment) lines.push(` ${entry.key} = ${entry.value}${entry.provided ? " (provided here)" : ""}`);
|
|
1500
|
+
}
|
|
1501
|
+
if (report.semantics !== void 0) {
|
|
1502
|
+
const parts = [
|
|
1503
|
+
report.semantics.role === void 0 ? void 0 : `role ${report.semantics.role}`,
|
|
1504
|
+
report.semantics.label === void 0 ? void 0 : `label ${report.semantics.label}`,
|
|
1505
|
+
report.semantics.value === void 0 ? void 0 : `value ${report.semantics.value}`,
|
|
1506
|
+
report.semantics.states === void 0 || report.semantics.states.length === 0 ? void 0 : `states ${report.semantics.states.join(", ")}`
|
|
1507
|
+
].filter((part) => part !== void 0);
|
|
1508
|
+
if (parts.length > 0) lines.push(`semantics: ${parts.join(" · ")}`);
|
|
1509
|
+
}
|
|
1510
|
+
lines.push(report.explanation);
|
|
1511
|
+
return lines.join("\n");
|
|
1512
|
+
}
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/worker/captureConsole.ts
|
|
1515
|
+
const LEVELS = [
|
|
1516
|
+
"log",
|
|
1517
|
+
"info",
|
|
1518
|
+
"warn",
|
|
1519
|
+
"error",
|
|
1520
|
+
"debug"
|
|
1521
|
+
];
|
|
1522
|
+
/**
|
|
1523
|
+
* Copies every `console.*` call on `target` (the worker global's
|
|
1524
|
+
* console by default) to `sink`. Returns a function that restores the
|
|
1525
|
+
* original methods.
|
|
1526
|
+
*
|
|
1527
|
+
* Idempotent per target: a second capture on the same console replaces
|
|
1528
|
+
* the first's sink rather than nesting, so toggling a panel on twice
|
|
1529
|
+
* does not log twice.
|
|
1530
|
+
*/
|
|
1531
|
+
function captureConsole(sink, target = console) {
|
|
1532
|
+
const installed = target[CAPTURED];
|
|
1533
|
+
if (installed !== void 0) {
|
|
1534
|
+
installed.sink = sink;
|
|
1535
|
+
return installed.restore;
|
|
1536
|
+
}
|
|
1537
|
+
const originals = /* @__PURE__ */ new Map();
|
|
1538
|
+
const state = {
|
|
1539
|
+
sink,
|
|
1540
|
+
restore: () => {
|
|
1541
|
+
for (const [level, original] of originals) target[level] = original;
|
|
1542
|
+
delete target[CAPTURED];
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
for (const level of LEVELS) {
|
|
1546
|
+
const original = target[level];
|
|
1547
|
+
originals.set(level, original);
|
|
1548
|
+
target[level] = (...args) => {
|
|
1549
|
+
original.apply(target, args);
|
|
1550
|
+
try {
|
|
1551
|
+
state.sink({
|
|
1552
|
+
level,
|
|
1553
|
+
args: args.map(formatConsoleArg),
|
|
1554
|
+
at: Date.now()
|
|
1555
|
+
});
|
|
1556
|
+
} catch {}
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
target[CAPTURED] = state;
|
|
1560
|
+
return state.restore;
|
|
1561
|
+
}
|
|
1562
|
+
const CAPTURED = Symbol.for("gesso:console-captured");
|
|
1563
|
+
/**
|
|
1564
|
+
* One console argument as the panel prints it.
|
|
1565
|
+
*
|
|
1566
|
+
* `printPropValue` already prints the values a canvas UI is likely to
|
|
1567
|
+
* log; an Error is the one thing it prints badly (`{}`), and the one
|
|
1568
|
+
* thing a developer most wants to read whole.
|
|
1569
|
+
*/
|
|
1570
|
+
function formatConsoleArg(value) {
|
|
1571
|
+
if (value instanceof Error) return value.stack !== void 0 && value.stack !== "" ? value.stack : `${value.name}: ${value.message}`;
|
|
1572
|
+
return printPropValue(value);
|
|
1573
|
+
}
|
|
1574
|
+
function isConsoleForwardingMessage(value) {
|
|
1575
|
+
const message = value;
|
|
1576
|
+
return message?.type === "gesso:console" && typeof message.enabled === "boolean";
|
|
1577
|
+
}
|
|
1578
|
+
function isConsoleEntryMessage(value) {
|
|
1579
|
+
const message = value;
|
|
1580
|
+
return message?.type === "gesso:console" && typeof message.entry === "object" && message.entry !== null;
|
|
1581
|
+
}
|
|
1582
|
+
//#endregion
|
|
1583
|
+
//#region src/worker/WorkerPorts.ts
|
|
1584
|
+
/**
|
|
1585
|
+
* Named `MessagePort`s into a worker.
|
|
1586
|
+
*
|
|
1587
|
+
* A worker's global `onmessage` is a single channel, so a worker that
|
|
1588
|
+
* receives messages on it can host exactly one conversation. That is
|
|
1589
|
+
* why a store in a data worker used to mean a worker per store: the
|
|
1590
|
+
* client claimed the `Worker` object itself, and a second one had
|
|
1591
|
+
* nowhere to go.
|
|
1592
|
+
*
|
|
1593
|
+
* A handshake fixes it. The client opens a `MessageChannel`, keeps one
|
|
1594
|
+
* end and transfers the other with a name; the worker serves that name
|
|
1595
|
+
* and the two ends talk privately from then on. The global channel is
|
|
1596
|
+
* used once per conversation and carries nothing else.
|
|
1597
|
+
*
|
|
1598
|
+
* Nothing here knows what travels over a port. It is the transport the
|
|
1599
|
+
* store replication in `../store/worker` runs on today and the barrier
|
|
1600
|
+
* contract will run on next.
|
|
1601
|
+
*/
|
|
1602
|
+
function isPortHandshake(value) {
|
|
1603
|
+
const message = value;
|
|
1604
|
+
return message?.type === "gesso:port" && typeof message.key === "string";
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Stands for "whichever worker the shell spawned for the application".
|
|
1608
|
+
*
|
|
1609
|
+
* A registration inside the render worker cannot name that worker: it
|
|
1610
|
+
* is created by the shell and its port only arrives with `init`, long
|
|
1611
|
+
* after `useChannel` and `useService` have run. This sentinel is what a
|
|
1612
|
+
* registration puts there instead, and the render worker swaps it for
|
|
1613
|
+
* the real handle once the port shows up.
|
|
1614
|
+
*
|
|
1615
|
+
* Opening a port on it before then is a bug rather than a race, so it
|
|
1616
|
+
* says so.
|
|
1617
|
+
*/
|
|
1618
|
+
const APPLICATION_WORKER = {
|
|
1619
|
+
open() {
|
|
1620
|
+
throw new Error("APPLICATION_WORKER was used directly. It is a placeholder the render worker replaces with the shell's port; reaching it means no application-logic worker was supplied. Pass appLogicWorker to createApp.");
|
|
1621
|
+
},
|
|
1622
|
+
spawned: false,
|
|
1623
|
+
terminate() {}
|
|
1624
|
+
};
|
|
1625
|
+
/**
|
|
1626
|
+
* A handle over an endpoint someone else owns.
|
|
1627
|
+
*
|
|
1628
|
+
* The shell spawns the application worker and hands the render worker
|
|
1629
|
+
* one end of a channel to it; this is what the render worker opens
|
|
1630
|
+
* named ports over. `terminate` is a no-op — the lifetime belongs to
|
|
1631
|
+
* whoever created the endpoint, and a handle that could kill a worker
|
|
1632
|
+
* it did not spawn would be a surprising thing to hand out.
|
|
1633
|
+
*/
|
|
1634
|
+
function portHandle(endpoint) {
|
|
1635
|
+
return {
|
|
1636
|
+
open(key) {
|
|
1637
|
+
const channel = new MessageChannel();
|
|
1638
|
+
endpoint.postMessage({
|
|
1639
|
+
type: "gesso:port",
|
|
1640
|
+
key
|
|
1641
|
+
}, [channel.port2]);
|
|
1642
|
+
return channel.port1;
|
|
1643
|
+
},
|
|
1644
|
+
get spawned() {
|
|
1645
|
+
return true;
|
|
1646
|
+
},
|
|
1647
|
+
terminate() {}
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
function isHubMessage(value) {
|
|
1651
|
+
return value?.type === "gesso:hub";
|
|
1652
|
+
}
|
|
1653
|
+
const BASE_INSTALLED = Symbol.for("gesso:port-base-installed");
|
|
1654
|
+
const CONSOLE_RESTORE = Symbol.for("gesso:port-console-restore");
|
|
1655
|
+
/**
|
|
1656
|
+
* Starts or stops copying this worker's console to whoever posts to
|
|
1657
|
+
* it, as `ConsoleEntryMessage`s. A host with no `postMessage` (a test's
|
|
1658
|
+
* bare object) has nowhere to send them and forwards nothing.
|
|
1659
|
+
*/
|
|
1660
|
+
function setConsoleForwarding(host, enabled) {
|
|
1661
|
+
host[CONSOLE_RESTORE]?.();
|
|
1662
|
+
delete host[CONSOLE_RESTORE];
|
|
1663
|
+
const post = host.postMessage;
|
|
1664
|
+
if (!enabled || typeof post !== "function") return;
|
|
1665
|
+
host[CONSOLE_RESTORE] = captureConsole((entry) => {
|
|
1666
|
+
const message = {
|
|
1667
|
+
type: "gesso:console",
|
|
1668
|
+
entry
|
|
1669
|
+
};
|
|
1670
|
+
post.call(host, message);
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* The handler every `servePorts` chain sits on top of.
|
|
1675
|
+
*
|
|
1676
|
+
* It owns the two things no individual server can: routing a hub port
|
|
1677
|
+
* through the whole chain, and answering a handshake that nobody
|
|
1678
|
+
* accepted. Both have to be innermost — the first because the chain is
|
|
1679
|
+
* only complete once every server has wrapped `onmessage`, the second
|
|
1680
|
+
* because "nobody accepted" is only known after every server has
|
|
1681
|
+
* declined.
|
|
1682
|
+
*/
|
|
1683
|
+
function installBase(host) {
|
|
1684
|
+
if (host[BASE_INSTALLED] === true) return;
|
|
1685
|
+
host[BASE_INSTALLED] = true;
|
|
1686
|
+
const previous = host.onmessage;
|
|
1687
|
+
const registered = host[SERVED_NAMES] ?? [];
|
|
1688
|
+
host.onmessage = (event) => {
|
|
1689
|
+
if (isConsoleForwardingMessage(event.data)) {
|
|
1690
|
+
setConsoleForwarding(host, event.data.enabled);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (isHubMessage(event.data)) {
|
|
1694
|
+
const port = event.ports?.[0];
|
|
1695
|
+
if (port === void 0) throw new Error("A hub message arrived with no port attached.");
|
|
1696
|
+
port.onmessage = host.onmessage;
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (!isPortHandshake(event.data)) {
|
|
1700
|
+
previous?.(event);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const port = event.ports?.[0];
|
|
1704
|
+
if (port === void 0) throw new Error(`Port handshake for '${event.data.key}' arrived with no port attached.`);
|
|
1705
|
+
const served = registered.flatMap((get) => [...get()]).sort();
|
|
1706
|
+
const error = {
|
|
1707
|
+
type: "port:error",
|
|
1708
|
+
message: `Nothing is served under '${event.data.key}'. This worker serves: ${served.length > 0 ? served.join(", ") : "(nothing)"}.`
|
|
1709
|
+
};
|
|
1710
|
+
port.postMessage(error);
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Wraps a worker factory so the worker is created once and shared.
|
|
1715
|
+
*
|
|
1716
|
+
* A factory rather than a URL for the same reason the render worker
|
|
1717
|
+
* takes one: a bundler only emits a chunk for a worker it can see
|
|
1718
|
+
* constructed literally in the calling module.
|
|
1719
|
+
*
|
|
1720
|
+
* const data = workerHandle(
|
|
1721
|
+
* () => new Worker(new URL('./data.worker.ts', import.meta.url), { type: 'module' })
|
|
1722
|
+
* );
|
|
1723
|
+
*/
|
|
1724
|
+
function workerHandle(factory) {
|
|
1725
|
+
let worker;
|
|
1726
|
+
return {
|
|
1727
|
+
open(key) {
|
|
1728
|
+
worker ??= factory();
|
|
1729
|
+
const channel = new MessageChannel();
|
|
1730
|
+
worker.postMessage({
|
|
1731
|
+
type: "gesso:port",
|
|
1732
|
+
key
|
|
1733
|
+
}, [channel.port2]);
|
|
1734
|
+
return channel.port1;
|
|
1735
|
+
},
|
|
1736
|
+
get spawned() {
|
|
1737
|
+
return worker !== void 0;
|
|
1738
|
+
},
|
|
1739
|
+
terminate() {
|
|
1740
|
+
worker?.terminate();
|
|
1741
|
+
worker = void 0;
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
function isPortErrorMessage(value) {
|
|
1746
|
+
const message = value;
|
|
1747
|
+
return message?.type === "port:error" && typeof message.message === "string";
|
|
1748
|
+
}
|
|
1749
|
+
/** Every name served on a host, across all `servePorts` calls on it. */
|
|
1750
|
+
const SERVED_NAMES = Symbol.for("gesso:served-port-names");
|
|
1751
|
+
/**
|
|
1752
|
+
* Serves named ports inside a worker.
|
|
1753
|
+
*
|
|
1754
|
+
* Call it synchronously at the top level of the worker module, before
|
|
1755
|
+
* any await, so no handshake is missed.
|
|
1756
|
+
*
|
|
1757
|
+
* `onPort` returns whether it took the port. Returning false passes
|
|
1758
|
+
* the handshake to whatever was serving before, which is what lets two
|
|
1759
|
+
* kinds of thing — stores and channels, during the migration — share
|
|
1760
|
+
* one worker: each answers for its own names and declines the rest.
|
|
1761
|
+
* When nobody accepts, the port is answered with an error naming
|
|
1762
|
+
* everything the worker does serve, because a handshake that silently
|
|
1763
|
+
* matched nothing leaves the client waiting forever with nothing said.
|
|
1764
|
+
*
|
|
1765
|
+
* `names` is only read to build that message.
|
|
1766
|
+
*
|
|
1767
|
+
* Returns a function that stops serving.
|
|
1768
|
+
*/
|
|
1769
|
+
function servePorts(onPort, names, host = self) {
|
|
1770
|
+
const withNames = host;
|
|
1771
|
+
const registered = withNames[SERVED_NAMES] ??= [];
|
|
1772
|
+
registered.push(names);
|
|
1773
|
+
installBase(host);
|
|
1774
|
+
const previous = host.onmessage;
|
|
1775
|
+
host.onmessage = (event) => {
|
|
1776
|
+
if (!isPortHandshake(event.data)) {
|
|
1777
|
+
previous?.(event);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const port = event.ports?.[0];
|
|
1781
|
+
if (port === void 0) throw new Error(`Port handshake for '${event.data.key}' arrived with no port attached.`);
|
|
1782
|
+
if (onPort(event.data.key, port)) return;
|
|
1783
|
+
previous?.(event);
|
|
1784
|
+
};
|
|
1785
|
+
return () => {
|
|
1786
|
+
host.onmessage = previous;
|
|
1787
|
+
const index = registered.indexOf(names);
|
|
1788
|
+
if (index >= 0) registered.splice(index, 1);
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
//#endregion
|
|
1792
|
+
//#region src/channel/serveChannels.ts
|
|
1793
|
+
/**
|
|
1794
|
+
* One served channel, with its source checked against its token.
|
|
1795
|
+
*
|
|
1796
|
+
* `ServedChannel` is erased on purpose, so one list can hold channels
|
|
1797
|
+
* of every shape; the cost is that a view key the token declares and
|
|
1798
|
+
* the source forgets is found at startup, by the error `provide`
|
|
1799
|
+
* reports, rather than by the compiler. This is the typed seam: the
|
|
1800
|
+
* source must hold an Observable for every key of the token's view and
|
|
1801
|
+
* a handler for every command, and each handler takes the arguments
|
|
1802
|
+
* the token declares, so none of them needs an annotation.
|
|
1803
|
+
*
|
|
1804
|
+
* serveChannels([
|
|
1805
|
+
* serve(Catalog, { view: catalog, commands: { add: name => catalog.add(name) } })
|
|
1806
|
+
* ]);
|
|
1807
|
+
*
|
|
1808
|
+
* The view may be any object with the right observables on it, which
|
|
1809
|
+
* is often the domain object itself when its properties are named
|
|
1810
|
+
* after the keys. Only the declared keys are read from it.
|
|
1811
|
+
*/
|
|
1812
|
+
function serve(token, source) {
|
|
1813
|
+
return {
|
|
1814
|
+
token,
|
|
1815
|
+
source
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Publishes channels from an application worker.
|
|
1820
|
+
*
|
|
1821
|
+
* Call it synchronously at the top level of the worker module, before
|
|
1822
|
+
* any await, so no handshake is missed:
|
|
1823
|
+
*
|
|
1824
|
+
* const catalog = new CatalogViewModel(new CatalogDomain(new OpfsStore()));
|
|
1825
|
+
* serveChannels([
|
|
1826
|
+
* serve(Catalog, { view: { products: catalog.products$ }, commands: { … } })
|
|
1827
|
+
* ]);
|
|
1828
|
+
*
|
|
1829
|
+
* Everything above this call is the application's own — plain classes,
|
|
1830
|
+
* plain observables, no framework import. This function is the entire
|
|
1831
|
+
* seam between it and the view.
|
|
1832
|
+
*
|
|
1833
|
+
* Returns a function that stops serving and disposes what it provided.
|
|
1834
|
+
*/
|
|
1835
|
+
function serveChannels(channels, host) {
|
|
1836
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1837
|
+
for (const served of channels) byName.set(served.token.name, served);
|
|
1838
|
+
const provided = [];
|
|
1839
|
+
const stop = servePorts((key, port) => {
|
|
1840
|
+
const served = byName.get(key);
|
|
1841
|
+
if (served === void 0) return false;
|
|
1842
|
+
provided.push(provide(served.token, served.source, port));
|
|
1843
|
+
return true;
|
|
1844
|
+
}, () => [...byName.keys()], host);
|
|
1845
|
+
return () => {
|
|
1846
|
+
stop();
|
|
1847
|
+
for (const channel of provided) channel.dispose();
|
|
1848
|
+
provided.length = 0;
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
//#endregion
|
|
1852
|
+
//#region src/channel/pick.ts
|
|
1853
|
+
/**
|
|
1854
|
+
* One key of a view model, as its own Observable, emitting only when
|
|
1855
|
+
* that key's value changes.
|
|
1856
|
+
*
|
|
1857
|
+
* `provide` and `serveChannels` want one Observable per view key, so
|
|
1858
|
+
* the differ can patch each key on its own, while a view model is most
|
|
1859
|
+
* naturally one Observable of one object. This is the seam between the
|
|
1860
|
+
* two, and every application worker was about to write it.
|
|
1861
|
+
*/
|
|
1862
|
+
function pick(source, key) {
|
|
1863
|
+
return source.pipe(map((value) => value[key]), distinctUntilChanged());
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Every key of a view model as its own Observable: the `view` a channel
|
|
1867
|
+
* source wants, from the one Observable a view model has.
|
|
1868
|
+
*
|
|
1869
|
+
* serveChannels([{ token: Queue, source: { view: pickKeys(queue.view, QUEUE_KEYS), commands } }])
|
|
1870
|
+
*/
|
|
1871
|
+
function pickKeys(source, keys) {
|
|
1872
|
+
const out = {};
|
|
1873
|
+
for (const key of keys) out[key] = pick(source, key);
|
|
1874
|
+
return out;
|
|
1875
|
+
}
|
|
1876
|
+
//#endregion
|
|
1877
|
+
//#region src/storage/StorageAdapter.ts
|
|
1878
|
+
/**
|
|
1879
|
+
* Which of the four outcomes a thrown platform error is.
|
|
1880
|
+
*
|
|
1881
|
+
* The names are the ones the storage APIs actually throw.
|
|
1882
|
+
* `QuotaExceededError` is the DOM's word for full, and every browser
|
|
1883
|
+
* uses it for OPFS, IndexedDB and `localStorage` alike. `SecurityError`
|
|
1884
|
+
* and `NotAllowedError` are what a blocked origin gets. A missing API
|
|
1885
|
+
* (no `navigator.storage`, no `indexedDB`) is a `TypeError` here and
|
|
1886
|
+
* is `denied` for the same reason: nothing the application does will
|
|
1887
|
+
* produce a store.
|
|
1888
|
+
*/
|
|
1889
|
+
function classifyStorageError(error) {
|
|
1890
|
+
const name = error instanceof Error ? error.name : "";
|
|
1891
|
+
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return "full";
|
|
1892
|
+
if (name === "SecurityError" || name === "NotAllowedError" || name === "TypeError") return "denied";
|
|
1893
|
+
return "failed";
|
|
1894
|
+
}
|
|
1895
|
+
/** A thrown value as the message a screen could show. */
|
|
1896
|
+
function storageErrorMessage(error) {
|
|
1897
|
+
return error instanceof Error ? error.message : String(error);
|
|
1898
|
+
}
|
|
1899
|
+
/** A read that could not answer, as one record. */
|
|
1900
|
+
function storageReadFailure(error) {
|
|
1901
|
+
return {
|
|
1902
|
+
outcome: classifyStorageError(error),
|
|
1903
|
+
value: null,
|
|
1904
|
+
error: storageErrorMessage(error)
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
/** A read that answered, whether or not it found anything. */
|
|
1908
|
+
function storageReadValue(value) {
|
|
1909
|
+
return {
|
|
1910
|
+
outcome: "ok",
|
|
1911
|
+
value,
|
|
1912
|
+
error: null
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* The same contract in memory, for specs and for a platform with no
|
|
1917
|
+
* store at all.
|
|
1918
|
+
*
|
|
1919
|
+
* Not a fallback anything installs on its own. An application that
|
|
1920
|
+
* would rather run with an unremembered session than fail says so by
|
|
1921
|
+
* passing one of these; one that would rather tell the person its
|
|
1922
|
+
* settings will not be kept reads the `denied` outcome and says so.
|
|
1923
|
+
* Choosing between those two on an application's behalf is exactly the
|
|
1924
|
+
* kind of decision the thread model keeps out of the framework.
|
|
1925
|
+
*/
|
|
1926
|
+
var MemoryStorage = class {
|
|
1927
|
+
records = /* @__PURE__ */ new Map();
|
|
1928
|
+
/** Written by a spec that wants to see what a full store does. */
|
|
1929
|
+
full = false;
|
|
1930
|
+
read(key) {
|
|
1931
|
+
return Promise.resolve(storageReadValue(this.records.get(key) ?? null));
|
|
1932
|
+
}
|
|
1933
|
+
write(key, value) {
|
|
1934
|
+
if (this.full) return Promise.resolve("full");
|
|
1935
|
+
this.records.set(key, value);
|
|
1936
|
+
return Promise.resolve("ok");
|
|
1937
|
+
}
|
|
1938
|
+
remove(key) {
|
|
1939
|
+
this.records.delete(key);
|
|
1940
|
+
return Promise.resolve("ok");
|
|
1941
|
+
}
|
|
1942
|
+
keys() {
|
|
1943
|
+
return Promise.resolve([...this.records.keys()]);
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/undo/UndoStack.ts
|
|
1948
|
+
const NOTHING = {
|
|
1949
|
+
undo: null,
|
|
1950
|
+
redo: null
|
|
1951
|
+
};
|
|
1952
|
+
/**
|
|
1953
|
+
* The application's undo, as a stack of named transactions.
|
|
1954
|
+
*
|
|
1955
|
+
* A component registers what it did and how to unmake it, and a menu,
|
|
1956
|
+
* a button or a keyboard shortcut drives the stack. Nothing here
|
|
1957
|
+
* listens to anything or knows what an application's state is: the two
|
|
1958
|
+
* functions in a transaction are the whole of the coupling, which is
|
|
1959
|
+
* why this can sit on either thread and why it does not become a data
|
|
1960
|
+
* layer.
|
|
1961
|
+
*
|
|
1962
|
+
* const undo = ctx.inject(UndoStack);
|
|
1963
|
+
*
|
|
1964
|
+
* queue.send.remove(at);
|
|
1965
|
+
* undo.push({
|
|
1966
|
+
* label: `Remove ${track.title}`,
|
|
1967
|
+
* undo: () => queue.send.addToQueue(track.id),
|
|
1968
|
+
* redo: () => queue.send.remove(at)
|
|
1969
|
+
* });
|
|
1970
|
+
*
|
|
1971
|
+
* ## How this relates to the undo inside a text field
|
|
1972
|
+
*
|
|
1973
|
+
* `EditableTextModel` has had its own undo since text became editable,
|
|
1974
|
+
* and the two are deliberately separate. A field's undo is a stack of
|
|
1975
|
+
* **snapshots of one string**, private to the field, and it has to be:
|
|
1976
|
+
* the model is the only thing that knows where the caret was, which
|
|
1977
|
+
* run of typing coalesces with which, and what an IME composition is
|
|
1978
|
+
* doing. This one is a stack of **inverse operations** over whatever
|
|
1979
|
+
* an application's state happens to be, and it cannot see inside a
|
|
1980
|
+
* field at all.
|
|
1981
|
+
*
|
|
1982
|
+
* They meet at one key press, and the rule there is that the focused
|
|
1983
|
+
* field wins: `registerUndoShortcuts` skips Mod+Z while something is
|
|
1984
|
+
* being typed into, so undo in a field undoes typing and undo
|
|
1985
|
+
* everywhere else undoes the application's last change. Merging the
|
|
1986
|
+
* two stacks would mean a keystroke and a queue reorder sharing one
|
|
1987
|
+
* history, which is not what either of them means.
|
|
1988
|
+
*
|
|
1989
|
+
* ## Reentrancy
|
|
1990
|
+
*
|
|
1991
|
+
* A push while an undo or a redo is running is dropped. Without it an
|
|
1992
|
+
* application whose edit path records itself would record the undo as
|
|
1993
|
+
* a new edit and the stack would never empty. Write the inverse
|
|
1994
|
+
* functions to call the state directly rather than through the same
|
|
1995
|
+
* path that records, and the guard never fires.
|
|
1996
|
+
*/
|
|
1997
|
+
var UndoStack = class {
|
|
1998
|
+
entries = [];
|
|
1999
|
+
undone = [];
|
|
2000
|
+
limit;
|
|
2001
|
+
/** True while `undo()` or `redo()` is running one of the functions. */
|
|
2002
|
+
running = false;
|
|
2003
|
+
/** Set by `endRun`, so the next push starts a new entry whatever its key. */
|
|
2004
|
+
sealed = false;
|
|
2005
|
+
labels;
|
|
2006
|
+
/** What undoing would undo, or null when there is nothing to undo. */
|
|
2007
|
+
undoLabel;
|
|
2008
|
+
/** What redoing would redo, or null when there is nothing to redo. */
|
|
2009
|
+
redoLabel;
|
|
2010
|
+
/** Whether there is anything to undo, for a menu item's `disabled`. */
|
|
2011
|
+
canUndo;
|
|
2012
|
+
canRedo;
|
|
2013
|
+
constructor(options = {}) {
|
|
2014
|
+
this.limit = Math.max(1, options.limit ?? 100);
|
|
2015
|
+
this.labels = internalState(NOTHING, options.label);
|
|
2016
|
+
this.undoLabel = select(this.labels, "undo");
|
|
2017
|
+
this.redoLabel = select(this.labels, "redo");
|
|
2018
|
+
this.canUndo = computed(() => this.undoLabel.value !== null);
|
|
2019
|
+
this.canRedo = computed(() => this.redoLabel.value !== null);
|
|
2020
|
+
}
|
|
2021
|
+
/** How many entries are held, for a spec or a budget. */
|
|
2022
|
+
get size() {
|
|
2023
|
+
return this.entries.length;
|
|
2024
|
+
}
|
|
2025
|
+
/** How many redos are waiting, for a spec or a budget. */
|
|
2026
|
+
get redoSize() {
|
|
2027
|
+
return this.undone.length;
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Records a change that has already been made.
|
|
2031
|
+
*
|
|
2032
|
+
* Pushing is what discards the redo branch: making a change after
|
|
2033
|
+
* undoing two is the person choosing the other future, and keeping
|
|
2034
|
+
* the abandoned one would mean redoing into a state that no longer
|
|
2035
|
+
* follows from what is on screen.
|
|
2036
|
+
*/
|
|
2037
|
+
push(transaction) {
|
|
2038
|
+
if (this.running) return;
|
|
2039
|
+
this.undone.length = 0;
|
|
2040
|
+
const previous = this.entries[this.entries.length - 1];
|
|
2041
|
+
const sealed = this.sealed;
|
|
2042
|
+
this.sealed = false;
|
|
2043
|
+
if (!sealed && previous !== void 0 && transaction.coalesce !== void 0 && previous.coalesce === transaction.coalesce) {
|
|
2044
|
+
this.entries[this.entries.length - 1] = {
|
|
2045
|
+
label: transaction.label,
|
|
2046
|
+
undo: previous.undo,
|
|
2047
|
+
redo: transaction.redo,
|
|
2048
|
+
coalesce: transaction.coalesce
|
|
2049
|
+
};
|
|
2050
|
+
this.publish();
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
this.entries.push(transaction);
|
|
2054
|
+
while (this.entries.length > this.limit) this.entries.shift();
|
|
2055
|
+
this.publish();
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Groups everything pushed inside `body` into one entry.
|
|
2059
|
+
*
|
|
2060
|
+
* For a change an application makes as several calls and a person
|
|
2061
|
+
* made as one press: undoing runs the group's undos in reverse, and
|
|
2062
|
+
* redoing runs its redos in order. Unlike `coalesce`, a group keeps
|
|
2063
|
+
* every step, because a group is written down as a group rather than
|
|
2064
|
+
* discovered from a run of similar pushes.
|
|
2065
|
+
*/
|
|
2066
|
+
transact(label, body) {
|
|
2067
|
+
if (this.running) return body();
|
|
2068
|
+
const outer = this.entries.length;
|
|
2069
|
+
const result = body();
|
|
2070
|
+
const collected = this.entries.splice(outer);
|
|
2071
|
+
if (collected.length > 0) {
|
|
2072
|
+
this.entries.push({
|
|
2073
|
+
label,
|
|
2074
|
+
undo: () => {
|
|
2075
|
+
for (let at = collected.length - 1; at >= 0; at--) collected[at].undo();
|
|
2076
|
+
},
|
|
2077
|
+
redo: () => {
|
|
2078
|
+
for (const step of collected) step.redo();
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
this.sealed = true;
|
|
2082
|
+
this.publish();
|
|
2083
|
+
}
|
|
2084
|
+
return result;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Ends the current run, so the next push starts its own entry.
|
|
2088
|
+
*
|
|
2089
|
+
* The twin of `EditableTextModel.endTypingRun`. A drag calls it when
|
|
2090
|
+
* the pointer comes up: without it, dragging a row, letting go, and
|
|
2091
|
+
* dragging the same row again would coalesce into one entry, and one
|
|
2092
|
+
* undo would put the row back where it was two gestures ago.
|
|
2093
|
+
*/
|
|
2094
|
+
endRun() {
|
|
2095
|
+
this.sealed = true;
|
|
2096
|
+
}
|
|
2097
|
+
/** Undoes the last change. False when there was nothing to undo. */
|
|
2098
|
+
undo() {
|
|
2099
|
+
const entry = this.entries.pop();
|
|
2100
|
+
if (entry === void 0) return false;
|
|
2101
|
+
this.run(entry.undo);
|
|
2102
|
+
this.undone.push(entry);
|
|
2103
|
+
this.sealed = true;
|
|
2104
|
+
this.publish();
|
|
2105
|
+
return true;
|
|
2106
|
+
}
|
|
2107
|
+
/** Redoes the last undone change. False when there was nothing to redo. */
|
|
2108
|
+
redo() {
|
|
2109
|
+
const entry = this.undone.pop();
|
|
2110
|
+
if (entry === void 0) return false;
|
|
2111
|
+
this.run(entry.redo);
|
|
2112
|
+
this.entries.push(entry);
|
|
2113
|
+
this.sealed = true;
|
|
2114
|
+
this.publish();
|
|
2115
|
+
return true;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Forgets everything, in both directions.
|
|
2119
|
+
*
|
|
2120
|
+
* What an application calls when the thing the entries refer to is
|
|
2121
|
+
* gone: a queue emptied, a document closed, a signed-out account's
|
|
2122
|
+
* library replaced. `EditableTextModel.setText` does the same for
|
|
2123
|
+
* the same reason, and the reason is that an inverse function whose
|
|
2124
|
+
* subject no longer exists is not an undo, it is a surprise.
|
|
2125
|
+
*/
|
|
2126
|
+
clear() {
|
|
2127
|
+
this.entries.length = 0;
|
|
2128
|
+
this.undone.length = 0;
|
|
2129
|
+
this.sealed = false;
|
|
2130
|
+
this.publish();
|
|
2131
|
+
}
|
|
2132
|
+
run(action) {
|
|
2133
|
+
this.running = true;
|
|
2134
|
+
try {
|
|
2135
|
+
action();
|
|
2136
|
+
} finally {
|
|
2137
|
+
this.running = false;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
publish() {
|
|
2141
|
+
const next = {
|
|
2142
|
+
undo: this.entries[this.entries.length - 1]?.label ?? null,
|
|
2143
|
+
redo: this.undone[this.undone.length - 1]?.label ?? null
|
|
2144
|
+
};
|
|
2145
|
+
if (next.undo !== this.labels.value.undo || next.redo !== this.labels.value.redo) this.labels.value = next;
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
//#endregion
|
|
2149
|
+
//#region src/undo/undoable.ts
|
|
2150
|
+
/**
|
|
2151
|
+
* Puts a `mutate` on an undo stack, by way of its own inverse.
|
|
2152
|
+
*
|
|
2153
|
+
* private readonly liked = internalState<readonly string[]>([]);
|
|
2154
|
+
* private readonly like = mutate(this.liked, toggled, id => api.favourite(id));
|
|
2155
|
+
* readonly toggleLike = undoable(undo, this.like, id => id, { label: () => 'Like' });
|
|
2156
|
+
*
|
|
2157
|
+
* The whole of it is that undoing an optimistic change is another
|
|
2158
|
+
* optimistic change. Nothing here writes the cell behind the
|
|
2159
|
+
* mutation's back, which is the thing that would go wrong if an undo
|
|
2160
|
+
* stack held values rather than operations: it would restore a value
|
|
2161
|
+
* the server has not been told about, and the next rollback would
|
|
2162
|
+
* fight it.
|
|
2163
|
+
*
|
|
2164
|
+
* `invert` answers the argument that undoes this one. For a toggle
|
|
2165
|
+
* that is the same argument again, which is why the example above
|
|
2166
|
+
* looks like it does nothing.
|
|
2167
|
+
*
|
|
2168
|
+
* **A refused write is not on the stack.** `mutate` already puts the
|
|
2169
|
+
* cell back when a commit rejects or resolves `false`, so the change
|
|
2170
|
+
* did not happen and there is nothing to undo; pushing it would give a
|
|
2171
|
+
* person an undo that undoes something they never saw.
|
|
2172
|
+
*
|
|
2173
|
+
* The undo and the redo call `mutation.run` rather than this wrapper,
|
|
2174
|
+
* so running them records nothing and the stack's reentrancy guard
|
|
2175
|
+
* never has to fire.
|
|
2176
|
+
*/
|
|
2177
|
+
function undoable(stack, mutation, invert, options) {
|
|
2178
|
+
const { label, coalesce } = options;
|
|
2179
|
+
return async (argument) => {
|
|
2180
|
+
if (!await mutation.run(argument)) return false;
|
|
2181
|
+
stack.push({
|
|
2182
|
+
label: typeof label === "function" ? label(argument) : label,
|
|
2183
|
+
undo: () => void mutation.run(invert(argument)),
|
|
2184
|
+
redo: () => void mutation.run(argument),
|
|
2185
|
+
...coalesce === void 0 ? {} : { coalesce: typeof coalesce === "function" ? coalesce(argument) : coalesce }
|
|
2186
|
+
});
|
|
2187
|
+
return true;
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
//#endregion
|
|
2191
|
+
//#region src/storage/OpfsStorage.ts
|
|
2192
|
+
/**
|
|
2193
|
+
* A store in the origin's private file system.
|
|
2194
|
+
*
|
|
2195
|
+
* The right default for an application's own state. It is reachable
|
|
2196
|
+
* from a worker, which `localStorage` is not, so the thread that owns
|
|
2197
|
+
* the state is the thread that writes it and nothing has to cross the
|
|
2198
|
+
* barrier to be remembered. It is asynchronous throughout, so nothing
|
|
2199
|
+
* it does blocks a frame. And it is per-origin and invisible to the
|
|
2200
|
+
* person, which is the right place for a queue or a draft and the
|
|
2201
|
+
* wrong place for anything they should be able to find and delete.
|
|
2202
|
+
*
|
|
2203
|
+
* One file per key, named by the key with the characters a file system
|
|
2204
|
+
* would refuse escaped, so a key is recoverable from a listing and a
|
|
2205
|
+
* key containing a slash cannot reach out of the folder.
|
|
2206
|
+
*
|
|
2207
|
+
* What happens on each failure:
|
|
2208
|
+
*
|
|
2209
|
+
* - **The platform has no OPFS**, or the browser refuses it (a private
|
|
2210
|
+
* window, a blocked origin): every method answers `denied` and the
|
|
2211
|
+
* error message says which. The store is not usable this session and
|
|
2212
|
+
* `persisted` stops writing to it after the first denial.
|
|
2213
|
+
* - **The quota is spent**: the write answers `full`. Nothing is
|
|
2214
|
+
* rolled back, because the value the application holds is the real
|
|
2215
|
+
* one and only the remembering failed.
|
|
2216
|
+
* - **There is no such record**: the read answers `ok` with `null`.
|
|
2217
|
+
* Not having been written yet is the ordinary first run, not a
|
|
2218
|
+
* failure.
|
|
2219
|
+
* - **Anything else**: `failed`, with the platform's message. A file
|
|
2220
|
+
* whose *contents* are not what this version writes is a different
|
|
2221
|
+
* thing and is `persisted`'s to judge, which it does by discarding
|
|
2222
|
+
* it, the same call `Tokens.ts` makes.
|
|
2223
|
+
*/
|
|
2224
|
+
var OpfsStorage = class {
|
|
2225
|
+
folder;
|
|
2226
|
+
rootOf;
|
|
2227
|
+
/** The folder, once opened. Reused, because opening it is a round trip. */
|
|
2228
|
+
opening = null;
|
|
2229
|
+
constructor(options = {}) {
|
|
2230
|
+
this.folder = options.directory ?? "gesso";
|
|
2231
|
+
this.rootOf = options.root ?? defaultRoot;
|
|
2232
|
+
}
|
|
2233
|
+
async read(key) {
|
|
2234
|
+
try {
|
|
2235
|
+
return storageReadValue(await (await (await (await this.open()).getFileHandle(fileFor(key))).getFile()).text());
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
if (error instanceof Error && error.name === "NotFoundError") return storageReadValue(null);
|
|
2238
|
+
return storageReadFailure(error);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
async write(key, value) {
|
|
2242
|
+
try {
|
|
2243
|
+
const writable = await (await (await this.open()).getFileHandle(fileFor(key), { create: true })).createWritable();
|
|
2244
|
+
await writable.write(value);
|
|
2245
|
+
await writable.close();
|
|
2246
|
+
return "ok";
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
return classifyStorageError(error);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
async remove(key) {
|
|
2252
|
+
try {
|
|
2253
|
+
await (await this.open()).removeEntry(fileFor(key));
|
|
2254
|
+
return "ok";
|
|
2255
|
+
} catch (error) {
|
|
2256
|
+
if (error instanceof Error && error.name === "NotFoundError") return "ok";
|
|
2257
|
+
return classifyStorageError(error);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
async keys() {
|
|
2261
|
+
try {
|
|
2262
|
+
const found = [];
|
|
2263
|
+
for await (const name of (await this.open()).keys()) found.push(keyFor(name));
|
|
2264
|
+
return found;
|
|
2265
|
+
} catch {
|
|
2266
|
+
return [];
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* The folder, opened once and kept.
|
|
2271
|
+
*
|
|
2272
|
+
* A failed open is *not* kept. A rejected promise left in `opening`
|
|
2273
|
+
* would answer every later call with the same rejection, so one
|
|
2274
|
+
* refusal at start-up would be a store that never worked again even
|
|
2275
|
+
* after the person granted storage or made room. Kept when it
|
|
2276
|
+
* succeeds, dropped when it does not, which is one line and the
|
|
2277
|
+
* difference between a cache and a poison.
|
|
2278
|
+
*/
|
|
2279
|
+
open() {
|
|
2280
|
+
if (this.opening === null) {
|
|
2281
|
+
const opening = this.rootOf().then((root) => root.getDirectoryHandle(this.folder, { create: true }));
|
|
2282
|
+
this.opening = opening;
|
|
2283
|
+
opening.catch(() => {
|
|
2284
|
+
if (this.opening === opening) this.opening = null;
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
return this.opening;
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
function defaultRoot() {
|
|
2291
|
+
const storage = globalThis.navigator?.storage;
|
|
2292
|
+
if (storage?.getDirectory === void 0) return Promise.reject(/* @__PURE__ */ new TypeError("This environment has no Origin Private File System."));
|
|
2293
|
+
return storage.getDirectory();
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* A key as a file name.
|
|
2297
|
+
*
|
|
2298
|
+
* `encodeURIComponent` and not a hash, so a listing of the folder in
|
|
2299
|
+
* devtools reads as the keys the application wrote. It escapes the
|
|
2300
|
+
* slash and the dot, which is what stops a key reaching a directory it
|
|
2301
|
+
* was not given.
|
|
2302
|
+
*/
|
|
2303
|
+
function fileFor(key) {
|
|
2304
|
+
return `${encodeURIComponent(key)}.json`;
|
|
2305
|
+
}
|
|
2306
|
+
function keyFor(name) {
|
|
2307
|
+
return decodeURIComponent(name.replace(/\.json$/, ""));
|
|
2308
|
+
}
|
|
2309
|
+
//#endregion
|
|
2310
|
+
//#region src/storage/IndexedDbStorage.ts
|
|
2311
|
+
/**
|
|
2312
|
+
* A store in IndexedDB.
|
|
2313
|
+
*
|
|
2314
|
+
* Beside OPFS rather than instead of it, because the two fail in
|
|
2315
|
+
* different places and an application picks by which failure it
|
|
2316
|
+
* minds. IndexedDB is reachable from every thread, survives longer
|
|
2317
|
+
* under a browser's own eviction, and is what a Safari that has
|
|
2318
|
+
* disabled OPFS still has; OPFS is faster for one large record and
|
|
2319
|
+
* simpler to inspect. Neither is a default the framework picks: an
|
|
2320
|
+
* application names the one it wants.
|
|
2321
|
+
*
|
|
2322
|
+
* One object store of strings keyed by string, which is the shape
|
|
2323
|
+
* `StorageAdapter` describes and no more. Indexes, versions past the
|
|
2324
|
+
* first, and cursors over ranges are what an application builds when
|
|
2325
|
+
* it has outgrown a key-value store, and at that point it is writing
|
|
2326
|
+
* against IndexedDB rather than against this.
|
|
2327
|
+
*
|
|
2328
|
+
* What happens on each failure:
|
|
2329
|
+
*
|
|
2330
|
+
* - **No `indexedDB`, or an origin that may not open one** (a private
|
|
2331
|
+
* window in some browsers, a blocked third-party context): every
|
|
2332
|
+
* method answers `denied`, and the open is retried next time rather
|
|
2333
|
+
* than cached, because a `denied` can be lifted by a site setting
|
|
2334
|
+
* mid-session.
|
|
2335
|
+
* - **The quota is spent**: the write answers `full`. IndexedDB
|
|
2336
|
+
* reports this on the transaction rather than on the request, which
|
|
2337
|
+
* is why the write waits for `oncomplete` and not for
|
|
2338
|
+
* `onsuccess`: a put that succeeded into a transaction that then
|
|
2339
|
+
* aborted has not been written, and answering `ok` for it would be
|
|
2340
|
+
* the adapter assuming success.
|
|
2341
|
+
* - **A version change from another tab**: the connection is closed
|
|
2342
|
+
* and dropped, so the next call opens a fresh one. Answering
|
|
2343
|
+
* `failed` and holding a dead connection would make every later
|
|
2344
|
+
* call fail too.
|
|
2345
|
+
* - **Anything else**: `failed`, with the platform's message.
|
|
2346
|
+
*/
|
|
2347
|
+
var IndexedDbStorage = class {
|
|
2348
|
+
database;
|
|
2349
|
+
store;
|
|
2350
|
+
factory;
|
|
2351
|
+
connecting = null;
|
|
2352
|
+
constructor(options = {}) {
|
|
2353
|
+
this.database = options.database ?? "gesso";
|
|
2354
|
+
this.store = options.store ?? "records";
|
|
2355
|
+
this.factory = options.factory ?? globalThis.indexedDB;
|
|
2356
|
+
}
|
|
2357
|
+
async read(key) {
|
|
2358
|
+
try {
|
|
2359
|
+
const value = await this.transact("readonly", (store) => store.get(key));
|
|
2360
|
+
return storageReadValue(typeof value === "string" ? value : null);
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
return storageReadFailure(error);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
async write(key, value) {
|
|
2366
|
+
return this.outcomeOf(() => this.transact("readwrite", (store) => store.put(value, key)));
|
|
2367
|
+
}
|
|
2368
|
+
async remove(key) {
|
|
2369
|
+
return this.outcomeOf(() => this.transact("readwrite", (store) => store.delete(key)));
|
|
2370
|
+
}
|
|
2371
|
+
async keys() {
|
|
2372
|
+
try {
|
|
2373
|
+
const found = await this.transact("readonly", (store) => store.getAllKeys());
|
|
2374
|
+
return Array.isArray(found) ? found.filter((key) => typeof key === "string") : [];
|
|
2375
|
+
} catch {
|
|
2376
|
+
return [];
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
/** Lets go of the connection, for a spec or an application shutting down. */
|
|
2380
|
+
close() {
|
|
2381
|
+
const connecting = this.connecting;
|
|
2382
|
+
this.connecting = null;
|
|
2383
|
+
connecting?.then((database) => database.close(), () => void 0);
|
|
2384
|
+
}
|
|
2385
|
+
async outcomeOf(work) {
|
|
2386
|
+
try {
|
|
2387
|
+
await work();
|
|
2388
|
+
return "ok";
|
|
2389
|
+
} catch (error) {
|
|
2390
|
+
const outcome = classifyStorageError(error);
|
|
2391
|
+
if (outcome === "denied") this.connecting = null;
|
|
2392
|
+
return outcome;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Runs one request inside one transaction and answers its result.
|
|
2397
|
+
*
|
|
2398
|
+
* A write resolves on the transaction completing rather than on the
|
|
2399
|
+
* request succeeding, because those are two different claims: the
|
|
2400
|
+
* second says the put was accepted, and only the first says it
|
|
2401
|
+
* reached the disk.
|
|
2402
|
+
*/
|
|
2403
|
+
async transact(mode, run) {
|
|
2404
|
+
const database = await this.connect();
|
|
2405
|
+
return new Promise((resolve, reject) => {
|
|
2406
|
+
let answer;
|
|
2407
|
+
const transaction = database.transaction(this.store, mode);
|
|
2408
|
+
const request = run(transaction.objectStore(this.store));
|
|
2409
|
+
request.onsuccess = () => {
|
|
2410
|
+
answer = request.result;
|
|
2411
|
+
};
|
|
2412
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("The request failed."));
|
|
2413
|
+
transaction.oncomplete = () => resolve(answer);
|
|
2414
|
+
transaction.onabort = () => reject(transaction.error ?? /* @__PURE__ */ new Error("The transaction was aborted."));
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
connect() {
|
|
2418
|
+
this.connecting ??= this.open();
|
|
2419
|
+
return this.connecting;
|
|
2420
|
+
}
|
|
2421
|
+
open() {
|
|
2422
|
+
const factory = this.factory;
|
|
2423
|
+
if (factory === void 0) return Promise.reject(/* @__PURE__ */ new TypeError("This environment has no IndexedDB."));
|
|
2424
|
+
return new Promise((resolve, reject) => {
|
|
2425
|
+
let request;
|
|
2426
|
+
try {
|
|
2427
|
+
request = factory.open(this.database, 1);
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
reject(error);
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
request.onupgradeneeded = () => {
|
|
2433
|
+
if (!request.result.objectStoreNames.contains(this.store)) request.result.createObjectStore(this.store);
|
|
2434
|
+
};
|
|
2435
|
+
request.onsuccess = () => {
|
|
2436
|
+
request.result.onversionchange = () => this.close();
|
|
2437
|
+
resolve(request.result);
|
|
2438
|
+
};
|
|
2439
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("The database could not be opened."));
|
|
2440
|
+
request.onblocked = () => reject(/* @__PURE__ */ new Error("The database is open in another tab at a different version."));
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2444
|
+
//#endregion
|
|
2445
|
+
//#region src/storage/persisted.ts
|
|
2446
|
+
const MESSAGES = {
|
|
2447
|
+
denied: "This browser will not let the application store anything.",
|
|
2448
|
+
full: "There is no room left to store this.",
|
|
2449
|
+
failed: "The store could not be written to."
|
|
2450
|
+
};
|
|
2451
|
+
/**
|
|
2452
|
+
* A value that survives the application being closed.
|
|
2453
|
+
*
|
|
2454
|
+
* Hydration is a `resource`, which is not a detail: reading from a
|
|
2455
|
+
* disk is a keyed request that can be slow, can answer "there is
|
|
2456
|
+
* nothing", and can fail, which is the same set of outcomes a request
|
|
2457
|
+
* over the network has. So the statuses here are `ResourceStatus`
|
|
2458
|
+
* itself rather than a fourth enum saying the same five things in
|
|
2459
|
+
* different words.
|
|
2460
|
+
*
|
|
2461
|
+
* readonly draft = persisted(new OpfsStorage(), 'draft', { initial: '' });
|
|
2462
|
+
*
|
|
2463
|
+
* // on a screen
|
|
2464
|
+
* <TextInput value={draft.value} onChange={text => draft.set(text)} />
|
|
2465
|
+
*
|
|
2466
|
+
* ## What a screen sees before hydration finishes
|
|
2467
|
+
*
|
|
2468
|
+
* The default, and `status` reading `loading`. Nothing waits, nothing
|
|
2469
|
+
* is null, and no screen has a shape it only has for the first eighty
|
|
2470
|
+
* milliseconds. When the read lands the value changes like any other
|
|
2471
|
+
* cell change, and a screen that wants to say "restoring" reads
|
|
2472
|
+
* `status`.
|
|
2473
|
+
*
|
|
2474
|
+
* The one race that needs a rule is a person changing the value before
|
|
2475
|
+
* the disk has answered, which is not rare: a queue is a press away
|
|
2476
|
+
* and OPFS is a round trip away. **What they did wins.** A hydration
|
|
2477
|
+
* answer is applied only if nothing has been `set` since, on the same
|
|
2478
|
+
* reasoning as `mutate`'s guarded rollback: an answer that was
|
|
2479
|
+
* overtaken is stale, and putting it on screen would undo something
|
|
2480
|
+
* the person just did.
|
|
2481
|
+
*
|
|
2482
|
+
* ## What happens when storing fails
|
|
2483
|
+
*
|
|
2484
|
+
* - `denied`: nothing is written this session and nothing is tried
|
|
2485
|
+
* again, because the answer will not change. `status` is `failed`
|
|
2486
|
+
* and `saveError` says so once rather than on every keystroke.
|
|
2487
|
+
* - `full`: the write failed and the value in memory is kept. Nothing
|
|
2488
|
+
* is rolled back: the change is real and only the remembering of it
|
|
2489
|
+
* failed. The next change is still attempted, because a quota can be
|
|
2490
|
+
* given back.
|
|
2491
|
+
* - `failed`: the same as `full`, and for the same reason.
|
|
2492
|
+
* - A record that parses but is not the shape `revive` accepts is
|
|
2493
|
+
* treated as `missing`: the application starts from its default
|
|
2494
|
+
* rather than showing an error about a file the person cannot see.
|
|
2495
|
+
*
|
|
2496
|
+
* ## It is a helper
|
|
2497
|
+
*
|
|
2498
|
+
* Nothing in the framework holds one, and nothing is reachable only
|
|
2499
|
+
* through it. A channel is served from plain Observables as it always
|
|
2500
|
+
* was, and an application that would rather read and write a store
|
|
2501
|
+
* itself is writing against the same `StorageAdapter` this is written
|
|
2502
|
+
* against. The thread model declined to own an application's data
|
|
2503
|
+
* architecture, and remembering a value is not the exception to that.
|
|
2504
|
+
*/
|
|
2505
|
+
var PersistedState = class {
|
|
2506
|
+
adapter;
|
|
2507
|
+
key;
|
|
2508
|
+
held;
|
|
2509
|
+
saves;
|
|
2510
|
+
failure;
|
|
2511
|
+
record;
|
|
2512
|
+
writing;
|
|
2513
|
+
/** True once the application has written a value of its own. */
|
|
2514
|
+
touched = false;
|
|
2515
|
+
/** Set when the store said `denied`, which is permanent for the session. */
|
|
2516
|
+
refused = false;
|
|
2517
|
+
/** The text last known to be on disk, so hydration does not write itself back. */
|
|
2518
|
+
stored = null;
|
|
2519
|
+
/** What `forget` goes back to. */
|
|
2520
|
+
initial;
|
|
2521
|
+
/** Where the first read got to; the same five words `resource` uses. */
|
|
2522
|
+
status;
|
|
2523
|
+
/** Why the read did not answer, as a message; null when it did. */
|
|
2524
|
+
error;
|
|
2525
|
+
/** What is remembered: the default until the read lands. */
|
|
2526
|
+
value;
|
|
2527
|
+
/** Writes in the air, for a saving indicator. A count, as `mutate.pending` is. */
|
|
2528
|
+
saving;
|
|
2529
|
+
/** Why the last write did not happen, as a message; null when it did. */
|
|
2530
|
+
saveError;
|
|
2531
|
+
/** Resolves when the first read has settled, whatever it found. */
|
|
2532
|
+
hydrated;
|
|
2533
|
+
constructor(adapter, key, options) {
|
|
2534
|
+
this.adapter = adapter;
|
|
2535
|
+
this.key = key;
|
|
2536
|
+
const label = options.label;
|
|
2537
|
+
this.initial = options.initial;
|
|
2538
|
+
this.held = internalState(options.initial, label);
|
|
2539
|
+
this.saves = internalState(0, label === void 0 ? void 0 : `${label}.saving`);
|
|
2540
|
+
this.failure = internalState(null, label === void 0 ? void 0 : `${label}.saveError`);
|
|
2541
|
+
this.value = this.held;
|
|
2542
|
+
this.saving = this.saves;
|
|
2543
|
+
this.saveError = this.failure;
|
|
2544
|
+
this.record = resource(of(key), () => this.load(options.revive), label === void 0 ? {} : { label: `${label}.hydration` });
|
|
2545
|
+
this.status = this.record.status;
|
|
2546
|
+
this.error = this.record.error;
|
|
2547
|
+
this.hydrated = this.record.settled.then(() => this.apply());
|
|
2548
|
+
this.writing = debounced(this.held, options.settle ?? 250).pipe(skip(1)).subscribe((value) => void this.flush(value));
|
|
2549
|
+
}
|
|
2550
|
+
/** What is remembered right now, for code that is not subscribing. */
|
|
2551
|
+
get current() {
|
|
2552
|
+
return this.held.value;
|
|
2553
|
+
}
|
|
2554
|
+
/** Remembers a new value. The write follows once the changes stop. */
|
|
2555
|
+
set(value) {
|
|
2556
|
+
this.touched = true;
|
|
2557
|
+
this.held.value = value;
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Writes what is held now, without waiting for the gate.
|
|
2561
|
+
*
|
|
2562
|
+
* For the moment an application knows it is about to lose the thread:
|
|
2563
|
+
* a `visibilitychange`, a route away from an editor, a sign-out.
|
|
2564
|
+
*/
|
|
2565
|
+
save() {
|
|
2566
|
+
return this.flush(this.held.value);
|
|
2567
|
+
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Forgets the record and goes back to the default.
|
|
2570
|
+
*
|
|
2571
|
+
* Both halves, because a stored value removed while the cell still
|
|
2572
|
+
* holds it would be written straight back by the next change.
|
|
2573
|
+
*/
|
|
2574
|
+
async forget() {
|
|
2575
|
+
this.stored = null;
|
|
2576
|
+
this.touched = true;
|
|
2577
|
+
this.held.value = this.initial;
|
|
2578
|
+
await this.adapter.remove(this.key);
|
|
2579
|
+
}
|
|
2580
|
+
/** Gives back the write subscription and the resource's. */
|
|
2581
|
+
dispose() {
|
|
2582
|
+
this.writing.unsubscribe();
|
|
2583
|
+
this.record.dispose();
|
|
2584
|
+
}
|
|
2585
|
+
async load(revive) {
|
|
2586
|
+
const read = await this.adapter.read(this.key);
|
|
2587
|
+
if (read.outcome !== "ok") {
|
|
2588
|
+
if (read.outcome === "denied") this.refused = true;
|
|
2589
|
+
throw new Error(read.error ?? MESSAGES[read.outcome] ?? "The store could not be read.");
|
|
2590
|
+
}
|
|
2591
|
+
if (read.value === null) return null;
|
|
2592
|
+
this.stored = read.value;
|
|
2593
|
+
let parsed;
|
|
2594
|
+
try {
|
|
2595
|
+
parsed = JSON.parse(read.value);
|
|
2596
|
+
} catch {
|
|
2597
|
+
return null;
|
|
2598
|
+
}
|
|
2599
|
+
return revive === void 0 ? parsed : revive(parsed);
|
|
2600
|
+
}
|
|
2601
|
+
apply() {
|
|
2602
|
+
const found = this.record.value.value;
|
|
2603
|
+
if (found === null || this.touched) return;
|
|
2604
|
+
this.held.value = found;
|
|
2605
|
+
}
|
|
2606
|
+
async flush(value) {
|
|
2607
|
+
if (this.refused) return;
|
|
2608
|
+
let text;
|
|
2609
|
+
try {
|
|
2610
|
+
text = JSON.stringify(value);
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
this.failure.value = error instanceof Error ? error.message : String(error);
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
if (text === this.stored) return;
|
|
2616
|
+
this.saves.value = this.saves.value + 1;
|
|
2617
|
+
try {
|
|
2618
|
+
const outcome = await this.adapter.write(this.key, text);
|
|
2619
|
+
if (outcome === "ok") {
|
|
2620
|
+
this.stored = text;
|
|
2621
|
+
this.failure.value = null;
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
if (outcome === "denied") this.refused = true;
|
|
2625
|
+
this.failure.value = MESSAGES[outcome] ?? "The store could not be written to.";
|
|
2626
|
+
} finally {
|
|
2627
|
+
this.saves.value = Math.max(0, this.saves.value - 1);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
/**
|
|
2632
|
+
* A value read from a store on start and written back as it changes.
|
|
2633
|
+
*
|
|
2634
|
+
* const settings = persisted(new IndexedDbStorage(), 'settings', {
|
|
2635
|
+
* initial: DEFAULTS,
|
|
2636
|
+
* revive: raw => (isSettings(raw) ? raw : null)
|
|
2637
|
+
* });
|
|
2638
|
+
*
|
|
2639
|
+
* See `PersistedState` for what a screen sees before the read lands
|
|
2640
|
+
* and what each kind of storage failure does.
|
|
2641
|
+
*/
|
|
2642
|
+
function persisted(adapter, key, options) {
|
|
2643
|
+
return new PersistedState(adapter, key, options);
|
|
2644
|
+
}
|
|
2645
|
+
//#endregion
|
|
2646
|
+
export { InternalState as $, printPropValue as A, channel as B, captureConsole as C, formatNodePath as D, formatAge as E, applyPatch as F, mutate as G, viewKeys as H, applyPatches as I, select as J, Resource as K, diffProjection as L, provide as M, findUnplainPath as N, formatNodeReport as O, requirePlainData as P, structurallyEqual as Q, isChannelClientMessage as R, workerHandle as S, describeStream as T, debounced as U, defineChannel as V, throttled as W, computed as X, ComputedCell as Y, derive as Z, isHubMessage as _, undoable as a, output as at, portHandle as b, classifyStorageError as c, storageReadValue as d, internalState as et, pick as f, APPLICATION_WORKER as g, serveChannels as h, OpfsStorage as i, isOutputTarget as it, ProvidedChannel as j, formatStream as k, storageErrorMessage as l, serve as m, persisted as n, input as nt, UndoStack as o, outputTargetOf as ot, pickKeys as p, resource as q, IndexedDbStorage as r, into as rt, MemoryStorage as s, withBodyOf as st, PersistedState as t, InputCell as tt, storageReadFailure as u, isPortErrorMessage as v, isConsoleEntryMessage as w, servePorts as x, isPortHandshake as y, isChannelHostMessage as z };
|
|
2647
|
+
|
|
2648
|
+
//# sourceMappingURL=persisted-Ddb51avc.js.map
|