Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,12 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m
`@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.

- 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.
- Client-created DOM never reparses template syntax; it clones marker-free `h` and resolves `tx`, `ag`, `cl`, `rl`, and event target paths directly.
- Client-created DOM never reparses template syntax; it clones marker-free `h`,
upgrades the detached custom-element subtree, resolves `tx`, `ag`, `cl`, `rl`,
and event target paths directly, then applies the first binding pass before
appending nodes to the connected DOM. Child components therefore observe
initial parent `:` property bindings in `connectedCallback`, while later parent
updates remain live.
- 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.
- 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.

Expand Down
6 changes: 6 additions & 0 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ Supported operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`
<my-widget :config="{{settings}}"></my-widget>
```

Property bindings use `:` to write directly to DOM properties. For
client-created component trees, initial property bindings are applied before a
child component's `connectedCallback` runs. Children can read parent-provided
values during setup, initialize their own fallback when a value is missing, and
still receive later parent updates through the live binding.

### Events (client-side only)

```html
Expand Down
10 changes: 10 additions & 0 deletions docs/guide/concepts/interactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ Toggle HTML attributes with the `?` prefix:
<details ?open="{{isExpanded}}">...</details>
```

### Property Bindings

Use the `:` prefix to pass rich values directly to child DOM properties:

```html
<profile-card :config="{{settings}}"></profile-card>
```

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.

### List Rendering

Iterate over arrays with `<for>`:
Expand Down
10 changes: 10 additions & 0 deletions packages/webui-framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui

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

### Property binding lifecycle

Property bindings use the `:` prefix to pass values directly to child DOM properties:

```html
<profile-card :config="{{settings}}"></profile-card>
```

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.

### DOM strategy (`--dom`)

The `--dom` flag controls how the server renders component content:
Expand Down
4 changes: 2 additions & 2 deletions packages/webui-framework/RENDERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Compile metadata Inject SSR markers existing DOM,
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.
2. **Browser parses HTML.** The parser creates shadow roots inline. The user sees a fully painted page before any framework code runs.
3. **JavaScript loads.** The component class registers via `customElements.define`. The browser upgrades pre-existing tags and fires `connectedCallback`.
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.
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.
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.
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.
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.
Expand Down Expand Up @@ -151,7 +151,7 @@ The compiler emits one `TemplateMeta` per component, delivered as a JS IIFE insi
The same metadata serves both paths:

- **SSR hydration** reads paths to compute ordinals, which are then translated against the live SSR DOM.
- **Client-created creation** clones `h` and walks paths directly, since the cloned DOM matches `h` exactly.
- **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.

### Condition AST

Expand Down
70 changes: 60 additions & 10 deletions packages/webui-framework/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class WebUIElement extends HTMLElement {

let root: Node;
let isSSR: boolean;
let clientRoot: HTMLElement | null = null;

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

} else {
this.$root = this.$wire(root, meta);
clientRoot = this.$createStagingRoot(meta);
this.$root = this.$wire(clientRoot, meta);
}

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

hydrationEnd();
Expand Down Expand Up @@ -531,6 +534,49 @@ export class WebUIElement extends HTMLElement {
return tpl.content.cloneNode(true) as DocumentFragment;
}

private $createStagingRoot(meta: TemplateBlockMeta): HTMLElement {
const wrapper = document.createElement('div');
const fragment = this.$parseTemplate(meta);
wrapper.appendChild(fragment);
customElements.upgrade(wrapper);
return wrapper;
}

private $appendStagedChildren(root: Node, stagingRoot: Node): void {
const first = stagingRoot.firstChild;
if (!first) return;
if (!first.nextSibling) {
root.appendChild(first);
return;
}
const fragment = document.createDocumentFragment();
while (stagingRoot.firstChild) {
fragment.appendChild(stagingRoot.firstChild);
}
root.appendChild(fragment);
}

private $releaseStagingRepeatContainers(instance: TemplateInstance | null, stagingRoot: Node | null): void {
if (!instance || !stagingRoot) return;
if (instance.repeats.length === 0 && instance.conds.length === 0) return;
const stack: TemplateInstance[] = [instance];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
for (let i = 0; i < current.repeats.length; i++) {
const repeat = current.repeats[i];
if (repeat.container === stagingRoot) repeat.container = null;
for (let j = 0; j < repeat.instances.length; j++) {
stack.push(repeat.instances[j].instance);
}
}
for (let i = 0; i < current.conds.length; i++) {
const child = current.conds[i].instance;
if (child) stack.push(child);
}
}
}

// ═══════════════════════════════════════════════════════════════
// Client-created wiring — exact childNode index resolution
// ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -1399,8 +1445,9 @@ export class WebUIElement extends HTMLElement {
for (const n of c.instance.nodes) frag.appendChild(n);
c.anchor.parentNode?.insertBefore(frag, c.anchor.nextSibling);
}
} else {
this.$updateInstance(c.instance);
}
if (c.instance) this.$updateInstance(c.instance);
} else if (c.instance) {
this.$removeInstance(c.instance);
c.instance = null;
Expand Down Expand Up @@ -1445,11 +1492,14 @@ export class WebUIElement extends HTMLElement {
$createBlockInstance(blockIndex: number, scope?: ScopeFrame): TemplateInstance | null {
const bm = this.$block(blockIndex);
if (!bm) return null;
const frag = this.$parseTemplate(bm);
const wrapper = document.createElement('div');
wrapper.appendChild(frag);
const wrapper = this.$createStagingRoot(bm);
const inst = this.$wire(wrapper, bm, scope);
inst.nodes = childNodesArray(wrapper);
this.$updateInstance(inst);
if (inst.repeats.length !== 0 || inst.conds.length !== 0) {
inst.nodes = childNodesArray(wrapper);
this.$releaseStagingRepeatContainers(inst, wrapper);
}
return inst;
}

Expand Down
14 changes: 7 additions & 7 deletions packages/webui-framework/src/element/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,11 @@ export function syncRepeat(

rep.instances = next;

// Reorder + update
let cursor: Node | null = rep.start;
for (let i = 0; i < next.length; i += 1) {
cursor = host.$insertInstanceAfter(cursor, container, next[i].instance);
}
for (let i = 0; i < next.length; i += 1) {
for (let i = 0; i < reuseCount; i += 1) {
host.$updateInstance(next[i].instance);
}
return;
Expand Down Expand Up @@ -182,14 +181,15 @@ export function syncRepeat(
rep.instances = next;

// ── Reorder DOM (forward pass) ──────────────────────────────────
// Walk forward, skip nodes already in position.
// Newly-created instances were patched while detached. Reused instances
// update after moving so nested structural nodes stay with the item.
let cursor: Node | null = rep.start;
for (let i = 0; i < next.length; i += 1) {
cursor = host.$insertInstanceAfter(cursor, container, next[i].instance);
}

// ── Update bindings ─────────────────────────────────────────────
for (let i = 0; i < next.length; i += 1) {
host.$updateInstance(next[i].instance);
for (let i = 0; i < oldInstances.length; i += 1) {
const entry = oldInstances[i];
const k = entry.key;
if (k != null && !oldByKey.has(k)) host.$updateInstance(entry.instance);
}
}
2 changes: 1 addition & 1 deletion packages/webui-framework/src/element/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ export interface RepeatItemInstance {
*/
export interface RepeatHost {
$resolveValue(path: string, scope?: ScopeFrame): unknown;
/** Create, wire, and perform the first binding pass while detached. */
$createBlockInstance(blockIndex: number, scope?: ScopeFrame): TemplateInstance | null;
$updateInstance(instance: TemplateInstance): void;
$removeInstance(instance: TemplateInstance): void;
$insertInstanceAfter(cursor: Node | null, container: ParentNode & Node, instance: TemplateInstance): Node | null;
}

Loading
Loading