@microsoft/webui-framework 0.0.9 β†’ 0.0.11

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
@@ -8,11 +8,11 @@ This package is the browser-side runtime used by `webui build --plugin=webui`. I
8
8
  - `@observable`, `@attr`, and `@volatile` decorators
9
9
  - compiled template path mapping for direct DOM binding resolution
10
10
  - light DOM or shadow DOM rendering (`--dom=light|shadow` flag)
11
- - SSR state seeding from `window.__webui_state` (like Preact's props)
11
+ - SSR state seeding from `window.__webui.state` (like Preact's props)
12
12
 
13
13
  If you are building WebUI apps in this repo, this is the component model used by examples like `examples/app/todo-webui`, `examples/app/commerce`, and `examples/app/contact-book-manager`.
14
14
 
15
- > πŸ“– **Full documentation at [microsoft.github.io/webui](https://microsoft.github.io/webui)** β€” see the [Interactivity Guide](https://microsoft.github.io/webui/guide/concepts/interactivity) for component authoring patterns.
15
+ > πŸ“– **Full documentation at [microsoft.github.io/webui](https://microsoft.github.io/webui)**, see the [Interactivity Guide](https://microsoft.github.io/webui/guide/concepts/interactivity) for component authoring patterns. For framework internals (hydration, path resolution, reactive update model), see [RENDERING.md](./RENDERING.md).
16
16
 
17
17
  ## Install
18
18
 
@@ -93,7 +93,7 @@ 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 consumed by the runtime. In normal app code, you should not need to hand-author `window.__webui.templates`.
97
97
 
98
98
  ### DOM strategy (`--dom`)
99
99
 
@@ -107,7 +107,7 @@ The `--dom` flag controls how the server renders component content:
107
107
  The runtime auto-detects which mode was used at hydration time:
108
108
  - If a `shadowRoot` already exists β†’ shadow DOM SSR path
109
109
  - If `childNodes` exist but no shadow root β†’ light DOM SSR path
110
- - If neither β†’ client-created path (uses `meta.sd` or `window.__webui_shadow` to decide)
110
+ - If neither β†’ client-created path (uses `meta.sd` to decide)
111
111
 
112
112
  Light DOM is useful for simpler styling (CSS inheritance works naturally) and
113
113
  better search-engine indexing. Shadow DOM provides style encapsulation.
@@ -125,7 +125,7 @@ Base class for framework components.
125
125
  | `static define(tagName)` | Register the class as a custom element |
126
126
  | `$emit(name, detail?)` | Dispatch a bubbling, composed `CustomEvent` |
127
127
  | `$update()` | Force a reactive update (normally called automatically) |
128
- | `setInitialState(state, params?)` | Populate `@observable` properties from router state |
128
+ | `setState(state)` | Populate `@observable` properties from router/server state |
129
129
  | `disconnectedCallback()` | Override for cleanup (global listeners, etc.) |
130
130
 
131
131
  In most components you do not call `$update()` directly. Property changes through `@observable` and `@attr` trigger updates for you.
@@ -300,7 +300,7 @@ When contributing to the runtime, avoid these patterns:
300
300
  β”‚ β”‚ β”‚ (Rust/Go/C#/…) β”‚ β”‚ β”‚
301
301
  β”‚ HTML template β”‚ β”‚ β”‚ β”‚ SSR HTML (light or β”‚
302
302
  β”‚ + expressions │────▢│ TemplateMeta (JSON) │────▢│ shadow DOM) + β”‚
303
- β”‚ + @if / @for β”‚ β”‚ + state data β”‚ β”‚ __webui_state JSON β”‚
303
+ β”‚ + @if / @for β”‚ β”‚ + state data β”‚ β”‚ __webui.state JSON β”‚
304
304
  β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
305
305
  β”‚ Outputs: β”‚ β”‚ Renders: β”‚ β”‚ Hydrates: β”‚
306
306
  β”‚ β€’ TemplateMeta β”‚ β”‚ β€’ Full HTML page β”‚ β”‚ β€’ Path-based DOM β”‚
@@ -329,7 +329,7 @@ flowchart LR
329
329
  subgraph Serve ["Server (Any Language)"]
330
330
  M --> R[Route Handler]
331
331
  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;"]
332
+ R --> HTML["Full SSR HTML<br/>(shadow or light DOM)<br/>+ TemplateMeta &lt;script&gt;<br/>+ __webui.state &lt;script&gt;"]
333
333
  end
334
334
 
335
335
  subgraph Browser ["Browser"]
@@ -377,7 +377,7 @@ graph TD
377
377
  ### SSR Hydration Path
378
378
 
379
379
  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`
380
+ shadow root or as light DOM children) along with a `window.__webui.state`
381
381
  JSON payload. The browser parses this DOM before any JavaScript runs.
382
382
  When the component's JS loads and `connectedCallback` fires, the framework
383
383
  uses compiled template paths to resolve SSR DOM nodes without any marker
@@ -390,13 +390,13 @@ sequenceDiagram
390
390
  participant CE as Custom Element
391
391
  participant FW as Framework
392
392
 
393
- Server->>Browser: HTML (shadow or light DOM)<br/>+ __webui_state JSON
393
+ Server->>Browser: HTML (shadow or light DOM)<br/>+ __webui.state JSON
394
394
  Browser->>Browser: Parse HTML β†’ DOM exists
395
395
  Browser->>CE: Custom element upgrade
396
396
  CE->>CE: attributeChangedCallback (pre-existing attrs)
397
397
  CE->>FW: connectedCallback() β†’ $mount()
398
398
  FW->>FW: SSR DOM detected (shadow root or children exist)
399
- FW->>FW: $applySSRState() β€” seed observables from __webui_state
399
+ FW->>FW: $applySSRState() β€” seed observables from __webui.state
400
400
  FW->>FW: $hydrate() β€” template-parallel path resolution
401
401
  FW->>FW: $resolveSSR() β€” match SSR nodes via ordinal traversal
402
402
  FW->>FW: $wireEvents() + $wireRefs()
@@ -552,7 +552,7 @@ browser sees `42` in the DOM but the JavaScript property `this.count` is still
552
552
  `0` (the class default). Without seeding, the first `$update()` would
553
553
  overwrite the SSR content with the wrong value.
554
554
 
555
- State seeding uses `window.__webui_state` β€” a JSON object emitted by the
555
+ State seeding uses `window.__webui.state` β€” a JSON object emitted by the
556
556
  server handler as a `<script>` tag. Like Preact's props, this delivers the
557
557
  same data used for SSR rendering to the client. During `$mount()`,
558
558
  `$applySSRState()` writes matching keys directly to observable backing fields
@@ -560,7 +560,7 @@ before any bindings are wired:
560
560
 
561
561
  ```mermaid
562
562
  flowchart LR
563
- SCRIPT["&lt;script&gt;<br/>window.__webui_state = {<br/> count: 42,<br/> title: 'Hello'<br/>}"] --> APPLY["$applySSRState()"]
563
+ SCRIPT["&lt;script&gt;<br/>window.__webui.state = {<br/> count: 42,<br/> title: 'Hello'<br/>}"] --> APPLY["$applySSRState()"]
564
564
  APPLY --> SEED["Write to backing fields:<br/>this._count = 42<br/>this._title = 'Hello'"]
565
565
  SEED --> HYDRATE["$hydrate() β€” bindings match<br/>server-rendered DOM"]
566
566
  ```
@@ -611,7 +611,7 @@ are removed; new items are appended.
611
611
  On initial hydration, the repeat system walks existing SSR children and
612
612
  reconstructs collection instances by matching them against the compiled
613
613
  template via `$resolveSSR` path traversal. State is already seeded from
614
- `window.__webui_state`, so repeat items reflect the server-rendered list
614
+ `window.__webui.state`, so repeat items reflect the server-rendered list
615
615
  without parsing marker comments.
616
616
 
617
617
  ---
@@ -1,4 +1,32 @@
1
- /** Convert camelCase to kebab-case for attribute reflection. */
1
+ /**
2
+ * Convert a camelCase DOM property name into its kebab-case HTML attribute form.
3
+ *
4
+ * This function is optimized for framework-level hot paths where attribute
5
+ * normalization may run thousands of times per render. It performs three
6
+ * progressively cheaper checks:
7
+ *
8
+ * 1. **Direct lookup for irregular mappings**
9
+ * Many DOM properties (e.g., `readOnly`, `tabIndex`, `crossOrigin`) do not
10
+ * follow simple camelCase β†’ kebab-case rules. These are resolved through a
11
+ * precomputed `propertyToAttribute` map for O(1) returns with no string
12
+ * processing.
13
+ *
14
+ * 2. **Fast path for ARIA attributes**
15
+ * ARIA properties always begin with `aria` followed by an uppercase letter
16
+ * (e.g., `ariaDescribedBy`). These map to `aria-` + the lowercase remainder.
17
+ * This branch avoids the general loop and uses the engine-optimized
18
+ * `.toLowerCase()` for the suffix.
19
+ *
20
+ * 3. **General camelCase β†’ kebab-case conversion**
21
+ * For all other inputs, the function performs a tight ASCII-only scan:
22
+ * uppercase A–Z (65–90) are converted to lowercase and prefixed with `-`,
23
+ * while all other characters are copied as-is. This avoids regex engines,
24
+ * callback allocations, and match objects, producing predictable,
25
+ * allocation-minimal performance ideal for DOM attribute reflection.
26
+ *
27
+ * The result is a predictable, JIT-friendly transformation suitable for
28
+ * attribute diffing, SSR serialization, and runtime DOM patching.
29
+ */
2
30
  export declare function toKebabCase(str: string): string;
3
31
  export declare function getObservableNames(ctor: Function): Set<string>;
4
32
  /**
@@ -12,44 +12,11 @@
12
12
  /**
13
13
  * Map of camelCase property names to their HTML attribute names.
14
14
  *
15
- * Covers two categories of irregular mappings:
16
- *
17
- * 1. Multi-word ARIA attributes β€” concatenated lowercase after `aria-`
18
- * (e.g., `ariaDescribedBy` β†’ `aria-describedby`), per the ARIAMixin spec.
19
- * 2. HTML global/element attributes β€” concatenated lowercase attribute names
20
- * with camelCase property counterparts (e.g., `readOnly` β†’ `readonly`).
15
+ * ARIA attributes (`ariaXxxYyy β†’ aria-` + lowercase remainder) are handled
16
+ * algorithmically in `toKebabCase`. Only HTML global/element attributes
17
+ * with irregular mappings (concatenated lowercase) need explicit entries.
21
18
  */
22
19
  const propertyToAttribute = Object.assign(Object.create(null), {
23
- // --- ARIA (ARIAMixin) ---
24
- ariaActiveDescendant: 'aria-activedescendant',
25
- ariaAutoComplete: 'aria-autocomplete',
26
- ariaBrailleLabel: 'aria-braillelabel',
27
- ariaBrailleRoleDescription: 'aria-brailleroledescription',
28
- ariaColCount: 'aria-colcount',
29
- ariaColIndex: 'aria-colindex',
30
- ariaColIndexText: 'aria-colindextext',
31
- ariaColSpan: 'aria-colspan',
32
- ariaDescribedBy: 'aria-describedby',
33
- ariaDropEffect: 'aria-dropeffect',
34
- ariaErrorMessage: 'aria-errormessage',
35
- ariaFlowTo: 'aria-flowto',
36
- ariaHasPopup: 'aria-haspopup',
37
- ariaKeyShortcuts: 'aria-keyshortcuts',
38
- ariaLabelledBy: 'aria-labelledby',
39
- ariaMultiLine: 'aria-multiline',
40
- ariaMultiSelectable: 'aria-multiselectable',
41
- ariaPosInSet: 'aria-posinset',
42
- ariaReadOnly: 'aria-readonly',
43
- ariaRoleDescription: 'aria-roledescription',
44
- ariaRowCount: 'aria-rowcount',
45
- ariaRowIndex: 'aria-rowindex',
46
- ariaRowIndexText: 'aria-rowindextext',
47
- ariaRowSpan: 'aria-rowspan',
48
- ariaSetSize: 'aria-setsize',
49
- ariaValueMax: 'aria-valuemax',
50
- ariaValueMin: 'aria-valuemin',
51
- ariaValueNow: 'aria-valuenow',
52
- ariaValueText: 'aria-valuetext',
53
20
  // --- HTML global/element attributes ---
54
21
  accessKey: 'accesskey',
55
22
  autoCapitalize: 'autocapitalize',
@@ -73,10 +40,49 @@ const propertyToAttribute = Object.assign(Object.create(null), {
73
40
  tabIndex: 'tabindex',
74
41
  useMap: 'usemap',
75
42
  });
76
- /** Convert camelCase to kebab-case for attribute reflection. */
43
+ /**
44
+ * Convert a camelCase DOM property name into its kebab-case HTML attribute form.
45
+ *
46
+ * This function is optimized for framework-level hot paths where attribute
47
+ * normalization may run thousands of times per render. It performs three
48
+ * progressively cheaper checks:
49
+ *
50
+ * 1. **Direct lookup for irregular mappings**
51
+ * Many DOM properties (e.g., `readOnly`, `tabIndex`, `crossOrigin`) do not
52
+ * follow simple camelCase β†’ kebab-case rules. These are resolved through a
53
+ * precomputed `propertyToAttribute` map for O(1) returns with no string
54
+ * processing.
55
+ *
56
+ * 2. **Fast path for ARIA attributes**
57
+ * ARIA properties always begin with `aria` followed by an uppercase letter
58
+ * (e.g., `ariaDescribedBy`). These map to `aria-` + the lowercase remainder.
59
+ * This branch avoids the general loop and uses the engine-optimized
60
+ * `.toLowerCase()` for the suffix.
61
+ *
62
+ * 3. **General camelCase β†’ kebab-case conversion**
63
+ * For all other inputs, the function performs a tight ASCII-only scan:
64
+ * uppercase A–Z (65–90) are converted to lowercase and prefixed with `-`,
65
+ * while all other characters are copied as-is. This avoids regex engines,
66
+ * callback allocations, and match objects, producing predictable,
67
+ * allocation-minimal performance ideal for DOM attribute reflection.
68
+ *
69
+ * The result is a predictable, JIT-friendly transformation suitable for
70
+ * attribute diffing, SSR serialization, and runtime DOM patching.
71
+ */
77
72
  export function toKebabCase(str) {
78
73
  const mapped = propertyToAttribute[str];
79
- return mapped ?? str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
74
+ if (mapped)
75
+ return mapped;
76
+ // ARIA properties: ariaXxxYyy β†’ aria- + lowercase remainder
77
+ if (str.length > 4 && str.charCodeAt(0) === 97 /* a */ && str.startsWith('aria') && str.charCodeAt(4) >= 65 && str.charCodeAt(4) <= 90) {
78
+ return 'aria-' + str.slice(4).toLowerCase();
79
+ }
80
+ let out = '';
81
+ for (let i = 0; i < str.length; i++) {
82
+ const code = str.charCodeAt(i);
83
+ out += code >= 65 && code <= 90 ? '-' + String.fromCharCode(code + 32) : str[i];
84
+ }
85
+ return out;
80
86
  }
81
87
  /**
82
88
  * Shared logic for installing a reactive getter/setter on a class prototype.
@@ -28,7 +28,7 @@ export function dotWalk(cursor, path, from) {
28
28
  export function resolveRepeatValue(scopeVar, scope, path) {
29
29
  if (path === scopeVar)
30
30
  return scope;
31
- if (!path.startsWith(`${scopeVar}.`))
31
+ if (path.length <= scopeVar.length || path.charCodeAt(scopeVar.length) !== 46 /* '.' */ || !path.startsWith(scopeVar))
32
32
  return undefined;
33
33
  return dotWalk(scope, path, scopeVar.length + 1);
34
34
  }
package/dist/element.d.ts CHANGED
@@ -17,23 +17,31 @@ export declare class WebUIElement extends HTMLElement {
17
17
  /** Mount the component after children are available. */
18
18
  private $mount;
19
19
  disconnectedCallback(): void;
20
+ /**
21
+ * Permanently destroy this component's own bindings and DOM references.
22
+ * Each component is responsible for its own cleanup β€” child WebUI
23
+ * elements handle theirs via their own `disconnectedCallback`.
24
+ */
25
+ $destroy(): void;
26
+ /** Break all DOM references held by a binding instance and its nested blocks. */
27
+ private $teardown;
20
28
  /** Dispatch a bubbling custom event. Uses composed:true when in shadow DOM. */
21
29
  $emit(name: string, detail?: unknown): boolean;
22
- /** Populate @observable properties from router state.
30
+ /** Populate @observable properties from server or router state.
23
31
  *
24
32
  * Each property is set through its reactive setter, which coalesces
25
33
  * updates into a single pending microtask. We then synchronously
26
34
  * flush those pending path updates so the DOM is current before any
27
35
  * view-transition snapshot captures it.
28
36
  */
29
- setInitialState(state: Record<string, unknown>): void;
37
+ setState(state: Record<string, unknown>): void;
30
38
  /**
31
- * Apply SSR state from the global `window.__webui_state` object.
39
+ * Apply SSR state from `window.__webui.state`.
32
40
  *
33
- * Passing the same props to both server render and client
34
- * hydrate, this ensures component observables match the server-rendered
35
- * DOM. The handler emits the state as a `<script>` tag at the end of
36
- * the page. Only observable properties are set β€” unknown keys are ignored.
41
+ * The handler emits all SSR metadata in a single consolidated
42
+ * `window.__webui` script block. State lives at `.state` β€” the same
43
+ * props passed to the server render so observables match the DOM.
44
+ * Only observable properties are set β€” unknown keys are ignored.
37
45
  *
38
46
  * Writes directly to the backing field (`_prop`) to avoid triggering
39
47
  * reactive updates before bindings are wired.
package/dist/element.js CHANGED
@@ -48,8 +48,10 @@ import { ATTR_KIND_BOOLEAN, ATTR_KIND_COMPLEX, ATTR_KIND_TEMPLATE, } from './ele
48
48
  // ── Caches ──────────────────────────────────────────────────────
49
49
  /** Parsed template cache β€” cloneNode(true) is faster than re-parsing. */
50
50
  const templateCache = new WeakMap();
51
- /** Parsed template DOM for SSR path mapping, keyed by meta.h string. */
52
- const templateDOMCache = new Map();
51
+ /** Parsed template DOM for SSR path mapping, keyed by TemplateBlockMeta. */
52
+ const templateDOMCache = new WeakMap();
53
+ /** Cached root tag name extracted from meta.h before it's released. */
54
+ const rootTagCache = new WeakMap();
53
55
  /** Pre-computed ordinals for template nodes: childIndex β†’ [nodeType, ordinal].
54
56
  * Avoids re-counting element/text siblings on every $resolveSSR call. */
55
57
  const tplOrdinalCache = new WeakMap();
@@ -88,12 +90,12 @@ function childNodesArray(parent) {
88
90
  }
89
91
  // ── Helper: parse template HTML into a temp container ────────────
90
92
  function getTemplateDom(meta) {
91
- let cached = templateDOMCache.get(meta.h);
93
+ let cached = templateDOMCache.get(meta);
92
94
  if (cached)
93
95
  return cached;
94
96
  const div = document.createElement('div');
95
97
  div.innerHTML = meta.h;
96
- templateDOMCache.set(meta.h, div);
98
+ templateDOMCache.set(meta, div);
97
99
  return div;
98
100
  }
99
101
  // ═══════════════════════════════════════════════════════════════════
@@ -126,7 +128,7 @@ export class WebUIElement extends HTMLElement {
126
128
  const meta = getTemplate(tag);
127
129
  if (!meta) {
128
130
  console.warn(`[WebUI] Template metadata for <${tag}> not found. ` +
129
- `Ensure the component is included in the SSR output or registered via __webui_templates.`);
131
+ `Ensure the component is included in the SSR output or registered via __webui.templates.`);
130
132
  return;
131
133
  }
132
134
  this.$meta = meta;
@@ -152,7 +154,7 @@ export class WebUIElement extends HTMLElement {
152
154
  hydrationStart();
153
155
  // Auto-detect shadow vs light DOM
154
156
  const hasShadow = !!this.shadowRoot;
155
- const wantShadow = hasShadow || !!meta.sd || !!window.__webui_shadow;
157
+ const wantShadow = hasShadow || !!meta.sd;
156
158
  let root;
157
159
  let isSSR;
158
160
  if (hasShadow) {
@@ -208,7 +210,53 @@ export class WebUIElement extends HTMLElement {
208
210
  }
209
211
  hydrationEnd();
210
212
  }
211
- disconnectedCallback() { }
213
+ disconnectedCallback() {
214
+ // Schedule teardown on microtask β€” if the element is re-connected
215
+ // before then (e.g. repeat reconciliation), skip the cleanup.
216
+ if (this.$root) {
217
+ queueMicrotask(() => {
218
+ if (!this.isConnected)
219
+ this.$destroy();
220
+ });
221
+ }
222
+ }
223
+ /**
224
+ * Permanently destroy this component's own bindings and DOM references.
225
+ * Each component is responsible for its own cleanup β€” child WebUI
226
+ * elements handle theirs via their own `disconnectedCallback`.
227
+ */
228
+ $destroy() {
229
+ if (!this.$root)
230
+ return;
231
+ this.$teardown(this.$root);
232
+ this.$root = null;
233
+ this.$pathIndex = undefined;
234
+ this.$wildcardBindings = undefined;
235
+ this.$dirtyPaths = null;
236
+ this.$pendingFlush = false;
237
+ this.$ready = false;
238
+ }
239
+ /** Break all DOM references held by a binding instance and its nested blocks. */
240
+ $teardown(instance) {
241
+ for (const c of instance.conds) {
242
+ if (c.instance)
243
+ this.$teardown(c.instance);
244
+ c.instance = null;
245
+ }
246
+ for (const r of instance.repeats) {
247
+ for (const item of r.instances)
248
+ this.$teardown(item.instance);
249
+ r.instances.length = 0;
250
+ r.container = null;
251
+ r.start = null;
252
+ r.end = null;
253
+ }
254
+ instance.nodes.length = 0;
255
+ instance.texts.length = 0;
256
+ instance.attrs.length = 0;
257
+ instance.conds.length = 0;
258
+ instance.repeats.length = 0;
259
+ }
212
260
  /** Dispatch a bubbling custom event. Uses composed:true when in shadow DOM. */
213
261
  $emit(name, detail) {
214
262
  return this.dispatchEvent(new CustomEvent(name, {
@@ -218,16 +266,18 @@ export class WebUIElement extends HTMLElement {
218
266
  detail,
219
267
  }));
220
268
  }
221
- /** Populate @observable properties from router state.
269
+ /** Populate @observable properties from server or router state.
222
270
  *
223
271
  * Each property is set through its reactive setter, which coalesces
224
272
  * updates into a single pending microtask. We then synchronously
225
273
  * flush those pending path updates so the DOM is current before any
226
274
  * view-transition snapshot captures it.
227
275
  */
228
- setInitialState(state) {
276
+ setState(state) {
229
277
  const names = getObservableNames(this.constructor);
230
- for (const key of Object.keys(state)) {
278
+ const keys = Object.keys(state);
279
+ for (let i = 0; i < keys.length; i++) {
280
+ const key = keys[i];
231
281
  if (names.has(key)) {
232
282
  this[key] = state[key];
233
283
  }
@@ -235,18 +285,18 @@ export class WebUIElement extends HTMLElement {
235
285
  this.$flushUpdates();
236
286
  }
237
287
  /**
238
- * Apply SSR state from the global `window.__webui_state` object.
288
+ * Apply SSR state from `window.__webui.state`.
239
289
  *
240
- * Passing the same props to both server render and client
241
- * hydrate, this ensures component observables match the server-rendered
242
- * DOM. The handler emits the state as a `<script>` tag at the end of
243
- * the page. Only observable properties are set β€” unknown keys are ignored.
290
+ * The handler emits all SSR metadata in a single consolidated
291
+ * `window.__webui` script block. State lives at `.state` β€” the same
292
+ * props passed to the server render so observables match the DOM.
293
+ * Only observable properties are set β€” unknown keys are ignored.
244
294
  *
245
295
  * Writes directly to the backing field (`_prop`) to avoid triggering
246
296
  * reactive updates before bindings are wired.
247
297
  */
248
298
  $applySSRState() {
249
- const state = window.__webui_state;
299
+ const state = window.__webui?.state;
250
300
  if (!state || typeof state !== 'object')
251
301
  return;
252
302
  const names = getObservableNames(this.constructor);
@@ -775,23 +825,25 @@ export class WebUIElement extends HTMLElement {
775
825
  */
776
826
  $hydrateCondContent(condAnchor, blockMeta, scope) {
777
827
  const rootTag = this.$rootTag(blockMeta);
778
- if (rootTag) {
828
+ const tplDom = getTemplateDom(blockMeta);
829
+ if (rootTag && tplDom.children.length === 1) {
830
+ // Single-root optimisation: hydrate the element in-place (pathStart=1).
779
831
  const el = nextElement(condAnchor);
780
832
  if (el) {
781
- const inst = this.$hydrate(el, blockMeta, getTemplateDom(blockMeta), scope, 1);
833
+ const inst = this.$hydrate(el, blockMeta, tplDom, scope, 1);
782
834
  this.$updateInstance(inst);
783
835
  return inst;
784
836
  }
785
837
  return null;
786
838
  }
787
- // Text-only conditional: collect nodes between <!--wc--> and <!--/wc-->
839
+ // Multi-root or text-only conditional: collect nodes between <!--wc--> and <!--/wc-->
788
840
  const condNodes = this.$collectBetween(condAnchor, MARKER_COND_END);
789
841
  if (condNodes.length === 0)
790
842
  return null;
791
843
  const wrapper = document.createElement('div');
792
844
  for (let cn = 0; cn < condNodes.length; cn++)
793
845
  wrapper.appendChild(condNodes[cn]);
794
- const inst = this.$hydrate(wrapper, blockMeta, getTemplateDom(blockMeta), scope);
846
+ const inst = this.$hydrate(wrapper, blockMeta, tplDom, scope);
795
847
  inst.nodes = childNodesArray(wrapper);
796
848
  let afterNode = condAnchor;
797
849
  for (let cn = 0; cn < inst.nodes.length; cn++) {
@@ -849,9 +901,14 @@ export class WebUIElement extends HTMLElement {
849
901
  }
850
902
  /** Extract root tag name from block metadata. */
851
903
  $rootTag(meta) {
904
+ let cached = rootTagCache.get(meta);
905
+ if (cached !== undefined)
906
+ return cached;
852
907
  const h = meta.h;
853
- if (!h || h.charCodeAt(0) !== 60)
908
+ if (!h || h.charCodeAt(0) !== 60) {
909
+ rootTagCache.set(meta, null);
854
910
  return null;
911
+ }
855
912
  let end = 1;
856
913
  while (end < h.length) {
857
914
  const c = h.charCodeAt(end);
@@ -859,7 +916,9 @@ export class WebUIElement extends HTMLElement {
859
916
  break;
860
917
  end++;
861
918
  }
862
- return h.slice(1, end).toLowerCase();
919
+ const tag = h.slice(1, end).toLowerCase();
920
+ rootTagCache.set(meta, tag);
921
+ return tag;
863
922
  }
864
923
  // ═══════════════════════════════════════════════════════════════
865
924
  // Shared: binding wiring, event wiring, refs
@@ -907,14 +966,11 @@ export class WebUIElement extends HTMLElement {
907
966
  }
908
967
  }
909
968
  /** Attach a single event listener. */
910
- $addEvent(target, eventName, handlerName, needsEvent) {
969
+ $addEvent(target, eventName, handlerName, _needsEvent) {
911
970
  const method = this[handlerName];
912
971
  if (typeof method !== 'function')
913
972
  return;
914
- const self = this;
915
- target.addEventListener(eventName, needsEvent
916
- ? (e) => method.call(self, e)
917
- : () => method.call(self));
973
+ target.addEventListener(eventName, method.bind(this));
918
974
  }
919
975
  /** Find w-ref attributes and assign to component properties. */
920
976
  $wireRefs(root) {
@@ -21,10 +21,12 @@ export type { CompiledAttrGroupMeta, CompiledAttrMeta, CompiledAttrPart, Compile
21
21
  import type { TemplateMeta } from './template-types.js';
22
22
  declare global {
23
23
  interface Window {
24
- __webui_templates?: Record<string, TemplateMeta>;
25
- __webui_state?: Record<string, unknown>;
26
- /** When true, all client-created components use shadow DOM. */
27
- __webui_shadow?: boolean;
24
+ /** Consolidated SSR bootstrap object β€” single script block. */
25
+ __webui?: {
26
+ state?: Record<string, unknown>;
27
+ templates?: Record<string, TemplateMeta>;
28
+ [key: string]: unknown;
29
+ };
28
30
  }
29
31
  }
30
32
  export declare function getTemplate(name: string): TemplateMeta | undefined;
package/dist/template.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT license.
3
3
  export function getTemplate(name) {
4
- return window.__webui_templates?.[name];
4
+ return window.__webui?.templates?.[name];
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/webui-framework",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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.9"
22
+ "@microsoft/webui-test-support": "0.0.11"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc",