@microsoft/webui-framework 0.0.17 → 0.0.19
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/README.md +164 -70
- package/dist/component-asset.d.ts +30 -0
- package/dist/component-asset.js +213 -0
- package/dist/decorators.d.ts +3 -53
- package/dist/decorators.js +96 -171
- package/dist/element/diff.d.ts +3 -21
- package/dist/element/diff.js +201 -131
- package/dist/element/markers.d.ts +0 -44
- package/dist/element/markers.js +18 -59
- package/dist/element/styles.d.ts +0 -10
- package/dist/element/styles.js +2 -63
- package/dist/element/types.d.ts +20 -47
- package/dist/element/types.js +3 -3
- package/dist/element.d.ts +13 -131
- package/dist/element.js +102 -1355
- package/dist/hydration-mismatch.d.ts +22 -0
- package/dist/hydration-mismatch.js +88 -0
- package/dist/index.d.ts +0 -19
- package/dist/index.js +2 -21
- package/dist/lifecycle.d.ts +0 -8
- package/dist/lifecycle.js +0 -32
- package/dist/static-host.d.ts +1 -0
- package/dist/static-host.js +66 -0
- package/dist/template-element.d.ts +87 -0
- package/dist/template-element.js +1392 -0
- package/dist/template-events.d.ts +6 -0
- package/dist/template-events.js +23 -0
- package/dist/template-roots.d.ts +4 -0
- package/dist/template-roots.js +24 -0
- package/dist/template-types.d.ts +18 -16
- package/dist/template-types.js +0 -2
- package/dist/template.d.ts +3 -18
- package/dist/template.js +9 -2
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@ This package is the browser-side runtime used by `webui build --plugin=webui`. I
|
|
|
6
6
|
|
|
7
7
|
- `WebUIElement` for SSR hydration and client-created elements
|
|
8
8
|
- `@observable`, `@attr`, and `@volatile` decorators
|
|
9
|
-
-
|
|
9
|
+
- direct DOM binding updates
|
|
10
10
|
- light DOM or shadow DOM rendering (`--dom=light|shadow` flag)
|
|
11
|
-
- SSR state seeding
|
|
11
|
+
- SSR state seeding
|
|
12
12
|
|
|
13
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
14
|
|
|
@@ -87,13 +87,44 @@ Build with `--dom=shadow` (default) to wrap in a declarative shadow root, or `--
|
|
|
87
87
|
<counter-card label="Taps"></counter-card>
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
### HTML-only dormant components
|
|
91
|
+
|
|
92
|
+
If a component has no event handlers, custom lifecycle code, or client-only
|
|
93
|
+
methods, it can ship only `component.html` and optional `component.css`.
|
|
94
|
+
|
|
95
|
+
The sibling `.ts` or `.js` file is the authored behavior boundary. With
|
|
96
|
+
manifest-enabled projection, only `@observable` and `@attr` fields opt into
|
|
97
|
+
initial state hydration; template-only roots stay in the trusted SSR DOM.
|
|
98
|
+
Without a module, template bindings render on the server and the component
|
|
99
|
+
contributes no projected keys. Without projection metadata, the server
|
|
100
|
+
preserves full state. The compiler still emits template metadata for scriptless
|
|
101
|
+
components. When the framework is loaded, it can activate that template when
|
|
102
|
+
browser state or client-side creation needs it.
|
|
103
|
+
If that first write omits a repeat collection, the host preserves the existing
|
|
104
|
+
SSR items until the collection is explicitly supplied.
|
|
105
|
+
|
|
106
|
+
Create a custom element only for an Interactive Island: event handlers, custom
|
|
107
|
+
lifecycle code, imperative methods, or state that TypeScript code reads or
|
|
108
|
+
mutates. `@observable` and `@attr` are optional; add them when JavaScript needs
|
|
109
|
+
to access the value or when the value is part of the component's public API.
|
|
110
|
+
|
|
90
111
|
### Build with the WebUI plugin
|
|
91
112
|
|
|
92
113
|
```bash
|
|
93
114
|
cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui
|
|
94
115
|
```
|
|
95
116
|
|
|
96
|
-
The
|
|
117
|
+
The WebUI plugin prepares component templates for the browser. Bundle your
|
|
118
|
+
source browser entry directly. Import `@microsoft/webui-framework` from authored
|
|
119
|
+
component modules. An app that stays static after SSR needs no framework
|
|
120
|
+
browser import. Import the framework once when HTML-only components must accept
|
|
121
|
+
browser state or participate in soft navigation.
|
|
122
|
+
|
|
123
|
+
The plugin alone preserves full server state. To emit exact `@observable` and
|
|
124
|
+
`@attr` state surfaces, run the application's bundler first with
|
|
125
|
+
`@microsoft/webui/projection.js`, then pass its manifest to `webui build` with
|
|
126
|
+
`--projection-manifest`. The manifest tooling is build-only; this runtime
|
|
127
|
+
package does not depend on esbuild or TypeScript.
|
|
97
128
|
|
|
98
129
|
### Property binding lifecycle
|
|
99
130
|
|
|
@@ -103,7 +134,29 @@ Property bindings use the `:` prefix to pass values directly to child DOM proper
|
|
|
103
134
|
<profile-card :config="{{settings}}"></profile-card>
|
|
104
135
|
```
|
|
105
136
|
|
|
106
|
-
For client-created component trees,
|
|
137
|
+
For client-created component trees, WebUI applies initial property bindings
|
|
138
|
+
before child `connectedCallback` methods run. A child can read an initial
|
|
139
|
+
parent-provided property in `connectedCallback`. If the parent value is not set,
|
|
140
|
+
the child may initialize its own fallback there, and later parent updates still
|
|
141
|
+
flow through the live binding.
|
|
142
|
+
|
|
143
|
+
During SSR hydration the framework trusts the server-rendered DOM and does not
|
|
144
|
+
re-render it. An `@observable` written before hydration finishes — in a field
|
|
145
|
+
initializer, the `constructor`, or before `super.connectedCallback()` — cannot
|
|
146
|
+
update that DOM, so the write is dropped and the runtime logs a
|
|
147
|
+
`[WebUI] Hydration mismatch` warning naming the properties. Seed such values in
|
|
148
|
+
the SSR state, or assign them after `super.connectedCallback()`. The warning is
|
|
149
|
+
development-only and is dead-code-eliminated from production bundles via the
|
|
150
|
+
`__WEBUI_DEV__` compile-time flag (on by default; `webui-press build` sets it to
|
|
151
|
+
`false`). See the
|
|
152
|
+
[Interactivity Guide](https://microsoft.github.io/webui/guide/concepts/interactivity#setting-observable-state-during-setup).
|
|
153
|
+
|
|
154
|
+
`super.connectedCallback()` is the synchronous hydration boundary for an
|
|
155
|
+
authored component. When it returns, bindings, events, and `w-ref` references
|
|
156
|
+
are wired. Use a parser-inserted, non-async ES module script or a classic
|
|
157
|
+
`defer` script. A blocking classic script must follow every SSR instance it may
|
|
158
|
+
upgrade. Descendants must not structurally mutate a containing component's SSR
|
|
159
|
+
subtree before it hydrates, because hydration relies on stable compiled paths.
|
|
107
160
|
|
|
108
161
|
### DOM strategy (`--dom`)
|
|
109
162
|
|
|
@@ -135,15 +188,50 @@ Base class for framework components.
|
|
|
135
188
|
| `static define(tagName)` | Register the class as a custom element |
|
|
136
189
|
| `$emit(name, detail?)` | Dispatch a bubbling, composed `CustomEvent` |
|
|
137
190
|
| `$update()` | Force a reactive update (normally called automatically) |
|
|
138
|
-
| `setState(state)` | Populate `@observable` properties from router/server state |
|
|
139
191
|
| `disconnectedCallback()` | Override for cleanup (global listeners, etc.) |
|
|
140
192
|
|
|
141
193
|
In most components you do not call `$update()` directly. Property changes through `@observable` and `@attr` trigger updates for you.
|
|
142
194
|
|
|
195
|
+
### Static component assets
|
|
196
|
+
|
|
197
|
+
`webui build --plugin=webui --emit-component-assets settings-dialog` emits
|
|
198
|
+
`settings-dialog.webui.js` next to `protocol.bin`. Load the ESM asset before
|
|
199
|
+
creating the component when you are not using `@microsoft/webui-router`:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { settingsAssets } from './lazy-assets.js';
|
|
203
|
+
|
|
204
|
+
settingsAssets.preload('settings-dialog');
|
|
205
|
+
panelSlot.replaceChildren(await settingsAssets.create('settings-dialog'));
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// lazy-assets.ts
|
|
210
|
+
import { defineComponentAssets } from '@microsoft/webui-framework/component-asset.js';
|
|
211
|
+
|
|
212
|
+
export const settingsAssets = defineComponentAssets({
|
|
213
|
+
'settings-dialog': {
|
|
214
|
+
asset: '/settings-dialog.webui.js',
|
|
215
|
+
module: () => import('./settings-dialog/settings-dialog.js'),
|
|
216
|
+
data: async () => await (await fetch('/settings-dialog-data.json')).json(),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The asset module carries the component's template and style payload. Use
|
|
222
|
+
`preload(tag)` to start template, module, and optional data work early, then
|
|
223
|
+
`create(tag)` to create the element after template/module work is ready.
|
|
224
|
+
Concurrent asset requests share one in-flight load and CSS module styles are
|
|
225
|
+
deduped. `create(tag)` does not block on optional data by default. Use
|
|
226
|
+
`create(tag, { awaitData: true, dataTimeoutMs: 150 })` only when a component must
|
|
227
|
+
wait briefly for state before mounting.
|
|
228
|
+
|
|
143
229
|
### `@observable`
|
|
144
230
|
|
|
145
|
-
Marks a property as reactive.
|
|
146
|
-
|
|
231
|
+
Marks a property as reactive. When the value changes, the framework
|
|
232
|
+
updates template bindings that reference it. Use it for state that TypeScript
|
|
233
|
+
code reads or mutates. Values used only by the template do not need an
|
|
234
|
+
`@observable` class field.
|
|
147
235
|
|
|
148
236
|
```ts
|
|
149
237
|
class SearchPanel extends WebUIElement {
|
|
@@ -170,7 +258,8 @@ Notes:
|
|
|
170
258
|
|
|
171
259
|
- default attribute names use kebab-case
|
|
172
260
|
- attribute values arrive as strings
|
|
173
|
-
-
|
|
261
|
+
- during SSR hydration, an existing host attribute wins over projected state
|
|
262
|
+
- use `@observable` for state that client code reads or mutates
|
|
174
263
|
|
|
175
264
|
### `@volatile`
|
|
176
265
|
|
|
@@ -189,7 +278,7 @@ class CartSummary extends WebUIElement {
|
|
|
189
278
|
|
|
190
279
|
## Template Features
|
|
191
280
|
|
|
192
|
-
The WebUI plugin
|
|
281
|
+
The WebUI plugin supports these template features:
|
|
193
282
|
|
|
194
283
|
- text bindings: `{{title}}`
|
|
195
284
|
- attribute bindings: `href="{{item.href}}"`
|
|
@@ -198,6 +287,10 @@ The WebUI plugin compiles these template features into runtime metadata:
|
|
|
198
287
|
- conditionals: `<if condition="...">`
|
|
199
288
|
- repeats: `<for each="item in items">`
|
|
200
289
|
|
|
290
|
+
Components that use `@event` must have authored `.ts` or `.js` code that
|
|
291
|
+
defines a `WebUIElement` for the tag. HTML-only components do not provide
|
|
292
|
+
application event handlers.
|
|
293
|
+
|
|
201
294
|
Example from `examples/app/todo-webui`:
|
|
202
295
|
|
|
203
296
|
```html
|
|
@@ -222,11 +315,17 @@ Root-level events (e.g. `@toggle-item="{onToggleItem(e)}"`) can be declared on t
|
|
|
222
315
|
|
|
223
316
|
## Recommended Patterns
|
|
224
317
|
|
|
225
|
-
- Treat decorated properties as the source of truth
|
|
318
|
+
- Treat decorated properties as the source of truth for state used by
|
|
319
|
+
TypeScript code.
|
|
226
320
|
- Update state with property assignments such as `this.open = !this.open`.
|
|
227
321
|
- Use `$emit()` for child-to-parent communication.
|
|
228
322
|
- Use `w-ref` for true DOM-only concerns like focus or reading input values.
|
|
229
|
-
-
|
|
323
|
+
- Omit `@observable` for values that are only read by the template and seeded
|
|
324
|
+
externally after construction.
|
|
325
|
+
- Omit the TypeScript class when compiled template behavior is sufficient,
|
|
326
|
+
including browser-applied state, route updates, and client-created instances.
|
|
327
|
+
Add a same-named module only for authored events, lifecycle, decorators, or
|
|
328
|
+
imperative APIs.
|
|
230
329
|
|
|
231
330
|
Avoid imperative DOM mutation for application state that can be represented by reactive properties.
|
|
232
331
|
|
|
@@ -360,7 +459,7 @@ flowchart LR
|
|
|
360
459
|
graph TD
|
|
361
460
|
EL["element.ts (~850 lines)<br/><i>Orchestrator</i><br/>$mount, $wire, $hydrate,<br/>$resolveSSR, $applySSRState,<br/>$update, events, cleanup"]
|
|
362
461
|
|
|
363
|
-
DIFF["element/diff.ts
|
|
462
|
+
DIFF["element/diff.ts<br/><i>List Reconciliation</i><br/>positional + explicit-key diffing<br/>for <for> repeat blocks"]
|
|
364
463
|
|
|
365
464
|
COND["element/conditions.ts<br/><i>Condition Evaluation</i><br/>evaluateCondition (iterative),<br/>conditionUsesPath"]
|
|
366
465
|
|
|
@@ -407,7 +506,7 @@ sequenceDiagram
|
|
|
407
506
|
CE->>CE: attributeChangedCallback (pre-existing attrs)
|
|
408
507
|
CE->>FW: connectedCallback() → $mount()
|
|
409
508
|
FW->>FW: SSR DOM detected (shadow root or children exist)
|
|
410
|
-
FW->>FW: $applySSRState() — seed
|
|
509
|
+
FW->>FW: $applySSRState() — seed decorated state
|
|
411
510
|
FW->>FW: $hydrate() — template-parallel path resolution
|
|
412
511
|
FW->>FW: $resolveSSR() — match SSR nodes via ordinal traversal
|
|
413
512
|
FW->>FW: $wireEvents() + $wireRefs()
|
|
@@ -454,15 +553,15 @@ interface TemplateMeta {
|
|
|
454
553
|
tx?: [slot, parts][]; // Text run locators
|
|
455
554
|
a?: CompiledAttrMeta[]; // Attribute bindings
|
|
456
555
|
ag?: [path, start, count][]; // Attribute target groups
|
|
457
|
-
c?: [conditionAST, blockIndex][];
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
rl?: SlotPath[]; // Repeat anchor slots
|
|
461
|
-
e?: [event, handler, argSpecs, targetPath][]; // Events
|
|
556
|
+
c?: [conditionAST, blockIndex, slot][]; // Conditional blocks
|
|
557
|
+
r?: [collection, itemVar, blockIdx, slot][]; // Repeat blocks
|
|
558
|
+
eg?: [event, [[handler, argSpecs, targetPath, usesEvent?]]][]; // Events
|
|
462
559
|
b?: TemplateBlockMeta[]; // Nested block metadata
|
|
463
560
|
sa?: string; // Adopted stylesheet specifier
|
|
464
561
|
sd?: boolean; // Shadow DOM flag for client-created
|
|
465
562
|
re?: [event, handler, argSpecs][]; // Root-level events
|
|
563
|
+
tr?: string[]; // Template state roots
|
|
564
|
+
ta?: string[]; // Host attributes aligned with tr
|
|
466
565
|
}
|
|
467
566
|
```
|
|
468
567
|
|
|
@@ -482,7 +581,7 @@ Compiled metadata:
|
|
|
482
581
|
[[[0], 0], [["title"]]], // slot in <h1>, dynamic "title"
|
|
483
582
|
[[[1], 1], ["Count: ", ["count"]]] // slot in <button>, static + dynamic
|
|
484
583
|
],
|
|
485
|
-
|
|
584
|
+
eg: [["click", [["increment", [], [1]]]]] // click -> increment, no event args
|
|
486
585
|
}
|
|
487
586
|
```
|
|
488
587
|
|
|
@@ -523,15 +622,16 @@ sequenceDiagram
|
|
|
523
622
|
### Why Updates Are O(affected)
|
|
524
623
|
|
|
525
624
|
After hydration, every dynamic value in the template is connected to a direct
|
|
526
|
-
DOM node reference stored in a binding array.
|
|
527
|
-
|
|
625
|
+
DOM node reference stored in a binding array. A per-path index maps each
|
|
626
|
+
decorated property or compiled template root to the subset of bindings that
|
|
627
|
+
reference it.
|
|
528
628
|
|
|
529
629
|
When `this.count = 5` fires, the `@observable` setter calls `$update('count')`,
|
|
530
630
|
which looks up `'count'` in the index and only patches the bindings that
|
|
531
631
|
actually depend on `count` — not every binding in the component.
|
|
532
632
|
|
|
533
|
-
Computed/volatile getters
|
|
534
|
-
under a wildcard key and always included in targeted updates.
|
|
633
|
+
Computed/volatile getters and other paths that are not known state roots are
|
|
634
|
+
stored under a wildcard key and always included in targeted updates.
|
|
535
635
|
|
|
536
636
|
```typescript
|
|
537
637
|
// Targeted update (simplified):
|
|
@@ -553,64 +653,58 @@ path index ensures only affected pointers are visited.
|
|
|
553
653
|
|
|
554
654
|
## SSR State Seeding
|
|
555
655
|
|
|
556
|
-
When the server renders `<span>42</span>` for
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
656
|
+
When the server renders `<span>42</span>` for a template binding, the browser
|
|
657
|
+
sees `42` in the DOM before the component's JavaScript state exists. Without
|
|
658
|
+
seeding, the first `$update()` would overwrite the SSR content with the wrong
|
|
659
|
+
value.
|
|
560
660
|
|
|
561
|
-
State seeding uses `window.__webui.state`
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
661
|
+
State seeding uses `window.__webui.state` loaded from the server-emitted
|
|
662
|
+
`#webui-data` block. When the protocol contains projection metadata, only
|
|
663
|
+
`@observable` and `@attr` keys from reachable authored components select
|
|
664
|
+
initial state; HTML-only dormant components and authored template-only roots
|
|
665
|
+
contribute no startup keys. Without projection metadata, the server preserves
|
|
666
|
+
full state. During `$mount()`, `$applySSRState()` writes matching decorated keys
|
|
667
|
+
directly to observable backing fields before any bindings are wired:
|
|
566
668
|
|
|
567
669
|
```mermaid
|
|
568
670
|
flowchart LR
|
|
569
|
-
SCRIPT["<script type='application/json' id='webui-data'><br/>{ state: { count: 42
|
|
570
|
-
APPLY --> SEED["Write
|
|
671
|
+
SCRIPT["<script type='application/json' id='webui-data'><br/>{ state: { count: 42 } }"] --> APPLY["$applySSRState()"]
|
|
672
|
+
APPLY --> SEED["Write decorated backing fields"]
|
|
571
673
|
SEED --> HYDRATE["$hydrate() — bindings match<br/>server-rendered DOM"]
|
|
572
674
|
```
|
|
573
675
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
676
|
+
Decorated writes go to the backing field (`_prop`) directly, avoiding reactive
|
|
677
|
+
updates before bindings are wired. For `@attr`, an existing SSR host attribute
|
|
678
|
+
takes precedence and the projected value is skipped. Template-only values
|
|
679
|
+
remain represented by the SSR DOM until browser state explicitly changes them.
|
|
680
|
+
Later `setState()` calls, including router partials, accept both decorated
|
|
681
|
+
properties and compiled template roots; undecorated roots are stored in hidden
|
|
682
|
+
framework state. The first write to a dormant HTML-only host replays only the
|
|
683
|
+
roots present in that write, preserving omitted SSR text, attributes,
|
|
684
|
+
conditions, and repeats.
|
|
577
685
|
|
|
578
686
|
---
|
|
579
687
|
|
|
580
688
|
## Repeat Reconciliation
|
|
581
689
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
C2["<todo-item> key=C ← reused"]
|
|
601
|
-
A2["<todo-item> key=A ← reused"]
|
|
602
|
-
B2["key=B ← removed"]
|
|
603
|
-
end
|
|
604
|
-
|
|
605
|
-
A1 -.->|"moved"| A2
|
|
606
|
-
C1 -.->|"moved"| C2
|
|
607
|
-
B1 -.->|"destroyed"| B2
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
### Sequential Reconciliation
|
|
611
|
-
|
|
612
|
-
When no keying attributes exist, items are matched by position. Excess items
|
|
613
|
-
are removed; new items are appended.
|
|
690
|
+
`<for>` blocks reconcile by array position by default. The existing block at
|
|
691
|
+
index `i` receives the current item at index `i`; only a new or removed tail
|
|
692
|
+
creates or removes blocks.
|
|
693
|
+
|
|
694
|
+
Duplicate values and attributes are safe because dynamic attributes never act
|
|
695
|
+
as hidden keys. Reordering rebinds existing blocks in place, so local
|
|
696
|
+
browser-owned or component state remains associated with positions rather than
|
|
697
|
+
logical items.
|
|
698
|
+
|
|
699
|
+
For reorderable or stateful lists, author `key="{{item.id}}"` on the first
|
|
700
|
+
child inside `<for>` to move existing blocks with their logical items.
|
|
701
|
+
`key="{{item}}"` supports arrays of unique string or finite-number primitives.
|
|
702
|
+
`key` is compiler-only metadata and is removed from SSR and client HTML;
|
|
703
|
+
`data-key` remains an ordinary attribute with no identity semantics. Key paths
|
|
704
|
+
are compiler-validated and stored only for explicitly keyed repeats, so
|
|
705
|
+
unkeyed bindings carry no key state or map allocation. Duplicate or invalid
|
|
706
|
+
runtime keys warn once, clear identity, and use positional reconciliation until
|
|
707
|
+
valid identity is re-established.
|
|
614
708
|
|
|
615
709
|
### SSR State Reading
|
|
616
710
|
|
|
@@ -684,7 +778,7 @@ This template-parallel traversal eliminates the need for any marker comments,
|
|
|
684
778
|
| Initial hydration | O(bindings) | Single pass over compiled path mappings |
|
|
685
779
|
| Reactive update | O(affected) | Per-path index skips unrelated bindings |
|
|
686
780
|
| Conditional toggle | O(block size) | Create/destroy a block instance |
|
|
687
|
-
| Repeat reconciliation | O(items) |
|
|
781
|
+
| Repeat reconciliation | O(items) | Positional scan; explicit keys use a reusable map only when order changes |
|
|
688
782
|
| Event wiring | O(events) | One-time during hydration |
|
|
689
783
|
|
|
690
784
|
### What the framework does NOT do
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type CompiledConditionFn, type TemplateMeta } from './template.js';
|
|
2
|
+
export interface ComponentAsset {
|
|
3
|
+
type?: 'webui-component-asset';
|
|
4
|
+
version?: number;
|
|
5
|
+
components?: string[];
|
|
6
|
+
templateStyles?: string[];
|
|
7
|
+
templates?: Record<string, TemplateMeta>;
|
|
8
|
+
templateFunctions?: Record<string, CompiledConditionFn[]>;
|
|
9
|
+
}
|
|
10
|
+
export type ComponentAssetState = Record<string, unknown>;
|
|
11
|
+
export interface ComponentAssetManifestEntry<Data extends ComponentAssetState = ComponentAssetState> {
|
|
12
|
+
asset: string | URL;
|
|
13
|
+
module?: () => Promise<unknown>;
|
|
14
|
+
data?: () => Promise<Data>;
|
|
15
|
+
}
|
|
16
|
+
export type ComponentAssetManifest = Record<string, ComponentAssetManifestEntry>;
|
|
17
|
+
export interface ComponentAssetPreload<Data extends ComponentAssetState = ComponentAssetState> {
|
|
18
|
+
asset: Promise<void>;
|
|
19
|
+
module?: Promise<unknown>;
|
|
20
|
+
data?: Promise<Data>;
|
|
21
|
+
}
|
|
22
|
+
export interface ComponentAssetCreateOptions {
|
|
23
|
+
awaitData?: boolean;
|
|
24
|
+
dataTimeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface ComponentAssetRegistry {
|
|
27
|
+
preload<Data extends ComponentAssetState = ComponentAssetState>(tag: string): ComponentAssetPreload<Data>;
|
|
28
|
+
create(tag: string, options?: ComponentAssetCreateOptions): Promise<HTMLElement>;
|
|
29
|
+
}
|
|
30
|
+
export declare function defineComponentAssets(manifest: ComponentAssetManifest): ComponentAssetRegistry;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { getTemplate, registerTemplateData, } from './template.js';
|
|
2
|
+
const ASSET_TYPE = 'webui-component-asset';
|
|
3
|
+
const ASSET_VERSION = 1;
|
|
4
|
+
const injectedAssetStyles = new Set();
|
|
5
|
+
const assetLoadPromises = new Map();
|
|
6
|
+
let assetStylesSeeded = false;
|
|
7
|
+
function assetGlobal() {
|
|
8
|
+
return window.__webui;
|
|
9
|
+
}
|
|
10
|
+
export function defineComponentAssets(manifest) {
|
|
11
|
+
const preloads = new Map();
|
|
12
|
+
function preload(tag) {
|
|
13
|
+
const existing = preloads.get(tag);
|
|
14
|
+
if (existing)
|
|
15
|
+
return existing;
|
|
16
|
+
const entry = manifest[tag];
|
|
17
|
+
if (!entry) {
|
|
18
|
+
throw new Error(`[WebUI] No component asset manifest entry for <${tag}>.`);
|
|
19
|
+
}
|
|
20
|
+
const next = {
|
|
21
|
+
asset: loadComponentAsset(tag, entry.asset),
|
|
22
|
+
};
|
|
23
|
+
if (entry.module) {
|
|
24
|
+
next.module = entry.module();
|
|
25
|
+
}
|
|
26
|
+
if (entry.data) {
|
|
27
|
+
next.data = entry.data();
|
|
28
|
+
}
|
|
29
|
+
next.asset.catch(() => { });
|
|
30
|
+
next.module?.catch(() => { });
|
|
31
|
+
next.data?.catch(() => { });
|
|
32
|
+
preloads.set(tag, next);
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
async function create(tag, options = {}) {
|
|
36
|
+
const pending = preload(tag);
|
|
37
|
+
await waitForElementResources(pending);
|
|
38
|
+
const element = document.createElement(tag);
|
|
39
|
+
if (pending.data) {
|
|
40
|
+
if (options.awaitData) {
|
|
41
|
+
const state = options.dataTimeoutMs === undefined
|
|
42
|
+
? await pending.data
|
|
43
|
+
: await dataWithTimeout(pending.data, options.dataTimeoutMs);
|
|
44
|
+
if (state) {
|
|
45
|
+
applyState(element, state);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
applyDataWhenReady(element, pending.data);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
applyDataWhenReady(element, pending.data);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return element;
|
|
56
|
+
}
|
|
57
|
+
return { preload, create };
|
|
58
|
+
}
|
|
59
|
+
async function waitForElementResources(pending) {
|
|
60
|
+
await pending.asset;
|
|
61
|
+
if (pending.module)
|
|
62
|
+
await pending.module;
|
|
63
|
+
}
|
|
64
|
+
function applyState(element, state) {
|
|
65
|
+
const setState = element.setState;
|
|
66
|
+
if (typeof setState === 'function') {
|
|
67
|
+
setState.call(element, state);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function applyDataWhenReady(element, data) {
|
|
71
|
+
const elementRef = new WeakRef(element);
|
|
72
|
+
void data.then(state => {
|
|
73
|
+
const liveElement = elementRef.deref();
|
|
74
|
+
if (liveElement)
|
|
75
|
+
applyState(liveElement, state);
|
|
76
|
+
}).catch(() => { });
|
|
77
|
+
}
|
|
78
|
+
function dataWithTimeout(data, timeoutMs) {
|
|
79
|
+
if (timeoutMs < 0)
|
|
80
|
+
return data;
|
|
81
|
+
return Promise.race([
|
|
82
|
+
data,
|
|
83
|
+
new Promise(resolve => {
|
|
84
|
+
setTimeout(() => resolve(undefined), timeoutMs);
|
|
85
|
+
}),
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
function loadComponentAsset(tag, url) {
|
|
89
|
+
if (getTemplate(tag))
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
const assetUrl = new URL(url, document.baseURI);
|
|
92
|
+
const href = assetUrl.href;
|
|
93
|
+
let promise = assetLoadPromises.get(href);
|
|
94
|
+
if (promise)
|
|
95
|
+
return promise;
|
|
96
|
+
promise = importAndRegisterComponentAsset(assetUrl)
|
|
97
|
+
.finally(() => {
|
|
98
|
+
assetLoadPromises.delete(href);
|
|
99
|
+
});
|
|
100
|
+
assetLoadPromises.set(href, promise);
|
|
101
|
+
return promise;
|
|
102
|
+
}
|
|
103
|
+
function registerComponentAsset(asset) {
|
|
104
|
+
validateAsset(asset);
|
|
105
|
+
if (asset.templates && templatesAlreadyRegistered(asset.templates))
|
|
106
|
+
return;
|
|
107
|
+
registerAssetStyles(asset.templateStyles, readNonce());
|
|
108
|
+
if (asset.templates) {
|
|
109
|
+
registerTemplateData(asset.templates, asset.templateFunctions);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function importAndRegisterComponentAsset(assetUrl) {
|
|
113
|
+
const imported = await import(assetUrl.href);
|
|
114
|
+
registerComponentAsset(readComponentAssetModule(imported));
|
|
115
|
+
}
|
|
116
|
+
function readComponentAssetModule(module) {
|
|
117
|
+
if (!isObject(module) || !isObject(module.default)) {
|
|
118
|
+
throw new Error('[WebUI] Component asset module must default-export an asset object.');
|
|
119
|
+
}
|
|
120
|
+
return module.default;
|
|
121
|
+
}
|
|
122
|
+
function isObject(value) {
|
|
123
|
+
return typeof value === 'object' && value !== null;
|
|
124
|
+
}
|
|
125
|
+
function validateAsset(asset) {
|
|
126
|
+
if (asset.type !== ASSET_TYPE) {
|
|
127
|
+
throw new Error(`[WebUI] Invalid component asset type: ${String(asset.type)}`);
|
|
128
|
+
}
|
|
129
|
+
if (asset.version !== ASSET_VERSION) {
|
|
130
|
+
throw new Error(`[WebUI] Unsupported component asset version: ${String(asset.version)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function templatesAlreadyRegistered(templates) {
|
|
134
|
+
const tags = Object.keys(templates);
|
|
135
|
+
if (tags.length === 0)
|
|
136
|
+
return false;
|
|
137
|
+
for (let i = 0; i < tags.length; i++) {
|
|
138
|
+
if (!getTemplate(tags[i]))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function readNonce() {
|
|
144
|
+
const nonce = assetGlobal()?.nonce;
|
|
145
|
+
if (nonce)
|
|
146
|
+
return nonce;
|
|
147
|
+
const meta = document.querySelector('meta[name="webui-nonce"]');
|
|
148
|
+
return meta?.content ?? '';
|
|
149
|
+
}
|
|
150
|
+
function seedAssetStyleSet() {
|
|
151
|
+
if (assetStylesSeeded)
|
|
152
|
+
return;
|
|
153
|
+
assetStylesSeeded = true;
|
|
154
|
+
const styles = assetGlobal()?.styles;
|
|
155
|
+
if (!styles)
|
|
156
|
+
return;
|
|
157
|
+
for (let i = 0; i < styles.length; i++) {
|
|
158
|
+
injectedAssetStyles.add(styles[i]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function registerAssetStyles(templateStyles, nonce) {
|
|
162
|
+
if (!templateStyles || templateStyles.length === 0)
|
|
163
|
+
return;
|
|
164
|
+
seedAssetStyleSet();
|
|
165
|
+
for (let i = 0; i < templateStyles.length; i++) {
|
|
166
|
+
const imports = parseImportMap(templateStyles[i]);
|
|
167
|
+
const nextImports = {};
|
|
168
|
+
let hasNewImport = false;
|
|
169
|
+
const specifiers = Object.keys(imports);
|
|
170
|
+
for (let j = 0; j < specifiers.length; j++) {
|
|
171
|
+
const specifier = specifiers[j];
|
|
172
|
+
if (injectedAssetStyles.has(specifier))
|
|
173
|
+
continue;
|
|
174
|
+
injectedAssetStyles.add(specifier);
|
|
175
|
+
nextImports[specifier] = imports[specifier];
|
|
176
|
+
hasNewImport = true;
|
|
177
|
+
}
|
|
178
|
+
if (!hasNewImport)
|
|
179
|
+
continue;
|
|
180
|
+
const script = document.createElement('script');
|
|
181
|
+
script.type = 'importmap';
|
|
182
|
+
if (nonce)
|
|
183
|
+
script.nonce = nonce;
|
|
184
|
+
script.textContent = JSON.stringify({ imports: nextImports });
|
|
185
|
+
document.head.appendChild(script);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function parseImportMap(scriptMarkup) {
|
|
189
|
+
const trimmed = scriptMarkup.trim();
|
|
190
|
+
if (!trimmed.startsWith('<script')) {
|
|
191
|
+
throw new Error('[WebUI] Component asset templateStyles entry must be a <script type="importmap"> tag.');
|
|
192
|
+
}
|
|
193
|
+
const openTagEnd = trimmed.indexOf('>');
|
|
194
|
+
const closeTagStart = trimmed.lastIndexOf('</script>');
|
|
195
|
+
if (openTagEnd < 0 || closeTagStart <= openTagEnd) {
|
|
196
|
+
throw new Error('[WebUI] Component asset importmap tag is malformed.');
|
|
197
|
+
}
|
|
198
|
+
const parsed = JSON.parse(trimmed.substring(openTagEnd + 1, closeTagStart));
|
|
199
|
+
if (!parsed.imports || typeof parsed.imports !== 'object') {
|
|
200
|
+
throw new Error('[WebUI] Component asset importmap is missing an imports object.');
|
|
201
|
+
}
|
|
202
|
+
const imports = {};
|
|
203
|
+
const specifiers = Object.keys(parsed.imports);
|
|
204
|
+
for (let i = 0; i < specifiers.length; i++) {
|
|
205
|
+
const specifier = specifiers[i];
|
|
206
|
+
const uri = parsed.imports[specifier];
|
|
207
|
+
if (typeof uri !== 'string' || !uri.startsWith('data:text/css,')) {
|
|
208
|
+
throw new Error(`[WebUI] Component asset importmap entry "${specifier}" must be a data:text/css URI.`);
|
|
209
|
+
}
|
|
210
|
+
imports[specifier] = uri;
|
|
211
|
+
}
|
|
212
|
+
return imports;
|
|
213
|
+
}
|