@microsoft/webui-framework 0.0.5 → 0.0.7

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