@microsoft/webui-framework 0.0.14 → 0.0.16

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
@@ -32,7 +32,7 @@ Outside the workspace:
32
32
  pnpm add @microsoft/webui-framework
33
33
  ```
34
34
 
35
- TypeScript must use legacy decorators:
35
+ TypeScript must enable decorator emit:
36
36
 
37
37
  ```json
38
38
  {
@@ -93,7 +93,17 @@ Build with `--dom=shadow` (default) to wrap in a declarative shadow root, or `--
93
93
  cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui
94
94
  ```
95
95
 
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`.
96
+ The compiler/plugin generates the template metadata and condition closure arrays consumed by the runtime. In normal app code, you should not need to hand-author `window.__webui.templates` or `window.__webui.templateFns`.
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.
97
107
 
98
108
  ### DOM strategy (`--dom`)
99
109
 
@@ -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
  ---
@@ -300,7 +311,7 @@ When contributing to the runtime, avoid these patterns:
300
311
  │ │ │ (Rust/Go/C#/…) │ │ │
301
312
  │ HTML template │ │ │ │ SSR HTML (light or │
302
313
  │ + expressions │────▶│ TemplateMeta (JSON) │────▶│ shadow DOM) + │
303
- │ + @if / @for │ │ + state data │ │ __webui.state JSON │
314
+ │ + @if / @for │ │ + state data │ │ webui-data JSON │
304
315
  │ │ │ │ │ │
305
316
  │ Outputs: │ │ Renders: │ │ Hydrates: │
306
317
  │ • TemplateMeta │ │ • Full HTML page │ │ • Path-based DOM │
@@ -329,7 +340,7 @@ flowchart LR
329
340
  subgraph Serve ["Server (Any Language)"]
330
341
  M --> R[Route Handler]
331
342
  S[State Data] --> R
332
- R --> HTML["Full SSR HTML<br/>(shadow or light DOM)<br/>+ TemplateMeta &lt;script&gt;<br/>+ __webui.state &lt;script&gt;"]
343
+ R --> HTML["Full SSR HTML<br/>(shadow or light DOM)<br/>+ inert #webui-data"]
333
344
  end
334
345
 
335
346
  subgraph Browser ["Browser"]
@@ -355,7 +366,7 @@ graph TD
355
366
 
356
367
  TYPES["element/types.ts<br/><i>Shared Types</i><br/>TemplateInstance, TextBinding,<br/>AttrBinding, CondBinding,<br/>RepeatBinding, ScopeFrame,<br/>RepeatHost"]
357
368
 
358
- TMPL["template.ts<br/><i>Metadata Types + Registry</i><br/>TemplateMeta, getTemplate"]
369
+ TMPL["template.ts<br/><i>Metadata Types + Registry</i><br/>TemplateMeta, getTemplate,<br/>registerTemplateData"]
359
370
 
360
371
  DEC["decorators.ts<br/><i>Reactive Properties</i><br/>@observable, @attr, @volatile"]
361
372
 
@@ -377,7 +388,7 @@ graph TD
377
388
  ### SSR Hydration Path
378
389
 
379
390
  When the server renders a component, it emits HTML content (as a declarative
380
- shadow root or as light DOM children) along with a `window.__webui.state`
391
+ shadow root or as light DOM children) along with an inert `#webui-data`
381
392
  JSON payload. The browser parses this DOM before any JavaScript runs.
382
393
  When the component's JS loads and `connectedCallback` fires, the framework
383
394
  uses compiled template paths to resolve SSR DOM nodes without any marker
@@ -390,7 +401,7 @@ sequenceDiagram
390
401
  participant CE as Custom Element
391
402
  participant FW as Framework
392
403
 
393
- Server->>Browser: HTML (shadow or light DOM)<br/>+ __webui.state JSON
404
+ Server->>Browser: HTML (shadow or light DOM)<br/>+ inert #webui-data JSON
394
405
  Browser->>Browser: Parse HTML → DOM exists
395
406
  Browser->>CE: Custom element upgrade
396
407
  CE->>CE: attributeChangedCallback (pre-existing attrs)
@@ -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,24 +482,20 @@ 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
 
480
- ### Condition AST
481
-
482
- Conditions are emitted as compact tuples:
489
+ ### Condition references
483
490
 
484
- | Tuple | Meaning | Example |
485
- |-------|---------|---------|
486
- | `[0, path]` | Identifier (truthy check) | `@if(visible)` |
487
- | `[1, left, op, right]` | Comparison predicate | `@if(count > 0)` |
488
- | `[2, inner]` | Logical NOT | `@if(!visible)` |
489
- | `[3, left, op, right]` | Compound AND/OR | `@if(a && b)` |
491
+ Conditions are emitted as `[functionIndex, paths]` references. The index points
492
+ to a component-local closure in `window.__webui.templateFns[tagName]`, while
493
+ `paths` lets the runtime build targeted reactive indexes without parsing
494
+ function source.
490
495
 
491
- The runtime evaluates these iteratively (stack-based, no recursion) to avoid
492
- call-stack depth in hot update paths.
496
+ The runtime normalizes each condition reference into `[fn, paths]` once before
497
+ hydration or client-created wiring, so hot update paths call the closure
498
+ directly.
493
499
 
494
500
  ---
495
501
 
@@ -552,15 +558,15 @@ browser sees `42` in the DOM but the JavaScript property `this.count` is still
552
558
  `0` (the class default). Without seeding, the first `$update()` would
553
559
  overwrite the SSR content with the wrong value.
554
560
 
555
- State seeding uses `window.__webui.state` — a JSON object emitted by the
556
- server handler as a `<script>` tag. Like Preact's props, this delivers the
561
+ State seeding uses `window.__webui.state` — a JSON object loaded from the
562
+ server-emitted `#webui-data` block. Like Preact's props, this delivers the
557
563
  same data used for SSR rendering to the client. During `$mount()`,
558
564
  `$applySSRState()` writes matching keys directly to observable backing fields
559
565
  before any bindings are wired:
560
566
 
561
567
  ```mermaid
562
568
  flowchart LR
563
- SCRIPT["&lt;script&gt;<br/>window.__webui.state = {<br/> count: 42,<br/> title: 'Hello'<br/>}"] --> APPLY["$applySSRState()"]
569
+ SCRIPT["&lt;script type='application/json' id='webui-data'&gt;<br/>{ state: { count: 42, title: 'Hello' } }"] --> APPLY["$applySSRState()"]
564
570
  APPLY --> SEED["Write to backing fields:<br/>this._count = 42<br/>this._title = 'Hello'"]
565
571
  SEED --> HYDRATE["$hydrate() — bindings match<br/>server-rendered DOM"]
566
572
  ```
@@ -624,7 +630,7 @@ The framework supports three CSS delivery strategies:
624
630
  |----------|-------------|
625
631
  | **Link** | `<link>` tag baked into `meta.h` — loaded by the browser naturally |
626
632
  | **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 |
633
+ | **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
634
 
629
635
  CSS module stylesheets are cached so each component instance adopts the same
630
636
  parsed sheet without re-parsing CSS. The `meta.sa` field specifies the
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * Reactive decorators for WebUIElement properties.
5
5
  *
6
- * Uses legacy/experimental TypeScript decorators (`experimentalDecorators: true`)
7
- * for compatibility with the FAST ecosystem conventions.
6
+ * Uses TypeScript's `experimentalDecorators` emit, matching the FAST ecosystem
7
+ * conventions.
8
8
  */
9
9
  // ---------------------------------------------------------------------------
10
10
  // Internal helpers
@@ -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
  // ═══════════════════════════════════════════════════════════════
@@ -491,7 +540,7 @@ export class WebUIElement extends HTMLElement {
491
540
  const parent = parentPath.length > 0 ? this.$resolve(root, parentPath) : root;
492
541
  if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11))
493
542
  continue;
494
- condRefs.push({ parent, ref: parent.childNodes[beforeIndex] || null, condition, blockIndex });
543
+ condRefs.push({ parent, ref: parent.childNodes[beforeIndex] || null, condition: condition, blockIndex });
495
544
  }
496
545
  }
497
546
  const repRefs = [];
@@ -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
  }
@@ -665,7 +719,7 @@ export class WebUIElement extends HTMLElement {
665
719
  }
666
720
  }
667
721
  instance.conds.push({
668
- condition, blockIndex,
722
+ condition: condition, blockIndex,
669
723
  anchor: condAnchor,
670
724
  scope, instance: condInstance,
671
725
  });
@@ -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) {
package/dist/index.d.ts CHANGED
@@ -19,6 +19,6 @@
19
19
  */
20
20
  export { WebUIElement } from './element.js';
21
21
  export { observable, attr } from './decorators.js';
22
- export { getTemplate } from './template.js';
22
+ export { getTemplate, registerTemplateData } from './template.js';
23
23
  export type { TemplateMeta } from './template.js';
24
24
  export { hydrationStart, hydrationEnd } from './lifecycle.js';
package/dist/index.js CHANGED
@@ -21,5 +21,5 @@
21
21
  */
22
22
  export { WebUIElement } from './element.js';
23
23
  export { observable, attr } from './decorators.js';
24
- export { getTemplate } from './template.js';
24
+ export { getTemplate, registerTemplateData } from './template.js';
25
25
  export { hydrationStart, hydrationEnd } from './lifecycle.js';
@@ -16,21 +16,23 @@ export type CompiledAttrGroupMeta = [
16
16
  count: number
17
17
  ];
18
18
  /**
19
- * Compiled condition — a pre-compiled JS function plus the paths it references.
20
- * The Rust compiler emits the function body at build time so the runtime
21
- * doesn't need a condition AST interpreter.
19
+ * Compiled condition — JSON metadata carries a function index plus the paths it
20
+ * references. The Rust compiler emits the actual function bodies in a separate
21
+ * closure array, and the runtime normalizes indexes to functions once.
22
22
  *
23
- * - `[0]` — evaluator function: `(resolve, scope) => boolean`
23
+ * - `[0]` — evaluator function or component-local function index
24
24
  * - `[1]` — referenced paths for the reactive path index
25
25
  */
26
- export type CompiledCondition = [
27
- fn: (v: (path: string, s?: unknown) => unknown, s?: unknown) => boolean,
28
- paths: string[]
29
- ];
30
- export type CompiledConditionalMeta = [condition: CompiledCondition, blockIndex: number, slot: TemplateSlotPath];
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[]];
26
+ export type CompiledConditionFn = (v: (path: string, s?: unknown) => unknown, s?: unknown) => boolean;
27
+ export type CompiledCondition = [fn: CompiledConditionFn, paths: string[]];
28
+ export type SerializedCompiledCondition = [fnIndex: number, paths: string[]];
29
+ export type TemplateCondition = CompiledCondition | SerializedCompiledCondition;
30
+ export type CompiledConditionalMeta = [condition: TemplateCondition, blockIndex: number, slot: TemplateSlotPath];
31
+ export type CompiledAttrMeta = [name: string, kind: 0, value: string] | [name: string, kind: 1, value: string] | [name: string, kind: 2, condition: TemplateCondition] | [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
  }
