@microsoft/webui-framework 0.0.13 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,16 @@ cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui
95
95
 
96
96
  The compiler/plugin generates the template metadata consumed by the runtime. In normal app code, you should not need to hand-author `window.__webui.templates`.
97
97
 
98
+ ### Property binding lifecycle
99
+
100
+ Property bindings use the `:` prefix to pass values directly to child DOM properties:
101
+
102
+ ```html
103
+ <profile-card :config="{{settings}}"></profile-card>
104
+ ```
105
+
106
+ For client-created component trees, the runtime upgrades the cloned child elements while they are still detached, wires bindings, and applies the first binding pass before appending them to the connected DOM. A child can read an initial parent-provided property in `connectedCallback`. If the parent value is not set, the child may initialize its own fallback there, and later parent updates still flow through the live binding.
107
+
98
108
  ### DOM strategy (`--dom`)
99
109
 
100
110
  The `--dom` flag controls how the server renders component content:
@@ -183,7 +193,7 @@ The WebUI plugin compiles these template features into runtime metadata:
183
193
 
184
194
  - text bindings: `{{title}}`
185
195
  - attribute bindings: `href="{{item.href}}"`
186
- - event handlers: `@click="{onClick()}"`
196
+ - event handlers: `@click="{onClick()}"`, `@click="{onSelect(item.id, e)}"`
187
197
  - refs: `w-ref="addInput"`
188
198
  - conditionals: `<if condition="...">`
189
199
  - repeats: `<for each="item in items">`
@@ -244,10 +254,10 @@ resource-constrained devices.
244
254
  and cached as a `DocumentFragment`. Every subsequent instance uses
245
255
  `cloneNode(true)` — DOM cloning is significantly faster than HTML parsing.
246
256
 
247
- 4. **Delegate events, don't multiply listeners.**
248
- Event bindings use delegation: one listener per event type on the
249
- component root, with handler names resolved from compiled paths. 200
250
- items × 5 events = 1 delegated listener, not 1000 closures.
257
+ 4. **Resolve event targets once.**
258
+ Event bindings store their target path in compiled metadata. Hydration
259
+ resolves each target once, installs the listener directly, and captures the
260
+ active repeat scope so handler arguments like `item.id` are read at dispatch.
251
261
 
252
262
  5. **Single-pass hydration via path mapping.**
253
263
  SSR DOM is matched to compiled template bindings through
@@ -285,7 +295,8 @@ When contributing to the runtime, avoid these patterns:
285
295
  pre-resolved at hydration time via compiled path mapping.
286
296
  - **Don't use recursion in hot paths.** Condition evaluation and DOM walks
287
297
  use iterative stacks.
288
- - **Don't create closures per binding.** Use delegation or shared handlers.
298
+ - **Don't allocate on the update path for events.** Event listeners are created
299
+ once during hydration and should not trigger extra DOM lookup work later.
289
300
  - **Don't re-parse template HTML.** Always clone from the cached fragment.
290
301
 
291
302
  ---
@@ -447,12 +458,11 @@ interface TemplateMeta {
447
458
  cl?: SlotPath[]; // Conditional anchor slots
448
459
  r?: [collection, itemVar, blockIdx][];// Repeat blocks
449
460
  rl?: SlotPath[]; // Repeat anchor slots
450
- e?: [event, handler, needsEvent][]; // Events
451
- el?: NodePath[]; // Event target paths
461
+ e?: [event, handler, argSpecs, targetPath][]; // Events
452
462
  b?: TemplateBlockMeta[]; // Nested block metadata
453
463
  sa?: string; // Adopted stylesheet specifier
454
464
  sd?: boolean; // Shadow DOM flag for client-created
455
- re?: [event, handler, needsEvent][]; // Root-level events
465
+ re?: [event, handler, argSpecs][]; // Root-level events
456
466
  }
457
467
  ```
458
468
 
@@ -461,7 +471,7 @@ interface TemplateMeta {
461
471
  Template:
462
472
  ```html
463
473
  <h1>{{title}}</h1>
