Skip to content

Commit 0f40666

Browse files
Fix client binding lifecycle ordering (#329)
1 parent f97bdc6 commit 0f40666

19 files changed

Lines changed: 495 additions & 21 deletions

File tree

DESIGN.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1369,7 +1369,12 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m
13691369
`@microsoft/webui-framework` consumes the metadata object above plus the SSR markers emitted by `WebUIHydrationPlugin`. This follows an Islands Architecture approach: the server delivers fully-rendered HTML, and only interactive Web Components hydrate on the client — leaving static content untouched.
13701370

13711371
- SSR hydration uses one DOM walk to discover `<!--wr-->`, `<!--wi-->`, and `<!--wc-->` comment markers, wire the relevant bindings using compiled metadata path indices, then remove SSR-only markers.
1372-
- Client-created DOM never reparses template syntax; it clones marker-free `h` and resolves `tx`, `ag`, `cl`, `rl`, and event target paths directly.
1372+
- Client-created DOM never reparses template syntax; it clones marker-free `h`,
1373+
upgrades the detached custom-element subtree, resolves `tx`, `ag`, `cl`, `rl`,
1374+
and event target paths directly, then applies the first binding pass before
1375+
appending nodes to the connected DOM. Child components therefore observe
1376+
initial parent `:` property bindings in `connectedCallback`, while later parent
1377+
updates remain live.
13731378
- Events are resolved from compiled `e[]` metadata entries using path indices. The runtime installs listeners on target elements and resolves handler arguments against the scope captured when that block was rendered. Root events from `re[]` attach directly to the host element.
13741379
- The full package entrypoint supports repeat metadata (`r[]` / `rl[]`). The additive `@microsoft/webui-framework/element-no-repeat` entrypoint preserves the same public `WebUIElement` API but must reject compiled templates that contain repeat metadata.
13751380

docs/ai.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ Supported operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`
182182
<my-widget :config="{{settings}}"></my-widget>
183183
```
184184

185+
Property bindings use `:` to write directly to DOM properties. For
186+
client-created component trees, initial property bindings are applied before a
187+
child component's `connectedCallback` runs. Children can read parent-provided
188+
values during setup, initialize their own fallback when a value is missing, and
189+
still receive later parent updates through the live binding.
190+
185191
### Events (client-side only)
186192

187193
```html

docs/guide/concepts/interactivity.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,16 @@ Toggle HTML attributes with the `?` prefix:
230230
<details ?open="{{isExpanded}}">...</details>
231231
```
232232

233+
### Property Bindings
234+
235+
Use the `:` prefix to pass rich values directly to child DOM properties:
236+
237+
```html
238+
<profile-card :config="{{settings}}"></profile-card>
239+
```
240+
241+
For client-created component trees, WebUI applies initial property bindings before child `connectedCallback` methods run. This lets a child read a parent-provided property during setup. If the parent has not provided a value, the child can initialize a fallback in `connectedCallback`; later parent updates still flow through the live binding.
242+
233243
### List Rendering
234244

235245
Iterate over arrays with `<for>`:

packages/webui-framework/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui
9595

9696
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`.
9797

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+
98108
### DOM strategy (`--dom`)
99109

100110
The `--dom` flag controls how the server renders component content:

packages/webui-framework/RENDERING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Compile metadata Inject SSR markers existing DOM,
3939
1. **Server renders HTML.** The handler walks compiled template metadata and application state and emits Declarative Shadow DOM (or light DOM) with five comment markers around structural blocks, plus a `<script>` tag carrying `window.__webui.state` and the per-component template metadata.
4040
2. **Browser parses HTML.** The parser creates shadow roots inline. The user sees a fully painted page before any framework code runs.
4141
3. **JavaScript loads.** The component class registers via `customElements.define`. The browser upgrades pre-existing tags and fires `connectedCallback`.
42-
4. **`$mount` decides client-or-SSR.** If a shadow root exists or the element already has children, the framework treats the DOM as SSR. Otherwise it parses the static template HTML (`meta.h`) and clones it into the element.
42+
4. **`$mount` decides client-or-SSR.** If a shadow root exists or the element already has children, the framework treats the DOM as SSR. Otherwise it parses the static template HTML (`meta.h`) into a detached staging root, upgrades custom elements, wires bindings, applies the first binding pass, and only then appends the nodes. Child `connectedCallback` methods see initial parent `:` property bindings.
4343
5. **`$applySSRState` seeds observables.** Backing fields (`_count`, `_title`, ...) are written directly from `window.__webui.state` so reactive bindings observe values that match the painted DOM.
4444
6. **`$hydrate` walks the DOM once.** Text, attribute, conditional, repeat, and event bindings are resolved by a single in-order pass that uses path indices plus marker-aware ordinal traversal.
4545
7. **Stale markers are removed.** Item markers (`<!--wi-->`) and closing markers (`<!--/wc-->`, `<!--/wr-->`) are deleted; start markers (`<!--wc-->`, `<!--wr-->`) stay as anchors for runtime updates.
@@ -151,7 +151,7 @@ The compiler emits one `TemplateMeta` per component, delivered as a JS IIFE insi
151151
The same metadata serves both paths:
152152

153153
- **SSR hydration** reads paths to compute ordinals, which are then translated against the live SSR DOM.
154-
- **Client-created creation** clones `h` and walks paths directly, since the cloned DOM matches `h` exactly.
154+
- **Client-created creation** clones `h` into a detached staging root, upgrades custom elements, walks paths directly, and applies initial bindings before the staged nodes are appended to the connected DOM.
155155

156156
### Condition AST
157157

packages/webui-framework/src/element.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export class WebUIElement extends HTMLElement {
216216

217217
let root: Node;
218218
let isSSR: boolean;
219+
let clientRoot: HTMLElement | null = null;
219220

220221
if (hasShadow) {
221222
// Shadow DOM SSR — declarative shadow root already has content
@@ -233,13 +234,9 @@ export class WebUIElement extends HTMLElement {
233234
// Existing children are slot content — they stay in light DOM
234235
// and project through the template's <slot>.
235236
root = this.attachShadow({ mode: 'open' });
236-
const fragment = this.$parseTemplate(meta);
237-
root.appendChild(fragment);
238237
isSSR = false;
239238
} else {
240239
// Light DOM client-created — populate from template (no shadow = no link issue)
241-
const fragment = this.$parseTemplate(meta);
242-
this.appendChild(fragment);
243240
root = this;
244241
isSSR = false;
245242
}
@@ -254,7 +251,8 @@ export class WebUIElement extends HTMLElement {
254251
this.$root = this.$hydrate(root, meta, getTemplateDom(meta));
255252

256253
} else {
257-
this.$root = this.$wire(root, meta);
254+
clientRoot = this.$createStagingRoot(meta);
255+
this.$root = this.$wire(clientRoot, meta);
258256
}
259257

260258
this.$meta = meta;
@@ -266,8 +264,13 @@ export class WebUIElement extends HTMLElement {
266264
// into the freshly-wired template DOM. Call $updateInstance directly
267265
// to avoid the $update() path-index build — it will be lazy-built
268266
// on the first reactive change instead.
269-
if (!isSSR) {
267+
if (!isSSR && clientRoot) {
270268
this.$updateInstance(this.$root);
269+
if (this.$root.repeats.length !== 0 || this.$root.conds.length !== 0) {
270+
this.$root.nodes = childNodesArray(clientRoot);
271+
this.$releaseStagingRepeatContainers(this.$root, clientRoot);
272+
}
273+
this.$appendStagedChildren(root, clientRoot);
271274
}
272275

273276
hydrationEnd();
@@ -531,6 +534,49 @@ export class WebUIElement extends HTMLElement {
531534
return tpl.content.cloneNode(true) as DocumentFragment;
532535
}
533536

537+
private $createStagingRoot(meta: TemplateBlockMeta): HTMLElement {
538+
const wrapper = document.createElement('div');
539+
const fragment = this.$parseTemplate(meta);
540+
wrapper.appendChild(fragment);
541+
customElements.upgrade(wrapper);
542+
return wrapper;
543+
}
544+
545+
private $appendStagedChildren(root: Node, stagingRoot: Node): void {
546+
const first = stagingRoot.firstChild;
547+
if (!first) return;
548+
if (!first.nextSibling) {
549+
root.appendChild(first);
550+
return;
551+
}
552+
const fragment = document.createDocumentFragment();
553+
while (stagingRoot.firstChild) {
554+
fragment.appendChild(stagingRoot.firstChild);
555+
}
556+
root.appendChild(fragment);
557+
}
558+
559+
private $releaseStagingRepeatContainers(instance: TemplateInstance | null, stagingRoot: Node | null): void {
560+
if (!instance || !stagingRoot) return;
561+
if (instance.repeats.length === 0 && instance.conds.length === 0) return;
562+
const stack: TemplateInstance[] = [instance];
563+
while (stack.length > 0) {
564+
const current = stack.pop();
565+
if (!current) continue;
566+
for (let i = 0; i < current.repeats.length; i++) {
567+
const repeat = current.repeats[i];
568+
if (repeat.container === stagingRoot) repeat.container = null;
569+
for (let j = 0; j < repeat.instances.length; j++) {
570+
stack.push(repeat.instances[j].instance);
571+
}
572+
}
573+
for (let i = 0; i < current.conds.length; i++) {
574+
const child = current.conds[i].instance;
575+
if (child) stack.push(child);
576+
}
577+
}
578+
}
579+
534580
// ═══════════════════════════════════════════════════════════════
535581
// Client-created wiring — exact childNode index resolution
536582
// ═══════════════════════════════════════════════════════════════
@@ -1399,8 +1445,9 @@ export class WebUIElement extends HTMLElement {
13991445
for (const n of c.instance.nodes) frag.appendChild(n);
14001446
c.anchor.parentNode?.insertBefore(frag, c.anchor.nextSibling);
14011447
}
1448+
} else {
1449+
this.$updateInstance(c.instance);
14021450
}
1403-
if (c.instance) this.$updateInstance(c.instance);
14041451
} else if (c.instance) {
14051452
this.$removeInstance(c.instance);
14061453
c.instance = null;
@@ -1445,11 +1492,14 @@ export class WebUIElement extends HTMLElement {
14451492
$createBlockInstance(blockIndex: number, scope?: ScopeFrame): TemplateInstance | null {
14461493
const bm = this.$block(blockIndex);
14471494
if (!bm) return null;
1448-
const frag = this.$parseTemplate(bm);
1449-
const wrapper = document.createElement('div');
1450-
wrapper.appendChild(frag);
1495+
const wrapper = this.$createStagingRoot(bm);
14511496
const inst = this.$wire(wrapper, bm, scope);
14521497
inst.nodes = childNodesArray(wrapper);
1498+
this.$updateInstance(inst);
1499+
if (inst.repeats.length !== 0 || inst.conds.length !== 0) {
1500+
inst.nodes = childNodesArray(wrapper);
1501+
this.$releaseStagingRepeatContainers(inst, wrapper);
1502+
}
14531503
return inst;
14541504
}
14551505

packages/webui-framework/src/element/diff.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,11 @@ export function syncRepeat(
132132

133133
rep.instances = next;
134134

135-
// Reorder + update
136135
let cursor: Node | null = rep.start;
137136
for (let i = 0; i < next.length; i += 1) {
138137
cursor = host.$insertInstanceAfter(cursor, container, next[i].instance);
139138
}
140-
for (let i = 0; i < next.length; i += 1) {
139+
for (let i = 0; i < reuseCount; i += 1) {
141140
host.$updateInstance(next[i].instance);
142141
}
143142
return;
@@ -182,14 +181,15 @@ export function syncRepeat(
182181
rep.instances = next;
183182

184183
// ── Reorder DOM (forward pass) ──────────────────────────────────
185-
// Walk forward, skip nodes already in position.
184+
// Newly-created instances were patched while detached. Reused instances
185+
// update after moving so nested structural nodes stay with the item.
186186
let cursor: Node | null = rep.start;
187187
for (let i = 0; i < next.length; i += 1) {
188188
cursor = host.$insertInstanceAfter(cursor, container, next[i].instance);
189189
}
190-
191-
// ── Update bindings ─────────────────────────────────────────────
192-
for (let i = 0; i < next.length; i += 1) {
193-
host.$updateInstance(next[i].instance);
190+
for (let i = 0; i < oldInstances.length; i += 1) {
191+
const entry = oldInstances[i];
192+
const k = entry.key;
193+
if (k != null && !oldByKey.has(k)) host.$updateInstance(entry.instance);
194194
}
195195
}

packages/webui-framework/src/element/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ export interface RepeatItemInstance {
115115
*/
116116
export interface RepeatHost {
117117
$resolveValue(path: string, scope?: ScopeFrame): unknown;
118+
/** Create, wire, and perform the first binding pass while detached. */
118119
$createBlockInstance(blockIndex: number, scope?: ScopeFrame): TemplateInstance | null;
119120
$updateInstance(instance: TemplateInstance): void;
120121
$removeInstance(instance: TemplateInstance): void;
121122
$insertInstanceAfter(cursor: Node | null, container: ParentNode & Node, instance: TemplateInstance): Node | null;
122123
}
123-

0 commit comments

Comments
 (0)