@jarenjs/view 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/dom.js ADDED
@@ -0,0 +1,1173 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The DOM renderer — a keyed vnode-JSON patcher.
4
+ *
5
+ * This is the only module in the Jaren suite that touches the DOM. It
6
+ * follows the repository philosophy at the render level: decide once,
7
+ * then run tight loops.
8
+ *
9
+ * Reconciliation contract (see docs/VIEW-FORMAT.md §5):
10
+ *
11
+ * - `oldVnode === newVnode` skips the whole subtree in O(1). The JSLT
12
+ * engine's structural sharing (unchanged input → shared output) is what
13
+ * makes this fast path fire in practice.
14
+ * - Children reconcile with a head/tail two-pointer sweep plus a key map
15
+ * for the middle — keyed moves reuse DOM nodes, unkeyed children patch
16
+ * positionally.
17
+ * - Vnodes are never mutated or annotated: the DOM-node ↔ vnode
18
+ * correspondence lives in parallel arrays local to each patch, so
19
+ * frozen or shared vnode JSON (a JSLT output, a cached document) is
20
+ * always safe.
21
+ * - The render boundary is serialized: a `render` entered from inside
22
+ * a widget hook or event callback (a synchronous `emit` chain) never
23
+ * nests — it queues behind the running patch, nested requests
24
+ * coalesce to the latest vnode, and it applies against the committed
25
+ * baseline. No widget sees `update` before its `mount` returned or
26
+ * receives stale previous props.
27
+ *
28
+ * Event handling stores the binding JSON on the DOM node and attaches one
29
+ * shared proxy listener per event type; rebinding a handler on re-render
30
+ * never touches `addEventListener`.
31
+ *
32
+ * Widgets (VIEW-FORMAT §7): a vnode with the reserved tag `jaren-widget`
33
+ * mounts a registered JavaScript widget into a host element the patcher
34
+ * owns but never descends into. Widget bookkeeping lives on the DOM node
35
+ * (`__jarenWidget`), never on the vnode — §5.1 forbids annotating vnodes
36
+ * — and the destroy walk that guarantees `unmount` only runs when a
37
+ * widget has actually been created, so widget-free documents keep O(1)
38
+ * subtree removal.
39
+ */
40
+
41
+ import { kebabCase } from '@jarenjs/core/string';
42
+ import {
43
+ isTextNode,
44
+ isElementNode,
45
+ isSameNode,
46
+ propsOf,
47
+ keyOf,
48
+ childrenOf,
49
+ EMPTY_PROPS,
50
+ WIDGET_TAG,
51
+ } from './vnode.js';
52
+ import { createSafePolicy } from './safe.js';
53
+
54
+ const SVG_NS = 'http://www.w3.org/2000/svg';
55
+
56
+ /** Props that are renderer instructions, never written to the DOM. */
57
+ const SKIP_PROPS = { key: true, on: true, memo: true };
58
+
59
+ /**
60
+ * Widget-vnode props that configure the widget instead of the host
61
+ * element. Every other prop (`key`/`on` included) flows through
62
+ * `setProp` exactly as on any element vnode.
63
+ */
64
+ const WIDGET_SKIP_PROPS = { name: true, props: true, tag: true };
65
+
66
+ /**
67
+ * @callback EventBindingHandler
68
+ * @param {any} binding - The opaque `on` binding JSON from the vnode.
69
+ * @param {any} event - The native DOM event.
70
+ * @returns {void}
71
+ */
72
+
73
+ /**
74
+ * @callback WidgetEmit
75
+ * @param {any} binding - An opaque binding, delivered verbatim to the
76
+ * renderer's `onEvent` hook — the identical contract to a vnode `on`
77
+ * member (VIEW-FORMAT §4).
78
+ * @param {any} [nativeEvent] - The native event, when the emission was
79
+ * caused by one.
80
+ * @returns {void}
81
+ */
82
+
83
+ /**
84
+ * A registered widget definition (VIEW-FORMAT §7). The renderer owns the
85
+ * host element and the widget owns the host's subtree.
86
+ *
87
+ * Failure policy: a throwing `mount` or `update` poisons the widget —
88
+ * siblings and the frame still complete, the first error surfaces after
89
+ * the frame settles, and the NEXT render replaces it with a fresh
90
+ * lifecycle (`unmount` runs on the old instance only when its `mount`
91
+ * had succeeded). Recovery never depends on the producer allocating a
92
+ * fresh vnode: while any widget is poisoned the `===` subtree fast
93
+ * path is suspended, so a memoized reference-equal tree still reaches
94
+ * and replaces it. A poisoned widget never receives further `update`
95
+ * calls.
96
+ * @typedef {Object} WidgetDef
97
+ * @property {(host: any, props: any, emit: WidgetEmit) => any} mount -
98
+ * Called with the host element after it is connected to the rendered
99
+ * tree; returns an opaque handle threaded to `update`/`unmount`.
100
+ * @property {(handle: any, props: any, prevProps: any) => void} [update]
101
+ * Called when the vnode's `props` reference changed. Absent: the
102
+ * renderer falls back to `unmount` + fresh `mount` into the same host.
103
+ * @property {(handle: any) => void} [unmount] - Called exactly once when
104
+ * the widget leaves the tree; timers, listeners and observers die here.
105
+ * @property {(props: any) => any} [ssr] - A vnode for `renderToString`.
106
+ */
107
+
108
+ /**
109
+ * @typedef {Object} DomRendererOptions
110
+ * @property {EventBindingHandler} [onEvent] - Receives every fired `on`
111
+ * binding; without it, `on` props are stored but never fired.
112
+ * @property {Record<string, WidgetDef>} [widgets] - Registered widget
113
+ * definitions by name (VIEW-FORMAT §7).
114
+ * @property {any} [document] - The document to create nodes with
115
+ * (defaults to `container.ownerDocument`).
116
+ * @property {boolean} [safe=false] - Render under the SAFE policy
117
+ * ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
118
+ * restricted to an inert HTML/SVG allow-list, scripting-sink and inline
119
+ * `on*` properties are dropped, injection-shaped names are rejected, URL
120
+ * attributes are sanitized, and `on` event bindings are stripped. The
121
+ * default is trusted rendering — the equivalent of writing the DOM by
122
+ * hand — so a source-authored view is unaffected. Client and server share
123
+ * the one policy, so they neutralize an attack identically.
124
+ * @property {(info: { kind: 'tag' | 'prop' | 'event' | 'widget', name: string }) => void}
125
+ * [onUnsafe] - In safe mode, called for everything stripped: a disallowed
126
+ * `tag`, a rejected or sanitized-away `prop`, a stripped `on` binding
127
+ * (`event`) or a `widget`.
128
+ * @property {(thrown: unknown) => void} [onCleanupError] - Receives
129
+ * the first VALUE a widget `unmount` threw during TERMINAL teardown
130
+ * (`destroy()`, direct or deferred) — by identity, whatever host
131
+ * code threw — after every sibling cleaned up: the provenance
132
+ * channel that lets a host assign cleanup failures their own error
133
+ * policy, distinct from mount/update/render failures. Absent: the
134
+ * value surfaces after the teardown (thrown from `destroy()` or
135
+ * from the render pass that finished a deferred teardown).
136
+ * @property {(state: 'live' | 'destroyed') => void} [onFrame] - Called
137
+ * once at the end of every top-level render pass: `'live'` = a
138
+ * committed live frame settled (DOM patch and widget mounts done; a
139
+ * parked widget hook error, if any, is delivered AFTER this call),
140
+ * `'destroyed'` = the pass ended in terminal teardown. Not called
141
+ * for a post-destroy no-op render or by `destroy()` itself.
142
+ */
143
+
144
+ /**
145
+ * The renderer returned by {@link createDomRenderer}: the patch
146
+ * function, carrying the terminal `destroy()` (VIEW-FORMAT §7.3.1).
147
+ * @typedef {((vnode: any) => void) & { destroy: () => void }} DomRenderer
148
+ */
149
+
150
+ /**
151
+ * Create a renderer bound to a container element. The returned function
152
+ * patches the container's single root node to match the given vnode;
153
+ * `render.destroy()` is the terminal teardown.
154
+ *
155
+ * @example
156
+ * const render = createDomRenderer(document.getElementById('app'), {
157
+ * onEvent: (binding, event) => dispatch(binding, event),
158
+ * });
159
+ * render(['main', {}, ['h1', {}, 'Hello']]);
160
+ * render.destroy();
161
+ *
162
+ * @param {any} container - The DOM element to render into (emptied on
163
+ * first render).
164
+ * @param {DomRendererOptions} [options]
165
+ * @returns {DomRenderer}
166
+ */
167
+ export function createDomRenderer(container, options = {}) {
168
+ const ctx = {
169
+ doc: options.document ?? container.ownerDocument,
170
+ onEvent: options.onEvent ?? null,
171
+ widgets: options.widgets ?? EMPTY_PROPS,
172
+ /** The safe render policy, or null for trusted (default) rendering.
173
+ * Consulted for every tag and prop; the same object the SSR serializer
174
+ * uses, so client and server strip an attack identically. */
175
+ policy: options.safe ? createSafePolicy() : null,
176
+ /** Reports everything safe mode strips — a tag, a property, an `on`
177
+ * binding or a widget — so a host can observe a hostile document rather
178
+ * than have it silently vanish. The policy stays a pure decision; the
179
+ * renderer reports at the point it acts. */
180
+ onUnsafe: typeof options.onUnsafe === 'function' ? options.onUnsafe : null,
181
+ /** Controlled form-control nodes (`value`/`checked`), reconciled against
182
+ * the live DOM at the END of every render pass — so an authoritative
183
+ * value is reasserted even when the `===`/`memo` fast paths skip the
184
+ * subtree the control lives in. Trusted mode only: safe mode strips
185
+ * events, so a safe-mode view has no controlled inputs to fight the user
186
+ * over. @type {Set<any>} */
187
+ controlled: new Set(),
188
+ /** Widget host nodes created this patch, awaiting `mount` (§7). */
189
+ mountQueue: [],
190
+ /** True once any widget node exists — gates the destroy walk. */
191
+ hasWidgets: false,
192
+ /** First value a widget hook (`mount`/`update`/`unmount`) threw
193
+ * this frame (§7), as a PRESENCE record — host code may legally
194
+ * `throw null`/`throw undefined`, so the thrown value can never
195
+ * double as the absence sentinel. The walk, the patch and the
196
+ * mount flush always finish; the value surfaces BY IDENTITY after
197
+ * the frame settles.
198
+ * @type {{ value: unknown } | null} */
199
+ frameError: null,
200
+ /** Live poisoned widgets (§7.3). While non-zero, the `===` subtree
201
+ * fast path is disabled so structural sharing (the JSLT memo
202
+ * reusing a reference-equal vnode) can never leave a poisoned
203
+ * widget permanently inert — every render revisits it until it is
204
+ * replaced or removed. */
205
+ poisonedCount: 0,
206
+ /** True after `destroy()`: every later render is an exact no-op. */
207
+ destroyed: false,
208
+ /** Terminal-cleanup sink (see `teardown`): receives the first
209
+ * VALUE a widget `unmount` threw during terminal teardown — by
210
+ * identity, whatever it is — after every sibling cleaned up. */
211
+ onCleanupError: options.onCleanupError ?? null,
212
+ /** Frame-settlement sink: called once at the end of every
213
+ * top-level render pass with `'live'` (a committed live frame —
214
+ * possibly with a parked hook error, delivered afterwards) or
215
+ * `'destroyed'` (the pass ended in terminal teardown). Called
216
+ * BEFORE a parked hook error is thrown, so a committed frame's
217
+ * host callback is never starved by error delivery. */
218
+ onFrame: options.onFrame ?? null,
219
+ /** The one `emit` every widget of this renderer receives. */
220
+ emit: /** @type {WidgetEmit | null} */ (null),
221
+ };
222
+ ctx.emit = function emit(binding, nativeEvent) {
223
+ if (ctx.onEvent !== null) ctx.onEvent(binding, nativeEvent);
224
+ };
225
+ /** @type {any} */
226
+ let oldVnode = null;
227
+ /** @type {any} */
228
+ let rootNode = null;
229
+ /** True while a patch/mount pass runs: the renderer boundary is
230
+ * serialized — a render entered from inside a widget hook or event
231
+ * callback never nests. */
232
+ let rendering = false;
233
+ /** The latest vnode a nested render asked for. Nested renders
234
+ * COALESCE: intermediate trees are redundant because every call
235
+ * carries the full desired tree; only the last one is applied. */
236
+ let pendingVnode;
237
+ let destroyPending = false;
238
+
239
+ function render(vnode) {
240
+ if (ctx.destroyed) return; // a scheduled flush after destroy is a no-op
241
+ if (!isTextNode(vnode) && !isElementNode(vnode)) {
242
+ throw new TypeError('view: the root vnode must be a text or element vnode');
243
+ }
244
+ if (rendering) {
245
+ // re-entrant call (a widget mount/update emitted synchronously):
246
+ // queue behind the current patch — it applies after this frame,
247
+ // against the committed baseline, never against stale props
248
+ pendingVnode = vnode;
249
+ return;
250
+ }
251
+ rendering = true;
252
+ try {
253
+ let next = vnode;
254
+ do {
255
+ pendingVnode = undefined;
256
+ if (rootNode === null) {
257
+ container.textContent = '';
258
+ rootNode = createNode(ctx, next, null);
259
+ container.appendChild(rootNode);
260
+ }
261
+ else {
262
+ rootNode = patchNode(ctx, container, rootNode, oldVnode, next, null);
263
+ }
264
+ oldVnode = next;
265
+ // mount flush: after the patch completes every queued host is
266
+ // connected; an emit during mount defers into pendingVnode
267
+ flushMounts(ctx);
268
+ next = pendingVnode;
269
+ } while (next !== undefined && !ctx.destroyed);
270
+ // Controlled-input reconciliation, once per settled pass. It runs here,
271
+ // not inside the prop diff, so it survives every skip: a same-object
272
+ // re-render, a shared subtree and an equal `memo` marker all return
273
+ // before `patchProps`, but the control is still in the registry with
274
+ // its intended value. Also the moment a select's options all exist.
275
+ if (!ctx.destroyed && ctx.controlled.size > 0) {
276
+ reconcileControlledSet(ctx, container);
277
+ }
278
+ }
279
+ finally {
280
+ rendering = false;
281
+ pendingVnode = undefined;
282
+ }
283
+ if (destroyPending) {
284
+ destroyPending = false;
285
+ teardown();
286
+ }
287
+ // dual-failure settlement: the parked hook failure is copied and
288
+ // CLEARED before the frame callback runs, so a throwing callback
289
+ // can neither hide it nor push it into a later frame. The callback
290
+ // is isolated; frame settlement still precedes parked-error
291
+ // delivery (a committed live frame is real even when a widget hook
292
+ // failed during it). When both fail, the parked hook failure is
293
+ // primary; a host that must not lose its own callback failure
294
+ // isolates that callback itself (`createApp` does exactly that).
295
+ const parked = ctx.frameError;
296
+ ctx.frameError = null;
297
+ /** @type {{ value: unknown } | null} */
298
+ let callbackFailure = null;
299
+ if (ctx.onFrame !== null) {
300
+ try {
301
+ ctx.onFrame(ctx.destroyed ? 'destroyed' : 'live');
302
+ }
303
+ catch (err) {
304
+ callbackFailure = { value: err };
305
+ }
306
+ }
307
+ if (parked !== null) throw parked.value;
308
+ if (callbackFailure !== null) throw callbackFailure.value;
309
+ }
310
+
311
+ /**
312
+ * The destroy walk shared by `destroy()` and a deferred destroy.
313
+ * Terminal-cleanup errors carry PROVENANCE: with an `onCleanupError`
314
+ * sink registered (the app loop registers one to assign its stable
315
+ * cleanup code) the first error routes there — after every sibling
316
+ * cleaned up — instead of surfacing indistinguishably from a
317
+ * mount/update/render failure; without a sink it surfaces after the
318
+ * teardown, as before.
319
+ */
320
+ function teardown() {
321
+ ctx.mountQueue.length = 0;
322
+ // Release the controlled-node registry: a destroyed renderer must not
323
+ // retain detached form controls (nor their stored values) past teardown.
324
+ ctx.controlled.clear();
325
+ if (rootNode !== null) {
326
+ /** @type {unknown[]} */
327
+ const failures = [];
328
+ if (ctx.hasWidgets) {
329
+ destroyDomWalk(ctx, rootNode, failures);
330
+ }
331
+ container.textContent = '';
332
+ rootNode = null;
333
+ oldVnode = null;
334
+ if (failures.length > 0) {
335
+ // one delivery: a single failure surfaces by identity; several
336
+ // aggregate — the first is primary (errors[0]) and every later
337
+ // one stays observable instead of silently vanishing
338
+ const value = failures.length === 1
339
+ ? failures[0]
340
+ : new AggregateError(failures, 'multiple cleanup failures in one teardown');
341
+ if (ctx.onCleanupError !== null) ctx.onCleanupError(value);
342
+ else if (ctx.frameError === null) ctx.frameError = { value };
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Terminal teardown (VIEW-FORMAT §7.3.1): every mounted widget in the
349
+ * rendered tree unmounts exactly once (pending mounts are canceled),
350
+ * the container is left empty, and later `render` calls are exact
351
+ * no-ops. Idempotent. Called from inside a widget hook or a nested
352
+ * render it is still terminal: the active pass stops and the
353
+ * teardown runs when that pass unwinds. A throwing widget `unmount`
354
+ * never stops the walk; the first such error is thrown after the
355
+ * teardown completes.
356
+ */
357
+ render.destroy = function destroy() {
358
+ if (ctx.destroyed) return;
359
+ ctx.destroyed = true;
360
+ if (rendering) {
361
+ // called from inside the active pass: the pass sees `destroyed`,
362
+ // stops, and finishes the teardown as it unwinds
363
+ destroyPending = true;
364
+ return;
365
+ }
366
+ teardown();
367
+ if (ctx.frameError !== null) {
368
+ const { value } = ctx.frameError;
369
+ ctx.frameError = null;
370
+ throw value;
371
+ }
372
+ };
373
+
374
+ return render;
375
+ }
376
+
377
+ /**
378
+ * Mount every queued widget, in queue (document) order. A throwing
379
+ * `mount` poisons that widget (§7 failure policy): the host stays
380
+ * inert, siblings still mount, the first error parks on the frame, and
381
+ * the next render that revisits the widget replaces it with a fresh
382
+ * lifecycle. A poisoned-at-mount widget never receives `update` or
383
+ * `unmount` — it holds no successfully acquired resources.
384
+ * @param {any} ctx
385
+ */
386
+ function flushMounts(ctx) {
387
+ const queue = ctx.mountQueue;
388
+ while (queue.length > 0) {
389
+ if (ctx.destroyed) { queue.length = 0; return; }
390
+ const node = queue.shift();
391
+ const w = node.__jarenWidget;
392
+ if (w.mounted || w.destroyed) continue;
393
+ w.mounted = true;
394
+ try {
395
+ w.handle = w.def.mount(node, w.props, ctx.emit);
396
+ }
397
+ catch (err) {
398
+ w.failed = 'mount';
399
+ ctx.poisonedCount++;
400
+ appendFrameFailure(ctx, err);
401
+ }
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Create a fresh DOM node for a vnode.
407
+ * @param {any} ctx
408
+ * @param {any} vnode
409
+ * @param {string | null} ns
410
+ * @returns {any}
411
+ */
412
+ function createNode(ctx, vnode, ns) {
413
+ vnode = resolveForPolicy(ctx, vnode, true);
414
+ if (isTextNode(vnode)) {
415
+ return ctx.doc.createTextNode(String(vnode));
416
+ }
417
+ // Fail closed: a value that is neither text nor a valid element vnode (a
418
+ // stray object, say `{foo:'bar'}`, that slipped in as a child) renders as
419
+ // nothing — matching the serializer, and never reaching `createElement`
420
+ // with an undefined tag, which would build an `<undefined>` element without
421
+ // consulting the safe allow-list. Lists and skipped values were already
422
+ // resolved by `childrenOf`, so only a malformed node reaches here.
423
+ if (!isElementNode(vnode)) {
424
+ return ctx.doc.createTextNode('');
425
+ }
426
+ const tag = vnode[0];
427
+ if (tag === WIDGET_TAG) {
428
+ // Only reachable in trusted mode: `resolveForPolicy` already turned a
429
+ // safe-mode widget into an empty text node above.
430
+ return createWidgetNode(ctx, vnode, ns);
431
+ }
432
+ if (tag === 'svg') ns = SVG_NS;
433
+ const node = ns !== null
434
+ ? ctx.doc.createElementNS(ns, tag)
435
+ : ctx.doc.createElement(tag);
436
+ const props = propsOf(vnode);
437
+ for (const name in props) {
438
+ setProp(ctx, node, name, undefined, props[name], ns);
439
+ }
440
+ const children = childrenOf(vnode);
441
+ for (let i = 0; i < children.length; i++) {
442
+ node.appendChild(createNode(ctx, children[i], ns));
443
+ }
444
+ registerControlled(ctx, node, props);
445
+ return node;
446
+ }
447
+
448
+ /**
449
+ * Map a vnode the safe policy rejects — a widget (imperative JS) or an
450
+ * element whose tag is off the allow-list or is injection-shaped — to a
451
+ * stable empty-text sentinel. Trusted mode returns the vnode untouched.
452
+ *
453
+ * This runs at the top of BOTH `createNode` and `patchNode`, which is what
454
+ * closes the update-path escape: a rejected node has the identity of an empty
455
+ * text node on every frame, so it can never reach `patchWidgetNode` or the
456
+ * element patch — a stripped widget cannot mount on a later frame, and a
457
+ * blocked element cannot be patched as if its placeholder were real.
458
+ * @param {any} ctx
459
+ * @param {any} vnode
460
+ * @param {boolean} report - Whether to notify `onUnsafe` (the new side of a
461
+ * patch reports; the old side does not, so a persistently-rejected node is
462
+ * not reported twice per frame)
463
+ * @returns {any} the vnode, or `''` when the policy rejects it
464
+ */
465
+ function resolveForPolicy(ctx, vnode, report) {
466
+ if (ctx.policy === null || !isElementNode(vnode)) return vnode;
467
+ const tag = vnode[0];
468
+ if (tag === WIDGET_TAG) {
469
+ if (report && ctx.onUnsafe !== null) {
470
+ ctx.onUnsafe({ kind: 'widget', name: String(propsOf(vnode).name ?? '') });
471
+ }
472
+ return '';
473
+ }
474
+ if (ctx.policy.tag(tag) === null) {
475
+ if (report && ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'tag', name: String(tag) });
476
+ return '';
477
+ }
478
+ return vnode;
479
+ }
480
+
481
+ /**
482
+ * Patch `node` (rendered from `oldV`) to match `newV`; returns the node
483
+ * now in the DOM (a replacement when patching in place was impossible).
484
+ * @param {any} ctx
485
+ * @param {any} parent
486
+ * @param {any} node
487
+ * @param {any} oldV
488
+ * @param {any} newV
489
+ * @param {string | null} ns
490
+ * @returns {any}
491
+ */
492
+ function patchNode(ctx, parent, node, oldV, newV, ns) {
493
+ // a destroy() requested inside a widget hook stops the active pass:
494
+ // no later sibling may observe another update in this frame
495
+ if (ctx.destroyed) return node;
496
+ // Normalize BEFORE the identity diff: a safe-rejected node has the identity
497
+ // of an empty text node on both sides, so it can never enter the widget or
498
+ // element patch path (the update-path escape). The old side does not report
499
+ // — its strip was reported when it was first rendered.
500
+ if (ctx.policy !== null) {
501
+ oldV = resolveForPolicy(ctx, oldV, false);
502
+ newV = resolveForPolicy(ctx, newV, true);
503
+ }
504
+ // the === fast path is sound only while no widget is poisoned: a
505
+ // reference-equal subtree may hide a poisoned widget awaiting its
506
+ // replacement (§7.3), so recovery must not depend on the producer
507
+ // allocating a fresh vnode
508
+ if (oldV === newV && ctx.poisonedCount === 0) return node;
509
+ if (isTextNode(oldV) && isTextNode(newV)) {
510
+ const text = String(newV);
511
+ if (node.nodeValue !== text) node.nodeValue = text;
512
+ return node;
513
+ }
514
+ if (isSameNode(oldV, newV)) {
515
+ if (newV[0] === WIDGET_TAG) {
516
+ return patchWidgetNode(ctx, parent, node, oldV, newV, ns);
517
+ }
518
+ if (newV[0] === 'svg') ns = SVG_NS;
519
+ // the memo marker (§5.5): a producer-owned stability assertion —
520
+ // equal `memo` values on same-identity vnodes promise an identical
521
+ // subtree, so the diff skips it without touching props or
522
+ // children. `key`'s sibling: an instruction, never markup. It
523
+ // extends the `===` fast path across allocation boundaries (a
524
+ // rebuilt tree can still skip its unchanged regions) and shares
525
+ // its soundness condition — suspended while any widget is
526
+ // poisoned, so a memo-stable subtree can never hide one
527
+ const memo = propsOf(newV).memo;
528
+ if (memo !== undefined && Object.is(memo, propsOf(oldV).memo)
529
+ && ctx.poisonedCount === 0) {
530
+ return node;
531
+ }
532
+ patchProps(ctx, node, propsOf(oldV), propsOf(newV), ns);
533
+ patchChildren(ctx, node, childrenOf(oldV), childrenOf(newV), ns);
534
+ return node;
535
+ }
536
+ destroyNode(ctx, node);
537
+ const next = createNode(ctx, newV, ns);
538
+ parent.replaceChild(next, node);
539
+ return next;
540
+ }
541
+
542
+ /**
543
+ * Diff two props objects onto a DOM node.
544
+ * @param {any} ctx
545
+ * @param {any} node
546
+ * @param {Record<string, any>} oldProps
547
+ * @param {Record<string, any>} newProps
548
+ * @param {string | null} ns
549
+ */
550
+ function patchProps(ctx, node, oldProps, newProps, ns) {
551
+ if (oldProps !== newProps) {
552
+ for (const name in oldProps) {
553
+ if (!(name in newProps)) {
554
+ setProp(ctx, node, name, oldProps[name], undefined, ns);
555
+ }
556
+ }
557
+ for (const name in newProps) {
558
+ if (oldProps[name] !== newProps[name]) {
559
+ setProp(ctx, node, name, oldProps[name], newProps[name], ns);
560
+ }
561
+ }
562
+ }
563
+ // Record (or refresh) this control's authoritative value; the actual write
564
+ // happens in the end-of-pass reconciliation so it survives the skip paths.
565
+ registerControlled(ctx, node, newProps);
566
+ }
567
+
568
+ /**
569
+ * Record a form control's authoritative `value`/`checked` so the end-of-pass
570
+ * reconciliation can reassert it against the live DOM — even for a control
571
+ * inside a subtree a later frame skips. Trusted mode only: a safe-mode view
572
+ * strips events and is display-oriented, so there is no controlled state to
573
+ * fight the user over. Stores the intended value on the DOM node (never the
574
+ * vnode — §5.1) and keeps the node in `ctx.controlled`.
575
+ * @param {any} ctx
576
+ * @param {any} node
577
+ * @param {Record<string, any>} props
578
+ */
579
+ function registerControlled(ctx, node, props) {
580
+ if (ctx.policy !== null) return;
581
+ const kind = node.nodeName;
582
+ if (kind !== 'INPUT' && kind !== 'TEXTAREA' && kind !== 'SELECT') return;
583
+ const hasValue = 'value' in props;
584
+ const hasChecked = kind === 'INPUT' && 'checked' in props;
585
+ if (!hasValue && !hasChecked) {
586
+ if (node.__jarenControlled !== undefined) {
587
+ node.__jarenControlled = undefined;
588
+ ctx.controlled.delete(node);
589
+ }
590
+ return;
591
+ }
592
+ node.__jarenControlled = {
593
+ hasValue,
594
+ hasChecked,
595
+ value: hasValue ? props.value : undefined,
596
+ checked: hasChecked ? props.checked === true : undefined,
597
+ };
598
+ ctx.controlled.add(node);
599
+ }
600
+
601
+ /** Reconcile every registered controlled node against the live DOM, once per
602
+ * settled render pass. A node no longer connected to the container is dropped
603
+ * from the registry here, which is why registration needs no destroy hook. */
604
+ function reconcileControlledSet(ctx, container) {
605
+ for (const node of ctx.controlled) {
606
+ if (!isConnectedTo(node, container)) {
607
+ node.__jarenControlled = undefined;
608
+ ctx.controlled.delete(node);
609
+ continue;
610
+ }
611
+ reconcileControlled(node);
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Reassert one control's authoritative value/checked. React's controlled
617
+ * contract: the passed value wins over a user edit. Writes only on a genuine
618
+ * divergence, which preserves the caret on an unchanged control. Runs after
619
+ * the whole tree is built, so a `select` sees its options, and a `multiple`
620
+ * select applies an array by marking each option `selected`.
621
+ * @param {any} node
622
+ */
623
+ function reconcileControlled(node) {
624
+ const c = node.__jarenControlled;
625
+ if (c === undefined) return;
626
+ if (c.hasChecked) {
627
+ const want = c.checked === true;
628
+ if (node.checked !== want) node.checked = want;
629
+ }
630
+ if (!c.hasValue) return;
631
+ const isMultiple = node.multiple === true
632
+ || (typeof node.getAttribute === 'function' && node.getAttribute('multiple') != null);
633
+ if (node.nodeName === 'SELECT' && isMultiple && Array.isArray(c.value)) {
634
+ const want = new Set(c.value.map((v) => String(v)));
635
+ const options = node.options ?? node.childNodes ?? [];
636
+ for (let i = 0; i < options.length; i++) {
637
+ const opt = options[i];
638
+ const ov = opt.value != null ? opt.value
639
+ : (typeof opt.getAttribute === 'function' ? opt.getAttribute('value') : null);
640
+ const sel = ov != null && want.has(String(ov));
641
+ if (opt.selected !== sel) opt.selected = sel;
642
+ }
643
+ return;
644
+ }
645
+ const want = c.value == null ? '' : String(c.value);
646
+ if (node.value !== want) node.value = want;
647
+ }
648
+
649
+ /** Whether `node` is still attached beneath `root` (the render container).
650
+ * A detached node's ancestor chain stops before the container. */
651
+ function isConnectedTo(node, root) {
652
+ let n = node;
653
+ while (n != null) {
654
+ if (n === root) return true;
655
+ n = n.parentNode;
656
+ }
657
+ return false;
658
+ }
659
+
660
+ /**
661
+ * Write one prop change to the DOM.
662
+ * @param {any} ctx
663
+ * @param {any} node
664
+ * @param {string} name
665
+ * @param {any} oldValue
666
+ * @param {any} newValue
667
+ * @param {string | null} ns
668
+ */
669
+ function setProp(ctx, node, name, oldValue, newValue, ns) {
670
+ if (name === 'on') {
671
+ // Safe mode strips event bindings: an untrusted document must not bind
672
+ // the host's application actions. Never wires the listener.
673
+ if (ctx.policy !== null && ctx.policy.dropsEvents) {
674
+ if (ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'event', name: 'on' });
675
+ return;
676
+ }
677
+ setEvents(ctx, node, oldValue, newValue);
678
+ return;
679
+ }
680
+ if (name in SKIP_PROPS) return;
681
+ if (ctx.policy !== null) {
682
+ const decided = ctx.policy.prop(name, newValue);
683
+ // A rejected NAME (scripting sink, inline `on*`, injection-shaped) is
684
+ // dropped; a sanitized-away URL or dangerous style keeps its (safe) name
685
+ // and clears the value.
686
+ if (decided === null) {
687
+ if (ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'prop', name });
688
+ return;
689
+ }
690
+ name = decided.name;
691
+ newValue = decided.value;
692
+ if (newValue === null && ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'prop', name });
693
+ // SAFE MODE IS ATTRIBUTE-ONLY. Never `node[name] = …`: a live DOM property
694
+ // write can THROW (`input.files` is read-only) and it diverges from SSR
695
+ // for a cleared value (an empty reflected attribute vs an omitted one).
696
+ // Writing/removing the attribute matches exactly what the serializer does.
697
+ if (name === 'style' && typeof newValue === 'object' && newValue !== null) {
698
+ newValue = styleToString(newValue);
699
+ }
700
+ if (newValue == null || newValue === false) node.removeAttribute(name);
701
+ else node.setAttribute(name, newValue === true ? '' : String(newValue));
702
+ return;
703
+ }
704
+ // Trusted path (unchanged): a property where the node has one, else an
705
+ // attribute — the equivalent of writing the DOM by hand.
706
+ if (name === 'style' && typeof newValue === 'object' && newValue !== null) {
707
+ newValue = styleToString(newValue);
708
+ }
709
+ if (ns === null && name in node && name !== 'list' && name !== 'form') {
710
+ node[name] = newValue == null ? '' : newValue;
711
+ }
712
+ else if (newValue == null || newValue === false) {
713
+ node.removeAttribute(name);
714
+ }
715
+ else {
716
+ node.setAttribute(name, newValue === true ? '' : String(newValue));
717
+ }
718
+ }
719
+
720
+ /**
721
+ * The shared proxy listener: reads the current binding off the node, so
722
+ * re-renders rebind by data alone.
723
+ * @param {any} event
724
+ */
725
+ function eventProxy(event) {
726
+ const node = event.currentTarget;
727
+ const binding = node.__jarenOn?.[event.type];
728
+ if (binding !== undefined && node.__jarenEmit !== null) {
729
+ node.__jarenEmit(binding, event);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Reconcile the `on` prop: `{ [eventType]: binding }`.
735
+ * @param {any} ctx
736
+ * @param {any} node
737
+ * @param {Record<string, any> | undefined} oldOn
738
+ * @param {Record<string, any> | undefined} newOn
739
+ */
740
+ function setEvents(ctx, node, oldOn, newOn) {
741
+ const prev = node.__jarenOn ?? EMPTY_PROPS;
742
+ const next = newOn ?? EMPTY_PROPS;
743
+ node.__jarenOn = next;
744
+ node.__jarenEmit = ctx.onEvent;
745
+ for (const type in prev) {
746
+ if (!(type in next)) node.removeEventListener(type, eventProxy);
747
+ }
748
+ for (const type in next) {
749
+ if (!(type in prev)) node.addEventListener(type, eventProxy);
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Resolve and validate a widget vnode against the registry: a non-empty
755
+ * registered `name`, and no vnode children — the widget owns the host's
756
+ * subtree. Violations are configuration/producer errors, the same class
757
+ * as the root-vnode check.
758
+ * @param {any} ctx
759
+ * @param {any} vnode
760
+ * @param {Record<string, any>} props
761
+ * @returns {WidgetDef}
762
+ */
763
+ function widgetDef(ctx, vnode, props) {
764
+ const name = props.name;
765
+ if (typeof name !== 'string' || name === '') {
766
+ throw new TypeError('view: a jaren-widget vnode must have a non-empty "name" prop');
767
+ }
768
+ if (!Object.hasOwn(ctx.widgets, name)) {
769
+ throw new TypeError(`view: unregistered widget '${name}'`);
770
+ }
771
+ if (childrenOf(vnode).length !== 0) {
772
+ throw new TypeError(
773
+ `view: widget '${name}' must not have vnode children — the widget owns the host's subtree`);
774
+ }
775
+ return ctx.widgets[name];
776
+ }
777
+
778
+ /**
779
+ * Create a widget host element (VIEW-FORMAT §7): apply the host props,
780
+ * store the widget bookkeeping on the DOM node (never on the vnode) and
781
+ * queue the node for the post-patch mount flush.
782
+ * @param {any} ctx
783
+ * @param {any} vnode
784
+ * @param {string | null} ns
785
+ * @returns {any}
786
+ */
787
+ function createWidgetNode(ctx, vnode, ns) {
788
+ const props = propsOf(vnode);
789
+ const def = widgetDef(ctx, vnode, props);
790
+ const tag = props.tag ?? 'div';
791
+ const node = ns !== null
792
+ ? ctx.doc.createElementNS(ns, tag)
793
+ : ctx.doc.createElement(tag);
794
+ for (const name in props) {
795
+ if (name in WIDGET_SKIP_PROPS) continue;
796
+ setProp(ctx, node, name, undefined, props[name], ns);
797
+ }
798
+ node.__jarenWidget = {
799
+ name: props.name,
800
+ def,
801
+ props: props.props ?? null,
802
+ handle: undefined,
803
+ mounted: false,
804
+ destroyed: false,
805
+ /** `false`, or the poisoning hook: `'mount'` (skip unmount — the
806
+ * widget acquired nothing) or `'update'` (unmount still runs). A
807
+ * poisoned widget is replaced on the next render that revisits it. */
808
+ failed: /** @type {false | 'mount' | 'update'} */ (false),
809
+ };
810
+ ctx.hasWidgets = true;
811
+ ctx.mountQueue.push(node);
812
+ return node;
813
+ }
814
+
815
+ /**
816
+ * Patch two widget vnodes under the same key: a `name` or host-`tag`
817
+ * change replaces the widget (destroy old, create new, mount queued);
818
+ * otherwise the host props diff normally, `childNodes` are never
819
+ * touched, and a changed `props` *reference* reaches the widget through
820
+ * `update` (or the unmount + fresh-mount fallback). Reference-equal
821
+ * `props` never poke the widget — the JSLT memo option turns unchanged
822
+ * state into exactly that.
823
+ * @param {any} ctx
824
+ * @param {any} parent
825
+ * @param {any} node
826
+ * @param {any} oldV
827
+ * @param {any} newV
828
+ * @param {string | null} ns
829
+ * @returns {any}
830
+ */
831
+ function patchWidgetNode(ctx, parent, node, oldV, newV, ns) {
832
+ const oldProps = propsOf(oldV);
833
+ const newProps = propsOf(newV);
834
+ const w = node.__jarenWidget;
835
+ if (newProps.name !== oldProps.name || (newProps.tag ?? 'div') !== (oldProps.tag ?? 'div')
836
+ || w.failed !== false) {
837
+ // a poisoned widget (a hook threw) is replaced, not patched: the
838
+ // old lifecycle ends (unmount only if mount succeeded) and a fresh
839
+ // one begins — half-mounted handles never receive updates
840
+ destroyNode(ctx, node);
841
+ const next = createWidgetNode(ctx, newV, ns);
842
+ parent.replaceChild(next, node);
843
+ return next;
844
+ }
845
+ widgetDef(ctx, newV, newProps);
846
+ patchWidgetProps(ctx, node, oldProps, newProps, ns);
847
+ const props = newProps.props ?? null;
848
+ const prevProps = oldProps.props ?? null;
849
+ if (props !== prevProps) {
850
+ w.props = props;
851
+ if (w.mounted) {
852
+ // capability ACQUISITION is host-observable (an accessor or
853
+ // proxy trap can throw): the `update` read shares the poison
854
+ // boundary with its invocation — a lookup failure poisons the
855
+ // widget exactly like an invocation failure, the frame settles,
856
+ // and the next render replaces it
857
+ let update;
858
+ let acquired = true;
859
+ try {
860
+ update = w.def.update;
861
+ }
862
+ catch (err) {
863
+ acquired = false;
864
+ w.failed = 'update';
865
+ ctx.poisonedCount++;
866
+ appendFrameFailure(ctx, err);
867
+ }
868
+ if (acquired && update !== undefined) {
869
+ try {
870
+ update.call(w.def, w.handle, props, prevProps);
871
+ }
872
+ catch (err) {
873
+ w.failed = 'update';
874
+ ctx.poisonedCount++;
875
+ appendFrameFailure(ctx, err);
876
+ }
877
+ }
878
+ else if (acquired) {
879
+ // no update hook: recycle the host with a fresh lifecycle.
880
+ // The catch is PHASE-SENSITIVE: a failure before the old
881
+ // instance's `unmount` had its exactly-once chance (a hostile
882
+ // `unmount` lookup) leaves the OLD acquisition owned — poison
883
+ // as 'update' so replacement and terminal destroy still
884
+ // release it; only after the teardown attempt does a failure
885
+ // take mount-failure semantics (nothing left to release).
886
+ let unmountAttempted = false;
887
+ try {
888
+ const unmount = w.def.unmount;
889
+ if (unmount !== undefined) {
890
+ unmountAttempted = true;
891
+ unmount.call(w.def, w.handle);
892
+ }
893
+ else {
894
+ unmountAttempted = true; // nothing to run: teardown is complete
895
+ }
896
+ // the unmount may have requested terminal destroy: the old
897
+ // acquisition has ENDED (record it, or deferred teardown
898
+ // would unmount it a second time) and no fresh acquisition
899
+ // may begin — a mount after a terminal request would run
900
+ // brand-new host side effects on a destroyed renderer
901
+ if (ctx.destroyed) {
902
+ w.mounted = false;
903
+ w.handle = undefined;
904
+ w.destroyed = true;
905
+ return node;
906
+ }
907
+ w.handle = w.def.mount(node, props, ctx.emit);
908
+ }
909
+ catch (err) {
910
+ w.failed = unmountAttempted ? 'mount' : 'update';
911
+ ctx.poisonedCount++;
912
+ appendFrameFailure(ctx, err);
913
+ }
914
+ }
915
+ }
916
+ // not yet mounted (still queued): the pending mount reads w.props
917
+ }
918
+ return node;
919
+ }
920
+
921
+ /**
922
+ * Diff a widget host's props: `patchProps` minus the widget-local
923
+ * members (`name`/`props`/`tag`), which configure the widget, not the
924
+ * DOM.
925
+ * @param {any} ctx
926
+ * @param {any} node
927
+ * @param {Record<string, any>} oldProps
928
+ * @param {Record<string, any>} newProps
929
+ * @param {string | null} ns
930
+ */
931
+ function patchWidgetProps(ctx, node, oldProps, newProps, ns) {
932
+ if (oldProps === newProps) return;
933
+ for (const name in oldProps) {
934
+ if (name in WIDGET_SKIP_PROPS) continue;
935
+ if (!(name in newProps)) {
936
+ setProp(ctx, node, name, oldProps[name], undefined, ns);
937
+ }
938
+ }
939
+ for (const name in newProps) {
940
+ if (name in WIDGET_SKIP_PROPS) continue;
941
+ if (oldProps[name] !== newProps[name]) {
942
+ setProp(ctx, node, name, oldProps[name], newProps[name], ns);
943
+ }
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Notify every widget in a discarded subtree that it is leaving the
949
+ * tree. A no-op until a widget has actually been created, so widget-free
950
+ * documents keep O(1) removal. A throwing `unmount` must not stop the
951
+ * walk or the patch — one broken widget must not leak its siblings — so
952
+ * the first captured error parks on `ctx` and `render` rethrows it after
953
+ * the frame settles.
954
+ *
955
+ * The walk is OWNERSHIP-based: it follows the live DOM and this
956
+ * renderer's own widget-host marker, never a vnode. A vnode is a
957
+ * *description* — old, new, or (after a mid-pass `destroy()`) only
958
+ * partially committed — and pairing it with the DOM is exactly how a
959
+ * teardown skips a mounted widget or indexes a missing node. What the
960
+ * renderer actually acquired is recorded on the DOM nodes it owns, so
961
+ * that is what teardown drains.
962
+ * @param {any} ctx
963
+ * @param {any} node - The root DOM node of the discarded subtree.
964
+ */
965
+ function destroyNode(ctx, node) {
966
+ if (!ctx.hasWidgets) return;
967
+ /** @type {unknown[]} */
968
+ const failures = [];
969
+ destroyDomWalk(ctx, node, failures);
970
+ for (let i = 0; i < failures.length; i++) appendFrameFailure(ctx, failures[i]);
971
+ }
972
+
973
+ /**
974
+ * Park one more failure on the frame. EVERY failure of a frame stays
975
+ * observable regardless of how many internal walks or hooks produced
976
+ * them: the first surfaces by identity; from the second on, the frame
977
+ * delivers one framework-owned AggregateError over the originals in
978
+ * occurrence order (only the framework's OWN envelope is ever
979
+ * extended — a host-thrown value, an AggregateError included, is
980
+ * stored untouched as one element; nothing is inspected).
981
+ * @param {any} ctx
982
+ * @param {unknown} value
983
+ */
984
+ function appendFrameFailure(ctx, value) {
985
+ if (ctx.frameError === null) {
986
+ ctx.frameError = { value, envelope: false };
987
+ return;
988
+ }
989
+ const prior = ctx.frameError;
990
+ const items = prior.envelope
991
+ ? [.../** @type {AggregateError} */ (prior.value).errors, value]
992
+ : [prior.value, value];
993
+ ctx.frameError = {
994
+ value: new AggregateError(items, 'multiple failures in one frame'),
995
+ envelope: true,
996
+ };
997
+ }
998
+
999
+ /**
1000
+ * The recursive half of `destroyNode`: unmount marked widget hosts,
1001
+ * recurse through ordinary DOM children, never descend into a widget's
1002
+ * host subtree (the widget's own DOM may contain anything — including a
1003
+ * nested renderer whose widgets it, not this renderer, owns). A
1004
+ * destroyed poisoned widget leaves the live-poison count — replacement
1005
+ * and subtree removal are the two ways a poisoned widget recovers,
1006
+ * after which the `===` fast path is sound again.
1007
+ * @param {any} ctx
1008
+ * @param {any} node
1009
+ * @param {unknown[]} failures - Collects EVERY cleanup failure, in
1010
+ * document order — an array, never a thrown-value sentinel, so a
1011
+ * hook that legally throws `null` still counts and a second failure
1012
+ * is never hidden behind the first.
1013
+ */
1014
+ function destroyDomWalk(ctx, node, failures) {
1015
+ const w = node.__jarenWidget;
1016
+ if (w !== undefined) {
1017
+ if (!w.destroyed) {
1018
+ w.destroyed = true;
1019
+ if (w.failed !== false) ctx.poisonedCount--;
1020
+ if (w.mounted && w.failed !== 'mount') {
1021
+ // the `unmount` READ shares the collection boundary with its
1022
+ // call: a hostile accessor is a cleanup failure like any
1023
+ // other — every sibling still unmounts, the container still
1024
+ // empties
1025
+ try {
1026
+ const unmount = w.def.unmount;
1027
+ if (unmount !== undefined) unmount.call(w.def, w.handle);
1028
+ }
1029
+ catch (err) {
1030
+ failures.push(err);
1031
+ }
1032
+ }
1033
+ }
1034
+ return; // the widget owns everything below its host
1035
+ }
1036
+ const children = node.childNodes;
1037
+ if (children !== undefined) {
1038
+ // snapshot before invoking hooks: `childNodes` is live, and an
1039
+ // `unmount` that detaches its own host would shift the indices and
1040
+ // silently skip a sibling's cleanup
1041
+ const snapshot = [];
1042
+ for (let i = 0; i < children.length; i++) snapshot.push(children[i]);
1043
+ for (let i = 0; i < snapshot.length; i++) {
1044
+ destroyDomWalk(ctx, snapshot[i], failures);
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Serialize a style object to a CSS declaration string. CamelCase keys
1051
+ * become kebab-case; `--custom-properties` pass through.
1052
+ * @param {Record<string, any>} style
1053
+ * @returns {string}
1054
+ */
1055
+ export function styleToString(style) {
1056
+ let out = '';
1057
+ for (const name in style) {
1058
+ const value = style[name];
1059
+ if (value == null || value === false) continue;
1060
+ const cssName = name.startsWith('--') ? name : kebabCase(name);
1061
+ out += (out === '' ? '' : ';') + cssName + ':' + String(value);
1062
+ }
1063
+ return out;
1064
+ }
1065
+
1066
+ /**
1067
+ * Reconcile an element's children with a head/tail sweep plus a key map
1068
+ * for the middle. `oldCh[i]` corresponds to the i-th DOM child at entry;
1069
+ * the parallel `oldDom` snapshot keeps that correspondence stable across
1070
+ * moves (arrays are positional only at snapshot time — afterwards they
1071
+ * are identity maps, index → node).
1072
+ * @param {any} ctx
1073
+ * @param {any} parent
1074
+ * @param {any[]} oldCh
1075
+ * @param {any[]} newCh
1076
+ * @param {string | null} ns
1077
+ */
1078
+ function patchChildren(ctx, parent, oldCh, newCh, ns) {
1079
+ const oldDom = [];
1080
+ {
1081
+ const live = parent.childNodes;
1082
+ for (let i = 0; i < oldCh.length; i++) oldDom.push(live[i]);
1083
+ }
1084
+ let oldStart = 0;
1085
+ let oldEnd = oldCh.length - 1;
1086
+ let newStart = 0;
1087
+ let newEnd = newCh.length - 1;
1088
+ /** @type {Map<any, number> | null} */
1089
+ let keyMap = null;
1090
+ /** The DOM node currently sitting just after the unprocessed tail. */
1091
+ let tailRef = null;
1092
+
1093
+ while (oldStart <= oldEnd && newStart <= newEnd) {
1094
+ if (ctx.destroyed) return; // a mid-pass destroy stops the traversal
1095
+ const oS = oldCh[oldStart];
1096
+ if (oS === undefined) { oldStart++; continue; } // consumed by a keyed move
1097
+ const oE = oldCh[oldEnd];
1098
+ if (oE === undefined) { oldEnd--; continue; }
1099
+ const nS = newCh[newStart];
1100
+ const nE = newCh[newEnd];
1101
+
1102
+ if (isSameNode(oS, nS)) {
1103
+ patchNode(ctx, parent, oldDom[oldStart], oS, nS, ns);
1104
+ oldStart++; newStart++;
1105
+ }
1106
+ else if (isSameNode(oE, nE)) {
1107
+ patchNode(ctx, parent, oldDom[oldEnd], oE, nE, ns);
1108
+ tailRef = oldDom[oldEnd];
1109
+ oldEnd--; newEnd--;
1110
+ }
1111
+ else if (isSameNode(oS, nE)) {
1112
+ // old head moved to the tail
1113
+ const node = patchNode(ctx, parent, oldDom[oldStart], oS, nE, ns);
1114
+ parent.insertBefore(node, tailRef);
1115
+ tailRef = node;
1116
+ oldStart++; newEnd--;
1117
+ }
1118
+ else if (isSameNode(oE, nS)) {
1119
+ // old tail moved to the head
1120
+ const node = patchNode(ctx, parent, oldDom[oldEnd], oE, nS, ns);
1121
+ parent.insertBefore(node, oldDom[oldStart]);
1122
+ oldEnd--; newStart++;
1123
+ }
1124
+ else {
1125
+ if (keyMap === null) keyMap = buildKeyMap(oldCh, oldStart, oldEnd);
1126
+ const key = keyOf(nS);
1127
+ const idx = key !== undefined ? keyMap.get(key) : undefined;
1128
+ if (idx === undefined || oldCh[idx] === undefined || !isSameNode(oldCh[idx], nS)) {
1129
+ parent.insertBefore(createNode(ctx, nS, ns), oldDom[oldStart]);
1130
+ }
1131
+ else {
1132
+ const node = patchNode(ctx, parent, oldDom[idx], oldCh[idx], nS, ns);
1133
+ parent.insertBefore(node, oldDom[oldStart]);
1134
+ oldCh[idx] = undefined;
1135
+ }
1136
+ newStart++;
1137
+ }
1138
+ }
1139
+
1140
+ if (oldStart > oldEnd) {
1141
+ // old range exhausted: mount the remaining new children before the tail
1142
+ for (let i = newStart; i <= newEnd; i++) {
1143
+ if (ctx.destroyed) return;
1144
+ parent.insertBefore(createNode(ctx, newCh[i], ns), tailRef);
1145
+ }
1146
+ }
1147
+ else {
1148
+ // new range exhausted: unmount the remaining old children
1149
+ for (let i = oldStart; i <= oldEnd; i++) {
1150
+ if (ctx.destroyed) return;
1151
+ if (oldCh[i] !== undefined) {
1152
+ destroyNode(ctx, oldDom[i]);
1153
+ parent.removeChild(oldDom[i]);
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Key → index map over the unprocessed old range.
1161
+ * @param {any[]} oldCh
1162
+ * @param {number} start
1163
+ * @param {number} end
1164
+ * @returns {Map<any, number>}
1165
+ */
1166
+ function buildKeyMap(oldCh, start, end) {
1167
+ const map = new Map();
1168
+ for (let i = start; i <= end; i++) {
1169
+ const key = keyOf(oldCh[i]);
1170
+ if (key !== undefined && !map.has(key)) map.set(key, i);
1171
+ }
1172
+ return map;
1173
+ }