464
- <button @click="increment">Count: {{count}}</button>
474
+ <button @click="{increment()}">Count: {{count}}</button>
465
475
  ```
466
476
 
467
477
  Compiled metadata:
@@ -472,8 +482,7 @@ Compiled metadata:
472
482
  [[[0], 0], [["title"]]], // slot in <h1>, dynamic "title"
473
483
  [[[1], 1], ["Count: ", ["count"]]] // slot in <button>, static + dynamic
474
484
  ],
475
- e: [["click", "increment", 0]], // click → increment, no event arg
476
- el: [[1]] // event target is child[1] (button)
485
+ e: [["click", "increment", [], [1]]] // click -> increment, no event args
477
486
  }
478
487
  ```
479
488
 
@@ -624,7 +633,7 @@ The framework supports three CSS delivery strategies:
624
633
  |----------|-------------|
625
634
  | **Link** | `<link>` tag baked into `meta.h` — loaded by the browser naturally |
626
635
  | **Inline** | `<style>` tag baked into `meta.h` — no external request |
627
- | **Module** | `<style type="module" specifier="tag-name">` in the HTML payload, parsed into a `CSSStyleSheet` and applied via `adoptedStyleSheets` for shadow DOM isolation |
636
+ | **Module** | `<script type="importmap">{"imports":{"tag-name":"data:text/css,..."}}</script>` in the HTML payload registers the CSS as a module under `tag-name`. The framework imports it via `import(tag, { with: { type: 'css' } })` and applies the resulting `CSSStyleSheet` via `adoptedStyleSheets` for shadow DOM isolation |
628
637
 
629
638
  CSS module stylesheets are cached so each component instance adopts the same
630
639
  parsed sheet without re-parsing CSS. The `meta.sa` field specifies the
@@ -101,12 +101,11 @@ export function syncRepeat(host, rep) {
101
101
  host.$removeInstance(oldInstances[i].instance);
102
102
  }
103
103
  rep.instances = next;
104
- // Reorder + update
105
104
  let cursor = rep.start;
106
105
  for (let i = 0; i < next.length; i += 1) {
107
106
  cursor = host.$insertInstanceAfter(cursor, container, next[i].instance);
108
107
  }