@@ -6,27 +6,26 @@
6
6
  * - `tx` — text runs `[slot, parts]` for text binding positions
7
7
  * - `a` — attribute binding metadata
8
8
  * - `ag` — attribute target groups `[path, startIndex, count]`
9
- * - `c` — conditional blocks `[conditionAst, blockIndex]`
10
- * - `cl` — conditional anchor slots
11
- * - `r` — repeat/for blocks `[collection, itemVar, blockIndex]`
12
- * - `rl` — repeat anchor slots
13
- * - `e` — element events `[eventName, handlerName, needsEvent]`
14
- * - `el` — event target element paths
9
+ * - `c` — conditional blocks `[conditionRef, blockIndex, slot]`
10
+ * - `r` — repeat/for blocks `[collection, itemVar, blockIndex, slot]`
11
+ * - `e` — element events `[eventName, handlerName, argSpecs, targetPath]`
15
12
  * - `b` — nested compiled block metadata
16
13
  * - `sa` — adopted stylesheet specifier for CSS module strategy
17
14
  * - `sd` — shadow DOM flag for client-created components
18
15
  * - `re` — root events on the host element
19
16
  */
20
- export type { CompiledAttrGroupMeta, CompiledAttrMeta, CompiledAttrPart, CompiledCondition, CompiledConditionalMeta, CompiledTextRunMeta, TemplateBlockMeta, TemplateMeta, TemplateNodePath, TemplateSlotPath, } from './template-types.js';
21
- import type { TemplateMeta } from './template-types.js';
17
+ export type { CompiledAttrGroupMeta, CompiledAttrMeta, CompiledAttrPart, CompiledCondition, CompiledConditionFn, CompiledConditionalMeta, CompiledEventArg, CompiledEventArgs, SerializedCompiledCondition, TemplateCondition, CompiledTextRunMeta, TemplateBlockMeta, TemplateMeta, TemplateNodePath, TemplateSlotPath, } from './template-types.js';
18
+ import type { CompiledConditionFn, TemplateMeta } from './template-types.js';
22
19
  declare global {
23
20
  interface Window {
24
- /** Consolidated SSR bootstrap object — single script block. */
21
+ /** Consolidated SSR metadata loaded from `#webui-data` or partial responses. */
25
22
  __webui?: {
26
23
  state?: Record<string, unknown>;
27
24
  templates?: Record<string, TemplateMeta>;
25
+ templateFns?: Record<string, CompiledConditionFn[]>;
28
26
  [key: string]: unknown;
29
27
  };
30
28
  }
31
29
  }
