@microsoft/webui-framework 0.0.4
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/LICENSE +21 -0
- package/README.md +722 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Microsoft Corporation.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
# `@microsoft/webui-framework`
|
|
2
|
+
|
|
3
|
+
Lightweight Web Component runtime for WebUI apps.
|
|
4
|
+
|
|
5
|
+
This package is the browser-side runtime used by `webui build --plugin=webui`. It provides:
|
|
6
|
+
|
|
7
|
+
- `WebUIElement` for SSR hydration and client-created elements
|
|
8
|
+
- `@observable`, `@attr`, and `@volatile` decorators
|
|
9
|
+
- compiled template path mapping for direct DOM binding resolution
|
|
10
|
+
- light DOM or shadow DOM rendering (`--dom=light|shadow` flag)
|
|
11
|
+
- SSR state seeding from `window.__webui_state` (like Preact's props)
|
|
12
|
+
|
|
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
|
+
|
|
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.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
In this workspace:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@microsoft/webui-framework": "workspace:*"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Outside the workspace:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm add @microsoft/webui-framework
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
TypeScript must use legacy decorators:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"compilerOptions": {
|
|
40
|
+
"experimentalDecorators": true,
|
|
41
|
+
"useDefineForClassFields": false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Example
|
|
47
|
+
|
|
48
|
+
1. Author a component class in TypeScript
|
|
49
|
+
2. Author a WebUI template in HTML
|
|
50
|
+
3. Run `webui build --plugin=webui`
|
|
51
|
+
4. The runtime hydrates SSR output or creates client-side components using compiled path mapping
|
|
52
|
+
|
|
53
|
+
### `counter-card.ts`
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { WebUIElement, attr, observable, volatile } from '@microsoft/webui-framework';
|
|
57
|
+
|
|
58
|
+
export class CounterCard extends WebUIElement {
|
|
59
|
+
@attr label = 'Clicks';
|
|
60
|
+
@observable count = 0;
|
|
61
|
+
|
|
62
|
+
@volatile
|
|
63
|
+
get doubled(): number {
|
|
64
|
+
return this.count * 2;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
increment(): void {
|
|
68
|
+
this.count += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
CounterCard.define('counter-card');
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `counter-card.html`
|
|
76
|
+
|
|
77
|
+
```html
|
|
78
|
+
<p>{{label}}: {{count}} ({{doubled}})</p>
|
|
79
|
+
<button @click="{increment()}">Increment</button>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Build with `--dom=shadow` (default) to wrap in a declarative shadow root, or `--dom=light` for light DOM rendering.
|
|
83
|
+
|
|
84
|
+
### Use it from your page
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<counter-card label="Taps"></counter-card>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Build with the WebUI plugin
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui
|
|
94
|
+
```
|
|
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`.
|
|
97
|
+
|
|
98
|
+
### DOM strategy (`--dom`)
|
|
99
|
+
|
|
100
|
+
The `--dom` flag controls how the server renders component content:
|
|
101
|
+
|
|
102
|
+
| Flag | Behavior |
|
|
103
|
+
|------|----------|
|
|
104
|
+
| `--dom=shadow` (default) | Wraps component HTML in `<template shadowrootmode="open">` |
|
|
105
|
+
| `--dom=light` | Renders component content as direct children of the host element |
|
|
106
|
+
|
|
107
|
+
The runtime auto-detects which mode was used at hydration time:
|
|
108
|
+
- If a `shadowRoot` already exists → shadow DOM SSR path
|
|
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)
|
|
111
|
+
|
|
112
|
+
Light DOM is useful for simpler styling (CSS inheritance works naturally) and
|
|
113
|
+
better search-engine indexing. Shadow DOM provides style encapsulation.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## API Reference
|
|
118
|
+
|
|
119
|
+
### `WebUIElement`
|
|
120
|
+
|
|
121
|
+
Base class for framework components.
|
|
122
|
+
|
|
123
|
+
| Member | Purpose |
|
|
124
|
+
|--------|---------|
|
|
125
|
+
| `static define(tagName)` | Register the class as a custom element |
|
|
126
|
+
| `$emit(name, detail?)` | Dispatch a bubbling, composed `CustomEvent` |
|
|
127
|
+
| `$update()` | Force a reactive update (normally called automatically) |
|
|
128
|
+
| `setInitialState(state, params?)` | Populate `@observable` properties from router state |
|
|
129
|
+
| `disconnectedCallback()` | Override for cleanup (global listeners, etc.) |
|
|
130
|
+
|
|
131
|
+
In most components you do not call `$update()` directly. Property changes through `@observable` and `@attr` trigger updates for you.
|
|
132
|
+
|
|
133
|
+
### `@observable`
|
|
134
|
+
|
|
135
|
+
Marks a property as reactive. When the value changes, the framework
|
|
136
|
+
re-evaluates the compiled bindings that reference it.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
class SearchPanel extends WebUIElement {
|
|
140
|
+
@observable open = false;
|
|
141
|
+
|
|
142
|
+
toggle(): void {
|
|
143
|
+
this.open = !this.open;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `@attr`
|
|
149
|
+
|
|
150
|
+
Like `@observable` but also reflects to/from an HTML attribute (kebab-case).
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
class ProductPrice extends WebUIElement {
|
|
154
|
+
@attr currency = 'USD';
|
|
155
|
+
@attr({ attribute: 'amount-cents' }) amountCents = '0';
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Notes:
|
|
160
|
+
|
|
161
|
+
- default attribute names use kebab-case
|
|
162
|
+
- attribute values arrive as strings
|
|
163
|
+
- use `@observable` for richer client-only state
|
|
164
|
+
|
|
165
|
+
### `@volatile`
|
|
166
|
+
|
|
167
|
+
Marks a computed getter that should be re-read whenever bindings access it.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
class CartSummary extends WebUIElement {
|
|
171
|
+
@observable items: Array<{ count: number }> = [];
|
|
172
|
+
|
|
173
|
+
@volatile
|
|
174
|
+
get totalCount(): number {
|
|
175
|
+
return this.items.reduce((sum, item) => sum + item.count, 0);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Template Features
|
|
181
|
+
|
|
182
|
+
The WebUI plugin compiles these template features into runtime metadata:
|
|
183
|
+
|
|
184
|
+
- text bindings: `{{title}}`
|
|
185
|
+
- attribute bindings: `href="{{item.href}}"`
|
|
186
|
+
- event handlers: `@click="{onClick()}"`
|
|
187
|
+
- refs: `w-ref="addInput"`
|
|
188
|
+
- conditionals: `<if condition="...">`
|
|
189
|
+
- repeats: `<for each="item in items">`
|
|
190
|
+
|
|
191
|
+
Example from `examples/app/todo-webui`:
|
|
192
|
+
|
|
193
|
+
```html
|
|
194
|
+
<h1>{{title}}</h1>
|
|
195
|
+
|
|
196
|
+
<input
|
|
197
|
+
class="add-input"
|
|
198
|
+
w-ref="addInput"
|
|
199
|
+
@keydown="{onAddKeydown(e)}"
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<for each="item in items">
|
|
203
|
+
<todo-item
|
|
204
|
+
id="{{item.id}}"
|
|
205
|
+
title="{{item.title}}"
|
|
206
|
+
state="{{item.state}}"
|
|
207
|
+
></todo-item>
|
|
208
|
+
</for>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Root-level events (e.g. `@toggle-item="{onToggleItem(e)}"`) can be declared on the component's host element and are wired via `meta.re`.
|
|
212
|
+
|
|
213
|
+
## Recommended Patterns
|
|
214
|
+
|
|
215
|
+
- Treat decorated properties as the source of truth.
|
|
216
|
+
- Update state with property assignments such as `this.open = !this.open`.
|
|
217
|
+
- Use `$emit()` for child-to-parent communication.
|
|
218
|
+
- Use `w-ref` for true DOM-only concerns like focus or reading input values.
|
|
219
|
+
- Prefer `@observable someValue!: T;` when a value is expected to be seeded externally after construction.
|
|
220
|
+
|
|
221
|
+
Avoid imperative DOM mutation for application state that can be represented by reactive properties.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Performance Philosophy
|
|
226
|
+
|
|
227
|
+
This framework is designed for **minimal memory, minimal work, zero waste**.
|
|
228
|
+
Every design decision optimizes for real-world interactive performance on
|
|
229
|
+
resource-constrained devices.
|
|
230
|
+
|
|
231
|
+
### Design principles
|
|
232
|
+
|
|
233
|
+
1. **No work on the hot path that doesn't change the DOM.**
|
|
234
|
+
`$update(path)` only visits bindings that reference the changed property.
|
|
235
|
+
Everything else is skipped via a per-path index built once at hydration time.
|
|
236
|
+
|
|
237
|
+
2. **Zero allocations during updates.**
|
|
238
|
+
Targeted updates are a single `Map.get()` → direct array iteration.
|
|
239
|
+
No intermediate arrays, no object creation, no spread operators on the
|
|
240
|
+
update path.
|
|
241
|
+
|
|
242
|
+
3. **Parse once, clone forever.**
|
|
243
|
+
Compiled template HTML is parsed via `innerHTML` once per component tag
|
|
244
|
+
and cached as a `DocumentFragment`. Every subsequent instance uses
|
|
245
|
+
`cloneNode(true)` — DOM cloning is significantly faster than HTML parsing.
|
|
246
|
+
|
|
247
|
+
4. **Delegate events, don't multiply listeners.**
|
|
248
|
+
Event bindings use delegation: one listener per event type on the
|
|
249
|
+
component root, with handler names resolved from compiled paths. 200
|
|
250
|
+
items × 5 events = 1 delegated listener, not 1000 closures.
|
|
251
|
+
|
|
252
|
+
5. **Single-pass hydration via path mapping.**
|
|
253
|
+
SSR DOM is matched to compiled template bindings through
|
|
254
|
+
template-parallel traversal (`$resolveSSR`). No marker comments, no
|
|
255
|
+
data attributes — just path-based node resolution. The hydration walk
|
|
256
|
+
touches each DOM node exactly once.
|
|
257
|
+
|
|
258
|
+
6. **Keep the framework out of the GC's way.**
|
|
259
|
+
Fewer JS objects = fewer GC pauses. Binding arrays are pre-built at
|
|
260
|
+
hydration time and reused across updates. No per-update temporaries.
|
|
261
|
+
|
|
262
|
+
### Benchmark fixtures
|
|
263
|
+
|
|
264
|
+
The `tests/fixtures/bench/` directory contains Playwright-driven benchmarks
|
|
265
|
+
that validate these properties:
|
|
266
|
+
|
|
267
|
+
- **Update throughput**: 50k single-prop mutations with 65 bindings
|
|
268
|
+
- **Repeat instantiation**: 200 items created from compiled templates
|
|
269
|
+
- **Event memory**: 1000 event bindings measured via heap snapshots
|
|
270
|
+
|
|
271
|
+
Run benchmarks with:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
cd packages/webui-framework
|
|
275
|
+
npx playwright test tests/fixtures/bench/
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### What NOT to do
|
|
279
|
+
|
|
280
|
+
When contributing to the runtime, avoid these patterns:
|
|
281
|
+
|
|
282
|
+
- **Don't allocate on the update path.** No `[...spread]`, no `new Map()`,
|
|
283
|
+
no object literals inside `$updateBindings` or `$updateInstance`.
|
|
284
|
+
- **Don't add `querySelector` calls during updates.** All DOM references are
|
|
285
|
+
pre-resolved at hydration time via compiled path mapping.
|
|
286
|
+
- **Don't use recursion in hot paths.** Condition evaluation and DOM walks
|
|
287
|
+
use iterative stacks.
|
|
288
|
+
- **Don't create closures per binding.** Use delegation or shared handlers.
|
|
289
|
+
- **Don't re-parse template HTML.** Always clone from the cached fragment.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Architecture
|
|
294
|
+
|
|
295
|
+
### How It Fits Together
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
┌──────────────────────┐ ┌───────────────────────┐ ┌──────────────────────┐
|
|
299
|
+
│ Rust Compiler │ │ Any Server │ │ Browser │
|
|
300
|
+
│ │ │ (Rust/Go/C#/…) │ │ │
|
|
301
|
+
│ HTML template │ │ │ │ SSR HTML (light or │
|
|
302
|
+
│ + expressions │────▶│ TemplateMeta (JSON) │────▶│ shadow DOM) + │
|
|
303
|
+
│ + @if / @for │ │ + state data │ │ __webui_state JSON │
|
|
304
|
+
│ │ │ │ │ │
|
|
305
|
+
│ Outputs: │ │ Renders: │ │ Hydrates: │
|
|
306
|
+
│ • TemplateMeta │ │ • Full HTML page │ │ • Path-based DOM │
|
|
307
|
+
│ • Static HTML │ │ • Shadow or light │ │ resolution │
|
|
308
|
+
│ • Binding metadata │ │ • State as JSON │ │ • O(1) updates │
|
|
309
|
+
└──────────────────────┘ └───────────────────────┘ └──────────────────────┘
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Key differentiator: language-agnostic SSR.** React, Solid, Svelte, and
|
|
313
|
+
Angular all require a JavaScript runtime on the server. This framework's SSR
|
|
314
|
+
is driven by data (template metadata + state values), not code. Any language
|
|
315
|
+
that can read the compiled metadata and produce HTML can serve as the SSR
|
|
316
|
+
backend. No comment markers or data attributes are needed — the runtime
|
|
317
|
+
resolves SSR DOM nodes via template-parallel path traversal.
|
|
318
|
+
|
|
319
|
+
### Build → Serve → Hydrate → Update
|
|
320
|
+
|
|
321
|
+
```mermaid
|
|
322
|
+
flowchart LR
|
|
323
|
+
subgraph Build ["Build Time (Rust)"]
|
|
324
|
+
T[HTML Template] --> P[Parser Plugin]
|
|
325
|
+
P --> M[TemplateMeta JSON]
|
|
326
|
+
P --> H[Static HTML]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
subgraph Serve ["Server (Any Language)"]
|
|
330
|
+
M --> R[Route Handler]
|
|
331
|
+
S[State Data] --> R
|
|
332
|
+
R --> HTML["Full SSR HTML<br/>(shadow or light DOM)<br/>+ TemplateMeta <script><br/>+ __webui_state <script>"]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
subgraph Browser ["Browser"]
|
|
336
|
+
HTML --> CE[Custom Element Upgrade]
|
|
337
|
+
CE --> MT{$mount}
|
|
338
|
+
MT -- SSR DOM exists --> SSR["$applySSRState<br/>$hydrate (path-based)"]
|
|
339
|
+
MT -- No SSR DOM --> CL["$wire (from template)"]
|
|
340
|
+
SSR --> BIND[Binding Arrays]
|
|
341
|
+
CL --> BIND
|
|
342
|
+
BIND --> UPD["$update() — O(1) patches"]
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Module Structure
|
|
347
|
+
|
|
348
|
+
```mermaid
|
|
349
|
+
graph TD
|
|
350
|
+
EL["element.ts (~850 lines)<br/><i>Orchestrator</i><br/>$mount, $wire, $hydrate,<br/>$resolveSSR, $applySSRState,<br/>$update, events, cleanup"]
|
|
351
|
+
|
|
352
|
+
DIFF["element/diff.ts (~130 lines)<br/><i>List Reconciliation</i><br/>keyed/sequential diffing<br/>for @for repeat blocks"]
|
|
353
|
+
|
|
354
|
+
COND["element/conditions.ts<br/><i>Condition Evaluation</i><br/>evaluateCondition (iterative),<br/>conditionUsesPath"]
|
|
355
|
+
|
|
356
|
+
TYPES["element/types.ts<br/><i>Shared Types</i><br/>TemplateInstance, TextBinding,<br/>AttrBinding, CondBinding,<br/>RepeatBinding, ScopeFrame,<br/>RepeatHost"]
|
|
357
|
+
|
|
358
|
+
TMPL["template.ts<br/><i>Metadata Types + Registry</i><br/>TemplateMeta, getTemplate"]
|
|
359
|
+
|
|
360
|
+
DEC["decorators.ts<br/><i>Reactive Properties</i><br/>@observable, @attr, @volatile"]
|
|
361
|
+
|
|
362
|
+
LIFE["lifecycle.ts<br/><i>Hydration Timing</i><br/>Performance marks,<br/>hydration-complete event"]
|
|
363
|
+
|
|
364
|
+
EL --> DIFF
|
|
365
|
+
EL --> COND
|
|
366
|
+
EL --> TMPL
|
|
367
|
+
EL --> DEC
|
|
368
|
+
EL --> LIFE
|
|
369
|
+
DIFF --> TYPES
|
|
370
|
+
EL --> TYPES
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Lifecycle Detail
|
|
376
|
+
|
|
377
|
+
### SSR Hydration Path
|
|
378
|
+
|
|
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`
|
|
381
|
+
JSON payload. The browser parses this DOM before any JavaScript runs.
|
|
382
|
+
When the component's JS loads and `connectedCallback` fires, the framework
|
|
383
|
+
uses compiled template paths to resolve SSR DOM nodes without any marker
|
|
384
|
+
comments or data attributes:
|
|
385
|
+
|
|
386
|
+
```mermaid
|
|
387
|
+
sequenceDiagram
|
|
388
|
+
participant Server
|
|
389
|
+
participant Browser
|
|
390
|
+
participant CE as Custom Element
|
|
391
|
+
participant FW as Framework
|
|
392
|
+
|
|
393
|
+
Server->>Browser: HTML (shadow or light DOM)<br/>+ __webui_state JSON
|
|
394
|
+
Browser->>Browser: Parse HTML → DOM exists
|
|
395
|
+
Browser->>CE: Custom element upgrade
|
|
396
|
+
CE->>CE: attributeChangedCallback (pre-existing attrs)
|
|
397
|
+
CE->>FW: connectedCallback() → $mount()
|
|
398
|
+
FW->>FW: SSR DOM detected (shadow root or children exist)
|
|
399
|
+
FW->>FW: $applySSRState() — seed observables from __webui_state
|
|
400
|
+
FW->>FW: $hydrate() — template-parallel path resolution
|
|
401
|
+
FW->>FW: $resolveSSR() — match SSR nodes via ordinal traversal
|
|
402
|
+
FW->>FW: $wireEvents() + $wireRefs()
|
|
403
|
+
FW->>FW: $buildPathIndex(), $ready = true
|
|
404
|
+
Note over FW: DOM is already correct from SSR.<br/>No $update() call needed.
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Client-Created Path
|
|
408
|
+
|
|
409
|
+
When a component is created dynamically (e.g. inside a `@for` loop or via
|
|
410
|
+
`document.createElement`), there's no SSR DOM:
|
|
411
|
+
|
|
412
|
+
```mermaid
|
|
413
|
+
sequenceDiagram
|
|
414
|
+
participant App
|
|
415
|
+
participant CE as Custom Element
|
|
416
|
+
participant FW as Framework
|
|
417
|
+
|
|
418
|
+
App->>CE: document.createElement('my-comp')
|
|
419
|
+
App->>CE: Append to DOM
|
|
420
|
+
CE->>FW: connectedCallback() → $mount()
|
|
421
|
+
FW->>FW: No SSR DOM → client path
|
|
422
|
+
FW->>FW: Parse + clone template from meta.h
|
|
423
|
+
FW->>FW: Attach to shadow root or light DOM
|
|
424
|
+
FW->>FW: $wire(root, meta) — resolve via childNode paths
|
|
425
|
+
FW->>FW: $wireEvents() + $wireRefs()
|
|
426
|
+
FW->>FW: $buildPathIndex(), $ready = true
|
|
427
|
+
FW->>FW: $update() — flush initial property values
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Compiled Template Metadata
|
|
433
|
+
|
|
434
|
+
The Rust compiler transforms HTML templates into a `TemplateMeta` JSON object
|
|
435
|
+
that describes every dynamic binding without any template syntax. This object
|
|
436
|
+
is delivered to the browser as a `<script>` tag.
|
|
437
|
+
|
|
438
|
+
### Metadata Shape
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
interface TemplateMeta {
|
|
442
|
+
h: string; // Static HTML (no markers)
|
|
443
|
+
tx?: [slot, parts][]; // Text run locators
|
|
444
|
+
a?: CompiledAttrMeta[]; // Attribute bindings
|
|
445
|
+
ag?: [path, start, count][]; // Attribute target groups
|
|
446
|
+
c?: [conditionAST, blockIndex][]; // Conditional blocks
|
|
447
|
+
cl?: SlotPath[]; // Conditional anchor slots
|
|
448
|
+
r?: [collection, itemVar, blockIdx][];// Repeat blocks
|
|
449
|
+
rl?: SlotPath[]; // Repeat anchor slots
|
|
450
|
+
e?: [event, handler, needsEvent][]; // Events
|
|
451
|
+
el?: NodePath[]; // Event target paths
|
|
452
|
+
b?: TemplateBlockMeta[]; // Nested block metadata
|
|
453
|
+
sa?: string; // Adopted stylesheet specifier
|
|
454
|
+
sd?: boolean; // Shadow DOM flag for client-created
|
|
455
|
+
re?: [event, handler, needsEvent][]; // Root-level events
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Example
|
|
460
|
+
|
|
461
|
+
Template:
|
|
462
|
+
```html
|
|
463
|
+
<h1>{{title}}</h1>
|
|
464
|
+
<button @click="increment">Count: {{count}}</button>
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Compiled metadata:
|
|
468
|
+
```javascript
|
|
469
|
+
{
|
|
470
|
+
h: '<h1></h1><button>Count: </button>',
|
|
471
|
+
tx: [
|
|
472
|
+
[[[0], 0], [["title"]]], // slot in <h1>, dynamic "title"
|
|
473
|
+
[[[1], 1], ["Count: ", ["count"]]] // slot in <button>, static + dynamic
|
|
474
|
+
],
|
|
475
|
+
e: [["click", "increment", 0]], // click → increment, no event arg
|
|
476
|
+
el: [[1]] // event target is child[1] (button)
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Condition AST
|
|
481
|
+
|
|
482
|
+
Conditions are emitted as compact tuples:
|
|
483
|
+
|
|
484
|
+
| Tuple | Meaning | Example |
|
|
485
|
+
|-------|---------|---------|
|
|
486
|
+
| `[0, path]` | Identifier (truthy check) | `@if(visible)` |
|
|
487
|
+
| `[1, left, op, right]` | Comparison predicate | `@if(count > 0)` |
|
|
488
|
+
| `[2, inner]` | Logical NOT | `@if(!visible)` |
|
|
489
|
+
| `[3, left, op, right]` | Compound AND/OR | `@if(a && b)` |
|
|
490
|
+
|
|
491
|
+
The runtime evaluates these iteratively (stack-based, no recursion) to avoid
|
|
492
|
+
call-stack depth in hot update paths.
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Reactive Update Model
|
|
497
|
+
|
|
498
|
+
### How `@observable` Triggers Updates
|
|
499
|
+
|
|
500
|
+
```mermaid
|
|
501
|
+
sequenceDiagram
|
|
502
|
+
participant App as Application Code
|
|
503
|
+
participant Dec as @observable setter
|
|
504
|
+
participant FW as $update('count')
|
|
505
|
+
participant IDX as Path Index
|
|
506
|
+
participant DOM
|
|
507
|
+
|
|
508
|
+
App->>Dec: this.count = 5
|
|
509
|
+
Dec->>Dec: Store in _count backing field
|
|
510
|
+
Dec->>Dec: Call countChanged(old, new) if defined
|
|
511
|
+
Dec->>FW: $update('count') (if element.isConnected)
|
|
512
|
+
FW->>IDX: Look up 'count' bindings + '*' wildcards
|
|
513
|
+
IDX-->>FW: 2 text bindings + 1 volatile binding
|
|
514
|
+
FW->>DOM: Patch only affected nodes
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### Why Updates Are O(affected)
|
|
518
|
+
|
|
519
|
+
After hydration, every dynamic value in the template is connected to a direct
|
|
520
|
+
DOM node reference stored in a binding array. A per-path index maps each
|
|
521
|
+
`@observable` property name to the subset of bindings that reference it.
|
|
522
|
+
|
|
523
|
+
When `this.count = 5` fires, the `@observable` setter calls `$update('count')`,
|
|
524
|
+
which looks up `'count'` in the index and only patches the bindings that
|
|
525
|
+
actually depend on `count` — not every binding in the component.
|
|
526
|
+
|
|
527
|
+
Computed/volatile getters (paths not in the `@observable` set) are stored
|
|
528
|
+
under a wildcard key and always included in targeted updates.
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
// Targeted update (simplified):
|
|
532
|
+
const entry = this.$pathIndex.get(path); // O(1) map lookup
|
|
533
|
+
const wild = this.$pathIndex.get('*'); // volatile/computed bindings
|
|
534
|
+
// Only walk affected bindings, not all 65+
|
|
535
|
+
for (const binding of [...entry.texts, ...wild.texts]) {
|
|
536
|
+
if (binding.node.textContent !== str) {
|
|
537
|
+
binding.node.textContent = str; // Direct Text node reference
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
No virtual DOM diffing. No selector queries. No tree walking. Each binding
|
|
543
|
+
is a pre-resolved pointer to the exact DOM node that needs updating, and the
|
|
544
|
+
path index ensures only affected pointers are visited.
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## SSR State Seeding
|
|
549
|
+
|
|
550
|
+
When the server renders `<span>42</span>` for `@observable count = 0`, the
|
|
551
|
+
browser sees `42` in the DOM but the JavaScript property `this.count` is still
|
|
552
|
+
`0` (the class default). Without seeding, the first `$update()` would
|
|
553
|
+
overwrite the SSR content with the wrong value.
|
|
554
|
+
|
|
555
|
+
State seeding uses `window.__webui_state` — a JSON object emitted by the
|
|
556
|
+
server handler as a `<script>` tag. Like Preact's props, this delivers the
|
|
557
|
+
same data used for SSR rendering to the client. During `$mount()`,
|
|
558
|
+
`$applySSRState()` writes matching keys directly to observable backing fields
|
|
559
|
+
before any bindings are wired:
|
|
560
|
+
|
|
561
|
+
```mermaid
|
|
562
|
+
flowchart LR
|
|
563
|
+
SCRIPT["<script><br/>window.__webui_state = {<br/> count: 42,<br/> title: 'Hello'<br/>}"] --> APPLY["$applySSRState()"]
|
|
564
|
+
APPLY --> SEED["Write to backing fields:<br/>this._count = 42<br/>this._title = 'Hello'"]
|
|
565
|
+
SEED --> HYDRATE["$hydrate() — bindings match<br/>server-rendered DOM"]
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
`$applySSRState()` only sets properties that exist in the component's
|
|
569
|
+
`@observable` set — unknown keys are ignored. Writes go to the backing
|
|
570
|
+
field (`_prop`) directly, avoiding reactive updates before bindings are wired.
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## Repeat Reconciliation
|
|
575
|
+
|
|
576
|
+
`@for(item of items)` blocks support two reconciliation strategies,
|
|
577
|
+
implemented in `element/diff.ts` (~130 lines):
|
|
578
|
+
|
|
579
|
+
### Keyed Reconciliation
|
|
580
|
+
|
|
581
|
+
When the repeat block's root element has attribute bindings (e.g.
|
|
582
|
+
`<todo-item id="{{item.id}}">`), the framework uses the first attribute as a
|
|
583
|
+
key. This preserves DOM nodes across reorders:
|
|
584
|
+
|
|
585
|
+
```mermaid
|
|
586
|
+
flowchart TD
|
|
587
|
+
subgraph Before ["Before: items = [A, B, C]"]
|
|
588
|
+
A1["<todo-item> key=A"]
|
|
589
|
+
B1["<todo-item> key=B"]
|
|
590
|
+
C1["<todo-item> key=C"]
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
subgraph After ["After: items = [C, A]"]
|
|
594
|
+
C2["<todo-item> key=C ← reused"]
|
|
595
|
+
A2["<todo-item> key=A ← reused"]
|
|
596
|
+
B2["key=B ← removed"]
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
A1 -.->|"moved"| A2
|
|
600
|
+
C1 -.->|"moved"| C2
|
|
601
|
+
B1 -.->|"destroyed"| B2
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Sequential Reconciliation
|
|
605
|
+
|
|
606
|
+
When no keying attributes exist, items are matched by position. Excess items
|
|
607
|
+
are removed; new items are appended.
|
|
608
|
+
|
|
609
|
+
### SSR State Reading
|
|
610
|
+
|
|
611
|
+
On initial hydration, the repeat system walks existing SSR children and
|
|
612
|
+
reconstructs collection instances by matching them against the compiled
|
|
613
|
+
template via `$resolveSSR` path traversal. State is already seeded from
|
|
614
|
+
`window.__webui_state`, so repeat items reflect the server-rendered list
|
|
615
|
+
without parsing marker comments.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## CSS Strategies
|
|
620
|
+
|
|
621
|
+
The framework supports three CSS delivery strategies:
|
|
622
|
+
|
|
623
|
+
| Strategy | How it works |
|
|
624
|
+
|----------|-------------|
|
|
625
|
+
| **Link** | `<link>` tag baked into `meta.h` — loaded by the browser naturally |
|
|
626
|
+
| **Inline** | `<style>` tag baked into `meta.h` — no external request |
|
|
627
|
+
| **Module** | `<style type="module" specifier="tag-name">` in the HTML payload, parsed into a `CSSStyleSheet` and applied via `adoptedStyleSheets` for shadow DOM isolation |
|
|
628
|
+
|
|
629
|
+
CSS module stylesheets are cached so each component instance adopts the same
|
|
630
|
+
parsed sheet without re-parsing CSS. The `meta.sa` field specifies the
|
|
631
|
+
stylesheet specifier for a component.
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## Path-Based Binding Resolution
|
|
636
|
+
|
|
637
|
+
Unlike frameworks that use comment markers or data attributes to locate
|
|
638
|
+
dynamic content, this framework uses **compiled template paths** — arrays of
|
|
639
|
+
child-node indices that describe exactly where each binding lives in the DOM
|
|
640
|
+
tree.
|
|
641
|
+
|
|
642
|
+
### Client-created resolution (`$resolve`)
|
|
643
|
+
|
|
644
|
+
For client-created components, the DOM matches `meta.h` exactly (it was cloned
|
|
645
|
+
from the parsed template fragment). Resolution is a simple child-node index
|
|
646
|
+
walk:
|
|
647
|
+
|
|
648
|
+
```typescript
|
|
649
|
+
// path = [1, 0] → root.childNodes[1].childNodes[0]
|
|
650
|
+
let cur: Node = root;
|
|
651
|
+
for (const idx of path) {
|
|
652
|
+
cur = cur.childNodes[idx];
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### SSR resolution (`$resolveSSR`)
|
|
657
|
+
|
|
658
|
+
SSR DOM may differ from the compiled template — the browser's HTML parser can
|
|
659
|
+
strip whitespace-only text nodes. `$resolveSSR` walks the SSR DOM and the
|
|
660
|
+
compiled template DOM **in parallel**, translating each child-node index into
|
|
661
|
+
an element-ordinal or text-ordinal lookup:
|
|
662
|
+
|
|
663
|
+
```typescript
|
|
664
|
+
// For element nodes: count element siblings up to idx in template,
|
|
665
|
+
// then find the element at that ordinal in SSR DOM.
|
|
666
|
+
// For text nodes: same approach with text node ordinals.
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
This template-parallel traversal eliminates the need for any marker comments,
|
|
670
|
+
`data-*` attributes, or DOM annotations. The SSR server emits clean HTML.
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Performance Characteristics
|
|
675
|
+
|
|
676
|
+
| Operation | Cost | Why |
|
|
677
|
+
|-----------|------|-----|
|
|
678
|
+
| Initial hydration | O(bindings) | Single pass over compiled path mappings |
|
|
679
|
+
| Reactive update | O(affected) | Per-path index skips unrelated bindings |
|
|
680
|
+
| Conditional toggle | O(block size) | Create/destroy a block instance |
|
|
681
|
+
| Repeat reconciliation | O(items) | Keyed map lookup or sequential scan |
|
|
682
|
+
| Event wiring | O(events) | One-time during hydration |
|
|
683
|
+
|
|
684
|
+
### What the framework does NOT do
|
|
685
|
+
|
|
686
|
+
- **No virtual DOM** — no tree copy, no diff algorithm
|
|
687
|
+
- **No runtime template parsing** — the Rust compiler handles all syntax
|
|
688
|
+
- **No `innerHTML` on updates** — only `textContent` and `setAttribute`
|
|
689
|
+
- **No `querySelector` on updates** — all nodes are pre-resolved references
|
|
690
|
+
- **No recursion in hot paths** — conditions use iterative stack evaluation
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## Debugging Hydration
|
|
695
|
+
|
|
696
|
+
The runtime exposes hydration timing via the Performance API:
|
|
697
|
+
|
|
698
|
+
- Per component: `webui:hydrate:<tag>:start` / `webui:hydrate:<tag>:end`
|
|
699
|
+
- Global: `webui:hydrate:total:start` / `webui:hydrate:total:end`
|
|
700
|
+
- Window event: `webui:hydration-complete`
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
window.addEventListener('webui:hydration-complete', () => {
|
|
704
|
+
console.log('All initial framework components are hydrated.');
|
|
705
|
+
});
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
## Where to Look Next
|
|
711
|
+
|
|
712
|
+
- `examples/app/todo-webui`
|
|
713
|
+
- `examples/app/contact-book-manager`
|
|
714
|
+
- `examples/app/commerce`
|
|
715
|
+
|
|
716
|
+
## Package Development
|
|
717
|
+
|
|
718
|
+
```bash
|
|
719
|
+
pnpm --dir packages/webui-framework build
|
|
720
|
+
pnpm --dir packages/webui-framework typecheck
|
|
721
|
+
pnpm --dir packages/webui-framework test
|
|
722
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@microsoft/webui-framework",
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "WebUI Framework Next — Preact-inspired lightweight Web Component runtime with SSR hydration. 15KB minified, compiled-template path mapping, no hydration markers.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@playwright/test": "^1.58.2",
|
|
20
|
+
"@types/node": "^25.3.5",
|
|
21
|
+
"esbuild": "^0.27.3",
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"@microsoft/webui-test-support": "0.0.4"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "find src -name '*.ts' ! -name '*.test.ts' | xargs esbuild --outdir=dist --outbase=src --format=esm --platform=browser && tsc --emitDeclarationOnly",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"typecheck:e2e": "tsc -p tsconfig.test.json --noEmit",
|
|
29
|
+
"build:e2e:scripts": "esbuild ./tests/server.ts --bundle --external:esbuild --outdir=tests/dist --format=esm --platform=node --target=es2022",
|
|
30
|
+
"build:e2e": "pnpm build:e2e:scripts",
|
|
31
|
+
"test": "pnpm test:unit && pnpm test:e2e",
|
|
32
|
+
"test:unit": "esbuild \"src/**/*.test.ts\" --bundle --outdir=dist/tests --outbase=src --format=esm --platform=node --target=es2022 && node --test \"dist/tests/**/*.test.js\"",
|
|
33
|
+
"test:e2e": "pnpm typecheck:e2e && pnpm build:e2e && playwright test",
|
|
34
|
+
"test:update-snapshots": "pnpm test:e2e:update-snapshots",
|
|
35
|
+
"test:e2e:update-snapshots": "pnpm typecheck:e2e && pnpm build:e2e && playwright test --update-snapshots"
|
|
36
|
+
}
|
|
37
|
+
}
|