109
- for (let i = 0; i < next.length; i += 1) {
108
+ for (let i = 0; i < reuseCount; i += 1) {
110
109
  host.$updateInstance(next[i].instance);
111
110
  }
112
111
  return;
@@ -148,13 +147,16 @@ export function syncRepeat(host, rep) {
148
147
  }
149
148
  rep.instances = next;
150
149
  // ── Reorder DOM (forward pass) ──────────────────────────────────
151
- // Walk forward, skip nodes already in position.
150
+ // Newly-created instances were patched while detached. Reused instances
151
+ // update after moving so nested structural nodes stay with the item.
152
152
  let cursor = rep.start;
153
153
  for (let i = 0; i < next.length; i += 1) {
154
154
  cursor = host.$insertInstanceAfter(cursor, container, next[i].instance);
155
155
  }
156
- // ── Update bindings ─────────────────────────────────────────────
157
- for (let i = 0; i < next.length; i += 1) {
158
- host.$updateInstance(next[i].instance);
156
+ for (let i = 0; i < oldInstances.length; i += 1) {
157
+ const entry = oldInstances[i];
158
+ const k = entry.key;
159
+ if (k != null && !oldByKey.has(k))
160
+ host.$updateInstance(entry.instance);
159
161
  }
160
162
  }
@@ -10,17 +10,17 @@
10
10
  *
11
11
  * - **Style**: Inline `<style>` tags inside each shadow template.
12
12
  *
13
- * - **Module**: Uses the Declarative CSS Module Scripts proposal. During SSR,
14
- * `<style type="module" specifier="...">` definitions are emitted inline in
15
- * each rendered component's light DOM. The browser registers these globally
16
- * and automatically adopts them via `shadowrootadoptedstylesheets` on
17
- * declarative shadow roots.
13
+ * - **Module**: Uses CSS Modules registered via Import Maps. During SSR, the
14
+ * handler emits a `<script type="importmap">{"imports":{"<tag>":"data:text/css,..."}}</script>`
15
+ * in each rendered component's light DOM. The browser registers the
16
+ * stylesheet globally under `<tag>` and automatically adopts it via
17
+ * `shadowrootadoptedstylesheets` on declarative shadow roots.
18
18
  *
19
- * During SPA navigation, the router appends new `<style type="module">`
20
- * definitions to `<head>` via `templateStyles[]`. The framework uses
19
+ * During SPA navigation, the router appends new importmap script tags to
20
+ * `<head>` via `templateStyles[]`. The framework uses
21
21
  * `import(specifier, { with: { type: "css" } })` to retrieve the browser's
22
22
  * registered CSSStyleSheet and adopts it onto the shadow root. This is a
23
- * direct hash-map lookup in the browser's module registry — no DOM queries,
23
+ * direct hash-map lookup in the browser's module registry - no DOM queries,
24
24
  * no manual CSSStyleSheet construction.
25
25
  *
26
26
  * For light DOM components (no shadow root), Module mode injects a `<style>`
@@ -48,9 +48,10 @@ export function injectModuleStyle(specifier, shadowRoot) {
48
48
  if (shadowRoot.adoptedStyleSheets.length > 0)
49
49
  return;
50
50
  // SPA path: import the CSS module from the browser's registry.
51
- // The <style type="module" specifier="X"> definition was either
52
- // emitted inline during SSR or appended to <head> by the router.
53
- // The import resolves to the same CSSStyleSheet the browser registered.
51
+ // The specifier was registered via a `<script type="importmap">` tag
52
+ // (either inlined at SSR time or appended to <head> by the router during
53
+ // partial navigation). The import resolves to the same CSSStyleSheet the
54
+ // browser registered.
54
55
  import(specifier, { with: { type: 'css' } }).then((mod) => {
55
56
  shadowRoot.adoptedStyleSheets = [
56
57
  ...shadowRoot.adoptedStyleSheets,
@@ -98,6 +98,7 @@ export interface RepeatItemInstance {
98
98
  */
99
99
  export interface RepeatHost {
100
100
  $resolveValue(path: string, scope?: ScopeFrame): unknown;
101
+ /** Create, wire, and perform the first binding pass while detached. */
101
102
  $createBlockInstance(blockIndex: number, scope?: ScopeFrame): TemplateInstance | null;
102
103
  $updateInstance(instance: TemplateInstance): void;
103
104
  $removeInstance(instance: TemplateInstance): void;
package/dist/element.d.ts CHANGED
@@ -57,6 +57,9 @@ export declare class WebUIElement extends HTMLElement {
57
57
  private $resolve;
58
58
  private $resolveSSR;
59
59
  private $parseTemplate;
60
+ private $createStagingRoot;
61
+ private $appendStagedChildren;
62
+ private $releaseStagingRepeatContainers;
60
63
  private $wire;
61
64
  /**
62
65
  * Hydrate SSR-rendered DOM against compiled template metadata.
@@ -98,6 +101,8 @@ export declare class WebUIElement extends HTMLElement {
98
101
  * `<!--wr-->...<!--/wr-->` ranges to keep text ordinals aligned.
99
102
  */
100
103
  private $findSSRText;
104
+ /** Find the SSR insertion reference for an empty text slot. */
105
+ private $findSSRSlotRef;
101
106
  /** Extract root tag name from block metadata. */
102
107
  private $rootTag;
103
108
  /** Wire attribute bindings using a resolver (shared by $wire and $hydrate). */
@@ -110,6 +115,8 @@ export declare class WebUIElement extends HTMLElement {
110
115
  private $wireRoot;
111
116
  /** Attach a single event listener. */
112
117
  private $addEvent;
118
+ private $resolveEventArgs;
119
+ private $resolveEventArg;
113
120
  /** Find w-ref attributes and assign to component properties. */
114
121
  private $wireRefs;
115
122
  /** Create an AttrBinding from compiled metadata. */
package/dist/element.js CHANGED
@@ -157,6 +157,7 @@ export class WebUIElement extends HTMLElement {
157
157
  const wantShadow = hasShadow || !!meta.sd;
158
158
  let root;
159
159
  let isSSR;
160
+ let clientRoot = null;
160
161
  if (hasShadow) {
161
162
  // Shadow DOM SSR — declarative shadow root already has content
162
163
  root = this.shadowRoot;
@@ -175,14 +176,10 @@ export class WebUIElement extends HTMLElement {
175
176
  // Existing children are slot content — they stay in light DOM
176
177
  // and project through the template's <slot>.
177
178
  root = this.attachShadow({ mode: 'open' });
178
- const fragment = this.$parseTemplate(meta);
179
- root.appendChild(fragment);
180
179
  isSSR = false;
181
180
  }
182
181
  else {
183
182
  // Light DOM client-created — populate from template (no shadow = no link issue)
184
- const fragment = this.$parseTemplate(meta);
185
- this.appendChild(fragment);
186
183
  root = this;
187
184
  isSSR = false;
188
185
  }
@@ -196,7 +193,8 @@ export class WebUIElement extends HTMLElement {
196
193
  this.$root = this.$hydrate(root, meta, getTemplateDom(meta));
197
194
  }
198
195
  else {
199
- this.$root = this.$wire(root, meta);
196
+ clientRoot = this.$createStagingRoot(meta);
197
+ this.$root = this.$wire(clientRoot, meta);
200
198
  }
201
199
  this.$meta = meta;
202
200
  this.$hydrated = true;
@@ -206,8 +204,13 @@ export class WebUIElement extends HTMLElement {
206
204
  // into the freshly-wired template DOM. Call $updateInstance directly
207
205
  // to avoid the $update() path-index build — it will be lazy-built
208
206
  // on the first reactive change instead.
209
- if (!isSSR) {
207
+ if (!isSSR && clientRoot) {
210
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);
211
214
  }
212
215
  hydrationEnd();
213
216
  }
@@ -458,6 +461,52 @@ export class WebUIElement extends HTMLElement {
458
461
  templateCache.set(meta, tpl.content);
459
462
  return tpl.content.cloneNode(true);
460
463
  }
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);
482
+ }
483
+ root.appendChild(fragment);
484
+ }
485
+ $releaseStagingRepeatContainers(instance, stagingRoot) {
486
+ if (!instance || !stagingRoot)
487
+ 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
+ }
461
510
  // ═══════════════════════════════════════════════════════════════
462
511
  // Client-created wiring — exact childNode index resolution
463
512
  // ═══════════════════════════════════════════════════════════════
@@ -510,7 +559,7 @@ export class WebUIElement extends HTMLElement {
510
559
  // Events + refs — resolve BEFORE anchors shift childNode indices.
511
560
  // Events target element nodes (not text/comment positions), but anchor
512
561
  // insertions still shift childNode indices for sibling elements.
513
- this.$finalize(root, meta, (r, p) => this.$resolve(r, p));
562
+ this.$finalize(root, meta, (r, p) => this.$resolve(r, p), scope);
514
563
  // Now insert anchors using pre-resolved references
515
564
  // Text bindings
516
565
  for (const t of textRefs) {
@@ -602,7 +651,12 @@ export class WebUIElement extends HTMLElement {
602
651
  instance.texts.push({ node: textNode, parts, scope, raw: true, rawParent });
603
652
  }
604
653
  else {
605
- const textNode = this.$findSSRText(ssrParent, tplParent, beforeIndex);
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
+ }
606
660
  if (textNode)
607
661
  instance.texts.push({ node: textNode, parts, scope });
608
662
  }
@@ -686,7 +740,11 @@ export class WebUIElement extends HTMLElement {
686
740
  }
687
741
  const blockMeta = this.$block(blockIndex);
688
742
  const { attrMap, rootBindings } = this.$repeatMaps(blockIndex, itemVar);
689
- const rootTag = blockMeta ? this.$rootTag(blockMeta) : null;
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];
690
748
  // Find the next <!--wr--> marker in ssrParent (after any previously found one)
691
749
  const marker = this.$findMarker(ssrParent, MARKER_REPEAT_START, lastRepMarker);
692
750
  let anchor;
@@ -710,12 +768,11 @@ export class WebUIElement extends HTMLElement {
710
768
  const { items: itemMarkers, end: endMarker } = marker
711
769
  ? collectItemMarkers(anchor)
712
770
  : { items: [], end: null };
713
- if (blockMeta && items.length > 0 && anchor.parentNode && itemMarkers.length > 0) {
771
+ if (blockMeta && blockTplDom && items.length > 0 && anchor.parentNode && itemMarkers.length > 0) {
714
772
  if (itemMarkers.length !== items.length) {
715
773
  console.warn(`[webui] hydration: repeat marker count (${itemMarkers.length}) ≠ data length (${items.length}) for "${collection}"`);
716
774
  }
717
775
  const firstKey = Object.keys(attrMap)[0];
718
- const blockTplDom = getTemplateDom(blockMeta);
719
776
  const limit = Math.min(itemMarkers.length, items.length);
720
777
  for (let j = 0; j < limit; j++) {
721
778
  const itemValue = items[j];
@@ -731,62 +788,25 @@ export class WebUIElement extends HTMLElement {
731
788
  }
732
789
  }
733
790
  else {
734
- // Text-only repeat item — wire nested conditionals from markers
735
- const inst = {
736
- scope: itemScope, nodes: [],
737
- texts: EMPTY_ARR,
738
- attrs: EMPTY_ARR,
739
- conds: [],
740
- repeats: EMPTY_ARR,
741
- };
742
- if (blockMeta.c) {
743
- // Walk between this <!--wi--> and the next boundary to find <!--wc--> markers
744
- let cursor = itemMarkers[j].nextSibling;
745
- const nextBound = j + 1 < itemMarkers.length ? itemMarkers[j + 1] : endMarker;
746
- const itemParent = itemMarkers[j].parentNode;
747
- for (let ci = 0; ci < blockMeta.c.length; ci++) {
748
- const [condCond, condBlockIndex] = blockMeta.c[ci];
749
- // Find <!--wc--> within this item's range
750
- let condAnchor = null;
751
- while (cursor && cursor !== nextBound) {
752
- if (cursor.nodeType === 8 && cursor.data === MARKER_COND_START) {
753
- condAnchor = cursor;
754
- cursor = cursor.nextSibling;
755
- break;
756
- }
757
- cursor = cursor.nextSibling;
758
- }
759
- if (!condAnchor) {
760
- condAnchor = document.createComment('');
761
- if (itemParent)
762
- itemParent.insertBefore(condAnchor, cursor ?? null);
763
- }
764
- const condMet = condCond[0](this.$resolver, itemScope);
765
- let condInstance = null;
766
- if (condMet) {
767
- const condBlockMeta = this.$block(condBlockIndex);
768
- if (condBlockMeta) {
769
- condInstance = this.$hydrateCondContent(condAnchor, condBlockMeta, itemScope);
770
- }
771
- }
772
- // Remove <!--/wc--> end marker and advance cursor past it
773
- const lastNode = condInstance ? condInstance.nodes[condInstance.nodes.length - 1] : condAnchor;
774
- const endM = lastNode?.nextSibling;
775
- if (endM && endM.nodeType === 8 && endM.data === MARKER_COND_END) {
776
- cursor = endM.nextSibling;
777
- endM.parentNode?.removeChild(endM);
778
- }
779
- else {
780
- cursor = lastNode?.nextSibling ?? null;
781
- }
782
- inst.conds.push({
783
- condition: condCond, blockIndex: condBlockIndex,
784
- anchor: condAnchor, scope: itemScope,
785
- instance: condInstance,
786
- });
787
- }
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;
788
799
  }
789
- repeatInsts.push({ key: String(j), value: itemValue, instance: inst });
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 });
790
810
  }
791
811
  }
792
812
  // Defer <!--wi--> item marker removal (anchor <!--wr--> stays
@@ -808,7 +828,7 @@ export class WebUIElement extends HTMLElement {
808
828
  }
809
829
  }
810
830
  // Events + refs — this is the last phase that uses $resolveSSR.
811
- this.$finalize(ssrRoot, meta, (r, p) => this.$resolveSSR(r, tplDom, p, pathStart));
831
+ this.$finalize(ssrRoot, meta, (r, p) => this.$resolveSSR(r, tplDom, p, pathStart), scope);
812
832
  // All path-based resolution is complete. Remove the SSR markers that
813
833
  // were kept alive for structural-block skipping. Start markers
814
834
  // (<!--wc-->, <!--wr-->) are intentionally NOT collected — they
@@ -931,6 +951,18 @@ export class WebUIElement extends HTMLElement {
931
951
  }
932
952
  return null;
933
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]);
963
+ }
964
+ return null;
965
+ }
934
966
  /** Extract root tag name from block metadata. */
935
967
  $rootTag(meta) {
936
968
  let cached = rootTagCache.get(meta);
@@ -972,37 +1004,68 @@ export class WebUIElement extends HTMLElement {
972
1004
  }
973
1005
  }
974
1006
  /** Wire events + root events + refs (shared by $wire and $hydrate). */
975
- $finalize(root, meta, resolver) {
976
- this.$wireEvents(root, meta, resolver);
1007
+ $finalize(root, meta, resolver, scope) {
1008
+ this.$wireEvents(root, meta, resolver, scope);
977
1009
  if (meta.re)
978
1010
  this.$wireRoot(meta.re);
979
1011
  this.$wireRefs(root);
980
1012
  }
981
1013
  /** Wire events using a resolver function (works for both client and SSR). */
982
- $wireEvents(root, meta, resolver) {
1014
+ $wireEvents(root, meta, resolver, scope) {
983
1015
  if (!meta.e)
984
1016
  return;
985
1017
  for (let i = 0; i < meta.e.length; i++) {
986
- const [eventName, handlerName, needsEvent, target] = meta.e[i];
1018
+ const [eventName, handlerName, args, target] = meta.e[i];
987
1019
  const el = resolver(root, target);
988
1020
  if (!el || el.nodeType !== 1)
989
1021
  continue;
990
- this.$addEvent(el, eventName, handlerName, needsEvent);
1022
+ this.$addEvent(el, eventName, handlerName, args, scope);
991
1023
  }
992
1024
  }
993
1025
  /** Wire root-level events on the host element (or shadow root when present). */
994
1026
  $wireRoot(re) {
995
1027
  const target = this.shadowRoot ?? this;
996
1028
  for (let i = 0; i < re.length; i++) {
997
- this.$addEvent(target, re[i][0], re[i][1], re[i][2]);
1029
+ this.$addEvent(target, re[i][0], re[i][1], re[i][2], undefined);
998
1030
  }
999
1031
  }
1000
1032
  /** Attach a single event listener. */
1001
- $addEvent(target, eventName, handlerName, _needsEvent) {
1033
+ $addEvent(target, eventName, handlerName, args, scope) {
1002
1034
  const method = this[handlerName];
1003
1035
  if (typeof method !== 'function')
1004
1036
  return;
1005
- target.addEventListener(eventName, method.bind(this));
1037
+ if (args.length === 0) {
1038
+ target.addEventListener(eventName, () => {
1039
+ method.call(this);
1040
+ });
1041
+ return;
1042
+ }
1043
+ if (args.length === 1 && args[0][0] === 'e') {
1044
+ target.addEventListener(eventName, (event) => {
1045
+ method.call(this, event);
1046
+ });
1047
+ return;
1048
+ }
1049
+ target.addEventListener(eventName, (event) => {
1050
+ method.apply(this, this.$resolveEventArgs(args, event, scope));
1051
+ });
1052
+ }
1053
+ $resolveEventArgs(args, event, scope) {
1054
+ const resolved = [];
1055
+ for (let i = 0; i < args.length; i++) {
1056
+ resolved.push(this.$resolveEventArg(args[i], event, scope));
1057
+ }
1058
+ return resolved;
1059
+ }
1060
+ $resolveEventArg(arg, event, scope) {
1061
+ switch (arg[0]) {
1062
+ case 'e': return event;
1063
+ case 'p': return this.$resolveValue(arg[1], scope);
1064
+ case 's': return arg[1];
1065
+ case 'n': return arg[1];
1066
+ case 'b': return !!arg[1];
1067
+ case 'z': return null;
1068
+ }
1006
1069
  }
1007
1070
  /** Find w-ref attributes and assign to component properties. */
1008
1071
  $wireRefs(root) {
@@ -1253,8 +1316,9 @@ export class WebUIElement extends HTMLElement {
1253
1316
  c.anchor.parentNode?.insertBefore(frag, c.anchor.nextSibling);
1254
1317
  }
1255
1318
  }
1256
- if (c.instance)
1319
+ else {
1257
1320
  this.$updateInstance(c.instance);
1321
+ }
1258
1322
  }
1259
1323
  else if (c.instance) {
1260
1324
  this.$removeInstance(c.instance);
@@ -1300,11 +1364,14 @@ export class WebUIElement extends HTMLElement {
1300
1364
  const bm = this.$block(blockIndex);
1301
1365
  if (!bm)
1302
1366
  return null;
1303
- const frag = this.$parseTemplate(bm);
1304
- const wrapper = document.createElement('div');
1305
- wrapper.appendChild(frag);
1367
+ const wrapper = this.$createStagingRoot(bm);
1306
1368
  const inst = this.$wire(wrapper, bm, scope);
1307
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
+ }
1308
1375
  return inst;
1309
1376
  }
1310
1377
  $removeInstance(instance) {
@@ -30,7 +30,9 @@ export type CompiledCondition = [
30
30
  export type CompiledConditionalMeta = [condition: CompiledCondition, blockIndex: number, slot: TemplateSlotPath];
31
31
  export type CompiledAttrMeta = [name: string, kind: 0, value: string] | [name: string, kind: 1, value: string] | [name: string, kind: 2, condition: CompiledCondition] | [name: string, kind: 3, parts: CompiledAttrPart[]];
32
32
  export type CompiledRepeatMeta = [collection: string, itemVar: string, blockIndex: number, slot: TemplateSlotPath];
33
- export type CompiledEventMeta = [name: string, handler: string, needsEvent: number, target: TemplateNodePath];
33
+ export type CompiledEventArg = ['e'] | ['p', string] | ['s', string] | ['n', number] | ['b', number] | ['z'];
34
+ export type CompiledEventArgs = CompiledEventArg[];
35
+ export type CompiledEventMeta = [name: string, handler: string, args: CompiledEventArgs, target: TemplateNodePath];
34
36
  export interface TemplateBlockMeta {
35
37
  h: string;
36
38
  tx?: CompiledTextRunMeta[];
@@ -43,7 +45,7 @@ export interface TemplateBlockMeta {
43
45
  export interface TemplateMeta extends TemplateBlockMeta {
44
46
  b?: TemplateBlockMeta[];
45
47
  sa?: string;
46
- re?: [string, string, number][];
48
+ re?: [string, string, CompiledEventArgs][];
47
49
  /** Shadow DOM flag — when true, client-created components use shadow root. */
48
50
  sd?: boolean;
49
51
  }
@@ -10,14 +10,13 @@
10
10
  * - `cl` — conditional anchor slots
11
11
  * - `r` — repeat/for blocks `[collection, itemVar, blockIndex]`
12
12
  * - `rl` — repeat anchor slots
13
- * - `e` — element events `[eventName, handlerName, needsEvent]`
14
- * - `el` — event target element paths
13
+ * - `e` — element events `[eventName, handlerName, argSpecs, targetPath]`
15
14
  * - `b` — nested compiled block metadata
16
15
  * - `sa` — adopted stylesheet specifier for CSS module strategy
17
16
  * - `sd` — shadow DOM flag for client-created components
18
17
  * - `re` — root events on the host element
19
18
  */
20
- export type { CompiledAttrGroupMeta, CompiledAttrMeta, CompiledAttrPart, CompiledCondition, CompiledConditionalMeta, CompiledTextRunMeta, TemplateBlockMeta, TemplateMeta, TemplateNodePath, TemplateSlotPath, } from './template-types.js';
19
+ export type { CompiledAttrGroupMeta, CompiledAttrMeta, CompiledAttrPart, CompiledCondition, CompiledConditionalMeta, CompiledEventArg, CompiledEventArgs, CompiledTextRunMeta, TemplateBlockMeta, TemplateMeta, TemplateNodePath, TemplateSlotPath, } from './template-types.js';
21
20
  import type { TemplateMeta } from './template-types.js';
22
21
  declare global {
23
22
  interface Window {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/webui-framework",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "description": "WebUI Framework Next — Preact-inspired lightweight Web Component runtime with SSR hydration. 15KB minified, compiled-template path mapping, no hydration markers.",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "@playwright/test": "^1.58.2",
20
20
  "@types/node": "^25.3.5",
21
21
  "typescript": "^5.9.3",
22
- "@microsoft/webui-test-support": "0.0.13"
22
+ "@microsoft/webui-test-support": "0.0.15"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc",