@jarenjs/app 0.34.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/README.md +197 -0
- package/dist/types/actions.d.ts +89 -0
- package/dist/types/app.d.ts +293 -0
- package/dist/types/diagnostics.d.ts +67 -0
- package/dist/types/docstore.d.ts +52 -0
- package/dist/types/errors.d.ts +178 -0
- package/dist/types/focus.d.ts +89 -0
- package/dist/types/forms.d.ts +122 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/splitter.d.ts +37 -0
- package/dist/types/tasks.d.ts +129 -0
- package/docs/APP-FORMAT.md +804 -0
- package/docs/TASKS.md +254 -0
- package/package.json +57 -0
- package/schemas/jaren-app.draft-07.schema.json +86 -0
- package/schemas/jaren-app.schema.json +86 -0
- package/src/actions.js +167 -0
- package/src/app.js +1622 -0
- package/src/diagnostics.js +77 -0
- package/src/docstore.js +71 -0
- package/src/errors.js +245 -0
- package/src/focus.js +188 -0
- package/src/forms.js +325 -0
- package/src/index.js +16 -0
- package/src/splitter.js +113 -0
- package/src/tasks.js +299 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,1622 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The Jaren application loop.
|
|
4
|
+
*
|
|
5
|
+
* `createApp` takes an **app document** — one JSON value holding the
|
|
6
|
+
* initial state, a JSLT view stylesheet, named action documents and
|
|
7
|
+
* subscription entries — compiles every embedded document once, and runs
|
|
8
|
+
* a serialized dispatch loop over the compiled closures:
|
|
9
|
+
*
|
|
10
|
+
* DOM event → binding → action document → transition → next state
|
|
11
|
+
* → effects → listeners → subscriptions refresh → batched re-render
|
|
12
|
+
*
|
|
13
|
+
* JavaScript enters only at named, registered boundaries: effect and
|
|
14
|
+
* subscription handlers, the `compileTypeTest` hook, and the optional
|
|
15
|
+
* `validateState` invariant hook. Everything between the boundaries is
|
|
16
|
+
* data. See docs/APP-FORMAT.md for the document contract.
|
|
17
|
+
*
|
|
18
|
+
* **Transaction model (APP-FORMAT §8).** Every dispatch is one
|
|
19
|
+
* transaction on one FIFO queue. Only the queue drain evaluates and
|
|
20
|
+
* applies transitions; a dispatch from an effect, listener, observer,
|
|
21
|
+
* subscription handler, widget or lifecycle hook queues behind the
|
|
22
|
+
* current transaction and never nests. Within a transaction the order
|
|
23
|
+
* is: state commit → effect invocation → listener notification →
|
|
24
|
+
* subscription reconciliation → render scheduling → observer
|
|
25
|
+
* notification; all of it completes before the next transaction begins.
|
|
26
|
+
* Every listener therefore observes every transaction in the same
|
|
27
|
+
* order, and each notification carries the state produced by exactly
|
|
28
|
+
* that transaction. Native event data is reduced to JSON synchronously
|
|
29
|
+
* at dispatch time, before queuing. A listener, observer or cleanup
|
|
30
|
+
* error is isolated: it is reported through `onError`, and whatever the
|
|
31
|
+
* sink throws is re-thrown only after the drain has fully completed —
|
|
32
|
+
* the queue always drains, cleanups are never skipped.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { stableStringify } from '@jarenjs/core/object';
|
|
36
|
+
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
37
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
38
|
+
import { createDomRenderer } from '@jarenjs/view';
|
|
39
|
+
|
|
40
|
+
import { compileActions, compileSubs } from './actions.js';
|
|
41
|
+
import { AppCompileError, AppRuntimeError, toError, safeErrorMessage } from './errors.js';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} AppOptions
|
|
45
|
+
* @property {any} [node] - DOM element to mount into; omit for a headless
|
|
46
|
+
* app (drive it via `getVnode`/`subscribe`).
|
|
47
|
+
* @property {any} [document] - The DOM document (defaults to
|
|
48
|
+
* `node.ownerDocument`).
|
|
49
|
+
* @property {Record<string, (props: any, dispatch: Dispatch) => void>} [effects]
|
|
50
|
+
* Effect handlers by name. A handler function may carry an optional
|
|
51
|
+
* `dispose()` member, called exactly once by `app.destroy()` (a
|
|
52
|
+
* handler registered under several names is disposed once).
|
|
53
|
+
* @property {Record<string, (props: any, dispatch: Dispatch) => (() => void) | void>} [subs]
|
|
54
|
+
* Subscription handlers by name; may return a cleanup function.
|
|
55
|
+
* @property {Record<string, (nativeEvent: any) => any>} [eventFields]
|
|
56
|
+
* Named event-field extractors: when a binding requests a field by
|
|
57
|
+
* name, an extractor registered here wins over the built-in
|
|
58
|
+
* allow-list. An extractor receives the native event and MUST return
|
|
59
|
+
* a JSON value (`$event` stays serializable end to end).
|
|
60
|
+
* @property {Record<string, any>} [widgets] - Registered widget
|
|
61
|
+
* definitions by name for `jaren-widget` vnodes (VIEW-FORMAT §7),
|
|
62
|
+
* forwarded to the renderer — the mechanism lives in `@jarenjs/view`;
|
|
63
|
+
* the app only names the boundary, like effects and subs.
|
|
64
|
+
* @property {(schema: any, docPath: string) => ((value: any) => boolean)} [compileTypeTest]
|
|
65
|
+
* Enables JSON Schema operators (`$valid`/`$assert`/`$as`) and schema
|
|
66
|
+
* matches inside the view and the action documents.
|
|
67
|
+
* @property {(state: any, context: ValidateContext) => boolean | { valid: boolean, errors?: any }} [validateState]
|
|
68
|
+
* Invariant hook, called with every candidate next state plus a
|
|
69
|
+
* context carrying the previous state, the acting action name, its
|
|
70
|
+
* payload and the transition's changed paths (`null` = unknown, the
|
|
71
|
+
* whole state must be treated as changed — selective validation on
|
|
72
|
+
* `changes` is only sound when the hook falls back to a full check
|
|
73
|
+
* for `null`). A rejection (`false` or `{ valid: false }`) blocks the
|
|
74
|
+
* transition (fail closed) and reports `JA2005` through `onError`.
|
|
75
|
+
* The hook is host code and may itself fail: a throwing validator is
|
|
76
|
+
* isolated as `JA2015` (the transaction fails, the original cause is
|
|
77
|
+
* preserved, and the queue keeps draining — parked errors from the
|
|
78
|
+
* default rethrowing sink surface only after the drain). The hook
|
|
79
|
+
* also runs once at boot against the initial state (see
|
|
80
|
+
* {@link ValidateContext}).
|
|
81
|
+
* @property {(state: any) => any} [viewModel] - The derivation boundary:
|
|
82
|
+
* maps the state to the view stylesheet's input document before every
|
|
83
|
+
* render (default identity). This is where JS-computed derivations —
|
|
84
|
+
* `buildFormViewModel` from `@jarenjs/forms`, aggregations, joins —
|
|
85
|
+
* enter the render path without ever entering the state.
|
|
86
|
+
* @property {(error: Error) => void} [onError] - Runtime error sink;
|
|
87
|
+
* default rethrows. Errors reported from isolated sites (listeners,
|
|
88
|
+
* observers, cleanups, unknown event fields) never break the
|
|
89
|
+
* transaction queue: whatever the sink throws surfaces to the outer
|
|
90
|
+
* dispatch caller only after the drain completes.
|
|
91
|
+
* @property {(flush: () => void) => void} [schedule] - Render scheduler;
|
|
92
|
+
* default batches on a microtask. Pass `(f) => f()` for synchronous
|
|
93
|
+
* rendering (tests, SSR pipelines).
|
|
94
|
+
* @property {() => void} [afterRender] - Called exactly once per
|
|
95
|
+
* settled, NONTERMINAL committed frame: after the DOM patch and
|
|
96
|
+
* widget mounts complete, and — when a widget hook error was parked
|
|
97
|
+
* during the frame — BEFORE that error is delivered to `onError`
|
|
98
|
+
* (the committed frame is real; its callback is never starved by
|
|
99
|
+
* error delivery). Never called for a pass that ended in terminal
|
|
100
|
+
* teardown, and never on headless apps. The post-render
|
|
101
|
+
* focus/measurement queue (`createFocusEffect`) plugs in here.
|
|
102
|
+
* @property {number} [maxTurns] - The dispatch-loop guard (default
|
|
103
|
+
* 1000): the maximum number of transactions one drain may process
|
|
104
|
+
* before the queue is abandoned with `JA2010` — an accidental
|
|
105
|
+
* action→effect→action loop diagnoses instead of hanging.
|
|
106
|
+
* @property {number} [maxSubInstances] - The subscription fan-out
|
|
107
|
+
* bound (default 256): a `for` declaration resolving more instances
|
|
108
|
+
* than this reports `JA2017` and keeps its previous instance set —
|
|
109
|
+
* an unbounded fan-out driven by state is a resource bug waiting for
|
|
110
|
+
* a bad query.
|
|
111
|
+
* @property {boolean} [capturePayloads] - Include `payload` and `event`
|
|
112
|
+
* values in transaction records handed to observers (default false —
|
|
113
|
+
* diagnostics must not leak data by default).
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The context handed to `validateState` (second argument). During boot
|
|
118
|
+
* the hook is called once with the initial state and the **boot
|
|
119
|
+
* context**: `previous` and `action` are `null` (no transition
|
|
120
|
+
* produced the state) and `changes` is `null` (validate fully); this
|
|
121
|
+
* runs before any subscription starts or effect runs.
|
|
122
|
+
* @typedef {Object} ValidateContext
|
|
123
|
+
* @property {any} previous - The state the transition started from
|
|
124
|
+
* (`null` for the boot-time initial-state check).
|
|
125
|
+
* @property {string | null} action - The acting action name (`null`
|
|
126
|
+
* for the boot-time initial-state check).
|
|
127
|
+
* @property {any} payload - The dispatch payload (`null` when absent).
|
|
128
|
+
* @property {string[] | null} changes - Changed JSON Pointers when the
|
|
129
|
+
* transition was patch-only, else `null` (= unknown, validate fully).
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A transaction record handed to observers (APP-FORMAT §8.3). Payload
|
|
134
|
+
* and event members are present only when `capturePayloads` is on.
|
|
135
|
+
* @typedef {Object} TransactionRecord
|
|
136
|
+
* @property {number} seq - Monotonic transaction sequence number.
|
|
137
|
+
* @property {string} action - The dispatched action name.
|
|
138
|
+
* @property {string} source - What queued it: `'dispatch'` (external),
|
|
139
|
+
* `'binding'` (DOM/widget), `'effect'`, `'subscription'`, or
|
|
140
|
+
* `'setState'` (a whole-state replacement from outside the loop).
|
|
141
|
+
* @property {'applied' | 'noop' | 'rejected' | 'failed'} status
|
|
142
|
+
* @property {string[] | null} changedPaths - Changed JSON Pointers, or
|
|
143
|
+
* `null` when the whole state was replaced (unknown = everything).
|
|
144
|
+
* @property {string[]} scheduledEffects - Names of effects invoked.
|
|
145
|
+
* @property {number} durationMs
|
|
146
|
+
* @property {string | null} errorCode - The `JA2xxx` code when the
|
|
147
|
+
* transaction failed or was rejected.
|
|
148
|
+
* @property {any} [payload]
|
|
149
|
+
* @property {any} [event]
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @callback Dispatch
|
|
154
|
+
* @param {string} name - The action name.
|
|
155
|
+
* @param {any} [payload] - Bound to `$payload` (`null` when absent).
|
|
156
|
+
* @param {any} [domEvent] - A DOM event to derive `$event` from.
|
|
157
|
+
* @param {string[] | null} [eventFields] - Extra `$event` field names to
|
|
158
|
+
* resolve from the event (the binding's `event` member; headless
|
|
159
|
+
* callers get the same capability).
|
|
160
|
+
* @returns {void}
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/** The aggregate envelope message for multiple same-drain sink
|
|
164
|
+
* failures (see `safeError`); exact-match tested so nesting flattens. */
|
|
165
|
+
const MULTIPLE_SINK_FAILURES = 'multiple failures surfaced in one drain';
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compile an app document and start the loop.
|
|
169
|
+
*
|
|
170
|
+
* Boot is a transaction: compiling the documents, creating the
|
|
171
|
+
* renderer, validating the initial state, starting the initial
|
|
172
|
+
* subscriptions, painting the first frame and draining the dispatches
|
|
173
|
+
* queued by starting handlers either all succeed, or every
|
|
174
|
+
* already-acquired resource (subscriptions, effect handlers, the
|
|
175
|
+
* renderer — the container ends empty) is disposed and `createApp`
|
|
176
|
+
* throws `JA0007` (an `AppCompileError` carrying the original failure
|
|
177
|
+
* as `cause`). `onError` observes individual boot-time failures first —
|
|
178
|
+
* a sink that swallows a subscription-start failure, the initial-state
|
|
179
|
+
* check or an error inside queued boot work recovers it and boots the
|
|
180
|
+
* rest; renderer-construction and first-frame failures are always
|
|
181
|
+
* fatal; the default rethrowing sink aborts boot on any of them. After
|
|
182
|
+
* a successful boot the returned app never throws from `createApp`
|
|
183
|
+
* paths again.
|
|
184
|
+
*
|
|
185
|
+
* @param {any} appDoc
|
|
186
|
+
* @param {AppOptions} [options]
|
|
187
|
+
*/
|
|
188
|
+
export function createApp(appDoc, options = {}) {
|
|
189
|
+
if (appDoc === null || typeof appDoc !== 'object' || Array.isArray(appDoc)) {
|
|
190
|
+
throw new AppCompileError('JA0001', 'the app document must be an object', '');
|
|
191
|
+
}
|
|
192
|
+
if (appDoc.view === undefined) {
|
|
193
|
+
throw new AppCompileError('JA0002', 'the app document has no "view" stylesheet', '/view');
|
|
194
|
+
}
|
|
195
|
+
const queryOptions = { compileTypeTest: options.compileTypeTest };
|
|
196
|
+
|
|
197
|
+
let view;
|
|
198
|
+
try {
|
|
199
|
+
// memoized rule outputs: unchanged state subtrees yield reference-
|
|
200
|
+
// equal vnodes frame over frame, so the renderer's === fast path
|
|
201
|
+
// skips them (VIEW-FORMAT §5.1). Vnodes are immutable by contract,
|
|
202
|
+
// which is exactly the discipline memoization needs.
|
|
203
|
+
view = compileJsltStylesheet(appDoc.view, { ...queryOptions, memo: true });
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
const cause = toError(err);
|
|
207
|
+
throw new AppCompileError('JA0002',
|
|
208
|
+
`the "view" stylesheet failed to compile: ${safeErrorMessage(cause)}`,
|
|
209
|
+
'/view', cause);
|
|
210
|
+
}
|
|
211
|
+
const actions = compileActions(appDoc.actions, queryOptions);
|
|
212
|
+
const subs = compileSubs(appDoc.subs, queryOptions);
|
|
213
|
+
|
|
214
|
+
const effectHandlers = options.effects ?? {};
|
|
215
|
+
const subHandlers = options.subs ?? {};
|
|
216
|
+
const eventExtractors = options.eventFields ?? {};
|
|
217
|
+
const onError = options.onError ?? ((err) => { throw err; });
|
|
218
|
+
const schedule = options.schedule ?? ((flush) => queueMicrotask(flush));
|
|
219
|
+
const afterRender = options.afterRender ?? null;
|
|
220
|
+
const maxTurns = options.maxTurns ?? 1000;
|
|
221
|
+
// a loop guard that silently coerces (NaN, '50', 2.5, 0) is no guard
|
|
222
|
+
if (!Number.isInteger(maxTurns) || maxTurns <= 0) {
|
|
223
|
+
throw new TypeError('createApp: options.maxTurns must be a positive integer');
|
|
224
|
+
}
|
|
225
|
+
// Fan-out containment (D-discipline: a bound is printed, never
|
|
226
|
+
// silent): a `for` subscription resolving more instances than this
|
|
227
|
+
// reports JA2017 and keeps its previous instance set.
|
|
228
|
+
const maxSubInstances = options.maxSubInstances ?? 256;
|
|
229
|
+
if (!Number.isInteger(maxSubInstances) || maxSubInstances <= 0) {
|
|
230
|
+
throw new TypeError('createApp: options.maxSubInstances must be a positive integer');
|
|
231
|
+
}
|
|
232
|
+
const capturePayloads = options.capturePayloads === true;
|
|
233
|
+
|
|
234
|
+
let state = appDoc.state;
|
|
235
|
+
let running = true;
|
|
236
|
+
let destroyed = false;
|
|
237
|
+
let renderScheduled = false;
|
|
238
|
+
/** @type {Set<(state: any, changes: string[] | null) => void>} */
|
|
239
|
+
const stateListeners = new Set();
|
|
240
|
+
/** @type {Set<(tx: TransactionRecord) => void>} */
|
|
241
|
+
const observers = new Set();
|
|
242
|
+
/** Per compiled sub: the single-instance slot (`live`/`cleanup` plus
|
|
243
|
+
* the dynamic restart `key`) and, for `for` declarations, the keyed
|
|
244
|
+
* instance map (`key -> { cleanup, propsKey }`, insertion order =
|
|
245
|
+
* document order of the resolved item sequence). */
|
|
246
|
+
const subStates = subs.map(() => ({
|
|
247
|
+
live: false, cleanup: undefined, key: null,
|
|
248
|
+
/** True while this slot's handler is running: ownership is claimed
|
|
249
|
+
* before the handler, so a re-entrant reconciliation cannot start the
|
|
250
|
+
* same subscription a second time and orphan the first acquisition. */
|
|
251
|
+
starting: false,
|
|
252
|
+
/** @type {Map<string, { cleanup: any, propsKey: string }> | null} */
|
|
253
|
+
instances: null,
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The FIFO transaction queue (APP-FORMAT §8). Entries carry the
|
|
258
|
+
* already-JSON-reduced event — native events never wait in the queue.
|
|
259
|
+
* A `replace` entry carries a whole next state instead of an action
|
|
260
|
+
* name: `setState` is a transaction like any other, so it serializes
|
|
261
|
+
* with dispatches rather than cutting in front of them.
|
|
262
|
+
* @type {Array<{ kind?: 'replace', state?: any, source: string, name: string, payload: any, event: any, unknownFields: string[] | null, extractorFailures: Array<{ field: string, value: unknown }> | null }>}
|
|
263
|
+
*/
|
|
264
|
+
const actionQueue = [];
|
|
265
|
+
let draining = false;
|
|
266
|
+
let txSeq = 0;
|
|
267
|
+
/**
|
|
268
|
+
* Every value the `onError` sink threw while the drain was running,
|
|
269
|
+
* in report order — a FRAMEWORK-OWNED array, never a structure
|
|
270
|
+
* derived from the values themselves: appending performs no
|
|
271
|
+
* reflection or coercion on a sink-thrown value (a revoked proxy, a
|
|
272
|
+
* hostile accessor or a host-created AggregateError is stored and
|
|
273
|
+
* later surfaced BY IDENTITY, one element each). The drain always
|
|
274
|
+
* completes; the failures surface to the outermost caller afterwards
|
|
275
|
+
* — a single value as itself, several as one AggregateError over the
|
|
276
|
+
* originals in report order.
|
|
277
|
+
* @type {unknown[]}
|
|
278
|
+
*/
|
|
279
|
+
const pendingFailures = [];
|
|
280
|
+
|
|
281
|
+
/** @type {(((vnode: any) => void) & { destroy?: () => void }) | null} */
|
|
282
|
+
let renderer = null;
|
|
283
|
+
/** Boot atomicity for the frame callback: frames committed while the
|
|
284
|
+
* boot transaction runs defer `afterRender` (see the renderer's
|
|
285
|
+
* `onFrame` wiring); after a successful boot one deferred call
|
|
286
|
+
* fires. A failed boot never fires it. */
|
|
287
|
+
let booted = false;
|
|
288
|
+
let bootFrameLive = false;
|
|
289
|
+
/**
|
|
290
|
+
* Effect-handler identities, snapshotted inside the boot rollback
|
|
291
|
+
* BEFORE any resource is acquired: disposal must never depend on
|
|
292
|
+
* re-enumerating a host registry at destroy time (a hostile
|
|
293
|
+
* enumeration would skip every disposer with resources already
|
|
294
|
+
* live). An unenumerable registry fails the boot instead —
|
|
295
|
+
* rejection before ownership.
|
|
296
|
+
* @type {Array<[string, any]> | null}
|
|
297
|
+
*/
|
|
298
|
+
let effectDisposeEntries = null;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Report an error without ever breaking the drain: the sink runs, and
|
|
302
|
+
* anything it throws parks on `pendingError` until the drain (or the
|
|
303
|
+
* calling entry point) finishes.
|
|
304
|
+
* @param {Error} err
|
|
305
|
+
*/
|
|
306
|
+
function safeError(err) {
|
|
307
|
+
try {
|
|
308
|
+
onError(err);
|
|
309
|
+
}
|
|
310
|
+
catch (thrown) {
|
|
311
|
+
pendingFailures.push(thrown);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Surface the parked sink failures at an entry-point boundary: one
|
|
317
|
+
* failure crosses by identity; several cross as one AggregateError
|
|
318
|
+
* over the originals in report order (a host-created AggregateError
|
|
319
|
+
* stays ONE element — nothing is ever inspected or flattened).
|
|
320
|
+
*/
|
|
321
|
+
function flushPendingError() {
|
|
322
|
+
if (pendingFailures.length === 0) return;
|
|
323
|
+
const failures = pendingFailures.splice(0);
|
|
324
|
+
if (failures.length === 1) throw failures[0];
|
|
325
|
+
throw new AggregateError(failures, MULTIPLE_SINK_FAILURES);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Queue one transaction and drain if no drain is running. The native
|
|
330
|
+
* event is reduced to JSON here, synchronously — by the time the
|
|
331
|
+
* transaction runs, the event object may be recycled by the browser.
|
|
332
|
+
* @param {string} source
|
|
333
|
+
* @param {string} name
|
|
334
|
+
* @param {any} payload
|
|
335
|
+
* @param {any} domEvent
|
|
336
|
+
* @param {string[] | null} eventFields
|
|
337
|
+
*/
|
|
338
|
+
function queueDispatch(source, name, payload, domEvent, eventFields) {
|
|
339
|
+
if (!running) return;
|
|
340
|
+
let event = null;
|
|
341
|
+
let unknownFields = null;
|
|
342
|
+
let extractorFailures = null;
|
|
343
|
+
if (domEvent !== null && domEvent !== undefined) {
|
|
344
|
+
/** @type {string[]} */
|
|
345
|
+
const unknown = [];
|
|
346
|
+
/** @type {Array<{ field: string, value: unknown }>} */
|
|
347
|
+
const failures = [];
|
|
348
|
+
// tagged outcomes: a thrown `undefined` is a FAILURE, structurally
|
|
349
|
+
// distinct from an unknown field — the two must never share a signal
|
|
350
|
+
event = eventData(domEvent, eventFields, eventExtractors,
|
|
351
|
+
(outcome) => {
|
|
352
|
+
if (outcome.kind === 'unknown') unknown.push(outcome.field);
|
|
353
|
+
else failures.push({ field: outcome.field, value: outcome.value });
|
|
354
|
+
});
|
|
355
|
+
if (unknown.length > 0) unknownFields = unknown;
|
|
356
|
+
if (failures.length > 0) extractorFailures = failures;
|
|
357
|
+
}
|
|
358
|
+
actionQueue.push({ source, name, payload, event, unknownFields, extractorFailures });
|
|
359
|
+
drainQueue();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Queue a whole-state replacement and drain if no drain is running.
|
|
364
|
+
* @param {any} next
|
|
365
|
+
*/
|
|
366
|
+
function queueReplace(next) {
|
|
367
|
+
if (!running) return;
|
|
368
|
+
actionQueue.push({
|
|
369
|
+
kind: 'replace',
|
|
370
|
+
state: next,
|
|
371
|
+
source: 'setState',
|
|
372
|
+
name: 'setState',
|
|
373
|
+
payload: null,
|
|
374
|
+
event: null,
|
|
375
|
+
unknownFields: null,
|
|
376
|
+
extractorFailures: null,
|
|
377
|
+
});
|
|
378
|
+
drainQueue();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Drain the queue to empty; the sole caller of `runTransaction`. */
|
|
382
|
+
function drainQueue() {
|
|
383
|
+
if (draining) return;
|
|
384
|
+
draining = true;
|
|
385
|
+
let turns = 0;
|
|
386
|
+
try {
|
|
387
|
+
while (actionQueue.length > 0) {
|
|
388
|
+
if (++turns > maxTurns) {
|
|
389
|
+
actionQueue.length = 0;
|
|
390
|
+
safeError(new AppRuntimeError('JA2010',
|
|
391
|
+
`the dispatch loop exceeded ${maxTurns} queued transactions in one drain; `
|
|
392
|
+
+ 'the queue was abandoned (an action/effect dispatch loop?)'));
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
const entry = /** @type {NonNullable<ReturnType<typeof actionQueue.shift>>} */ (actionQueue.shift());
|
|
396
|
+
runTransaction(entry);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
draining = false;
|
|
401
|
+
}
|
|
402
|
+
flushPendingError();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** @type {Dispatch} */
|
|
406
|
+
function dispatch(name, payload = null, domEvent = null, eventFields = null) {
|
|
407
|
+
queueDispatch('dispatch', name, payload, domEvent, eventFields);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The dispatch handed to effect handlers: tags the source. */
|
|
411
|
+
function effectDispatch(name, payload = null, domEvent = null, eventFields = null) {
|
|
412
|
+
queueDispatch('effect', name, payload, domEvent, eventFields);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The dispatch handed to subscription handlers: tags the source. */
|
|
416
|
+
function subDispatch(name, payload = null, domEvent = null, eventFields = null) {
|
|
417
|
+
queueDispatch('subscription', name, payload, domEvent, eventFields);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* An `on` binding fired by the renderer (or a widget's `emit`): an
|
|
422
|
+
* action name, or `{ "action": name, "with"?: payload, "event"?:
|
|
423
|
+
* [fieldName, ...], "preventDefault"?: bool, "stopPropagation"?:
|
|
424
|
+
* bool }`. The two native controls run synchronously in the event
|
|
425
|
+
* callback, before the transaction is queued — but only a REGISTERED
|
|
426
|
+
* action owns the native behavior: an unknown action name suppresses
|
|
427
|
+
* nothing (it still queues and reports `JA2001`). A registered action
|
|
428
|
+
* that later fails keeps its already-applied modifiers — whether the
|
|
429
|
+
* action succeeds cannot retroactively change them. Headless events
|
|
430
|
+
* without the methods are a documented no-op.
|
|
431
|
+
* @param {any} binding
|
|
432
|
+
* @param {any} event
|
|
433
|
+
*/
|
|
434
|
+
function handleBinding(binding, event) {
|
|
435
|
+
if (typeof binding === 'string') {
|
|
436
|
+
queueDispatch('binding', binding, null, event, null);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (binding !== null && typeof binding === 'object'
|
|
440
|
+
&& typeof binding.action === 'string'
|
|
441
|
+
&& (binding.event === undefined || isFieldNameArray(binding.event))
|
|
442
|
+
&& (binding.preventDefault === undefined || typeof binding.preventDefault === 'boolean')
|
|
443
|
+
&& (binding.stopPropagation === undefined || typeof binding.stopPropagation === 'boolean')) {
|
|
444
|
+
const registered = actions.has(binding.action);
|
|
445
|
+
if (registered && binding.preventDefault === true
|
|
446
|
+
&& typeof event?.preventDefault === 'function') {
|
|
447
|
+
event.preventDefault();
|
|
448
|
+
}
|
|
449
|
+
if (registered && binding.stopPropagation === true
|
|
450
|
+
&& typeof event?.stopPropagation === 'function') {
|
|
451
|
+
event.stopPropagation();
|
|
452
|
+
}
|
|
453
|
+
queueDispatch('binding', binding.action, binding.with ?? null, event, binding.event ?? null);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
safeError(new AppRuntimeError('JA2001',
|
|
457
|
+
`unusable event binding: ${JSON.stringify(binding)}`));
|
|
458
|
+
if (!draining) flushPendingError();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Run one queued transaction to completion: evaluate the action,
|
|
463
|
+
* commit the state, invoke effects, notify listeners, reconcile
|
|
464
|
+
* subscriptions, schedule the render, then notify observers.
|
|
465
|
+
* @param {{ source: string, name: string, payload: any, event: any, unknownFields: string[] | null, extractorFailures: Array<{ field: string, value: unknown }> | null }} entry
|
|
466
|
+
*/
|
|
467
|
+
function runTransaction(entry) {
|
|
468
|
+
const started = now();
|
|
469
|
+
const seq = ++txSeq;
|
|
470
|
+
/** @type {'applied' | 'noop' | 'rejected' | 'failed'} */
|
|
471
|
+
let status = 'noop';
|
|
472
|
+
/** @type {string | null} */
|
|
473
|
+
let errorCode = null;
|
|
474
|
+
/** @type {string[] | null} */
|
|
475
|
+
let changes = null;
|
|
476
|
+
/** @type {string[]} */
|
|
477
|
+
const scheduledEffects = [];
|
|
478
|
+
/** Whether this transaction already scheduled a render (state changed). */
|
|
479
|
+
let renderScheduled = false;
|
|
480
|
+
|
|
481
|
+
// a typo in one requested event field binds null and reports; the
|
|
482
|
+
// dispatch itself is never dropped (JA2009 contract)
|
|
483
|
+
if (entry.unknownFields !== null) {
|
|
484
|
+
for (const field of entry.unknownFields) {
|
|
485
|
+
safeError(new AppRuntimeError('JA2009',
|
|
486
|
+
`a binding requested an unknown event field '${field}'`));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// an extractor is host code: a throw surfaces as the dispatching
|
|
490
|
+
// action's JA2002 (APP-FORMAT §5.4), the member binds null, and the
|
|
491
|
+
// dispatch itself is never dropped
|
|
492
|
+
if (entry.extractorFailures !== null) {
|
|
493
|
+
for (const { field, value } of entry.extractorFailures) {
|
|
494
|
+
const cause = toError(value);
|
|
495
|
+
safeError(new AppRuntimeError('JA2002',
|
|
496
|
+
`action '${entry.name}' event-field extractor '${field}' threw: ${safeErrorMessage(cause)}`,
|
|
497
|
+
{ cause }));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ————— a whole-state replacement (`setState`) —————
|
|
502
|
+
// It commits through the SAME path as an action: validated before
|
|
503
|
+
// commit, listeners in registration order over one state/changes pair,
|
|
504
|
+
// subscriptions reconciled once, one render scheduled, one record
|
|
505
|
+
// observed. Replacing state outside the queue broke every one of those
|
|
506
|
+
// — validation was skipped, a listener that dispatched saw its own
|
|
507
|
+
// transaction interleaved, and a sink failure surfaced out of the next
|
|
508
|
+
// unrelated dispatch instead of at the setState caller.
|
|
509
|
+
if (entry.kind === 'replace') {
|
|
510
|
+
const next = entry.state;
|
|
511
|
+
if (next === state) {
|
|
512
|
+
finish('noop', null);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (options.validateState !== undefined) {
|
|
516
|
+
let verdict;
|
|
517
|
+
try {
|
|
518
|
+
verdict = options.validateState(next, {
|
|
519
|
+
previous: state,
|
|
520
|
+
// the replacement is not an action; the context says so rather
|
|
521
|
+
// than borrowing a name no reducer ran under
|
|
522
|
+
action: null,
|
|
523
|
+
payload: null,
|
|
524
|
+
changes: null,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
const cause = toError(err);
|
|
529
|
+
safeError(new AppRuntimeError('JA2015',
|
|
530
|
+
`the validateState hook threw for an external state replacement: ${safeErrorMessage(cause)}`,
|
|
531
|
+
{ cause }));
|
|
532
|
+
finish('failed', 'JA2015');
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (verdict === false
|
|
536
|
+
|| (verdict !== null && typeof verdict === 'object' && verdict.valid === false)) {
|
|
537
|
+
const err = new AppRuntimeError('JA2005',
|
|
538
|
+
'an external state replacement violated the app\'s state invariants; it was rejected');
|
|
539
|
+
err.detail = typeof verdict === 'object' ? verdict.errors : undefined;
|
|
540
|
+
safeError(err);
|
|
541
|
+
finish('rejected', 'JA2005');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
state = next;
|
|
546
|
+
for (const listener of stateListeners) {
|
|
547
|
+
try {
|
|
548
|
+
listener(state, null);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
const cause = toError(err);
|
|
552
|
+
safeError(new AppRuntimeError('JA2011',
|
|
553
|
+
`a state listener threw: ${safeErrorMessage(cause)}`, { cause }));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
refreshSubs();
|
|
557
|
+
scheduleRender();
|
|
558
|
+
renderScheduled = true;
|
|
559
|
+
finish('applied', null);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const action = actions.get(entry.name);
|
|
564
|
+
if (action === undefined) {
|
|
565
|
+
safeError(new AppRuntimeError('JA2001', `unknown action '${entry.name}'`));
|
|
566
|
+
finish('failed', 'JA2001');
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let transition;
|
|
571
|
+
try {
|
|
572
|
+
transition = action.first(state, { event: entry.event, payload: entry.payload });
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
const cause = toError(err);
|
|
576
|
+
safeError(new AppRuntimeError('JA2002',
|
|
577
|
+
`action '${entry.name}' failed: ${safeErrorMessage(cause)}`, { cause }));
|
|
578
|
+
finish('failed', 'JA2002');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (transition === undefined || transition === null) {
|
|
583
|
+
finish('noop', null);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (typeof transition !== 'object' || Array.isArray(transition)) {
|
|
587
|
+
safeError(new AppRuntimeError('JA2003',
|
|
588
|
+
`action '${entry.name}' produced a transition that is not an object`));
|
|
589
|
+
finish('failed', 'JA2003');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let next = state;
|
|
594
|
+
// changed paths for this transition: an array of JSON Pointers when
|
|
595
|
+
// the transition was patch-only (the engine's tracked writes), else
|
|
596
|
+
// null = "unknown, treat everything as changed"
|
|
597
|
+
if ('state' in transition) next = transition.state;
|
|
598
|
+
if (transition.patch !== undefined) {
|
|
599
|
+
try {
|
|
600
|
+
const tracked = applyJSONPatch(next, transition.patch, { changes: true });
|
|
601
|
+
next = tracked.doc;
|
|
602
|
+
if (!('state' in transition)) changes = tracked.changes;
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
const cause = toError(err);
|
|
606
|
+
safeError(new AppRuntimeError('JA2004',
|
|
607
|
+
`action '${entry.name}' produced a patch that failed to apply: ${safeErrorMessage(cause)}`,
|
|
608
|
+
{ cause }));
|
|
609
|
+
finish('failed', 'JA2004');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (next !== state && options.validateState !== undefined) {
|
|
614
|
+
let verdict;
|
|
615
|
+
try {
|
|
616
|
+
verdict = options.validateState(next, {
|
|
617
|
+
previous: state,
|
|
618
|
+
action: entry.name,
|
|
619
|
+
payload: entry.payload,
|
|
620
|
+
changes,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
// the validator is host code: a throw is its own failure mode
|
|
625
|
+
// (JA2015), never a rejection verdict — the transaction fails,
|
|
626
|
+
// the queue keeps draining
|
|
627
|
+
const cause = toError(err);
|
|
628
|
+
safeError(new AppRuntimeError('JA2015',
|
|
629
|
+
`the validateState hook threw for action '${entry.name}': ${safeErrorMessage(cause)}`,
|
|
630
|
+
{ cause }));
|
|
631
|
+
finish('failed', 'JA2015');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (verdict === false
|
|
635
|
+
|| (verdict !== null && typeof verdict === 'object' && verdict.valid === false)) {
|
|
636
|
+
const err = new AppRuntimeError('JA2005',
|
|
637
|
+
`action '${entry.name}' violated the app's state invariants; transition rejected`);
|
|
638
|
+
err.detail = typeof verdict === 'object' ? verdict.errors : undefined;
|
|
639
|
+
safeError(err);
|
|
640
|
+
finish('rejected', 'JA2005');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const changed = next !== state;
|
|
646
|
+
state = next;
|
|
647
|
+
if (transition.effects !== undefined) {
|
|
648
|
+
runEffects(entry.name, transition.effects, scheduledEffects);
|
|
649
|
+
}
|
|
650
|
+
if (changed) {
|
|
651
|
+
for (const listener of stateListeners) {
|
|
652
|
+
try {
|
|
653
|
+
listener(state, changes);
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
const cause = toError(err);
|
|
657
|
+
safeError(new AppRuntimeError('JA2011',
|
|
658
|
+
`a state listener threw: ${safeErrorMessage(cause)}`, { cause }));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
refreshSubs();
|
|
662
|
+
scheduleRender();
|
|
663
|
+
renderScheduled = true;
|
|
664
|
+
}
|
|
665
|
+
finish(changed || scheduledEffects.length > 0 ? 'applied' : 'noop', null);
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Build the transaction record and notify observers (isolated: an
|
|
669
|
+
* observer failure never reaches the queue).
|
|
670
|
+
* @param {'applied' | 'noop' | 'rejected' | 'failed'} finalStatus
|
|
671
|
+
* @param {string | null} code
|
|
672
|
+
*/
|
|
673
|
+
function finish(finalStatus, code) {
|
|
674
|
+
status = finalStatus;
|
|
675
|
+
errorCode = code;
|
|
676
|
+
// Settlement: a DOM event (a `binding` source) may have moved a
|
|
677
|
+
// controlled input off authoritative state — a user keystroke — and if
|
|
678
|
+
// this transaction did not itself change state (a no-op, a rejected or
|
|
679
|
+
// failed action, an effects-only outcome) no render was scheduled, so
|
|
680
|
+
// the control would keep the user's value. Schedule one render so the
|
|
681
|
+
// view reasserts controlled values against the live DOM. It is cheap:
|
|
682
|
+
// unchanged state re-projects to a reference-equal vnode the patcher
|
|
683
|
+
// skips, leaving only the controlled reconciliation.
|
|
684
|
+
if (entry.source === 'binding' && !renderScheduled) {
|
|
685
|
+
renderScheduled = true;
|
|
686
|
+
scheduleRender();
|
|
687
|
+
}
|
|
688
|
+
if (observers.size === 0) return;
|
|
689
|
+
/** @type {TransactionRecord} */
|
|
690
|
+
const record = {
|
|
691
|
+
seq,
|
|
692
|
+
action: entry.name,
|
|
693
|
+
source: entry.source,
|
|
694
|
+
status,
|
|
695
|
+
changedPaths: status === 'applied' ? changes : null,
|
|
696
|
+
scheduledEffects,
|
|
697
|
+
durationMs: now() - started,
|
|
698
|
+
errorCode,
|
|
699
|
+
};
|
|
700
|
+
if (capturePayloads) {
|
|
701
|
+
record.payload = entry.payload;
|
|
702
|
+
record.event = entry.event;
|
|
703
|
+
}
|
|
704
|
+
for (const observer of observers) {
|
|
705
|
+
try {
|
|
706
|
+
observer(record);
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
const cause = toError(err);
|
|
710
|
+
safeError(new AppRuntimeError('JA2011',
|
|
711
|
+
`a transaction observer threw: ${safeErrorMessage(cause)}`, { cause }));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* @param {string} name
|
|
719
|
+
* @param {any} effects
|
|
720
|
+
* @param {string[]} scheduled - Records invoked effect names.
|
|
721
|
+
*/
|
|
722
|
+
function runEffects(name, effects, scheduled) {
|
|
723
|
+
if (!Array.isArray(effects)) {
|
|
724
|
+
safeError(new AppRuntimeError('JA2003',
|
|
725
|
+
`action '${name}' produced "effects" that are not an array`));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
for (const effect of effects) {
|
|
729
|
+
const run = effect?.run;
|
|
730
|
+
// registry member acquisition is host-observable (the registry
|
|
731
|
+
// may be a proxy or carry accessors): lookup and invocation
|
|
732
|
+
// share one boundary and one failure policy (JA2007)
|
|
733
|
+
let handler;
|
|
734
|
+
try {
|
|
735
|
+
handler = typeof run === 'string' ? effectHandlers[run] : undefined;
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
const cause = toError(err);
|
|
739
|
+
safeError(new AppRuntimeError('JA2007',
|
|
740
|
+
`effect '${String(run)}' threw during lookup: ${safeErrorMessage(cause)}`, { cause }));
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (handler === undefined) {
|
|
744
|
+
safeError(new AppRuntimeError('JA2006',
|
|
745
|
+
`action '${name}' invoked unregistered effect '${String(run)}'`));
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
scheduled.push(run);
|
|
749
|
+
try {
|
|
750
|
+
handler(effect.with ?? null, effectDispatch);
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
const cause = toError(err);
|
|
754
|
+
safeError(new AppRuntimeError('JA2007',
|
|
755
|
+
`effect '${run}' threw: ${safeErrorMessage(cause)}`, { cause }));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The restart key of a resolved value, BY VALUE: `stableStringify`
|
|
762
|
+
* (never `stableKeyString` — package-private to the query engine, and
|
|
763
|
+
* its `NaN`-by-name rule is a grouping decision this key does not
|
|
764
|
+
* want). `undefined` (an empty query result) normalizes to `null`
|
|
765
|
+
* first, so absence keys deterministically. A cyclic value overflows
|
|
766
|
+
* `stableStringify` (it has no cycle guard) — callers catch and reject
|
|
767
|
+
* with `JA2016` rather than hanging.
|
|
768
|
+
* @param {any} value
|
|
769
|
+
* @returns {string}
|
|
770
|
+
*/
|
|
771
|
+
function subKeyOf(value) {
|
|
772
|
+
return stableStringify(value === undefined ? null : value) ?? 'null';
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Report a failing dynamic subscription member (`withQuery`, `key`,
|
|
777
|
+
* `for`) — `JA2016`, carrying the member's own docPath. The
|
|
778
|
+
* subscription fails CLOSED, like a broken `when`.
|
|
779
|
+
* @param {any} sub
|
|
780
|
+
* @param {number} i
|
|
781
|
+
* @param {string} member
|
|
782
|
+
* @param {unknown} err
|
|
783
|
+
*/
|
|
784
|
+
function reportSubQueryFailure(sub, i, member, err) {
|
|
785
|
+
const cause = toError(err);
|
|
786
|
+
safeError(new AppRuntimeError('JA2016',
|
|
787
|
+
`subscription '${sub.run}' has a "${member}" that failed: ${safeErrorMessage(cause)}`,
|
|
788
|
+
{ docPath: `/subs/${i}/${member === 'keyQuery' ? 'key' : member}`, cause }));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Look up a subscription handler; `undefined` after a report means
|
|
793
|
+
* "cannot start" (`JA2008`/`JA2013`-lookup, matching the historical
|
|
794
|
+
* single-instance behaviour).
|
|
795
|
+
* @param {any} sub
|
|
796
|
+
* @returns {any}
|
|
797
|
+
*/
|
|
798
|
+
function lookupSubHandler(sub) {
|
|
799
|
+
let handler;
|
|
800
|
+
try {
|
|
801
|
+
handler = subHandlers[sub.run];
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
const cause = toError(err);
|
|
805
|
+
safeError(new AppRuntimeError('JA2013',
|
|
806
|
+
`subscription '${sub.run}' threw during lookup; it stays stopped: ${safeErrorMessage(cause)}`,
|
|
807
|
+
{ cause }));
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
if (handler === undefined) {
|
|
811
|
+
safeError(new AppRuntimeError('JA2008',
|
|
812
|
+
`subscription '${sub.run}' has no registered handler`));
|
|
813
|
+
}
|
|
814
|
+
return handler;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Run one subscription cleanup with the standard isolation (`JA2012`
|
|
819
|
+
* via `reportCleanup`, so destroy-scope collection still applies).
|
|
820
|
+
* @param {any} sub
|
|
821
|
+
* @param {any} cleanup
|
|
822
|
+
*/
|
|
823
|
+
function runSubCleanup(sub, cleanup) {
|
|
824
|
+
if (typeof cleanup !== 'function') return;
|
|
825
|
+
try {
|
|
826
|
+
cleanup();
|
|
827
|
+
}
|
|
828
|
+
catch (err) {
|
|
829
|
+
const cause = toError(err);
|
|
830
|
+
reportCleanup(
|
|
831
|
+
`subscription '${sub.run}' threw while cleaning up: ${safeErrorMessage(cause)}`,
|
|
832
|
+
cause);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Reconcile a `for` declaration's keyed instance set against the
|
|
838
|
+
* current state: stop removed instances (previous insertion order),
|
|
839
|
+
* then start added — and restart changed — instances in the document
|
|
840
|
+
* order of the resolved item sequence. Duplicate keys collapse to the
|
|
841
|
+
* first occurrence. Resolution failures fail the whole declaration
|
|
842
|
+
* closed (`JA2016`); exceeding `maxSubInstances` reports `JA2017` and
|
|
843
|
+
* keeps the previous set (the bound is printed, never silent).
|
|
844
|
+
* @param {any} sub
|
|
845
|
+
* @param {any} slot
|
|
846
|
+
* @param {number} i
|
|
847
|
+
* @param {boolean} live
|
|
848
|
+
*/
|
|
849
|
+
function refreshFanout(sub, slot, i, live) {
|
|
850
|
+
const instances = slot.instances ?? (slot.instances = new Map());
|
|
851
|
+
let next = null;
|
|
852
|
+
/** Set when the fan-out bound was crossed mid-enumeration. */
|
|
853
|
+
let overflow = 0;
|
|
854
|
+
if (live) {
|
|
855
|
+
next = new Map();
|
|
856
|
+
let failingMember = 'for';
|
|
857
|
+
try {
|
|
858
|
+
const resolved = sub.forQuery(state);
|
|
859
|
+
const items = resolved === undefined ? []
|
|
860
|
+
: Array.isArray(resolved) ? resolved : [resolved];
|
|
861
|
+
for (let k = 0; k < items.length; k++) {
|
|
862
|
+
// The bound stops the WORK, not just the retained instances.
|
|
863
|
+
// Checking it after the loop bounded what was kept while a
|
|
864
|
+
// runaway `for` query still ran every key and props expression
|
|
865
|
+
// and serialized every result first — so the limit cost memory
|
|
866
|
+
// and CPU proportional to the mistake it was there to contain.
|
|
867
|
+
if (next.size >= maxSubInstances) {
|
|
868
|
+
overflow = next.size + (items.length - k);
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
const item = items[k];
|
|
872
|
+
failingMember = sub.keyQuery !== null ? 'key' : 'for';
|
|
873
|
+
const key = sub.keyQuery !== null
|
|
874
|
+
? subKeyOf(sub.keyQuery(state, { item }))
|
|
875
|
+
: subKeyOf(item);
|
|
876
|
+
if (next.has(key)) continue; // duplicates collapse, first wins
|
|
877
|
+
let props;
|
|
878
|
+
if (sub.withQuery !== null) {
|
|
879
|
+
failingMember = 'withQuery';
|
|
880
|
+
const v = sub.withQuery(state, { item });
|
|
881
|
+
props = v === undefined ? null : v;
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
props = item === undefined ? null : item;
|
|
885
|
+
}
|
|
886
|
+
const propsKey = sub.withQuery !== null ? subKeyOf(props) : key;
|
|
887
|
+
next.set(key, { props, propsKey });
|
|
888
|
+
failingMember = 'for';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
reportSubQueryFailure(sub, i, failingMember, err);
|
|
893
|
+
next = null; // fail closed: treat as not live this refresh
|
|
894
|
+
}
|
|
895
|
+
if (next !== null && overflow > 0) {
|
|
896
|
+
safeError(new AppRuntimeError('JA2017',
|
|
897
|
+
`subscription '${sub.run}' fan-out reached maxSubInstances (${maxSubInstances}) `
|
|
898
|
+
+ `with at least ${overflow} items to resolve; enumeration stopped there`,
|
|
899
|
+
{ docPath: `/subs/${i}/for` }));
|
|
900
|
+
return; // the previous instance set is kept, deliberately
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (next === null) { // dead (or failed closed): stop everything
|
|
904
|
+
if (instances.size === 0) return;
|
|
905
|
+
for (const [key, inst] of [...instances]) {
|
|
906
|
+
instances.delete(key);
|
|
907
|
+
runSubCleanup(sub, inst.cleanup);
|
|
908
|
+
}
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
// stops first, in previous insertion order
|
|
912
|
+
for (const [key, inst] of [...instances]) {
|
|
913
|
+
if (!next.has(key)) {
|
|
914
|
+
instances.delete(key);
|
|
915
|
+
runSubCleanup(sub, inst.cleanup);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// starts and restarts, in resolved document order; the handler is
|
|
919
|
+
// looked up lazily so a refresh with nothing to start reports no
|
|
920
|
+
// JA2008, matching the single-instance path's would-start timing
|
|
921
|
+
let handler;
|
|
922
|
+
let handlerLooked = false;
|
|
923
|
+
for (const [key, spec] of next) {
|
|
924
|
+
const existing = instances.get(key);
|
|
925
|
+
if (existing !== undefined && existing.propsKey === spec.propsKey)
|
|
926
|
+
continue; // unchanged instance: untouched
|
|
927
|
+
if (!handlerLooked) {
|
|
928
|
+
handlerLooked = true;
|
|
929
|
+
handler = lookupSubHandler(sub);
|
|
930
|
+
}
|
|
931
|
+
if (handler === undefined) break; // reported; retried next refresh
|
|
932
|
+
if (existing !== undefined) {
|
|
933
|
+
instances.delete(key);
|
|
934
|
+
runSubCleanup(sub, existing.cleanup); // restart: stop-then-start
|
|
935
|
+
}
|
|
936
|
+
let cleanup;
|
|
937
|
+
try {
|
|
938
|
+
cleanup = handler(spec.props, subDispatch);
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
const cause = toError(err);
|
|
942
|
+
safeError(new AppRuntimeError('JA2013',
|
|
943
|
+
`subscription '${sub.run}' threw while starting; it stays stopped: ${safeErrorMessage(cause)}`,
|
|
944
|
+
{ cause }));
|
|
945
|
+
continue; // this instance stays stopped; siblings proceed
|
|
946
|
+
}
|
|
947
|
+
instances.set(key, { cleanup, propsKey: spec.propsKey });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Start and stop subscriptions to match their `when` queries against
|
|
953
|
+
* the current state. A broken `when` — or a broken dynamic member
|
|
954
|
+
* (`withQuery`/`key`/`for`, `JA2016`) — fails CLOSED (the
|
|
955
|
+
* subscription stops; a broken rule must never keep side effects
|
|
956
|
+
* alive) and is reported through `onError`.
|
|
957
|
+
*
|
|
958
|
+
* A DYNAMIC subscription (one with `withQuery`) also restarts when
|
|
959
|
+
* its resolved key changes: stop, then start with the new props,
|
|
960
|
+
* within one reconciliation. The key derives from the resolved props
|
|
961
|
+
* by value, or from the explicit `key` query; a static `with` entry
|
|
962
|
+
* has no key and never restarts (the historical behaviour, preserved
|
|
963
|
+
* exactly). `for` declarations reconcile per instance
|
|
964
|
+
* ({@link refreshFanout}). This is the same "key plus supersede
|
|
965
|
+
* policy" shape `createTaskEffect` models for effects.
|
|
966
|
+
*
|
|
967
|
+
* Startup is resource acquisition: a slot is committed live only
|
|
968
|
+
* after its handler returned. A throwing handler leaves the slot
|
|
969
|
+
* stopped (`JA2013`); a throwing cleanup is isolated (`JA2012`) and
|
|
970
|
+
* never skips its siblings — and never prevents the restart's start
|
|
971
|
+
* half. Because dispatches queue (they never nest), condition changes
|
|
972
|
+
* made by a starting handler coalesce: they are observed by the next
|
|
973
|
+
* transaction's reconciliation, which then disposes the just-started
|
|
974
|
+
* resource through the ordinary stop path.
|
|
975
|
+
*/
|
|
976
|
+
function refreshSubs() {
|
|
977
|
+
for (let i = 0; i < subs.length; i++) {
|
|
978
|
+
const sub = subs[i];
|
|
979
|
+
const slot = subStates[i];
|
|
980
|
+
let live = running;
|
|
981
|
+
if (live && sub.when !== null) {
|
|
982
|
+
try {
|
|
983
|
+
live = sub.when.ebv(state);
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
live = false;
|
|
987
|
+
const cause = toError(err);
|
|
988
|
+
safeError(new AppRuntimeError('JA2002',
|
|
989
|
+
`subscription '${sub.run}' has a "when" that failed: ${safeErrorMessage(cause)}`, { cause }));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (sub.forQuery !== null) {
|
|
993
|
+
refreshFanout(sub, slot, i, live);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
// resolve dynamic props and the restart key while live
|
|
997
|
+
let props = sub.props;
|
|
998
|
+
let key = null;
|
|
999
|
+
if (live && sub.withQuery !== null) {
|
|
1000
|
+
try {
|
|
1001
|
+
const v = sub.withQuery(state);
|
|
1002
|
+
props = v === undefined ? null : v;
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
live = false;
|
|
1006
|
+
reportSubQueryFailure(sub, i, 'withQuery', err);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (live && (sub.withQuery !== null || sub.keyQuery !== null)) {
|
|
1010
|
+
try {
|
|
1011
|
+
key = sub.keyQuery !== null
|
|
1012
|
+
? subKeyOf(sub.keyQuery(state))
|
|
1013
|
+
: subKeyOf(props);
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
live = false;
|
|
1017
|
+
reportSubQueryFailure(sub, i, sub.keyQuery !== null ? 'key' : 'withQuery', err);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const restart = live && slot.live && key !== slot.key;
|
|
1021
|
+
if (restart || (!live && slot.live)) {
|
|
1022
|
+
const cleanup = slot.cleanup;
|
|
1023
|
+
slot.live = false;
|
|
1024
|
+
slot.cleanup = undefined;
|
|
1025
|
+
slot.key = null;
|
|
1026
|
+
runSubCleanup(sub, cleanup);
|
|
1027
|
+
}
|
|
1028
|
+
if (live && !slot.live && !slot.starting) {
|
|
1029
|
+
const handler = lookupSubHandler(sub);
|
|
1030
|
+
if (handler === undefined) continue;
|
|
1031
|
+
let cleanup;
|
|
1032
|
+
// Ownership is claimed BEFORE the handler runs. A handler may
|
|
1033
|
+
// acquire a resource and then re-enter reconciliation — it is host
|
|
1034
|
+
// code, and the app surface is reachable from it — and a slot that
|
|
1035
|
+
// only became `live` on the way out looked startable to that
|
|
1036
|
+
// re-entry. It started again, and again, and each return overwrote
|
|
1037
|
+
// the single cleanup slot: every acquisition but the last leaked,
|
|
1038
|
+
// past destroy, forever.
|
|
1039
|
+
slot.starting = true;
|
|
1040
|
+
try {
|
|
1041
|
+
cleanup = handler(props, subDispatch);
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
slot.starting = false;
|
|
1045
|
+
const cause = toError(err);
|
|
1046
|
+
safeError(new AppRuntimeError('JA2013',
|
|
1047
|
+
`subscription '${sub.run}' threw while starting; it stays stopped: ${safeErrorMessage(cause)}`,
|
|
1048
|
+
{ cause }));
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
slot.starting = false;
|
|
1052
|
+
slot.live = true;
|
|
1053
|
+
slot.cleanup = cleanup;
|
|
1054
|
+
slot.key = key;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function scheduleRender() {
|
|
1060
|
+
if (renderer === null || renderScheduled) return;
|
|
1061
|
+
if (!booted) {
|
|
1062
|
+
// boot is ATOMIC under every scheduler: a boot frame commits
|
|
1063
|
+
// inside the boot window, never on a later microtask — a paint
|
|
1064
|
+
// pending outside the transaction would leave the deferred first
|
|
1065
|
+
// `afterRender` running against the OLD DOM, missing targets the
|
|
1066
|
+
// boot transaction already placed in state
|
|
1067
|
+
try {
|
|
1068
|
+
render();
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
safeError(toError(err));
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
renderScheduled = true;
|
|
1076
|
+
schedule(() => {
|
|
1077
|
+
renderScheduled = false;
|
|
1078
|
+
if (!running) return;
|
|
1079
|
+
// a renderer failure (a throwing widget hook) always routes
|
|
1080
|
+
// through the app error policy: under a synchronous scheduler it
|
|
1081
|
+
// must not corrupt the transaction queue (the parked error
|
|
1082
|
+
// surfaces after the drain); under a deferred scheduler `onError`
|
|
1083
|
+
// observes it and whatever the sink throws surfaces to the
|
|
1084
|
+
// scheduler's context
|
|
1085
|
+
try {
|
|
1086
|
+
render();
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
safeError(toError(err));
|
|
1090
|
+
}
|
|
1091
|
+
if (!draining) flushPendingError();
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const viewModel = options.viewModel ?? null;
|
|
1096
|
+
|
|
1097
|
+
/** The current view output (through the viewModel derivation). */
|
|
1098
|
+
function vnode() {
|
|
1099
|
+
return view(viewModel !== null ? viewModel(state) : state);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Render synchronously, now. `afterRender` is NOT called here: the
|
|
1103
|
+
* renderer's `onFrame` channel invokes it exactly once per settled,
|
|
1104
|
+
* nonterminal committed frame — a normal return is the wrong signal
|
|
1105
|
+
* (the renderer may have performed terminal teardown, or may be
|
|
1106
|
+
* about to deliver a parked widget error for a frame that DID
|
|
1107
|
+
* commit). */
|
|
1108
|
+
function render() {
|
|
1109
|
+
if (renderer === null) return;
|
|
1110
|
+
renderDepth++;
|
|
1111
|
+
try {
|
|
1112
|
+
renderer(vnode());
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
renderDepth--;
|
|
1116
|
+
// catch-all: a destroy requested inside this pass (a widget
|
|
1117
|
+
// hook calling app.destroy()) deferred the renderer teardown
|
|
1118
|
+
// into the pass itself — deliver the destroy-wide cleanup
|
|
1119
|
+
// outcome as the pass unwinds, never earlier and never split
|
|
1120
|
+
if (renderDepth === 0 && destroyed) deliverDestroyFailures();
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Dispose every live subscription and clear the listeners; shared by
|
|
1126
|
+
* stop/destroy/boot-rollback. Cleanup errors are isolated.
|
|
1127
|
+
*/
|
|
1128
|
+
function teardownLoop() {
|
|
1129
|
+
running = false;
|
|
1130
|
+
actionQueue.length = 0;
|
|
1131
|
+
refreshSubs();
|
|
1132
|
+
stateListeners.clear();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* The destroy-scope cleanup collector: while `destroy()` runs —
|
|
1137
|
+
* INCLUDING a renderer teardown deferred to the end of the active
|
|
1138
|
+
* render pass when the destroy was requested from inside a widget
|
|
1139
|
+
* hook — every cleanup failure (subscription cleanups, effect
|
|
1140
|
+
* disposal, renderer teardown) collects here and is delivered as
|
|
1141
|
+
* ONE `JA2012` whose cause is the single failure by identity or an
|
|
1142
|
+
* AggregateError over all of them in occurrence order. Outside
|
|
1143
|
+
* `destroy()` (stop(), per-transaction reconciliation) each failure
|
|
1144
|
+
* reports its own `JA2012` as before.
|
|
1145
|
+
* @type {Error[] | null}
|
|
1146
|
+
*/
|
|
1147
|
+
let destroyFailures = null;
|
|
1148
|
+
/** Non-zero while the app's render pass is on the stack — the only
|
|
1149
|
+
* path from which a widget hook can request an in-hook destroy. */
|
|
1150
|
+
let renderDepth = 0;
|
|
1151
|
+
|
|
1152
|
+
/** Deliver the destroy-wide cleanup outcome exactly once. */
|
|
1153
|
+
function deliverDestroyFailures() {
|
|
1154
|
+
const failures = destroyFailures;
|
|
1155
|
+
destroyFailures = null;
|
|
1156
|
+
if (failures !== null && failures.length > 0) {
|
|
1157
|
+
const cause = failures.length === 1
|
|
1158
|
+
? failures[0]
|
|
1159
|
+
: new AggregateError(failures, 'multiple cleanup failures in one destroy');
|
|
1160
|
+
safeError(new AppRuntimeError('JA2012',
|
|
1161
|
+
`cleanup failed while destroying the app: ${failures.length} failure(s)`,
|
|
1162
|
+
{ cause: /** @type {any} */ (cause) }));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Route one cleanup failure: into the destroy-scope collector when
|
|
1168
|
+
* one is active, else as its own immediate `JA2012` report.
|
|
1169
|
+
* @param {string} message
|
|
1170
|
+
* @param {Error} cause
|
|
1171
|
+
*/
|
|
1172
|
+
function reportCleanup(message, cause) {
|
|
1173
|
+
if (destroyFailures !== null) destroyFailures.push(cause);
|
|
1174
|
+
else safeError(new AppRuntimeError('JA2012', message, { cause }));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Dispose registered effect handlers, each identity exactly once.
|
|
1178
|
+
* Capability ACQUISITION shares the invocation boundary: a hostile
|
|
1179
|
+
* key enumeration, member read or `dispose` accessor is a cleanup
|
|
1180
|
+
* failure like a throwing `dispose()` — later disposers and the
|
|
1181
|
+
* renderer teardown always still run. */
|
|
1182
|
+
function disposeEffectHandlers() {
|
|
1183
|
+
if (effectDisposeEntries === null) return; // boot failed before the snapshot
|
|
1184
|
+
const seen = new Set();
|
|
1185
|
+
for (const [name, handler] of effectDisposeEntries) {
|
|
1186
|
+
try {
|
|
1187
|
+
if (handler === undefined || handler === null || seen.has(handler)) continue;
|
|
1188
|
+
seen.add(handler);
|
|
1189
|
+
const dispose = /** @type {any} */ (handler).dispose;
|
|
1190
|
+
if (typeof dispose === 'function') dispose.call(handler);
|
|
1191
|
+
}
|
|
1192
|
+
catch (err) {
|
|
1193
|
+
const cause = toError(err);
|
|
1194
|
+
reportCleanup(
|
|
1195
|
+
`effect handler '${name}' threw while disposing: ${safeErrorMessage(cause)}`,
|
|
1196
|
+
cause);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// boot: renderer construction, the initial-state check, the initial
|
|
1202
|
+
// subscriptions, the first frame AND the queued work they produce are
|
|
1203
|
+
// one transaction — any failure that would escape createApp rolls
|
|
1204
|
+
// back every acquired resource (subscriptions, effect handlers, the
|
|
1205
|
+
// renderer; the container ends empty, scheduled work becomes a no-op)
|
|
1206
|
+
// and throws one JA0007. A custom onError that swallows a reported
|
|
1207
|
+
// boot failure (a subscription start, the initial-state check, an
|
|
1208
|
+
// error inside queued boot work) recovers it and boot continues;
|
|
1209
|
+
// renderer construction and first-frame failures are always fatal.
|
|
1210
|
+
{
|
|
1211
|
+
/** A presence record: a boot step may legally throw `null`.
|
|
1212
|
+
* @type {{ value: unknown } | null} */
|
|
1213
|
+
let bootFailure = null;
|
|
1214
|
+
draining = true; // dispatches made by starting handlers queue
|
|
1215
|
+
try {
|
|
1216
|
+
// snapshot BEFORE ownership begins (see effectDisposeEntries)
|
|
1217
|
+
{
|
|
1218
|
+
const entries = [];
|
|
1219
|
+
for (const name of Object.keys(effectHandlers)) {
|
|
1220
|
+
entries.push([name, effectHandlers[name]]);
|
|
1221
|
+
}
|
|
1222
|
+
effectDisposeEntries = entries;
|
|
1223
|
+
}
|
|
1224
|
+
if (options.node !== undefined) {
|
|
1225
|
+
renderer = createDomRenderer(options.node, {
|
|
1226
|
+
document: options.document,
|
|
1227
|
+
onEvent: handleBinding,
|
|
1228
|
+
widgets: options.widgets,
|
|
1229
|
+
// terminal-cleanup provenance: a widget unmount that throws
|
|
1230
|
+
// during renderer teardown — deferred teardown after an
|
|
1231
|
+
// app.destroy() from inside a hook included — is a CLEANUP
|
|
1232
|
+
// failure (JA2012, original cause preserved, reported after
|
|
1233
|
+
// every sibling cleaned up), never an anonymous render error
|
|
1234
|
+
onCleanupError: (thrown) => {
|
|
1235
|
+
const cause = toError(thrown);
|
|
1236
|
+
reportCleanup(
|
|
1237
|
+
`the renderer threw while being destroyed: ${safeErrorMessage(cause)}`, cause);
|
|
1238
|
+
},
|
|
1239
|
+
// the committed-live-frame boundary: `afterRender` runs once
|
|
1240
|
+
// per SETTLED, NONTERMINAL frame — after the DOM patch and
|
|
1241
|
+
// widget mounts, before a parked hook error is delivered —
|
|
1242
|
+
// and never after terminal teardown (APP-FORMAT §8.4). Boot
|
|
1243
|
+
// is ATOMIC: frames committed during the boot transaction do
|
|
1244
|
+
// not fire the callback; one deferred call runs only after
|
|
1245
|
+
// the whole boot (queued drain included) succeeded, so no
|
|
1246
|
+
// post-render side effect can escape a boot that rolls back.
|
|
1247
|
+
onFrame: (state) => {
|
|
1248
|
+
if (state !== 'live') {
|
|
1249
|
+
// the pass ended in terminal teardown: a deferred
|
|
1250
|
+
// in-hook destroy has now finished its renderer walk —
|
|
1251
|
+
// the destroy-wide cleanup outcome is complete
|
|
1252
|
+
deliverDestroyFailures();
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (!booted) {
|
|
1256
|
+
bootFrameLive = true;
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (afterRender !== null) {
|
|
1260
|
+
// isolated: an afterRender failure (the focus queue's
|
|
1261
|
+
// JA2014 included) is reported through the app policy and
|
|
1262
|
+
// can never starve the same frame's parked widget error,
|
|
1263
|
+
// which the renderer delivers right after this returns
|
|
1264
|
+
try {
|
|
1265
|
+
afterRender();
|
|
1266
|
+
}
|
|
1267
|
+
catch (err) {
|
|
1268
|
+
safeError(toError(err));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
if (options.validateState !== undefined) {
|
|
1275
|
+
validateInitialState();
|
|
1276
|
+
}
|
|
1277
|
+
refreshSubs();
|
|
1278
|
+
render();
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
bootFailure = { value: err };
|
|
1282
|
+
}
|
|
1283
|
+
finally {
|
|
1284
|
+
draining = false;
|
|
1285
|
+
}
|
|
1286
|
+
if (bootFailure === null && pendingFailures.length > 0) {
|
|
1287
|
+
const failures = pendingFailures.splice(0);
|
|
1288
|
+
bootFailure = {
|
|
1289
|
+
value: failures.length === 1
|
|
1290
|
+
? failures[0]
|
|
1291
|
+
: new AggregateError(failures, MULTIPLE_SINK_FAILURES),
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
// subscriptions queued dispatches during boot: drain them inside
|
|
1295
|
+
// the boot ownership window, so a queued failure that escapes the
|
|
1296
|
+
// sink still rolls back instead of leaving a half-booted app behind
|
|
1297
|
+
if (bootFailure === null) {
|
|
1298
|
+
try {
|
|
1299
|
+
drainQueue();
|
|
1300
|
+
}
|
|
1301
|
+
catch (err) {
|
|
1302
|
+
bootFailure = { value: err };
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// the boot-atomic first-frame callback: boot's frames coalesce
|
|
1306
|
+
// into ONE deferred afterRender that runs only after the entire
|
|
1307
|
+
// boot transaction succeeded; its failure follows boot policy (a
|
|
1308
|
+
// swallowing sink recovers it, an escaping failure rolls back)
|
|
1309
|
+
if (bootFailure === null) {
|
|
1310
|
+
booted = true;
|
|
1311
|
+
if (bootFrameLive && afterRender !== null) {
|
|
1312
|
+
try {
|
|
1313
|
+
afterRender();
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
safeError(toError(err));
|
|
1317
|
+
}
|
|
1318
|
+
if (pendingFailures.length > 0) {
|
|
1319
|
+
const failures = pendingFailures.splice(0);
|
|
1320
|
+
bootFailure = {
|
|
1321
|
+
value: failures.length === 1
|
|
1322
|
+
? failures[0]
|
|
1323
|
+
: new AggregateError(failures, MULTIPLE_SINK_FAILURES),
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (bootFailure !== null) {
|
|
1329
|
+
// rollback: each step is isolated so a throwing cleanup never
|
|
1330
|
+
// skips its siblings; the original boot failure always wins
|
|
1331
|
+
try {
|
|
1332
|
+
teardownLoop();
|
|
1333
|
+
}
|
|
1334
|
+
catch { /* isolated */ }
|
|
1335
|
+
try {
|
|
1336
|
+
disposeEffectHandlers();
|
|
1337
|
+
}
|
|
1338
|
+
catch { /* isolated */ }
|
|
1339
|
+
if (renderer !== null) {
|
|
1340
|
+
try {
|
|
1341
|
+
if (typeof renderer.destroy === 'function') renderer.destroy();
|
|
1342
|
+
}
|
|
1343
|
+
catch { /* isolated */ }
|
|
1344
|
+
renderer = null;
|
|
1345
|
+
}
|
|
1346
|
+
pendingFailures.length = 0;
|
|
1347
|
+
const cause = toError(bootFailure.value);
|
|
1348
|
+
throw new AppCompileError('JA0007',
|
|
1349
|
+
`the app failed to boot: ${safeErrorMessage(cause)}`, '', cause);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* The boot-time initial-state check (see {@link ValidateContext}):
|
|
1355
|
+
* a rejection is `JA2005`, a throwing validator `JA2015` — both are
|
|
1356
|
+
* reported first, so a swallowing sink can accept the state and boot
|
|
1357
|
+
* on; under the default rethrowing sink they abort the boot.
|
|
1358
|
+
*/
|
|
1359
|
+
function validateInitialState() {
|
|
1360
|
+
/** @type {ReturnType<NonNullable<AppOptions['validateState']>>} */
|
|
1361
|
+
let verdict;
|
|
1362
|
+
try {
|
|
1363
|
+
verdict = /** @type {NonNullable<AppOptions['validateState']>} */ (options.validateState)(
|
|
1364
|
+
state, { previous: null, action: null, payload: null, changes: null });
|
|
1365
|
+
}
|
|
1366
|
+
catch (err) {
|
|
1367
|
+
const cause = toError(err);
|
|
1368
|
+
safeError(new AppRuntimeError('JA2015',
|
|
1369
|
+
`the validateState hook threw for the initial state: ${safeErrorMessage(cause)}`, { cause }));
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
if (verdict === false
|
|
1373
|
+
|| (verdict !== null && typeof verdict === 'object' && verdict.valid === false)) {
|
|
1374
|
+
const err = new AppRuntimeError('JA2005',
|
|
1375
|
+
'the initial state violates the app\'s state invariants');
|
|
1376
|
+
err.detail = typeof verdict === 'object' ? verdict.errors : undefined;
|
|
1377
|
+
safeError(err);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
return {
|
|
1382
|
+
dispatch,
|
|
1383
|
+
/** The current state (treat as immutable). */
|
|
1384
|
+
getState: () => state,
|
|
1385
|
+
/**
|
|
1386
|
+
* Replace the whole state from OUTSIDE the action loop and re-render.
|
|
1387
|
+
* Unlike `dispatch`, this runs no reducer and no effects — it is the
|
|
1388
|
+
* host-driven override for external state sync: SSR hydration, or a
|
|
1389
|
+
* studio hot-swapping an edited `state` block into a running nested
|
|
1390
|
+
* app without a reboot (the diff re-render keeps the DOM, so focus,
|
|
1391
|
+
* scroll and uncontrolled inputs survive). Listeners are notified with
|
|
1392
|
+
* `null` changed-paths (treat everything as changed); `when`-gated
|
|
1393
|
+
* subscriptions refresh; a render is scheduled. A no-op when the state
|
|
1394
|
+
* is reference-identical or the loop has been stopped.
|
|
1395
|
+
*
|
|
1396
|
+
* It is a TRANSACTION, with every guarantee a dispatch has: it takes
|
|
1397
|
+
* its turn in the FIFO queue, `validateState` decides before the
|
|
1398
|
+
* commit, listeners all observe the same state, the turn guard counts
|
|
1399
|
+
* it, one record reaches the observers, and a sink failure settles at
|
|
1400
|
+
* this caller. Replacing state directly had none of those — a listener
|
|
1401
|
+
* that dispatched saw the two transactions interleave and never
|
|
1402
|
+
* observed its own committed state, an invalid replacement committed
|
|
1403
|
+
* unvalidated, and the resulting `JA2011` emerged from the next
|
|
1404
|
+
* unrelated dispatch.
|
|
1405
|
+
* @param {any} next - the replacement state
|
|
1406
|
+
*/
|
|
1407
|
+
setState(next) {
|
|
1408
|
+
queueReplace(next);
|
|
1409
|
+
},
|
|
1410
|
+
/** The current view output — for SSR or custom renderers. */
|
|
1411
|
+
getVnode: vnode,
|
|
1412
|
+
render,
|
|
1413
|
+
/**
|
|
1414
|
+
* Observe state changes. The listener receives the new state and the
|
|
1415
|
+
* transition's changed paths: an array of JSON Pointers when the
|
|
1416
|
+
* transition was patch-only (see the patch engine's `changes` option
|
|
1417
|
+
* for the invalidation-sound semantics), or `null` when the whole
|
|
1418
|
+
* state was replaced — treat everything as changed. Listeners run
|
|
1419
|
+
* inside the transaction, in registration order, all observing the
|
|
1420
|
+
* same state/changes pair; a throwing listener is isolated (JA2011).
|
|
1421
|
+
* @param {(state: any, changes: string[] | null) => void} listener
|
|
1422
|
+
* @returns {() => void} unsubscribe
|
|
1423
|
+
*/
|
|
1424
|
+
subscribe(listener) {
|
|
1425
|
+
stateListeners.add(listener);
|
|
1426
|
+
return () => { stateListeners.delete(listener); };
|
|
1427
|
+
},
|
|
1428
|
+
/**
|
|
1429
|
+
* Observe completed transactions (APP-FORMAT §8.3). The observer
|
|
1430
|
+
* receives one bounded JSON metadata record per transaction, after
|
|
1431
|
+
* the transaction fully settled (state, effects, listeners,
|
|
1432
|
+
* subscriptions, render scheduling). Payload/event values are
|
|
1433
|
+
* included only when the app was created with `capturePayloads`.
|
|
1434
|
+
* A throwing observer is isolated and never corrupts the queue.
|
|
1435
|
+
* @param {(tx: TransactionRecord) => void} observer
|
|
1436
|
+
* @returns {() => void} unsubscribe
|
|
1437
|
+
*/
|
|
1438
|
+
observe(observer) {
|
|
1439
|
+
observers.add(observer);
|
|
1440
|
+
return () => { observers.delete(observer); };
|
|
1441
|
+
},
|
|
1442
|
+
/**
|
|
1443
|
+
* Stop the loop — one-way and nonterminal, not a resumable pause:
|
|
1444
|
+
* live subscriptions are cleaned up (isolated), listeners are
|
|
1445
|
+
* cleared and further dispatches are ignored, permanently. The
|
|
1446
|
+
* renderer and effect handlers stay untouched — `destroy()` is the
|
|
1447
|
+
* terminal teardown that owns them.
|
|
1448
|
+
*/
|
|
1449
|
+
stop() {
|
|
1450
|
+
teardownLoop();
|
|
1451
|
+
flushPendingError();
|
|
1452
|
+
},
|
|
1453
|
+
/**
|
|
1454
|
+
* Terminal teardown: `stop()` plus observer removal, effect-handler
|
|
1455
|
+
* `dispose()` (each handler identity once), and renderer
|
|
1456
|
+
* destruction (widgets unmount exactly once, the container is left
|
|
1457
|
+
* empty). Idempotent; scheduled render flushes become exact no-ops;
|
|
1458
|
+
* every cleanup error is isolated so siblings always run.
|
|
1459
|
+
*/
|
|
1460
|
+
destroy() {
|
|
1461
|
+
if (destroyed) return;
|
|
1462
|
+
destroyed = true;
|
|
1463
|
+
destroyFailures = [];
|
|
1464
|
+
teardownLoop();
|
|
1465
|
+
observers.clear();
|
|
1466
|
+
disposeEffectHandlers();
|
|
1467
|
+
if (renderer !== null) {
|
|
1468
|
+
try {
|
|
1469
|
+
if (typeof renderer.destroy === 'function') renderer.destroy();
|
|
1470
|
+
}
|
|
1471
|
+
catch (err) {
|
|
1472
|
+
const cause = toError(err);
|
|
1473
|
+
reportCleanup(
|
|
1474
|
+
`the renderer threw while being destroyed: ${safeErrorMessage(cause)}`, cause);
|
|
1475
|
+
}
|
|
1476
|
+
renderer = null;
|
|
1477
|
+
}
|
|
1478
|
+
// one terminal operation, one machine-readable cleanup outcome:
|
|
1479
|
+
// a single failure is the cause by identity; several aggregate
|
|
1480
|
+
// in occurrence order (subscriptions, then effect disposal, then
|
|
1481
|
+
// the renderer walk — whose own envelope arrives as one element).
|
|
1482
|
+
// When the destroy was requested from inside the active render
|
|
1483
|
+
// pass, the renderer teardown is still pending — the collector
|
|
1484
|
+
// stays open and delivers when that pass settles (onFrame
|
|
1485
|
+
// 'destroyed', with the render unwind as the catch-all).
|
|
1486
|
+
if (renderDepth === 0) deliverDestroyFailures();
|
|
1487
|
+
flushPendingError();
|
|
1488
|
+
},
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** Monotonic-ish milliseconds for transaction durations. */
|
|
1493
|
+
function now() {
|
|
1494
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Is this value a binding's `event` member: an array of field names?
|
|
1499
|
+
* @param {any} value
|
|
1500
|
+
* @returns {value is string[]}
|
|
1501
|
+
*/
|
|
1502
|
+
function isFieldNameArray(value) {
|
|
1503
|
+
if (!Array.isArray(value)) return false;
|
|
1504
|
+
for (const name of value) {
|
|
1505
|
+
if (typeof name !== 'string') return false;
|
|
1506
|
+
}
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* The built-in `$event` field allow-list (APP-FORMAT §3.1): field name →
|
|
1512
|
+
* where it is read from. Every entry is a JSON primitive by construction;
|
|
1513
|
+
* host-object-valued fields (`target`, `files`, touch lists) are
|
|
1514
|
+
* deliberately absent — `$event` must survive `JSON.stringify`, the same
|
|
1515
|
+
* invariant as state. Null prototype so lookups never walk to
|
|
1516
|
+
* `Object.prototype`.
|
|
1517
|
+
*/
|
|
1518
|
+
const EVENT_FIELD_SOURCES = Object.freeze(Object.assign(Object.create(null), {
|
|
1519
|
+
shiftKey: 'event', ctrlKey: 'event', altKey: 'event', metaKey: 'event',
|
|
1520
|
+
button: 'event', buttons: 'event',
|
|
1521
|
+
clientX: 'event', clientY: 'event', offsetX: 'event', offsetY: 'event',
|
|
1522
|
+
pageX: 'event', pageY: 'event', screenX: 'event', screenY: 'event',
|
|
1523
|
+
movementX: 'event', movementY: 'event',
|
|
1524
|
+
deltaX: 'event', deltaY: 'event', deltaMode: 'event',
|
|
1525
|
+
code: 'event', repeat: 'event', location: 'event', isComposing: 'event',
|
|
1526
|
+
detail: 'event',
|
|
1527
|
+
pointerId: 'event', pointerType: 'event', pressure: 'event', isPrimary: 'event',
|
|
1528
|
+
selectionStart: 'target', selectionEnd: 'target',
|
|
1529
|
+
}));
|
|
1530
|
+
|
|
1531
|
+
/** The default `$event` members; a requested name from this set is
|
|
1532
|
+
* already bound and is never overwritten (a `null` re-bind would erase
|
|
1533
|
+
* real data). A registered extractor still wins — the host chose to
|
|
1534
|
+
* redefine the member. */
|
|
1535
|
+
const DEFAULT_EVENT_MEMBERS = Object.freeze(Object.assign(Object.create(null), {
|
|
1536
|
+
type: true, value: true, checked: true, key: true,
|
|
1537
|
+
}));
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Bind one member of the `$event` object without ever mutating a
|
|
1541
|
+
* prototype: `__proto__` becomes an ordinary own data property.
|
|
1542
|
+
* @param {any} obj
|
|
1543
|
+
* @param {string} name
|
|
1544
|
+
* @param {any} value
|
|
1545
|
+
*/
|
|
1546
|
+
function setEventMember(obj, name, value) {
|
|
1547
|
+
if (name === '__proto__') {
|
|
1548
|
+
Object.defineProperty(obj, name, {
|
|
1549
|
+
value, writable: true, enumerable: true, configurable: true,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
obj[name] = value;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Resolve one requested `$event` field, in precedence order: a registered
|
|
1559
|
+
* host extractor, the built-in allow-list, else `null` + a JA2009 report
|
|
1560
|
+
* (a typo in one field must not swallow the dispatch). `undefined`
|
|
1561
|
+
* coerces to `null` so the result stays JSON.
|
|
1562
|
+
* @param {any} event
|
|
1563
|
+
* @param {string} name
|
|
1564
|
+
* @param {Record<string, (nativeEvent: any) => any>} extractors
|
|
1565
|
+
* @param {(outcome: { kind: 'unknown', field: string }) => void} report
|
|
1566
|
+
* @returns {any}
|
|
1567
|
+
*/
|
|
1568
|
+
function resolveEventField(event, name, extractors, report) {
|
|
1569
|
+
const source = EVENT_FIELD_SOURCES[name];
|
|
1570
|
+
if (source !== undefined) {
|
|
1571
|
+
const value = source === 'target' ? event?.target?.[name] : event?.[name];
|
|
1572
|
+
return value === undefined ? null : value;
|
|
1573
|
+
}
|
|
1574
|
+
report({ kind: 'unknown', field: name });
|
|
1575
|
+
return null;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* The serializable slice of a DOM event bound to `$event`: the default
|
|
1580
|
+
* `{ type, value, checked, key }` plus one member per requested field
|
|
1581
|
+
* name (APP-FORMAT §3.1). Runs synchronously at dispatch time — the
|
|
1582
|
+
* result is plain JSON by contract, never the native event.
|
|
1583
|
+
* @param {any} event
|
|
1584
|
+
* @param {string[] | null} fields - Requested field names, or `null`.
|
|
1585
|
+
* @param {Record<string, (nativeEvent: any) => any>} extractors
|
|
1586
|
+
* @param {(outcome: { kind: 'unknown', field: string } | { kind: 'threw', field: string, value: unknown }) => void} report
|
|
1587
|
+
* The failure sink, TAGGED so a thrown `undefined` can never be
|
|
1588
|
+
* mistaken for an unknown field: `'unknown'` = the field name
|
|
1589
|
+
* resolves nowhere (JA2009), `'threw'` = a registered extractor
|
|
1590
|
+
* threw (JA2002, the thrown value retained verbatim). Either way the
|
|
1591
|
+
* member binds `null` and the dispatch continues.
|
|
1592
|
+
* @returns {{ type: string, value: any, checked: any, key: any }}
|
|
1593
|
+
*/
|
|
1594
|
+
function eventData(event, fields, extractors, report) {
|
|
1595
|
+
const target = event?.target;
|
|
1596
|
+
const data = {
|
|
1597
|
+
type: event?.type ?? '',
|
|
1598
|
+
value: target?.value ?? null,
|
|
1599
|
+
checked: target?.checked ?? null,
|
|
1600
|
+
key: event?.key ?? null,
|
|
1601
|
+
};
|
|
1602
|
+
if (fields !== null) {
|
|
1603
|
+
for (const name of fields) {
|
|
1604
|
+
if (Object.hasOwn(extractors, name)) {
|
|
1605
|
+
let value = null;
|
|
1606
|
+
try {
|
|
1607
|
+
const out = extractors[name](event);
|
|
1608
|
+
value = out === undefined ? null : out;
|
|
1609
|
+
}
|
|
1610
|
+
catch (err) {
|
|
1611
|
+
report({ kind: 'threw', field: name, value: err });
|
|
1612
|
+
}
|
|
1613
|
+
setEventMember(data, name, value);
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
// a requested default member is already bound; never null it out
|
|
1617
|
+
if (DEFAULT_EVENT_MEMBERS[name] === true) continue;
|
|
1618
|
+
setEventMember(data, name, resolveEventField(event, name, extractors, report));
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return data;
|
|
1622
|
+
}
|