From 3e27f1f77d56d1e3d1fdc3581ae9b5c22b771292 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 27 Mar 2026 14:21:28 -0700 Subject: [PATCH 1/4] feat: add todo-webui app --- examples/app/todo-fast/package.json | 1 + examples/app/todo-webui/README.md | 31 ++ examples/app/todo-webui/data/state.json | 23 + examples/app/todo-webui/package.json | 21 + examples/app/todo-webui/playwright.config.ts | 18 + examples/app/todo-webui/src/index.html | 77 ++++ examples/app/todo-webui/src/index.ts | 39 ++ .../app/todo-webui/src/todo-app/todo-app.css | 64 +++ .../app/todo-webui/src/todo-app/todo-app.html | 30 ++ .../app/todo-webui/src/todo-app/todo-app.ts | 59 +++ .../todo-webui/src/todo-item/todo-item.css | 69 +++ .../todo-webui/src/todo-item/todo-item.html | 13 + .../app/todo-webui/src/todo-item/todo-item.ts | 24 + .../app/todo-webui/tests/todo-webui.spec.ts | 417 ++++++++++++++++++ examples/app/todo-webui/tsconfig.json | 16 + 15 files changed, 902 insertions(+) create mode 100644 examples/app/todo-webui/README.md create mode 100644 examples/app/todo-webui/data/state.json create mode 100644 examples/app/todo-webui/package.json create mode 100644 examples/app/todo-webui/playwright.config.ts create mode 100644 examples/app/todo-webui/src/index.html create mode 100644 examples/app/todo-webui/src/index.ts create mode 100644 examples/app/todo-webui/src/todo-app/todo-app.css create mode 100644 examples/app/todo-webui/src/todo-app/todo-app.html create mode 100644 examples/app/todo-webui/src/todo-app/todo-app.ts create mode 100644 examples/app/todo-webui/src/todo-item/todo-item.css create mode 100644 examples/app/todo-webui/src/todo-item/todo-item.html create mode 100644 examples/app/todo-webui/src/todo-item/todo-item.ts create mode 100644 examples/app/todo-webui/tests/todo-webui.spec.ts create mode 100644 examples/app/todo-webui/tsconfig.json diff --git a/examples/app/todo-fast/package.json b/examples/app/todo-fast/package.json index b5ab7fa1..b9ba01ae 100644 --- a/examples/app/todo-fast/package.json +++ b/examples/app/todo-fast/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "scripts": { + "build": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --metafile=dist/meta.json", "start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch", "start:server": "cargo run -p microsoft-webui-cli -- serve ./src --state ./data/state.json --plugin=fast --servedir ./dist --port 3001 --watch", "start": "cargo xtask dev todo-fast" diff --git a/examples/app/todo-webui/README.md b/examples/app/todo-webui/README.md new file mode 100644 index 00000000..a9a9811d --- /dev/null +++ b/examples/app/todo-webui/README.md @@ -0,0 +1,31 @@ + +### todo-webui (WebUI Framework hydration) + +```bash +# Install JS dependencies (esbuild, @microsoft/webui-framework) +pnpm install + +# Build the protocol with WebUI parser plugin +cargo run -p microsoft-webui-cli -- build examples/app/todo-webui/src --out examples/app/todo-webui/dist --plugin=webui + +# Or use the dev server with live rendering +cd examples/app/todo-webui +cargo run -p microsoft-webui-cli -- serve ./src --state ./data/state.json --plugin=webui --servedir ./dist --port 3006 +``` + +### Using `--plugin=webui` + +The `--plugin=webui` flag enables: + +1. **Parser plugin (`WebUIParserPlugin`)** — During `webui build`: + - Skips WebUI Framework runtime attributes (`@click`, `w-ref`, etc.) + - Counts dynamic attribute bindings per element and emits `Plugin` protocol fragments + - Tracks components and generates `` client template strings + +2. **Handler plugin (`WebUIHydrationPlugin`)** — During rendering: + - Wraps signals, for-loops, and if-conditions in `` comment markers + - Wraps for-loop items in `` comment markers + - Emits `data-w-b-*` / `data-w-c-*` attributes for element bindings + - Manages per-component/per-item scope counters for binding indices + +These markers enable `@microsoft/webui-framework`'s client-side hydration. \ No newline at end of file diff --git a/examples/app/todo-webui/data/state.json b/examples/app/todo-webui/data/state.json new file mode 100644 index 00000000..deb2f003 --- /dev/null +++ b/examples/app/todo-webui/data/state.json @@ -0,0 +1,23 @@ +{ + "textdirection": "ltr", + "language": "en", + "title": "Todo List", + "remainingCount": 2, + "items": [ + { + "id": "1", + "title": "Buy groceries", + "state": "done" + }, + { + "id": "2", + "title": "Write documentation", + "state": "pending" + }, + { + "id": "3", + "title": "Ship feature", + "state": "pending" + } + ] +} diff --git a/examples/app/todo-webui/package.json b/examples/app/todo-webui/package.json new file mode 100644 index 00000000..aa7316a5 --- /dev/null +++ b/examples/app/todo-webui/package.json @@ -0,0 +1,21 @@ +{ + "name": "todo-webui-example", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "build": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --metafile=dist/meta.json --minify", + "start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch", + "start:server": "cargo run -p microsoft-webui-cli -- serve ./src --state ./data/state.json --plugin=webui --servedir ./dist --port 3006 --watch", + "start": "cargo xtask dev todo-webui", + "test": "playwright test", + "test:update-snapshots": "playwright test --update-snapshots" + }, + "devDependencies": { + "@microsoft/webui-framework": "workspace:*", + "@playwright/test": "catalog:", + "esbuild": "catalog:", + "tslib": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/app/todo-webui/playwright.config.ts b/examples/app/todo-webui/playwright.config.ts new file mode 100644 index 00000000..929889a6 --- /dev/null +++ b/examples/app/todo-webui/playwright.config.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + snapshotPathTemplate: + '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}{ext}', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:3006', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], +}); diff --git a/examples/app/todo-webui/src/index.html b/examples/app/todo-webui/src/index.html new file mode 100644 index 00000000..ff540433 --- /dev/null +++ b/examples/app/todo-webui/src/index.html @@ -0,0 +1,77 @@ + + + + + + {{title}} + + + + + + + + + + diff --git a/examples/app/todo-webui/src/index.ts b/examples/app/todo-webui/src/index.ts new file mode 100644 index 00000000..b60e19ef --- /dev/null +++ b/examples/app/todo-webui/src/index.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Todo-webui entry point — bootstraps WebUI Framework hydration. + * + * The server pre-renders HTML with hydration markers via `webui build --plugin=webui`. + * Compiled templates are registered automatically by ` + + diff --git a/packages/webui-framework/tests/fixtures/root-event/root-event.spec.ts b/packages/webui-framework/tests/fixtures/root-event/root-event.spec.ts new file mode 100644 index 00000000..e5eda5ad --- /dev/null +++ b/packages/webui-framework/tests/fixtures/root-event/root-event.spec.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +test.describe('root-event fixture', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/root-event/fixture.html'); + await page.waitForSelector('test-root-event'); + }); + + test('root @click fires when clicking a child button', async ({ page }) => { + await page.locator('test-root-event .action').click(); + await expect(page.locator('test-root-event .total')).toHaveText('1'); + }); + + test('root @click fires for different child elements', async ({ page }) => { + await page.locator('test-root-event .action').click(); + await page.locator('test-root-event .other').click(); + await expect(page.locator('test-root-event .total')).toHaveText('2'); + }); + + test('root handler receives the event object with composedPath', async ({ page }) => { + await page.locator('test-root-event .action').click(); + + const action = await page.evaluate(() => { + const el = document.querySelector('test-root-event') as any; + return el?.lastAction; + }); + expect(action).toBe('ping'); + }); + + test('root handler distinguishes data-action from composedPath', async ({ page }) => { + await page.locator('test-root-event .other').click(); + + const action = await page.evaluate(() => { + const el = document.querySelector('test-root-event') as any; + return el?.lastAction; + }); + expect(action).toBe('pong'); + }); +}); From e8c0c19ed726db67f441d9100104663826958712 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 27 Mar 2026 14:54:06 -0700 Subject: [PATCH 4/4] fix comment --- packages/webui-framework/src/element.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index fc3fd717..550fedec 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -1394,9 +1394,12 @@ export class WebUIElement extends HTMLElement { const method = (this as unknown as Record)[handler]; if (typeof method !== 'function') return; - // Root-level events (target === this) use a direct listener on the host. - // The host element lives outside the shadow DOM so it cannot be reached - // by the delegated-listener parentElement walk inside the shadow root. + // Root-level events (target === this) and non-Element targets use a + // direct listener. The host element lives outside the shadow DOM so it + // cannot be reached by the delegated parentElement walk. When a component + // has both root and inner handlers for the same event type, both fire — + // the inner via delegation, the root via this direct listener — matching + // standard DOM bubbling semantics. if (target === this || !(target instanceof Element)) { target.addEventListener(eventName, (e: Event) => { if (needsEvent) method.call(this, e);