@microsoft/webui-framework 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/element.js CHANGED
@@ -1,267 +1,15 @@
1
- // Copyright (c) Microsoft Corporation.
2
- // Licensed under the MIT license.
3
- /**
4
- * WebUIElement — lightweight Web Component base class.
5
- *
6
- * Supports Shadow DOM or light DOM, SSR hydration, reactive updates, and compiled
7
- * SSR content is reused by matching existing DOM nodes through compiled
8
- * template path mapping. Client-created components use exact childNode
9
- * indices from the compiled template HTML.
10
- *
11
- * ## SSR hydration markers
12
- *
13
- * The server-side handler plugin emits lightweight HTML comment markers
14
- * around structural boundaries so the client can hydrate in-place:
15
- *
16
- * - `<!--wr-->` / `<!--/wr-->` — repeat (for-loop) block boundaries
17
- * - `<!--wi-->` — repeat item boundary (one per item)
18
- * - `<!--wc-->` / `<!--/wc-->` — conditional (if) block boundaries
19
- *
20
- * During hydration these markers are consumed: `<!--wi-->`, `<!--/wr-->`,
21
- * and `<!--/wc-->` are removed from the DOM. The `<!--wr-->` start
22
- * marker is kept as the runtime repeat anchor, and `<!--wc-->` is kept
23
- * as the runtime condition anchor.
24
- *
25
- * **Marker removal is deferred** until after all path-based resolution
26
- * (`$resolveSSR`, `$findSSRText`, `$finalize`) is complete. This is
27
- * critical because `$resolveSSR` uses marker pairs to skip structural
28
- * block content when counting element/text ordinals — removing a closing
29
- * marker mid-hydration would break later resolution calls.
30
- *
31
- * ## Comment anchors (client-created)
32
- *
33
- * For client-created components (no SSR), the framework inserts empty
34
- * comment nodes (`document.createComment('')`) as stable DOM anchors
35
- * for conditional and repeat blocks. When SSR markers are absent,
36
- * the same fallback anchors are used.
37
- *
38
- * These comments are invisible to the user, weigh ~0 bytes, and are the
39
- * minimum DOM structure needed for the framework to operate.
40
- */
41
- import { getTemplate } from './template.js';
42
- import { hydrationStart, hydrationEnd } from './lifecycle.js';
43
- import { getObservableNames, isAttributeProperty, syncAttrProperties } from './decorators.js';
44
- import { syncRepeat, dotWalk } from './element/diff.js';
45
- import { collectItemMarkers, nextElement, findByOrdinal, MARKER_COND_START, MARKER_COND_END, MARKER_REPEAT_START, } from './element/markers.js';
46
- import { injectModuleStyle, } from './element/styles.js';
47
- import { ATTR_KIND_BOOLEAN, ATTR_KIND_COMPLEX, ATTR_KIND_TEMPLATE, } from './element/types.js';
48
- // ── Caches ──────────────────────────────────────────────────────
49
- /** Parsed template cache — cloneNode(true) is faster than re-parsing. */
50
- const templateCache = new WeakMap();
51
- /** Parsed template DOM for SSR path mapping, keyed by TemplateBlockMeta. */
52
- const templateDOMCache = new WeakMap();
53
- /** Cached root tag name extracted from meta.h before it's released. */
54
- const rootTagCache = new WeakMap();
55
- /** Pre-computed ordinals for template nodes: childIndex → [nodeType, ordinal].
56
- * Avoids re-counting element/text siblings on every $resolveSSR call. */
57
- const tplOrdinalCache = new WeakMap();
58
- function getTplOrdinals(tplNode) {
59
- let map = tplOrdinalCache.get(tplNode);
60
- if (map)
61
- return map;
62
- map = new Map();
63
- let elemOrd = 0;
64
- let textOrd = 0;
65
- const children = tplNode.childNodes;
66
- for (let k = 0; k < children.length; k++) {
67
- const type = children[k].nodeType;
68
- if (type === 1) {
69
- map.set(k, [1, elemOrd]);
70
- elemOrd++;
71
- }
72
- else if (type === 3) {
73
- map.set(k, [3, textOrd]);
74
- textOrd++;
75
- }
76
- }
77
- tplOrdinalCache.set(tplNode, map);
78
- return map;
79
- }
80
- // ── Sentinels ───────────────────────────────────────────────────
81
- const EMPTY_ARR = [];
82
- // ── Helper: snapshot child nodes into a pre-allocated array ──────
83
- function childNodesArray(parent) {
84
- const children = parent.childNodes;
85
- const len = children.length;
86
- const result = new Array(len);
87
- for (let i = 0; i < len; i++)
88
- result[i] = children[i];
89
- return result;
90
- }
91
- // ── Helper: parse template HTML into a temp container ────────────
92
- function getTemplateDom(meta) {
93
- let cached = templateDOMCache.get(meta);
94
- if (cached)
95
- return cached;
96
- const div = document.createElement('div');
97
- div.innerHTML = meta.h;
98
- templateDOMCache.set(meta, div);
99
- return div;
100
- }
101
- // ═══════════════════════════════════════════════════════════════════
102
- // WebUIElement
103
- // ═══════════════════════════════════════════════════════════════════
104
- export class WebUIElement extends HTMLElement {
105
- constructor() {
106
- super(...arguments);
107
- this.$root = null;
108
- this.$ready = false;
109
- this.$hydrated = false;
110
- this.$dirtyPaths = null;
111
- this.$pendingFlush = false;
112
- /** Cached condition resolver — avoids allocating a closure per evaluation. */
113
- this.$resolver = (p, s) => this.$resolveValue(p, s);
1
+ import { TemplateElement } from './template-element.js';
2
+ import { getObservableNames, isAttributeProperty, syncAttrProperties, } from './decorators.js';
3
+ export class WebUIElement extends TemplateElement {
4
+ $observableNames() {
5
+ return getObservableNames(this.constructor);
114
6
  }
115
- static define(tagName) {
116
- customElements.define(tagName, this);
7
+ $shouldApplySSRState(key) {
8
+ return !isAttributeProperty(this.constructor, key);
117
9
  }
118
- // ── Lifecycle ─────────────────────────────────────────────────
119
- connectedCallback() {
120
- const tag = this.tagName.toLowerCase();
121
- if (this.$hydrated && this.$root) {
122
- hydrationStart();
123
- this.$ready = true;
124
- this.$update();
125
- hydrationEnd();
126
- return;
127
- }
128
- const meta = getTemplate(tag);
129
- if (!meta) {
130
- console.warn(`[WebUI] Template metadata for <${tag}> not found. ` +
131
- `Ensure the component is included in the SSR output or registered via __webui.templates.`);
132
- return;
133
- }
134
- this.$meta = meta;
135
- // Custom element upgrade timing: when the HTML parser encounters the
136
- // opening tag, connectedCallback fires BEFORE children are parsed.
137
- // If the document is still loading, defer to let the parser finish.
138
- if (document.readyState === 'loading') {
139
- const handler = () => {
140
- document.removeEventListener('DOMContentLoaded', handler);
141
- this.$mount(meta);
142
- };
143
- document.addEventListener('DOMContentLoaded', handler);
144
- }
145
- else {
146
- // Document is already parsed — children are available
147
- this.$mount(meta);
148
- }
149
- }
150
- /** Mount the component after children are available. */
151
- $mount(meta) {
152
- if (this.$hydrated)
153
- return;
154
- hydrationStart();
155
- // Auto-detect shadow vs light DOM
156
- const hasShadow = !!this.shadowRoot;
157
- const wantShadow = hasShadow || !!meta.sd;
158
- let root;
159
- let isSSR;
160
- let clientRoot = null;
161
- if (hasShadow) {
162
- // Shadow DOM SSR — declarative shadow root already has content
163
- root = this.shadowRoot;
164
- isSSR = true;
165
- }
166
- else if (this.childNodes.length > 0 && !meta.sd) {
167
- // SSR light-DOM — element already has server-rendered children.
168
- // Only treat as SSR when the template does NOT explicitly declare
169
- // shadow DOM (meta.sd). When meta.sd is set, existing children
170
- // are slot content from an SPA partial, not SSR output.
171
- root = this;
172
- isSSR = true;
173
- }
174
- else if (wantShadow) {
175
- // Shadow DOM client-created (or SPA partial with slot content).
176
- // Existing children are slot content — they stay in light DOM
177
- // and project through the template's <slot>.
178
- root = this.attachShadow({ mode: 'open' });
179
- isSSR = false;
180
- }
181
- else {
182
- // Light DOM client-created — populate from template (no shadow = no link issue)
183
- root = this;
184
- isSSR = false;
185
- }
186
- // Inject CSS module stylesheet after root is determined
187
- if (meta.sa)
188
- injectModuleStyle(meta.sa, this.shadowRoot);
189
- if (isSSR) {
190
- // Apply the same state that was used for SSR rendering
191
- // so client observables match the server-rendered DOM.
192
- this.$applySSRState();
193
- this.$root = this.$hydrate(root, meta, getTemplateDom(meta));
194
- }
195
- else {
196
- clientRoot = this.$createStagingRoot(meta);
197
- this.$root = this.$wire(clientRoot, meta);
198
- }
199
- this.$meta = meta;
200
- this.$hydrated = true;
201
- this.$ready = true;
10
+ $syncAuthoredAttributes() {
202
11
  syncAttrProperties(this, this.constructor);
203
- // Client-created components: flush current attr/observable values
204
- // into the freshly-wired template DOM. Call $updateInstance directly
205
- // to avoid the $update() path-index build — it will be lazy-built
206
- // on the first reactive change instead.
207
- if (!isSSR && clientRoot) {
208
- this.$updateInstance(this.$root);
209
- if (this.$root.repeats.length !== 0 || this.$root.conds.length !== 0) {
210
- this.$root.nodes = childNodesArray(clientRoot);
211
- this.$releaseStagingRepeatContainers(this.$root, clientRoot);
212
- }
213
- this.$appendStagedChildren(root, clientRoot);
214
- }
215
- hydrationEnd();
216
12
  }
217
- disconnectedCallback() {
218
- // Schedule teardown on microtask — if the element is re-connected
219
- // before then (e.g. repeat reconciliation), skip the cleanup.
220
- if (this.$root) {
221
- queueMicrotask(() => {
222
- if (!this.isConnected)
223
- this.$destroy();
224
- });
225
- }
226
- }
227
- /**
228
- * Permanently destroy this component's own bindings and DOM references.
229
- * Each component is responsible for its own cleanup — child WebUI
230
- * elements handle theirs via their own `disconnectedCallback`.
231
- */
232
- $destroy() {
233
- if (!this.$root)
234
- return;
235
- this.$teardown(this.$root);
236
- this.$root = null;
237
- this.$pathIndex = undefined;
238
- this.$wildcardBindings = undefined;
239
- this.$dirtyPaths = null;
240
- this.$pendingFlush = false;
241
- this.$ready = false;
242
- }
243
- /** Break all DOM references held by a binding instance and its nested blocks. */
244
- $teardown(instance) {
245
- for (const c of instance.conds) {
246
- if (c.instance)
247
- this.$teardown(c.instance);
248
- c.instance = null;
249
- }
250
- for (const r of instance.repeats) {
251
- for (const item of r.instances)
252
- this.$teardown(item.instance);
253
- r.instances.length = 0;
254
- r.container = null;
255
- r.start = null;
256
- r.end = null;
257
- }
258
- instance.nodes.length = 0;
259
- instance.texts.length = 0;
260
- instance.attrs.length = 0;
261
- instance.conds.length = 0;
262
- instance.repeats.length = 0;
263
- }
264
- /** Dispatch a bubbling custom event. Uses composed:true when in shadow DOM. */
265
13
  $emit(name, detail) {
266
14
  return this.dispatchEvent(new CustomEvent(name, {
267
15
  bubbles: true,
@@ -270,785 +18,123 @@ export class WebUIElement extends HTMLElement {
270
18
  detail,
271
19
  }));
272
20
  }
273
- /** Populate @observable properties from server or router state.
274
- *
275
- * Each property is set through its reactive setter, which coalesces
276
- * updates into a single pending microtask. We then synchronously
277
- * flush those pending path updates so the DOM is current before any
278
- * view-transition snapshot captures it.
279
- */
280
- setState(state) {
281
- const names = getObservableNames(this.constructor);
282
- const keys = Object.keys(state);
283
- for (let i = 0; i < keys.length; i++) {
284
- const key = keys[i];
285
- if (names.has(key)) {
286
- this[key] = state[key];
287
- }
288
- }
289
- this.$flushUpdates();
290
- }
291
- /**
292
- * Apply SSR state from `window.__webui.state`.
293
- *
294
- * The handler emits all SSR metadata in a single consolidated
295
- * `window.__webui` script block. State lives at `.state` — the same
296
- * props passed to the server render so observables match the DOM.
297
- * Only observable properties are set — unknown keys are ignored.
298
- *
299
- * Writes directly to the backing field (`_prop`) to avoid triggering
300
- * reactive updates before bindings are wired.
301
- */
302
- $applySSRState() {
303
- const state = window.__webui?.state;
304
- if (!state || typeof state !== 'object')
305
- return;
306
- const ctor = this.constructor;
307
- const names = getObservableNames(ctor);
308
- for (const key of Object.keys(state)) {
309
- if (names.has(key) && !isAttributeProperty(ctor, key)) {
310
- // Write to backing field directly — no reactive update yet
311
- this[`_${key}`] = state[key];
312
- }
313
- }
314
- }
315
- /** Reactive update — called by @observable/@attr setters. */
316
- $update(path) {
317
- if (!this.$ready || !this.$root)
318
- return;
319
- // Lazy-build path index on first update (deferred from hydration)
320
- if (!this.$pathIndex)
321
- this.$buildPathIndex();
322
- if (path && this.$pathIndex) {
323
- const entry = this.$pathIndex.get(path);
324
- if (entry) {
325
- // Batch path-specific updates via microtask coalescing.
326
- if (!this.$dirtyPaths)
327
- this.$dirtyPaths = new Set();
328
- this.$dirtyPaths.add(path);
329
- if (!this.$pendingFlush) {
330
- this.$pendingFlush = true;
331
- queueMicrotask(() => this.$flush());
332
- }
333
- return;
334
- }
335
- }
336
- // Full immediate update (initial mount, reconnect, or unknown path).
337
- this.$dirtyPaths = null;
338
- this.$updateInstance(this.$root);
339
- }
340
- /** Synchronously flush all queued path updates. Call this when you need
341
- * the DOM to reflect pending property changes immediately. */
342
- $flushUpdates() {
343
- if (this.$pendingFlush)
344
- this.$flush();
21
+ $finalize(instance, root, meta, resolver, scope) {
22
+ this.$wireEvents(instance, root, meta, resolver, scope);
23
+ if (meta.re)
24
+ this.$wireRoot(instance, meta.re);
25
+ this.$wireRefs(root);
345
26
  }
346
- /** Flush all queued path updates. Handles re-entrant setter calls. */
347
- $flush() {
348
- if (!this.$ready || !this.$root || !this.$pathIndex) {
349
- this.$dirtyPaths = null;
350
- this.$pendingFlush = false;
27
+ $wireEvents(instance, root, meta, resolver, scope) {
28
+ const groups = meta.eg;
29
+ if (!groups)
351
30
  return;
31
+ const delegateTarget = this.shadowRoot ?? this;
32
+ for (let i = 0; i < groups.length; i++) {
33
+ const [eventName, bindings] = groups[i];
34
+ const bucket = this.$resolveDelegatedEvents(root, bindings, resolver, scope);
35
+ this.$addDelegatedEvent(instance, delegateTarget, eventName, bucket);
352
36
  }
353
- while (this.$dirtyPaths && this.$dirtyPaths.size > 0) {
354
- // Snapshot and clear so re-entrant setters get a fresh set.
355
- const dirty = this.$dirtyPaths;
356
- this.$dirtyPaths = null;
357
- for (const path of dirty) {
358
- const entry = this.$pathIndex.get(path);
359
- if (entry) {
360
- this.$updateBindings(entry.texts, entry.attrs, entry.conds, entry.repeats);
361
- }
362
- }
363
- // Update wildcard bindings once per flush (not per dirty path)
364
- if (this.$wildcardBindings) {
365
- const wc = this.$wildcardBindings;
366
- this.$updateBindings(wc.texts, wc.attrs, wc.conds, wc.repeats);
367
- }
368
- }
369
- this.$pendingFlush = false;
370
- }
371
- // ── DOM resolution: client-created path ───────────────────────
372
- // Compiled paths are childNode indices in meta.h parsed by the browser.
373
- // For client-created components the DOM matches meta.h exactly.
374
- $resolve(root, path, pathStart = 0) {
375
- let cur = root;
376
- // When pathStart > 0, advance through the skipped segments so `cur`
377
- // aligns with the already-positioned SSR root.
378
- for (let i = 0; i < pathStart; i++) {
379
- const child = cur.childNodes[path[i]];
380
- if (!child)
381
- return null;
382
- cur = child;
383
- }
384
- for (let i = pathStart; i < path.length; i++) {
385
- const child = cur.childNodes[path[i]];
386
- if (!child)
387
- return null;
388
- cur = child;
389
- }
390
- return cur;
391
37
  }
392
- // ── DOM resolution: SSR hydration path ────────────────────────
393
- //
394
- // Compiled template metadata stores binding targets as paths of
395
- // childNode indices into the *static* template HTML (`meta.h`).
396
- // The SSR DOM, however, contains extra nodes injected by the server
397
- // for rendered structural blocks:
398
- //
399
- // - Conditional (`<if>`) blocks: `<!--wc-->` content `<!--/wc-->`
400
- // - Repeat (`<for>`) blocks: `<!--wr-->` items `<!--/wr-->`
401
- //
402
- // These extra nodes shift element/text ordinals in the SSR DOM
403
- // relative to the template. For example, a template with:
404
- //
405
- // <div class="grid"></div> ← element ordinal 0
406
- //
407
- // becomes in SSR (when a prior <if> block renders a <p>):
408
- //
409
- // <!--wc--><p>no results</p><!--/wc--> ← extra content
410
- // <div class="grid">...</div> ← now element ordinal 1
411
- //
412
- // To resolve the correct element, `$resolveSSR` walks SSR children
413
- // in parallel with the template DOM, skipping everything between
414
- // structural marker pairs. This requires closing markers to still
415
- // be present — marker removal is deferred to the end of $hydrate().
416
- //
417
- // pathStart: skip leading path segments for in-place block hydration.
418
- $resolveSSR(ssrRoot, tplRoot, path, pathStart = 0) {
419
- let ssr = ssrRoot;
420
- let tpl = tplRoot;
421
- // When pathStart > 0, ssr has already descended to the block root
422
- // but tpl still points at the wrapper from getTemplateDom().
423
- // Advance tpl through the skipped path segments to align them.
424
- for (let i = 0; i < pathStart; i++) {
425
- const tplChild = tpl.childNodes[path[i]];
426
- if (!tplChild)
427
- return null;
428
- tpl = tplChild;
429
- }
430
- for (let i = pathStart; i < path.length; i++) {
431
- const idx = path[i];
432
- const tplChild = tpl.childNodes[idx];
433
- if (!tplChild)
434
- return null;
435
- // Look up the target's nodeType and ordinal from the template.
436
- // getTplOrdinals maps childNode index → [nodeType, ordinal],
437
- // counting elements and text nodes separately (comments ignored).
438
- const ordinals = getTplOrdinals(tpl);
439
- const entry = ordinals.get(idx);
440
- if (!entry)
441
- return null;
442
- // Walk SSR children to find the Nth element/text node, skipping
443
- // structural block content that exists in SSR but not in meta.h.
444
- // See findByOrdinal() for the full algorithm and invariants.
445
- const [nodeType, ordinal] = entry;
446
- const child = findByOrdinal(ssr, nodeType, ordinal);
447
- if (!child)
448
- return null;
449
- ssr = child;
450
- tpl = tplChild;
38
+ $resolveDelegatedEvents(root, bindings, resolver, scope) {
39
+ const entries = [];
40
+ for (let i = 0; i < bindings.length; i++) {
41
+ const [handlerName, args, target, usesEvent] = bindings[i];
42
+ const el = resolver(root, target);
43
+ if (!el || el.nodeType !== 1)
44
+ continue;
45
+ const method = this[handlerName];
46
+ if (typeof method !== 'function')
47
+ continue;
48
+ entries.push({
49
+ target: el,
50
+ method: method,
51
+ args,
52
+ usesEvent: usesEvent === 1,
53
+ scope,
54
+ });
451
55
  }
452
- return ssr;
453
- }
454
- // ── Template parsing ──────────────────────────────────────────
455
- $parseTemplate(meta) {
456
- let cached = templateCache.get(meta);
457
- if (cached)
458
- return cached.cloneNode(true);
459
- const tpl = document.createElement('template');
460
- tpl.innerHTML = meta.h;
461
- templateCache.set(meta, tpl.content);
462
- return tpl.content.cloneNode(true);
56
+ return entries;
463
57
  }
464
- $createStagingRoot(meta) {
465
- const wrapper = document.createElement('div');
466
- const fragment = this.$parseTemplate(meta);
467
- wrapper.appendChild(fragment);
468
- customElements.upgrade(wrapper);
469
- return wrapper;
470
- }
471
- $appendStagedChildren(root, stagingRoot) {
472
- const first = stagingRoot.firstChild;
473
- if (!first)
474
- return;
475
- if (!first.nextSibling) {
476
- root.appendChild(first);
477
- return;
478
- }
479
- const fragment = document.createDocumentFragment();
480
- while (stagingRoot.firstChild) {
481
- fragment.appendChild(stagingRoot.firstChild);
58
+ $wireRoot(instance, re) {
59
+ const target = this.shadowRoot ?? this;
60
+ for (let i = 0; i < re.length; i++) {
61
+ this.$addEvent(instance, target, re[i][0], re[i][1], re[i][2], undefined);
482
62
  }
483
- root.appendChild(fragment);
484
63
  }
485
- $releaseStagingRepeatContainers(instance, stagingRoot) {
486
- if (!instance || !stagingRoot)
64
+ $addDelegatedEvent(instance, target, eventName, entries) {
65
+ if (entries.length === 0)
487
66
  return;
488
- if (instance.repeats.length === 0 && instance.conds.length === 0)
489
- return;
490
- const stack = [instance];
491
- while (stack.length > 0) {
492
- const current = stack.pop();
493
- if (!current)
494
- continue;
495
- for (let i = 0; i < current.repeats.length; i++) {
496
- const repeat = current.repeats[i];
497
- if (repeat.container === stagingRoot)
498
- repeat.container = null;
499
- for (let j = 0; j < repeat.instances.length; j++) {
500
- stack.push(repeat.instances[j].instance);
501
- }
502
- }
503
- for (let i = 0; i < current.conds.length; i++) {
504
- const child = current.conds[i].instance;
505
- if (child)
506
- stack.push(child);
507
- }
508
- }
509
- }
510
- // ═══════════════════════════════════════════════════════════════
511
- // Client-created wiring — exact childNode index resolution
512
- // ═══════════════════════════════════════════════════════════════
513
- $wire(root, meta, scope) {
514
- const instance = {
515
- scope, nodes: childNodesArray(root),
516
- texts: [], attrs: [], conds: [], repeats: [],
67
+ const listener = (event) => {
68
+ this.$dispatchDelegatedEvent(entries, event);
517
69
  };
518
- // Resolve ALL slot reference nodes BEFORE inserting any anchors.
519
- // Inserting comment anchors shifts childNode indices, so we must
520
- // capture target positions from the untouched DOM first.
521
- // Pre-resolve text binding slots
522
- const textRefs = [];
523
- if (meta.tx) {
524
- for (let i = 0; i < meta.tx.length; i++) {
525
- const entry = meta.tx[i];
526
- const [slot, parts] = entry;
527
- const raw = entry[2] === 1;
528
- const [parentPath, beforeIndex] = slot;
529
- const parent = parentPath.length > 0 ? this.$resolve(root, parentPath) : root;
530
- if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11))
531
- continue;
532
- textRefs.push({ parent, ref: parent.childNodes[beforeIndex] || null, parts, raw });
533
- }
534
- }
535
- const condRefs = [];
536
- if (meta.c) {
537
- for (let i = 0; i < meta.c.length; i++) {
538
- const [condition, blockIndex, slotMeta] = meta.c[i];
539
- const [parentPath, beforeIndex] = slotMeta;
540
- const parent = parentPath.length > 0 ? this.$resolve(root, parentPath) : root;
541
- if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11))
542
- continue;
543
- condRefs.push({ parent, ref: parent.childNodes[beforeIndex] || null, condition: condition, blockIndex });
544
- }
545
- }
546
- const repRefs = [];
547
- if (meta.r) {
548
- for (let i = 0; i < meta.r.length; i++) {
549
- const [collection, itemVar, blockIndex, slotMeta] = meta.r[i];
550
- const [parentPath, beforeIndex] = slotMeta;
551
- const parent = parentPath.length > 0 ? this.$resolve(root, parentPath) : root;
552
- if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11))
553
- continue;
554
- repRefs.push({ parent, ref: parent.childNodes[beforeIndex] || null, collection, itemVar, blockIndex });
555
- }
556
- }
557
- // Attribute bindings (no DOM mutation — safe to resolve inline)
558
- this.$wireAttrs(instance, meta, scope, (p) => this.$resolve(root, p));
559
- // Events + refs — resolve BEFORE anchors shift childNode indices.
560
- // Events target element nodes (not text/comment positions), but anchor
561
- // insertions still shift childNode indices for sibling elements.
562
- this.$finalize(root, meta, (r, p) => this.$resolve(r, p), scope);
563
- // Now insert anchors using pre-resolved references
564
- // Text bindings
565
- for (const t of textRefs) {
566
- const anchor = document.createComment('');
567
- t.parent.insertBefore(anchor, t.ref);
568
- if (t.raw) {
569
- // Raw binding: create a container span for innerHTML updates
570
- const container = document.createElement('span');
571
- t.parent.insertBefore(container, anchor);
572
- const textNode = document.createTextNode('');
573
- instance.texts.push({ node: textNode, parts: t.parts, scope, raw: true, rawParent: container });
574
- }
575
- else {
576
- const textNode = document.createTextNode('');
577
- t.parent.insertBefore(textNode, anchor);
578
- instance.texts.push({ node: textNode, parts: t.parts, scope });
579
- }
580
- }
581
- // Conditional bindings
582
- for (const c of condRefs) {
583
- const anchor = document.createComment('');
584
- c.parent.insertBefore(anchor, c.ref);
585
- instance.conds.push({ condition: c.condition, blockIndex: c.blockIndex, anchor, scope, instance: null });
586
- }
587
- // Repeat bindings
588
- for (let i = 0; i < repRefs.length; i++) {
589
- const r = repRefs[i];
590
- const anchor = document.createComment('');
591
- r.parent.insertBefore(anchor, r.ref);
592
- const { attrMap, rootBindings } = this.$repeatMaps(r.blockIndex, r.itemVar);
593
- instance.repeats.push({
594
- markerId: i, collection: r.collection, itemVar: r.itemVar, blockIndex: r.blockIndex,
595
- container: r.parent, start: anchor, end: null,
596
- scope, owner: instance, instances: [], rootTag: null,
597
- attrMap, rootBindings,
598
- });
599
- }
600
- // Evaluate conditionals and repeats inline so blocks are created
601
- // immediately — no deferred $update() flush needed.
602
- for (let i = 0; i < instance.conds.length; i++)
603
- this.$toggleCond(instance.conds[i]);
604
- return instance;
70
+ target.addEventListener(eventName, listener);
71
+ this.$addCleanup(instance, () => target.removeEventListener(eventName, listener));
605
72
  }
606
- // ═══════════════════════════════════════════════════════════════
607
- // SSR hydration — marker-based in-place DOM matching
608
- // ═══════════════════════════════════════════════════════════════
609
- /**
610
- * Hydrate SSR-rendered DOM against compiled template metadata.
611
- *
612
- * When pathStart=0 (default): ssrRoot is a container with children
613
- * (top-level component hydration).
614
- *
615
- * When pathStart=1: ssrRoot is a block element itself (repeat item
616
- * in-place hydration). The leading [0] wrapper segment is skipped
617
- * so compiled paths resolve directly against the element.
618
- */
619
- $hydrate(ssrRoot, meta, tplDom, scope, pathStart = 0) {
620
- const instance = {
621
- scope,
622
- nodes: pathStart > 0 ? [ssrRoot] : childNodesArray(ssrRoot),
623
- texts: [], attrs: [], conds: [], repeats: [],
624
- };
625
- // Collect SSR markers for deferred removal. Closing markers
626
- // (<!--/wc-->, <!--/wr-->) and item markers (<!--wi-->) must stay in
627
- // the DOM throughout the entire hydration pass so that $resolveSSR
628
- // and $findSSRText can correctly skip structural block content when
629
- // counting element/text ordinals. All collected markers are removed
630
- // in a single cleanup pass after $finalize() (events + refs).
631
- //
632
- // Hydration order: text → attrs → conditionals → repeats → events
633
- // All phases use $resolveSSR, so markers must survive until the end.
634
- const staleMarkers = [];
635
- // Text bindings — find existing text nodes rendered by the server
636
- if (meta.tx) {
637
- for (let i = 0; i < meta.tx.length; i++) {
638
- const entry = meta.tx[i];
639
- const [slot, parts] = entry;
640
- const raw = entry[2] === 1;
641
- const [parentPath, beforeIndex] = slot;
642
- const ssrParent = this.$resolveSSR(ssrRoot, tplDom, parentPath, pathStart);
643
- if (!ssrParent)
644
- continue;
645
- const tplParent = this.$resolve(tplDom, parentPath, pathStart);
646
- if (!tplParent)
647
- continue;
648
- if (raw) {
649
- const rawParent = ssrParent;
650
- const textNode = document.createTextNode('');
651
- instance.texts.push({ node: textNode, parts, scope, raw: true, rawParent });
73
+ $dispatchDelegatedEvent(entries, event) {
74
+ let current = event.target;
75
+ while (current) {
76
+ for (let i = 0; i < entries.length; i++) {
77
+ const entry = entries[i];
78
+ if (entry.target === current) {
79
+ this.$callEventHandler(entry.method, entry.args, event, entry.scope, entry.target, entry.usesEvent);
652
80
  }
653
- else {
654
- let textNode = this.$findSSRText(ssrParent, tplParent, beforeIndex);
655
- if (!textNode) {
656
- textNode = document.createTextNode('');
657
- const insertRef = this.$findSSRSlotRef(ssrParent, tplParent, beforeIndex);
658
- ssrParent.insertBefore(textNode, insertRef);
659
- }
660
- if (textNode)
661
- instance.texts.push({ node: textNode, parts, scope });
662
- }
663
- }
664
- }
665
- // Attribute bindings
666
- this.$wireAttrs(instance, meta, scope, (p) => this.$resolveSSR(ssrRoot, tplDom, p, pathStart));
667
- // Conditional bindings — use <!--wc--> markers as anchors
668
- if (meta.c) {
669
- let lastCondMarker = null;
670
- let lastCondParent = null;
671
- for (let i = 0; i < meta.c.length; i++) {
672
- const [condition, blockIndex, slotMeta] = meta.c[i];
673
- const [parentPath] = slotMeta;
674
- const ssrParent = this.$resolveSSR(ssrRoot, tplDom, parentPath, pathStart) ?? ssrRoot;
675
- const blockMeta = this.$block(blockIndex);
676
- const shown = condition[0](this.$resolver, scope);
677
- let condInstance = null;
678
- // Reset cursor when parent changes between iterations
679
- if (ssrParent !== lastCondParent) {
680
- lastCondMarker = null;
681
- lastCondParent = ssrParent;
682
- }
683
- // Find the next <!--wc--> marker in ssrParent (after any previously found one)
684
- const marker = this.$findMarker(ssrParent, MARKER_COND_START, lastCondMarker);
685
- let condAnchor;
686
- if (marker) {
687
- condAnchor = marker;
688
- }
689
- else {
690
- // No marker — insert anchor at the slot position
691
- condAnchor = document.createComment('');
692
- const [, beforeIndex] = slotMeta;
693
- const insertRef = ssrParent.childNodes[beforeIndex ?? ssrParent.childNodes.length] ?? null;
694
- ssrParent.insertBefore(condAnchor, insertRef);
695
- }
696
- if (marker)
697
- lastCondMarker = marker;
698
- // SSR hydration: if the marker exists, the server rendered this
699
- // conditional — hydrate its content regardless of the current
700
- // condition value. Complex properties from parent bindings may
701
- // not have arrived yet (the parent hydrates after children), so
702
- // the condition could evaluate to false even though the server
703
- // rendered it as true. Trust the SSR DOM and wire it up; the
704
- // condition will re-evaluate correctly once all data is set.
705
- const ssrContentPresent = marker && blockMeta && this.$hasContentAfterMarker(condAnchor, MARKER_COND_END);
706
- if (blockMeta && (shown || ssrContentPresent)) {
707
- if (marker) {
708
- condInstance = this.$hydrateCondContent(condAnchor, blockMeta, scope);
709
- }
710
- }
711
- // Collect <!--/wc--> end marker for deferred removal.
712
- // Do NOT remove here — later phases (repeats, events) still need
713
- // intact marker pairs for $resolveSSR structural-block skipping.
714
- if (marker) {
715
- const lastNode = condInstance ? condInstance.nodes[condInstance.nodes.length - 1] : condAnchor;
716
- const endMarker = lastNode?.nextSibling;
717
- if (endMarker && endMarker.nodeType === 8 && endMarker.data === MARKER_COND_END) {
718
- staleMarkers.push(endMarker);
719
- }
720
- }
721
- instance.conds.push({
722
- condition: condition, blockIndex,
723
- anchor: condAnchor,
724
- scope, instance: condInstance,
725
- });
726
- }
727
- }
728
- // Repeat bindings — use <!--wr--> markers as anchors, <!--wi--> for items
729
- if (meta.r) {
730
- let lastRepMarker = null;
731
- let lastRepParent = null;
732
- for (let i = 0; i < meta.r.length; i++) {
733
- const [collection, itemVar, blockIndex, slotMeta] = meta.r[i];
734
- const [parentPath] = slotMeta;
735
- const ssrParent = this.$resolveSSR(ssrRoot, tplDom, parentPath, pathStart) ?? ssrRoot;
736
- // Reset cursor when parent changes between iterations
737
- if (ssrParent !== lastRepParent) {
738
- lastRepMarker = null;
739
- lastRepParent = ssrParent;
740
- }
741
- const blockMeta = this.$block(blockIndex);
742
- const { attrMap, rootBindings } = this.$repeatMaps(blockIndex, itemVar);
743
- const blockTplDom = blockMeta ? getTemplateDom(blockMeta) : null;
744
- const rootTag = blockMeta && blockTplDom?.childNodes.length === 1 && blockTplDom.children.length === 1
745
- ? this.$rootTag(blockMeta)
746
- : null;
747
- const keyPath = Object.values(attrMap)[0];
748
- // Find the next <!--wr--> marker in ssrParent (after any previously found one)
749
- const marker = this.$findMarker(ssrParent, MARKER_REPEAT_START, lastRepMarker);
750
- let anchor;
751
- if (marker) {
752
- anchor = marker;
753
- }
754
- else {
755
- // No marker — insert anchor at the slot position for client-created content
756
- anchor = document.createComment('');
757
- const [, beforeIndex] = slotMeta;
758
- const tplParent = this.$resolve(tplDom, parentPath, pathStart);
759
- const staticCount = tplParent ? tplParent.childNodes.length : 0;
760
- const insertRef = ssrParent.childNodes[Math.min(beforeIndex ?? staticCount, ssrParent.childNodes.length)] ?? null;
761
- ssrParent.insertBefore(anchor, insertRef);
762
- }
763
- lastRepMarker = anchor;
764
- const repeatInsts = [];
765
- const itemsArr = this.$resolveValue(collection, scope);
766
- const items = Array.isArray(itemsArr) ? itemsArr : [];
767
- // Collect SSR markers — single walk captures items + end boundary
768
- const { items: itemMarkers, end: endMarker } = marker
769
- ? collectItemMarkers(anchor)
770
- : { items: [], end: null };
771
- if (blockMeta && blockTplDom && items.length > 0 && anchor.parentNode && itemMarkers.length > 0) {
772
- if (itemMarkers.length !== items.length) {
773
- console.warn(`[webui] hydration: repeat marker count (${itemMarkers.length}) ≠ data length (${items.length}) for "${collection}"`);
774
- }
775
- const firstKey = Object.keys(attrMap)[0];
776
- const limit = Math.min(itemMarkers.length, items.length);
777
- for (let j = 0; j < limit; j++) {
778
- const itemValue = items[j];
779
- const itemScope = { name: itemVar, value: itemValue, parent: scope };
780
- if (rootTag) {
781
- const itemEl = nextElement(itemMarkers[j]);
782
- if (itemEl) {
783
- const key = firstKey !== undefined
784
- ? itemEl.getAttribute(firstKey)
785
- : String(j);
786
- const childInstance = this.$hydrate(itemEl, blockMeta, blockTplDom, itemScope, 1);
787
- repeatInsts.push({ key, value: itemValue, instance: childInstance });
788
- }
789
- }
790
- else {
791
- const itemParent = itemMarkers[j].parentNode;
792
- const nextBound = j + 1 < itemMarkers.length ? itemMarkers[j + 1] : endMarker;
793
- const wrapper = document.createElement('div');
794
- let cursor = itemMarkers[j].nextSibling;
795
- while (cursor && cursor !== nextBound) {
796
- const next = cursor.nextSibling;
797
- wrapper.appendChild(cursor);
798
- cursor = next;
799
- }
800
- const inst = this.$hydrate(wrapper, blockMeta, blockTplDom, itemScope);
801
- inst.nodes = childNodesArray(wrapper);
802
- let afterNode = itemMarkers[j];
803
- for (let nodeIndex = 0; nodeIndex < inst.nodes.length; nodeIndex++) {
804
- const node = inst.nodes[nodeIndex];
805
- itemParent?.insertBefore(node, afterNode.nextSibling);
806
- afterNode = node;
807
- }
808
- const key = keyPath ? String(dotWalk(itemValue, keyPath, 0) ?? '') : null;
809
- repeatInsts.push({ key, value: itemValue, instance: inst });
810
- }
811
- }
812
- // Defer <!--wi--> item marker removal (anchor <!--wr--> stays
813
- // as the runtime repeat anchor; <!--/wr--> collected below).
814
- for (let m = 0; m < itemMarkers.length; m++) {
815
- staleMarkers.push(itemMarkers[m]);
816
- }
817
- }
818
- // Defer <!--/wr--> end marker removal (including empty repeats).
819
- if (endMarker)
820
- staleMarkers.push(endMarker);
821
- instance.repeats.push({
822
- markerId: i, collection, itemVar, blockIndex,
823
- container: (anchor.parentNode ?? ssrRoot),
824
- start: anchor, end: null,
825
- scope, owner: instance, instances: repeatInsts, rootTag,
826
- attrMap, rootBindings, synced: true,
827
- });
828
- }
829
- }
830
- // Events + refs — this is the last phase that uses $resolveSSR.
831
- this.$finalize(ssrRoot, meta, (r, p) => this.$resolveSSR(r, tplDom, p, pathStart), scope);
832
- // All path-based resolution is complete. Remove the SSR markers that
833
- // were kept alive for structural-block skipping. Start markers
834
- // (<!--wc-->, <!--wr-->) are intentionally NOT collected — they
835
- // remain as runtime anchors for conditional/repeat toggling.
836
- for (let i = 0; i < staleMarkers.length; i++) {
837
- staleMarkers[i].parentNode?.removeChild(staleMarkers[i]);
838
- }
839
- return instance;
840
- }
841
- // ── SSR helpers ───────────────────────────────────────────────
842
- /** Collect sibling nodes between a start marker and an end marker comment. */
843
- $collectBetween(start, endData) {
844
- const nodes = [];
845
- let node = start.nextSibling;
846
- while (node) {
847
- if (node.nodeType === 8 && node.data === endData)
848
- break;
849
- nodes.push(node);
850
- node = node.nextSibling;
851
- }
852
- return nodes;
853
- }
854
- /**
855
- * Hydrate a conditional block's content — shared by top-level and
856
- * repeat-item conditional hydration paths.
857
- */
858
- $hydrateCondContent(condAnchor, blockMeta, scope) {
859
- const rootTag = this.$rootTag(blockMeta);
860
- const tplDom = getTemplateDom(blockMeta);
861
- if (rootTag && tplDom.children.length === 1) {
862
- // Single-root optimisation: hydrate the element in-place (pathStart=1).
863
- const el = nextElement(condAnchor);
864
- if (el) {
865
- // Wire bindings only — do NOT call $updateInstance. SSR text
866
- // nodes already contain correct values; evaluating bindings now
867
- // would overwrite them with stale data (e.g. a complex property
868
- // from a parent that hasn't hydrated yet). This is consistent
869
- // with $mount which also skips $updateInstance for SSR roots.
870
- return this.$hydrate(el, blockMeta, tplDom, scope, 1);
871
- }
872
- return null;
873
- }
874
- // Multi-root or text-only conditional: collect nodes between <!--wc--> and <!--/wc-->
875
- const condNodes = this.$collectBetween(condAnchor, MARKER_COND_END);
876
- if (condNodes.length === 0)
877
- return null;
878
- const wrapper = document.createElement('div');
879
- for (let cn = 0; cn < condNodes.length; cn++)
880
- wrapper.appendChild(condNodes[cn]);
881
- const inst = this.$hydrate(wrapper, blockMeta, tplDom, scope);
882
- inst.nodes = childNodesArray(wrapper);
883
- let afterNode = condAnchor;
884
- for (let cn = 0; cn < inst.nodes.length; cn++) {
885
- condAnchor.parentNode?.insertBefore(inst.nodes[cn], afterNode.nextSibling);
886
- afterNode = inst.nodes[cn];
887
- }
888
- // Same as above — trust SSR DOM, skip binding evaluation.
889
- return inst;
890
- }
891
- /**
892
- * Find the next marker comment with the given data among a parent's children.
893
- * Starts searching from `after` (exclusive) if provided, or from firstChild.
894
- */
895
- $findMarker(parent, data, after) {
896
- let child = after ? after.nextSibling : parent.firstChild;
897
- while (child) {
898
- if (child.nodeType === 8 && child.data === data) {
899
- return child;
900
- }
901
- child = child.nextSibling;
902
- }
903
- return null;
904
- }
905
- /**
906
- * Check whether there is non-marker content between a conditional
907
- * start anchor and its closing marker. Used during SSR hydration to
908
- * detect server-rendered conditional content even when the runtime
909
- * condition value has not been set yet (e.g. complex property from a
910
- * parent repeat binding that hydrates after its children).
911
- */
912
- $hasContentAfterMarker(anchor, endData) {
913
- let sibling = anchor.nextSibling;
914
- while (sibling) {
915
- if (sibling.nodeType === 8 && sibling.data === endData) {
916
- return false; // reached end marker with no content in between
917
- }
918
- return true; // any non-end-marker node = content present
919
- }
920
- return false;
921
- }
922
- /**
923
- * Find existing SSR text node by mapping template text-node ordinal.
924
- *
925
- * Similar to `$resolveSSR`, the SSR DOM may contain extra text nodes
926
- * inside structural blocks (`<if>`/`<for>`) that are not in the
927
- * compiled template. We skip `<!--wc-->...<!--/wc-->` and
928
- * `<!--wr-->...<!--/wr-->` ranges to keep text ordinals aligned.
929
- */
930
- $findSSRText(ssrParent, tplParent, beforeIndex) {
931
- // Count how many text nodes precede `beforeIndex` in the template
932
- const ordinals = getTplOrdinals(tplParent);
933
- let textOrd = 0;
934
- for (let k = 0; k < beforeIndex; k++) {
935
- const entry = ordinals.get(k);
936
- if (entry && entry[0] === 3)
937
- textOrd++;
938
- }
939
- // Find the matching text node in SSR DOM, skipping structural block
940
- // content — same algorithm as $resolveSSR (see findByOrdinal).
941
- const found = findByOrdinal(ssrParent, 3 /* TEXT_NODE */, textOrd);
942
- if (found)
943
- return found;
944
- // Fallback: any text node with content
945
- let child = ssrParent.firstChild;
946
- while (child) {
947
- if (child.nodeType === 3 && child.data && child.data.trim()) {
948
- return child;
949
81
  }
950
- child = child.nextSibling;
951
- }
952
- return null;
953
- }
954
- /** Find the SSR insertion reference for an empty text slot. */
955
- $findSSRSlotRef(ssrParent, tplParent, beforeIndex) {
956
- const ordinals = getTplOrdinals(tplParent);
957
- const children = tplParent.childNodes;
958
- for (let i = beforeIndex; i < children.length; i++) {
959
- const entry = ordinals.get(i);
960
- if (!entry)
961
- continue;
962
- return findByOrdinal(ssrParent, entry[0], entry[1]);
82
+ current = current.parentNode;
963
83
  }
964
- return null;
965
84
  }
966
- /** Extract root tag name from block metadata. */
967
- $rootTag(meta) {
968
- let cached = rootTagCache.get(meta);
969
- if (cached !== undefined)
970
- return cached;
971
- const h = meta.h;
972
- if (!h || h.charCodeAt(0) !== 60) {
973
- rootTagCache.set(meta, null);
974
- return null;
975
- }
976
- let end = 1;
977
- while (end < h.length) {
978
- const c = h.charCodeAt(end);
979
- if (c === 32 || c === 62 || c === 47)
980
- break;
981
- end++;
982
- }
983
- const tag = h.slice(1, end).toLowerCase();
984
- rootTagCache.set(meta, tag);
985
- return tag;
986
- }
987
- // ═══════════════════════════════════════════════════════════════
988
- // Shared: binding wiring, event wiring, refs
989
- // ═══════════════════════════════════════════════════════════════
990
- /** Wire attribute bindings using a resolver (shared by $wire and $hydrate). */
991
- $wireAttrs(instance, meta, scope, resolve) {
992
- if (!meta.a || !meta.ag)
85
+ $addEvent(instance, target, eventName, handlerName, args, scope) {
86
+ const method = this[handlerName];
87
+ if (typeof method !== 'function')
993
88
  return;
994
- for (let g = 0; g < meta.ag.length; g++) {
995
- const [targetPath, start, count] = meta.ag[g];
996
- const el = resolve(targetPath);
997
- if (!el || el.nodeType !== 1)
998
- continue;
999
- for (let j = 0; j < count; j++) {
1000
- const entry = meta.a[start + j];
1001
- if (entry)
1002
- instance.attrs.push(this.$makeAttr(el, entry, scope));
1003
- }
1004
- }
89
+ const listener = (event) => this.$callEventHandler(method, args, event, scope);
90
+ target.addEventListener(eventName, listener);
91
+ this.$addCleanup(instance, () => target.removeEventListener(eventName, listener));
1005
92
  }
1006
- /** Wire events + root events + refs (shared by $wire and $hydrate). */
1007
- $finalize(root, meta, resolver, scope) {
1008
- this.$wireEvents(root, meta, resolver, scope);
1009
- if (meta.re)
1010
- this.$wireRoot(meta.re);
1011
- this.$wireRefs(root);
93
+ $addCleanup(instance, cleanup) {
94
+ (instance.cleanups ??= []).push(cleanup);
1012
95
  }
1013
- /** Wire events using a resolver function (works for both client and SSR). */
1014
- $wireEvents(root, meta, resolver, scope) {
1015
- if (!meta.e)
96
+ $callEventHandler(method, args, event, scope, currentTarget, usesEvent = false) {
97
+ if (currentTarget && usesEvent) {
98
+ this.$callEventHandlerWithCurrentTarget(method, args, event, scope, currentTarget);
1016
99
  return;
1017
- for (let i = 0; i < meta.e.length; i++) {
1018
- const [eventName, handlerName, args, target] = meta.e[i];
1019
- const el = resolver(root, target);
1020
- if (!el || el.nodeType !== 1)
1021
- continue;
1022
- this.$addEvent(el, eventName, handlerName, args, scope);
1023
100
  }
1024
- }
1025
- /** Wire root-level events on the host element (or shadow root when present). */
1026
- $wireRoot(re) {
1027
- const target = this.shadowRoot ?? this;
1028
- for (let i = 0; i < re.length; i++) {
1029
- this.$addEvent(target, re[i][0], re[i][1], re[i][2], undefined);
101
+ switch (args.length) {
102
+ case 0:
103
+ method.call(this);
104
+ return;
105
+ case 1:
106
+ method.call(this, this.$resolveEventArg(args[0], event, scope));
107
+ return;
108
+ case 2:
109
+ method.call(this, this.$resolveEventArg(args[0], event, scope), this.$resolveEventArg(args[1], event, scope));
110
+ return;
111
+ case 3:
112
+ method.call(this, this.$resolveEventArg(args[0], event, scope), this.$resolveEventArg(args[1], event, scope), this.$resolveEventArg(args[2], event, scope));
113
+ return;
114
+ case 4:
115
+ method.call(this, this.$resolveEventArg(args[0], event, scope), this.$resolveEventArg(args[1], event, scope), this.$resolveEventArg(args[2], event, scope), this.$resolveEventArg(args[3], event, scope));
116
+ return;
117
+ default:
118
+ method.apply(this, this.$resolveEventArgs(args, event, scope));
1030
119
  }
1031
120
  }
1032
- /** Attach a single event listener. */
1033
- $addEvent(target, eventName, handlerName, args, scope) {
1034
- const method = this[handlerName];
1035
- if (typeof method !== 'function')
1036
- return;
1037
- if (args.length === 0) {
1038
- target.addEventListener(eventName, () => {
1039
- method.call(this);
1040
- });
1041
- return;
121
+ $callEventHandlerWithCurrentTarget(method, args, event, scope, currentTarget) {
122
+ const descriptor = Object.getOwnPropertyDescriptor(event, 'currentTarget');
123
+ Object.defineProperty(event, 'currentTarget', {
124
+ configurable: true,
125
+ value: currentTarget,
126
+ });
127
+ try {
128
+ this.$callEventHandler(method, args, event, scope);
1042
129
  }
1043
- if (args.length === 1 && args[0][0] === 'e') {
1044
- target.addEventListener(eventName, (event) => {
1045
- method.call(this, event);
1046
- });
1047
- return;
130
+ finally {
131
+ if (descriptor) {
132
+ Object.defineProperty(event, 'currentTarget', descriptor);
133
+ }
134
+ else {
135
+ delete event.currentTarget;
136
+ }
1048
137
  }
1049
- target.addEventListener(eventName, (event) => {
1050
- method.apply(this, this.$resolveEventArgs(args, event, scope));
1051
- });
1052
138
  }
1053
139
  $resolveEventArgs(args, event, scope) {
1054
140
  const resolved = [];
@@ -1067,7 +153,6 @@ export class WebUIElement extends HTMLElement {
1067
153
  case 'z': return null;
1068
154
  }
1069
155
  }
1070
- /** Find w-ref attributes and assign to component properties. */
1071
156
  $wireRefs(root) {
1072
157
  if (root.nodeType !== 1 && root.nodeType !== 11)
1073
158
  return;
@@ -1081,343 +166,4 @@ export class WebUIElement extends HTMLElement {
1081
166
  this[name] = refs[i];
1082
167
  }
1083
168
  }
1084
- /** Create an AttrBinding from compiled metadata. */
1085
- $makeAttr(el, entry, scope) {
1086
- const name = entry[0];
1087
- const kind = entry[1];
1088
- if (kind === ATTR_KIND_BOOLEAN)
1089
- return { element: el, name, kind, condition: entry[2], scope };
1090
- if (kind === ATTR_KIND_TEMPLATE)
1091
- return { element: el, name, kind, parts: entry[2], scope };
1092
- return { element: el, name, kind: kind, path: entry[2] || '', scope };
1093
- }
1094
- /** Build attrMap and rootBindings for a repeat block. */
1095
- $repeatMaps(blockIndex, itemVar) {
1096
- const attrMap = {};
1097
- const rootBindings = [];
1098
- const bm = this.$block(blockIndex);
1099
- if (bm?.a && bm.ag) {
1100
- for (let g = 0; g < bm.ag.length; g++) {
1101
- const [tp, s, c] = bm.ag[g];
1102
- // tp.length === 0 means root of the block container;
1103
- // tp = [0] means the first (and typically only) child element,
1104
- // which IS the repeated root element in blocks like <todo-item>.
1105
- const isRoot = tp.length === 0 || (tp.length === 1 && tp[0] === 0);
1106
- if (isRoot) {
1107
- for (let j = 0; j < c; j++) {
1108
- const entry = bm.a[s + j];
1109
- if (entry) {
1110
- rootBindings.push(entry);
1111
- if (entry[1] === 0 || entry[1] === 3) {
1112
- const dp = this.$singleDynamic(entry[1] === 3 ? entry[2] : [[entry[2]]]);
1113
- // Only use item-scoped paths as keys; outer-scope bindings
1114
- // (e.g. group.name inside <for each="opt in ...">) would
1115
- // resolve to the same value for every item and break keying.
1116
- if (dp && dp.path.startsWith(itemVar + '.')) {
1117
- attrMap[entry[0]] = dp.path.slice(itemVar.length + 1);
1118
- }
1119
- }
1120
- }
1121
- }
1122
- }
1123
- }
1124
- }
1125
- return { attrMap, rootBindings };
1126
- }
1127
- // ═══════════════════════════════════════════════════════════════
1128
- // Reactive update system
1129
- // ═══════════════════════════════════════════════════════════════
1130
- $buildPathIndex() {
1131
- if (!this.$root)
1132
- return;
1133
- const observableNames = getObservableNames(this.constructor);
1134
- const index = new Map();
1135
- const ensure = (key) => {
1136
- let e = index.get(key);
1137
- if (!e) {
1138
- e = { texts: [], attrs: [], conds: [], repeats: [] };
1139
- index.set(key, e);
1140
- }
1141
- return e;
1142
- };
1143
- const keyFor = (path) => {
1144
- const dot = path.indexOf('.');
1145
- const root = dot > -1 ? path.slice(0, dot) : path;
1146
- return observableNames.has(root) ? root : '*';
1147
- };
1148
- const isLocalPath = (path, scope) => {
1149
- const dot = path.indexOf('.');
1150
- const root = dot > -1 ? path.slice(0, dot) : path;
1151
- let current = scope;
1152
- while (current) {
1153
- if (current.name === root)
1154
- return true;
1155
- current = current.parent;
1156
- }
1157
- return false;
1158
- };
1159
- const visit = (instance) => {
1160
- for (const t of instance.texts) {
1161
- if (t.parts) {
1162
- for (const p of t.parts) {
1163
- if (typeof p !== 'string' && !isLocalPath(p[0], t.scope)) {
1164
- ensure(keyFor(p[0])).texts.push(t);
1165
- }
1166
- }
1167
- }
1168
- else if (t.path && !isLocalPath(t.path, t.scope)) {
1169
- ensure(keyFor(t.path)).texts.push(t);
1170
- }
1171
- }
1172
- for (const a of instance.attrs) {
1173
- if (a.path && !isLocalPath(a.path, a.scope)) {
1174
- ensure(keyFor(a.path)).attrs.push(a);
1175
- }
1176
- if (a.parts) {
1177
- for (const p of a.parts) {
1178
- if (typeof p !== 'string' && !isLocalPath(p[0], a.scope)) {
1179
- ensure(keyFor(p[0])).attrs.push(a);
1180
- }
1181
- }
1182
- }
1183
- if (a.condition) {
1184
- for (const p of a.condition[1]) {
1185
- if (!isLocalPath(p, a.scope))
1186
- ensure(keyFor(p)).attrs.push(a);
1187
- }
1188
- }
1189
- }
1190
- for (const c of instance.conds) {
1191
- for (const p of c.condition[1]) {
1192
- if (!isLocalPath(p, c.scope))
1193
- ensure(keyFor(p)).conds.push(c);
1194
- }
1195
- if (c.instance)
1196
- visit(c.instance);
1197
- }
1198
- for (const rep of instance.repeats) {
1199
- if (!isLocalPath(rep.collection, rep.scope)) {
1200
- ensure(keyFor(rep.collection)).repeats.push(rep);
1201
- }
1202
- for (let i = 0; i < rep.instances.length; i++) {
1203
- visit(rep.instances[i].instance);
1204
- }
1205
- }
1206
- };
1207
- visit(this.$root);
1208
- // Store wildcard bindings separately — avoids duplicating them into every path
1209
- const wc = index.get('*');
1210
- if (wc) {
1211
- index.delete('*');
1212
- this.$wildcardBindings = wc;
1213
- }
1214
- else {
1215
- this.$wildcardBindings = null;
1216
- }
1217
- this.$pathIndex = index;
1218
- }
1219
- $updateBindings(texts, attrs, conds, repeats) {
1220
- for (let i = 0; i < texts.length; i++)
1221
- this.$patchText(texts[i]);
1222
- for (let i = 0; i < attrs.length; i++)
1223
- this.$patchAttr(attrs[i]);
1224
- for (let i = 0; i < conds.length; i++)
1225
- this.$toggleCond(conds[i]);
1226
- for (let i = 0; i < repeats.length; i++) {
1227
- syncRepeat(this, repeats[i]);
1228
- }
1229
- }
1230
- $updateInstance(instance) {
1231
- this.$updateBindings(instance.texts, instance.attrs, instance.conds, instance.repeats);
1232
- }
1233
- $patchText(b) {
1234
- let val;
1235
- if (b.parts) {
1236
- val = this.$resolveParts(b.parts, b.scope);
1237
- }
1238
- else if (b.path) {
1239
- const raw = this.$resolveValue(b.path, b.scope);
1240
- val = raw == null ? '' : String(raw);
1241
- }
1242
- else {
1243
- return;
1244
- }
1245
- if (b.raw && b.rawParent) {
1246
- // Raw binding: render unescaped HTML via innerHTML
1247
- if (b.rawParent.innerHTML !== val)
1248
- b.rawParent.innerHTML = val;
1249
- }
1250
- else {
1251
- if (b.node.data !== val)
1252
- b.node.data = val;
1253
- }
1254
- }
1255
- $patchAttr(b) {
1256
- const el = b.element;
1257
- switch (b.kind) {
1258
- case ATTR_KIND_COMPLEX: {
1259
- const v = this.$resolveValue(b.path, b.scope);
1260
- el[b.name] = v;
1261
- // If the target is a WebUIElement, flush its pending updates
1262
- // synchronously so child <for> loops re-render immediately.
1263
- // Without this, the child's microtask-coalesced update runs
1264
- // too late for view transitions that snapshot the DOM.
1265
- const flush = el['$flushUpdates'];
1266
- if (typeof flush === 'function')
1267
- flush.call(el);
1268
- break;
1269
- }
1270
- case ATTR_KIND_BOOLEAN: {
1271
- const show = b.condition[0](this.$resolver, b.scope);
1272
- if (show)
1273
- el.setAttribute(b.name, '');
1274
- else
1275
- el.removeAttribute(b.name);
1276
- // Form control properties must be set via DOM property, not attribute
1277
- if (b.name === 'checked' || b.name === 'selected' || b.name === 'disabled') {
1278
- el[b.name] = show;
1279
- }
1280
- break;
1281
- }
1282
- case ATTR_KIND_TEMPLATE: {
1283
- const v = this.$resolveParts(b.parts, b.scope);
1284
- if (el.getAttribute(b.name) !== v)
1285
- el.setAttribute(b.name, v);
1286
- break;
1287
- }
1288
- default: {
1289
- const v = this.$resolveValue(b.path, b.scope);
1290
- const s = v == null ? '' : String(v);
1291
- // Form control properties diverge from attributes after user interaction
1292
- if (b.name === 'checked' || b.name === 'selected') {
1293
- el[b.name] = !!v && v !== 'false' && v !== '0';
1294
- }
1295
- else if (b.name === 'value') {
1296
- if (el.value !== s)
1297
- el.value = s;
1298
- }
1299
- else {
1300
- if (el.getAttribute(b.name) !== s)
1301
- el.setAttribute(b.name, s);
1302
- }
1303
- break;
1304
- }
1305
- }
1306
- }
1307
- $toggleCond(c) {
1308
- const show = c.condition[0](this.$resolver, c.scope);
1309
- if (show) {
1310
- if (!c.instance) {
1311
- c.instance = this.$createBlockInstance(c.blockIndex, c.scope);
1312
- if (c.instance) {
1313
- const frag = document.createDocumentFragment();
1314
- for (const n of c.instance.nodes)
1315
- frag.appendChild(n);
1316
- c.anchor.parentNode?.insertBefore(frag, c.anchor.nextSibling);
1317
- }
1318
- }
1319
- else {
1320
- this.$updateInstance(c.instance);
1321
- }
1322
- }
1323
- else if (c.instance) {
1324
- this.$removeInstance(c.instance);
1325
- c.instance = null;
1326
- }
1327
- }
1328
- // ── Value resolution ──────────────────────────────────────────
1329
- $resolveValue(path, scope) {
1330
- // Check scope frames first (repeat item variables)
1331
- let frame = scope;
1332
- while (frame) {
1333
- if (path === frame.name)
1334
- return frame.value;
1335
- if (path.length > frame.name.length && path.charCodeAt(frame.name.length) === 46 && path.startsWith(frame.name)) {
1336
- return dotWalk(frame.value, path, frame.name.length + 1);
1337
- }
1338
- frame = frame.parent;
1339
- }
1340
- // Resolve against component — fast path for single-segment (no dot)
1341
- const dot = path.indexOf('.');
1342
- if (dot === -1)
1343
- return this[path];
1344
- return dotWalk(this[path.substring(0, dot)], path, dot + 1);
1345
- }
1346
- $resolveParts(parts, scope) {
1347
- let result = '';
1348
- for (let i = 0; i < parts.length; i++) {
1349
- const p = parts[i];
1350
- if (typeof p === 'string') {
1351
- result += p;
1352
- continue;
1353
- }
1354
- const v = this.$resolveValue(p[0], scope);
1355
- result += v == null ? '' : String(v);
1356
- }
1357
- return result;
1358
- }
1359
- // ── Block instance management ─────────────────────────────────
1360
- $block(blockIndex) {
1361
- return this.$meta?.b?.[blockIndex];
1362
- }
1363
- $createBlockInstance(blockIndex, scope) {
1364
- const bm = this.$block(blockIndex);
1365
- if (!bm)
1366
- return null;
1367
- const wrapper = this.$createStagingRoot(bm);
1368
- const inst = this.$wire(wrapper, bm, scope);
1369
- inst.nodes = childNodesArray(wrapper);
1370
- this.$updateInstance(inst);
1371
- if (inst.repeats.length !== 0 || inst.conds.length !== 0) {
1372
- inst.nodes = childNodesArray(wrapper);
1373
- this.$releaseStagingRepeatContainers(inst, wrapper);
1374
- }
1375
- return inst;
1376
- }
1377
- $removeInstance(instance) {
1378
- for (const n of instance.nodes)
1379
- n.parentNode?.removeChild(n);
1380
- for (const c of instance.conds) {
1381
- if (c.instance)
1382
- this.$removeInstance(c.instance);
1383
- }
1384
- for (const r of instance.repeats) {
1385
- for (const item of r.instances)
1386
- this.$removeInstance(item.instance);
1387
- }
1388
- }
1389
- $insertInstanceAfter(cursor, container, instance) {
1390
- const nodes = instance.nodes;
1391
- if (nodes.length === 0)
1392
- return cursor;
1393
- const ref = cursor ? cursor.nextSibling : container.firstChild;
1394
- if (nodes[0] === ref)
1395
- return nodes[nodes.length - 1];
1396
- const frag = document.createDocumentFragment();
1397
- for (let i = 0; i < nodes.length; i++)
1398
- frag.appendChild(nodes[i]);
1399
- container.insertBefore(frag, ref);
1400
- return nodes[nodes.length - 1];
1401
- }
1402
- /** Extract the single dynamic path from a compiled attr parts array. */
1403
- $singleDynamic(parts) {
1404
- let path = '';
1405
- let prefix = '';
1406
- let suffix = '';
1407
- let seen = false;
1408
- for (const p of parts) {
1409
- if (typeof p === 'string') {
1410
- if (seen)
1411
- suffix += p;
1412
- else
1413
- prefix += p;
1414
- continue;
1415
- }
1416
- if (seen)
1417
- return null;
1418
- path = p[0];
1419
- seen = true;
1420
- }
1421
- return seen ? { path, prefix, suffix } : null;
1422
- }
1423
169
  }