Skip to content
Merged
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
224 changes: 91 additions & 133 deletions .github/skills/webui-dev/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
name: webui-dev
description: Build interactive WebUI example apps with FAST-HTML hydration, template syntax, and component patterns.
description: Build interactive WebUI example apps with compiled-template hydration, template syntax, and component patterns.
---

# WebUI App Development with FAST-HTML
# WebUI App Development

Use this skill when building or modifying example apps under `examples/app/`.

WebUI server-renders HTML at request time. FAST-HTML hydrates the pre-rendered DOM on the client, making it interactive without a full re-render.
WebUI server-renders HTML at request time. The `@microsoft/webui-framework` runtime hydrates the pre-rendered DOM on the client using compiled template metadata and direct DOM binding — no virtual DOM, no runtime template parsing.

## Project structure

Expand All @@ -17,7 +17,7 @@ Every example app follows this layout:
examples/app/<name>/
├── src/
│ ├── index.html # HTML shell + CSS design tokens
│ ├── index.ts # Hydration bootstrap
│ ├── index.ts # Component registration
│ └── <component-name>/ # One directory per component
│ ├── <component-name>.ts
│ ├── <component-name>.html
Expand All @@ -35,24 +35,22 @@ examples/app/<name>/
"type": "module",
"scripts": {
"start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch",
"start:server": "cargo run -p microsoft-webui-cli -- start ./src --state ./data/state.json --plugin=fast --servedir ./dist --port 3001 --watch",
"start:server": "cargo run -p microsoft-webui-cli -- start ./src --state ./data/state.json --plugin=webui --servedir ./dist --port 3001 --watch",
"start": "cargo xtask dev <name>"
},
"devDependencies": {
"@microsoft/fast-element": "catalog:",
"@microsoft/fast-html": "catalog:",
"@microsoft/webui-framework": "workspace:*",
"esbuild": "catalog:",
"tslib": "catalog:",
"typescript": "catalog:"
}
}
```

Use `catalog:` for all dependency versions — they resolve from `pnpm-workspace.yaml`.
Use `catalog:` for dependency versions — they resolve from `pnpm-workspace.yaml`.

### tsconfig.json

Required settings for FAST-HTML:
Required settings:

```json
{
Expand All @@ -63,7 +61,7 @@ Required settings for FAST-HTML:
}
```

`useDefineForClassFields: false` is **mandatory** — FAST decorators rely on legacy class field behavior.
`useDefineForClassFields: false` is **mandatory** — decorators rely on legacy class field behavior.

## Template syntax (HTML)

Expand Down Expand Up @@ -148,123 +146,82 @@ For structured data that passes through as-is:
<my-component :config={{settings}}></my-component>
```

## FAST-HTML component patterns
## Component patterns

### Component class

Every component extends `RenderableFASTElement(FASTElement)`:
Every component extends `WebUIElement`:

```typescript
import { FASTElement, attr, observable } from '@microsoft/fast-element';
import { RenderableFASTElement } from '@microsoft/fast-html';
import { WebUIElement, attr, observable, volatile } from '@microsoft/webui-framework';

export class MyComponent extends RenderableFASTElement(FASTElement) {
@attr name!: string; // HTML attribute (string, reflected)
@observable items!: ItemData[]; // Reactive property (triggers re-render)
export class MyComponent extends WebUIElement {
@attr label = 'Default'; // HTML attribute (string, reflected)
@observable count = 0; // Reactive property
@observable items: Item[] = []; // Collection for @for loops

inputRef!: HTMLInputElement; // Template ref target (via f-ref)

async prepare(): Promise<void> {
// Hydration hook — read state from pre-rendered DOM
@volatile get doubled(): number { // Computed, re-evaluated on access
return this.count * 2;
}

connectedCallback(): void {
super.connectedCallback();
// Post-hydration setup (event listeners, focus, etc.)
increment(): void {
this.count += 1;
}
}

MyComponent.defineAsync({
name: 'my-component',
templateOptions: 'defer-and-hydrate',
});
MyComponent.define('my-component');
```

### `@attr` — HTML attributes

- Reflected to/from the HTML attribute on the element.
- **Use `!:` (no initializer)** for fields set in `prepare()`. Class field initializers run AFTER `super()`, overwriting values set during hydration.
- **Use `!:` (no initializer)** for fields seeded from SSR. Class field initializers run AFTER `super()`, overwriting values set during hydration.
- Hyphenated HTML attributes map via `@attr({ attribute: 'display-value' })`.
- Attribute values arrive as strings — use `@observable` for non-string state.

### `@observable` — Reactive properties

- Triggers FAST template re-rendering when changed.
- Use for data that drives `<for>` loops or conditional display.
- **Use `!:` (no initializer)** for fields set in `prepare()`.
- Triggers per-path targeted update when changed — only bindings referencing this property are visited.
- Use for data that drives `<for>` loops, `<if>` conditionals, or text/attribute bindings.
- **Use `!:` (no initializer)** for fields seeded from SSR.

### `@volatile` — Computed getters

### `prepare()` — Hydration hook
- Re-evaluated on every access (no caching).
- Automatically included in targeted updates via a wildcard binding.

Called during hydration to initialize component state from the pre-rendered DOM. This is **the** place to read server-rendered data:
### `$emit` — Custom events

```typescript
async prepare(): Promise<void> {
// Read @attr values from HTML attributes (initializers haven't run yet)
this.mode = this.getAttribute('mode') || 'default';

// Read child elements rendered by SSR
const items: ItemData[] = [];
for (const el of this.shadowRoot!.querySelectorAll('my-item')) {
items.push({
id: el.getAttribute('id') || '',
title: el.getAttribute('title') || '',
});
}
this.items = items;
}
this.$emit('toggle-item', { id: this.id });
```

Rules:

- Always read `@attr` values from `this.getAttribute()` — decorators initialize AFTER `prepare()`.
- Read child element data from `this.shadowRoot.querySelectorAll()`.
- Guard with `if (!this.shadowRoot) return;` when appropriate.
- Avoid `<for>` loops over `@observable` string arrays — use object arrays or static HTML.
Events bubble through the shadow DOM boundary via `composed: true`.

### Component template (HTML)
### `w-ref` — Template refs

The `<template shadowrootmode="open">` wrapper is **optional** — the framework adds it automatically when absent. Only include it when you need to attach event listeners or other directives to the template root:
Bind a DOM element to a component property for direct access:

```html
<!-- Minimal — no root-level bindings needed -->
<div>{{title}}</div>
<for each="item in items">
<child-component name="{{item.name}}"></child-component>
</for>

<!-- With root-level event listeners — wrapper required -->
<template shadowrootmode="open"
@custom-event="{onCustomEvent(e)}"
>
<div>{{title}}</div>
</template>
<input w-ref="myInput" @keydown="{onKeydown(e)}" />
```

### Component styles (CSS)

Scoped to the component via shadow DOM:

```css
:host {
display: block;
}

:host([state="active"]) .indicator {
background: var(--color-accent);
}
```typescript
myInput!: HTMLInputElement; // Populated during hydration

.content {
padding: var(--spacing-m);
onSubmit(): void {
const value = this.myInput.value;
this.myInput.value = '';
this.myInput.focus();
}
```

Use `:host([attr="value"])` selectors to style based on attribute state.
Use refs only for DOM-only concerns (focus, measurement, selection). Application state belongs in `@observable` / `@attr`.

## Event handling

### Template event binding — `@event`

Bind DOM and custom events to component methods in the template:

```html
<!-- Standard DOM events -->
<button @click="{onClick()}">Click</button>
Expand All @@ -279,66 +236,69 @@ Bind DOM and custom events to component methods in the template:

Pass `e` to receive the event object. Omit it when not needed.

### Emitting custom events`$emit`
Events use delegation internallyone listener per event type on the shadow root, not one closure per element.

Child components emit events to parent components:
## Component template (HTML)

```typescript
// Emit a custom event with detail data
this.$emit('toggle-item', { id: this.id });
The `<template shadowrootmode="open">` wrapper is **optional** — the framework adds it automatically when absent. Only include it when you need root-level event listeners:

// Parent catches it via @toggle-item="{onToggleItem(e)}" on its <template>
```
```html
<!-- Minimal — no root-level bindings needed -->
<div>{{title}}</div>
<for each="item in items">
<child-component name="{{item.name}}"></child-component>
</for>

Events bubble through the shadow DOM boundary via `composed: true`.
<!-- With root-level event listeners — wrapper required -->
<template shadowrootmode="open"
@custom-event="{onCustomEvent(e)}"
>
<div>{{title}}</div>
</template>
```

### Template refs — `f-ref`
## Component styles (CSS)

Bind a DOM element to a component property for direct access:
Scoped to the component via shadow DOM:

```html
<input f-ref="{myInput}" @keydown="{onKeydown(e)}" />
```
```css
:host {
display: block;
}

```typescript
myInput!: HTMLInputElement; // Populated after hydration
:host([state="active"]) .indicator {
background: var(--color-accent);
}

onSubmit(): void {
const value = this.myInput.value;
this.myInput.value = '';
this.myInput.focus();
.content {
padding: var(--spacing-m);
}
```

## Hydration bootstrap (index.ts)
Use `:host([attr="value"])` selectors to style based on attribute state.

The entry point registers components and activates hydration:
## SSR hydration

```typescript
performance.mark('app-hydration-started');
The framework handles two paths automatically:

import { TemplateElement } from '@microsoft/fast-html';
**SSR path** — the server renders a Declarative Shadow Root with hydration markers. On custom element upgrade, the framework:
1. Walks SSR markers once to connect bindings
2. Seeds `@observable` / `@attr` values from DOM content inline
3. Removes markers
4. DOM is already correct — no `$update()` needed

// Side-effect imports register components
import './my-app/my-app.js';
import './my-item/my-item.js';
**Client-created path** — for components created dynamically (e.g. inside `@for` loops):
1. Clones cached template HTML (`cloneNode`, not `innerHTML`)
2. Resolves binding locators from compiled metadata
3. Calls `$update()` to flush initial state

TemplateElement.options({
'my-app': { observerMap: 'all' },
'my-item': { observerMap: 'all' },
}).config({
hydrationComplete() {
performance.measure('app-hydration-completed', 'app-hydration-started');
console.log('Hydration complete!');
},
}).define({
name: 'f-template',
});
```
### State seeding

The framework automatically reconstructs `@observable` state from SSR DOM:
- `@observable count = 0` + SSR text `"42"` → `this.count = 42` (coerced to number)
- `@observable active = false` + SSR attr `"true"` → `this.active = true` (coerced to boolean)

- List **every** component in `.options()` with `{ observerMap: 'all' }`.
- `.define({ name: 'f-template' })` triggers hydration.
- Performance marks measure hydration time.
Do NOT manually read DOM values in `connectedCallback` — the framework does it for you.

## State (data/state.json)

Expand All @@ -355,7 +315,7 @@ Provides initial values for SSR template rendering:
}
```

Top-level keys become template signals (`{{title}}`). Arrays drive `<for>` loops. The state is passed to the WebUI CLI via `--state`.
Top-level keys become template signals (`{{title}}`). Arrays drive `<for>` loops.

## CSS design tokens

Expand All @@ -369,12 +329,10 @@ Define design tokens as CSS custom properties in `index.html`:
--border-radius-m: 6px;
--font-family-base: 'Segoe UI', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-family-base); }
</style>
```

Components reference tokens via `var(--token-name)`. WebUI hoists `var()` usages at build time into the protocol for host-language token resolution.
Components reference tokens via `var(--token-name)`.

## Dev workflow

Expand All @@ -387,7 +345,7 @@ pnpm start:client # esbuild watch
pnpm start:server # microsoft-webui-cli dev server

# Production build
webui build ./src --out ./dist --plugin=fast
webui build ./src --out ./dist --plugin=webui
```

The `cargo xtask dev` command auto-discovers apps from `examples/app/` — no registration needed.
8 changes: 6 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,19 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: e2e-updated-baselines
path: examples/app/*/tests/*.spec.ts-snapshots/
path: |
examples/app/*/tests/*.spec.ts-snapshots/
packages/*/tests/*.spec.ts-snapshots/
retention-days: 7

- name: Upload test results (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-test-results
path: examples/app/*/test-results/
path: |
examples/app/*/test-results/
packages/*/test-results/
retention-days: 7

# ── Phase 2: WASM (Ubuntu) ─────────────────────────────────────────
Expand Down
3 changes: 1 addition & 2 deletions examples/app/commerce/tests/commerce.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,7 @@ test.describe('category navigation flows', () => {
// Stickers → All
await page.locator('mp-category-nav').getByRole('link', { name: 'All' }).first().click();
await expect(page).toHaveURL('/search');
const allCount = await page.locator('mp-product-grid mp-product-card').count();
expect(allCount).toBeGreaterThanOrEqual(2);
await expect.poll(() => page.locator('mp-product-grid mp-product-card').count()).toBeGreaterThanOrEqual(2);
});

test('category active state updates in sidebar', async ({ page }) => {
Expand Down
Loading
Loading