32
30
  export declare function getTemplate(name: string): TemplateMeta | undefined;
31
+ export declare function registerTemplateData(templates: Record<string, TemplateMeta>, templateFns?: Record<string, CompiledConditionFn[]>): void;
package/dist/template.js CHANGED
@@ -1,5 +1,96 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT license.
3
+ const WEBUI_DATA_ID = 'webui-data';
4
+ const normalizedTemplates = new WeakSet();
5
+ let webuiDataLoaded = false;
3
6
  export function getTemplate(name) {
4
- return window.__webui?.templates?.[name];
7
+ let meta = window.__webui?.templates?.[name];
8
+ if (!meta) {
9
+ loadWebUIDataBlock();
10
+ meta = window.__webui?.templates?.[name];
11
+ }
12
+ if (meta)
13
+ normalizeTemplate(name, meta);
14
+ return meta;
15
+ }
16
+ export function registerTemplateData(templates, templateFns) {
17
+ const w = window;
18
+ if (!w.__webui)
19
+ w.__webui = {};
20
+ if (!w.__webui.templates)
21
+ w.__webui.templates = {};
22
+ if (templateFns) {
23
+ if (!w.__webui.templateFns)
24
+ w.__webui.templateFns = {};
25
+ const fnNames = Object.keys(templateFns);
26
+ for (let i = 0; i < fnNames.length; i++) {
27
+ const tag = fnNames[i];
28
+ w.__webui.templateFns[tag] = templateFns[tag];
29
+ }
30
+ }
31
+ const names = Object.keys(templates);
32
+ for (let i = 0; i < names.length; i++) {
33
+ const tag = names[i];
34
+ const meta = templates[tag];
35
+ w.__webui.templates[tag] = meta;
36
+ normalizeTemplate(tag, meta);
37
+ }
38
+ }
39
+ function loadWebUIDataBlock() {
40
+ if (webuiDataLoaded || window.__webui?.state !== undefined || typeof document === 'undefined')
41
+ return;
42
+ const el = document.getElementById(WEBUI_DATA_ID);
43
+ if (!el) {
44
+ webuiDataLoaded = true;
45
+ return;
46
+ }
47
+ const text = el.textContent;
48
+ if (text) {
49
+ const templateFns = window.__webui?.templateFns;
50
+ const parsed = JSON.parse(text);
51
+ if (templateFns)
52
+ parsed.templateFns = templateFns;
53
+ window.__webui = parsed;
54
+ }
55
+ el.remove();
56
+ webuiDataLoaded = true;
57
+ }
58
+ function normalizeTemplate(name, meta) {
59
+ if (normalizedTemplates.has(meta))
60
+ return;
61
+ const fns = window.__webui?.templateFns?.[name] ?? [];
62
+ const stack = [meta];
63
+ while (stack.length > 0) {
64
+ const block = stack.pop();
65
+ if (!block)
66
+ continue;
67
+ if (block.a) {
68
+ for (let i = 0; i < block.a.length; i++) {
69
+ const attr = block.a[i];
70
+ if (attr[1] === 2)
71
+ normalizeCondition(name, attr[2], fns);
72
+ }
73
+ }
74
+ if (block.c) {
75
+ for (let i = 0; i < block.c.length; i++) {
76
+ normalizeCondition(name, block.c[i][0], fns);
77
+ }
78
+ }
79
+ const children = block.b;
80
+ if (children) {
81
+ for (let i = 0; i < children.length; i++)
82
+ stack.push(children[i]);
83
+ }
84
+ }
85
+ normalizedTemplates.add(meta);
86
+ }
87
+ function normalizeCondition(tagName, condition, fns) {
88
+ const first = condition[0];
89
+ if (typeof first === 'function')
90
+ return;
91
+ const fn = fns[first];
92
+ if (typeof fn !== 'function') {
93
+ throw new Error(`[WebUI] Missing condition closure ${first} for <${tagName}>.`);
94
+ }
95
+ condition[0] = fn;
5
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/webui-framework",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
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.14"
22
+ "@microsoft/webui-test-support": "0.0.16"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc",