@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/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
+ }