From 1bce26cd38e116d9c2112689c6de2f54898cda98 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 30 Jun 2026 18:44:09 -0700 Subject: [PATCH 1/7] Add automatic HTML-only WebUI elements Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 26 ++ crates/webui-parser/src/component_registry.rs | 36 ++ crates/webui-parser/src/plugin/fast_v2.rs | 1 + crates/webui-parser/src/plugin/fast_v3.rs | 1 + crates/webui-parser/src/plugin/webui.rs | 67 ++- crates/webui-press/src/bundler.rs | 11 +- docs/ai.md | 25 +- docs/guide/concepts/interactivity.md | 25 +- docs/guide/concepts/routing.md | 12 +- .../src/calc-display/calc-display.ts | 12 - examples/app/calculator/src/index.ts | 4 +- .../app/calculator/tests/calculator.spec.ts | 21 +- examples/app/commerce/README.md | 3 +- .../commerce/src/atoms/mp-price/mp-price.ts | 13 - .../mp-product-image/mp-product-image.ts | 18 - examples/app/commerce/src/index.ts | 30 +- .../mp-product-label/mp-product-label.ts | 14 - .../commerce/src/organisms/mp-app/mp-app.ts | 9 - .../src/organisms/mp-carousel/mp-carousel.ts | 18 - .../organisms/mp-cart-panel/mp-cart-panel.ts | 21 +- .../mp-category-nav/mp-category-nav.ts | 3 +- .../mp-filter-list/mp-filter-list.ts | 3 +- .../src/organisms/mp-footer/mp-footer.ts | 8 - .../organisms/mp-hero-grid/mp-hero-grid.ts | 12 - .../mp-mobile-menu/mp-mobile-menu.ts | 6 - .../src/organisms/mp-navbar/mp-navbar.ts | 4 +- .../mp-product-card/mp-product-card.ts | 3 - .../mp-product-gallery/mp-product-gallery.ts | 2 - .../mp-product-grid/mp-product-grid.ts | 13 - .../src/pages/mp-page-about/mp-page-about.ts | 8 - .../src/pages/mp-page-faq/mp-page-faq.ts | 8 - .../src/pages/mp-page-home/mp-page-home.ts | 14 - .../pages/mp-page-privacy/mp-page-privacy.ts | 8 - .../pages/mp-page-product/mp-page-product.ts | 18 - .../pages/mp-page-search/mp-page-search.ts | 16 - .../mp-page-shipping/mp-page-shipping.ts | 8 - .../src/pages/mp-page-terms/mp-page-terms.ts | 8 - examples/app/commerce/tests/commerce.spec.ts | 26 ++ .../src/app-shell/app-shell.ts | 1 - .../src/asset-badge/asset-badge.ts | 10 - examples/app/component-assets/src/index.ts | 7 +- .../src/lazy-panel/lazy-panel.ts | 15 - .../tests/component-assets.spec.ts | 40 +- examples/app/contact-book-manager/README.md | 7 +- .../src/atoms/cb-avatar/cb-avatar.ts | 12 - .../src/atoms/cb-badge/cb-badge.ts | 11 - .../src/atoms/cb-button/cb-button.ts | 12 - .../atoms/cb-empty-state/cb-empty-state.ts | 12 - .../atoms/cb-icon-button/cb-icon-button.ts | 12 - .../src/atoms/cb-input/cb-input.ts | 13 - .../contact-book-manager/src/cb-app/cb-app.ts | 5 - .../app/contact-book-manager/src/index.ts | 11 +- .../molecules/cb-form-field/cb-form-field.ts | 15 - .../molecules/cb-stat-card/cb-stat-card.ts | 12 - .../cb-contact-card/cb-contact-card.ts | 25 - .../cb-contact-detail/cb-contact-detail.ts | 10 - .../cb-contact-list/cb-contact-list.ts | 29 -- .../src/organisms/cb-sidebar/cb-sidebar.ts | 14 - .../cb-page-contacts/cb-page-contacts.ts | 12 - .../cb-page-dashboard/cb-page-dashboard.ts | 17 - .../cb-page-favorites/cb-page-favorites.ts | 12 - .../src/pages/cb-page-group/cb-page-group.ts | 13 - .../tests/contact-book.spec.ts | 22 + examples/app/routes/src/index.ts | 1 - .../app/routes/src/lesson-page/lesson-page.ts | 16 - .../app/routes/tests/routes-webui.spec.ts | 29 ++ packages/webui-framework/README.md | 66 ++- packages/webui-framework/package.json | 8 +- .../webui-framework/scripts/size-report.mjs | 77 ++++ .../webui-framework/src/auto-element.test.ts | 180 ++++++++ packages/webui-framework/src/auto-element.ts | 97 ++++ packages/webui-framework/src/decorators.ts | 307 ++++++------ packages/webui-framework/src/element.ts | 435 ++++++++++++------ packages/webui-framework/src/index.ts | 4 + .../webui-framework/src/template-events.ts | 31 ++ .../webui-framework/src/template-roots.ts | 163 +++++++ .../webui-framework/src/template-types.ts | 2 + packages/webui-framework/src/template.ts | 9 + .../webui-framework/tests/auto-elements.ts | 4 + .../html-only-auto-element.spec.ts | 71 +++ .../html-only-auto-element/src/index.html | 10 + .../src/test-html-only/test-html-only.html | 12 + .../html-only-auto-element/state.json | 12 + .../optional-template-state/element.ts | 14 + .../optional-template-state.spec.ts | 73 +++ .../optional-template-state/src/index.html | 10 + .../test-optional-state.html | 15 + .../optional-template-state/state.json | 12 + packages/webui-framework/tests/server.ts | 10 + packages/webui-router/README.md | 9 +- packages/webui-router/package.json | 1 - packages/webui-router/src/loaders.ts | 9 +- packages/webui-router/src/router.test.ts | 37 ++ packages/webui-router/src/router.ts | 8 +- packages/webui-router/src/templates.ts | 22 + .../webui-test-support/src/fixture-render.ts | 5 +- pnpm-lock.yaml | 6 +- 97 files changed, 1746 insertions(+), 903 deletions(-) delete mode 100644 examples/app/calculator/src/calc-display/calc-display.ts delete mode 100644 examples/app/commerce/src/atoms/mp-price/mp-price.ts delete mode 100644 examples/app/commerce/src/atoms/mp-product-image/mp-product-image.ts delete mode 100644 examples/app/commerce/src/molecules/mp-product-label/mp-product-label.ts delete mode 100644 examples/app/commerce/src/organisms/mp-carousel/mp-carousel.ts delete mode 100644 examples/app/commerce/src/organisms/mp-footer/mp-footer.ts delete mode 100644 examples/app/commerce/src/organisms/mp-hero-grid/mp-hero-grid.ts delete mode 100644 examples/app/commerce/src/organisms/mp-product-grid/mp-product-grid.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-about/mp-page-about.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-faq/mp-page-faq.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-home/mp-page-home.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-privacy/mp-page-privacy.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-search/mp-page-search.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-shipping/mp-page-shipping.ts delete mode 100644 examples/app/commerce/src/pages/mp-page-terms/mp-page-terms.ts delete mode 100644 examples/app/component-assets/src/asset-badge/asset-badge.ts delete mode 100644 examples/app/component-assets/src/lazy-panel/lazy-panel.ts delete mode 100644 examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts delete mode 100644 examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts delete mode 100644 examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts delete mode 100644 examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts delete mode 100644 examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts delete mode 100644 examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts delete mode 100644 examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts delete mode 100644 examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts delete mode 100644 examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts delete mode 100644 examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts delete mode 100644 examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts delete mode 100644 examples/app/contact-book-manager/src/pages/cb-page-contacts/cb-page-contacts.ts delete mode 100644 examples/app/contact-book-manager/src/pages/cb-page-dashboard/cb-page-dashboard.ts delete mode 100644 examples/app/contact-book-manager/src/pages/cb-page-favorites/cb-page-favorites.ts delete mode 100644 examples/app/contact-book-manager/src/pages/cb-page-group/cb-page-group.ts delete mode 100644 examples/app/routes/src/lesson-page/lesson-page.ts create mode 100644 packages/webui-framework/scripts/size-report.mjs create mode 100644 packages/webui-framework/src/auto-element.test.ts create mode 100644 packages/webui-framework/src/auto-element.ts create mode 100644 packages/webui-framework/src/template-events.ts create mode 100644 packages/webui-framework/src/template-roots.ts create mode 100644 packages/webui-framework/tests/auto-elements.ts create mode 100644 packages/webui-framework/tests/fixtures/html-only-auto-element/html-only-auto-element.spec.ts create mode 100644 packages/webui-framework/tests/fixtures/html-only-auto-element/src/index.html create mode 100644 packages/webui-framework/tests/fixtures/html-only-auto-element/src/test-html-only/test-html-only.html create mode 100644 packages/webui-framework/tests/fixtures/html-only-auto-element/state.json create mode 100644 packages/webui-framework/tests/fixtures/optional-template-state/element.ts create mode 100644 packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts create mode 100644 packages/webui-framework/tests/fixtures/optional-template-state/src/index.html create mode 100644 packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html create mode 100644 packages/webui-framework/tests/fixtures/optional-template-state/state.json diff --git a/DESIGN.md b/DESIGN.md index 67d1a922..47fe150a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1540,6 +1540,32 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m appending nodes to the connected DOM. Child components therefore observe initial parent `:` property bindings in `connectedCallback`, while later parent updates remain live. +- Missing HTML-only custom elements are marked in compiled template metadata + with `ae: 1` when the component has no sibling client script. The framework + root runtime listens for template metadata and installs a static `CoreElement` + subclass only for compiler-marked tags when the tag is not already registered + and the compiled template contains no event handler metadata. `CoreElement` is + the static rendering core (hydration, template state, bindings, repeats, + conditionals, attribute reflection); the interactive `WebUIElement` superset + adds event wiring, `w-ref` wiring, and `$emit` on top. Because auto-elements + extend `CoreElement` — never `WebUIElement` — a purely static / HTML-only app + tree-shakes all event/ref/emit code out of its bundle. + The fallback derives reactive roots from `tx`, `a`, `c`, and `r` + metadata, observes the corresponding host attributes, seeds non-attribute + state from `window.__webui.state`, and supports router `setState()` updates + without developer-authored `@observable` / `@attr` stubs. The framework root + entrypoint stays side-effect free and tree-shakeable. Developer-authored + classes and lazy loaders own templates that contain event handlers. +- Developer-authored `WebUIElement` classes also treat compiled template roots + as stateful. `setState()` and SSR seeding store any undecorated + template-bound roots in hidden framework state, so `@observable` is only + required when TypeScript code reads or mutates the property directly. +- Template producers that bootstrap initial SSR metadata or load metadata after + initial SSR, including `@microsoft/webui-router`, publish a synchronous + `webui:templates-registered` event with `{ templates }` in `detail`. The + framework runtime listens for this optional platform-neutral event and claims + compiler-marked template-backed HTML-only tags before routers or asset loaders + create them. - 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. diff --git a/crates/webui-parser/src/component_registry.rs b/crates/webui-parser/src/component_registry.rs index 45fd75fb..d9c4345a 100644 --- a/crates/webui-parser/src/component_registry.rs +++ b/crates/webui-parser/src/component_registry.rs @@ -16,6 +16,8 @@ use std::path::PathBuf; use walkdir::WalkDir; type ProcessedCss = (String, Vec, Vec, Vec); +#[cfg(feature = "fs")] +const COMPONENT_SCRIPT_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"]; /// Represents a web component in the registry. #[derive(Debug, Clone)] @@ -44,6 +46,9 @@ pub struct Component { /// The class name that implements this component (if available) pub class_name: Option, + + /// Whether this component has an authored client script next to its HTML. + pub has_script: bool, } /// Registry of web components. @@ -57,6 +62,16 @@ pub struct ComponentRegistry { legal_comments: LegalComments, } +#[cfg(feature = "fs")] +fn component_has_script(html_path: &Path) -> bool { + for extension in COMPONENT_SCRIPT_EXTENSIONS { + if html_path.with_extension(extension).exists() { + return true; + } + } + false +} + impl Default for ComponentRegistry { fn default() -> Self { Self::new() @@ -180,6 +195,7 @@ impl ComponentRegistry { css_fallback_chains, source_path: html_path.to_path_buf(), class_name: None, + has_script: component_has_script(html_path), }; self.components.insert(tag_name.to_string(), component); @@ -228,6 +244,7 @@ impl ComponentRegistry { css_fallback_chains, source_path: PathBuf::new(), // Empty path since it's not from a file class_name: None, + has_script: false, }; // Register the component @@ -317,6 +334,25 @@ mod tests { .expect("Failed to retrieve registered component"); assert_eq!(component.html_content, html_content); assert_eq!(component.css_content.as_deref(), Some(css_content)); + assert!(!component.has_script); + } + + #[test] + fn test_register_component_detects_sibling_script() { + let mut fs = TestFileSystem::new(); + let html_path = fs.add_file("components/scripted-card.html", "

Scripted

"); + std::fs::write(html_path.with_extension("ts"), "export {};") + .expect("Failed to write sibling script"); + + let mut registry = ComponentRegistry::new(); + registry + .register_component_from_paths(&html_path, None::<&str>) + .expect("register failed"); + + let component = registry + .get("scripted-card") + .expect("Failed to retrieve registered component"); + assert!(component.has_script); } #[test] diff --git a/crates/webui-parser/src/plugin/fast_v2.rs b/crates/webui-parser/src/plugin/fast_v2.rs index 4d632a44..e022d226 100644 --- a/crates/webui-parser/src/plugin/fast_v2.rs +++ b/crates/webui-parser/src/plugin/fast_v2.rs @@ -681,6 +681,7 @@ mod tests { css_fallback_chains: Vec::new(), source_path: PathBuf::from("/test"), class_name: None, + has_script: false, } } diff --git a/crates/webui-parser/src/plugin/fast_v3.rs b/crates/webui-parser/src/plugin/fast_v3.rs index 45c15318..d1ff281a 100644 --- a/crates/webui-parser/src/plugin/fast_v3.rs +++ b/crates/webui-parser/src/plugin/fast_v3.rs @@ -681,6 +681,7 @@ mod tests { css_fallback_chains: Vec::new(), source_path: PathBuf::from("/test"), class_name: None, + has_script: false, } } diff --git a/crates/webui-parser/src/plugin/webui.rs b/crates/webui-parser/src/plugin/webui.rs index ea8fd550..1090e8f1 100644 --- a/crates/webui-parser/src/plugin/webui.rs +++ b/crates/webui-parser/src/plugin/webui.rs @@ -77,6 +77,7 @@ struct TrackedComponent { tag_name: String, template_html: String, root_event_source: String, + auto_element: bool, } /// WebUI Framework parser plugin. @@ -130,6 +131,7 @@ impl WebUIParserPlugin { &c.template_html, &c.root_event_source, use_shadow, + c.auto_element, )?; out.push(ComponentTemplateArtifact::webui( c.tag_name.clone(), @@ -145,19 +147,21 @@ impl WebUIParserPlugin { tag_name: &str, template_html: &str, root_event_source: &str, + auto_element: bool, ) { if let Some(component) = self.components.iter_mut().find(|c| c.tag_name == tag_name) { component.template_html.clear(); component.template_html.push_str(template_html); component.root_event_source.clear(); component.root_event_source.push_str(root_event_source); + component.auto_element = auto_element; return; } - self.components.push(TrackedComponent { tag_name: tag_name.to_string(), template_html: template_html.to_string(), root_event_source: root_event_source.to_string(), + auto_element, }); } } @@ -192,7 +196,12 @@ impl ParserPlugin for WebUIParserPlugin { component: &Component, processed_template: &str, ) -> Result<()> { - self.store_component_template(tag_name, processed_template, &component.html_content); + self.store_component_template( + tag_name, + processed_template, + &component.html_content, + !component.has_script, + ); Ok(()) } @@ -407,10 +416,14 @@ impl ConditionFunctionEmitter { /// Returns [`crate::ParserError::Template`] if the template contains an invalid /// `@event` handler or a non-braced `w-ref` binding. pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> Result { - Ok( - generate_compiled_template_with_root_source(tag_name, html_content, html_content, false)? - .template_json, - ) + Ok(generate_compiled_template_with_root_source( + tag_name, + html_content, + html_content, + false, + false, + )? + .template_json) } fn generate_compiled_template_with_root_source( @@ -418,6 +431,7 @@ fn generate_compiled_template_with_root_source( html_content: &str, root_event_source: &str, shadow_dom: bool, + auto_element: bool, ) -> Result { let trimmed = html_content.trim(); let root_events = extract_root_events(tag_name, root_event_source.trim())?; @@ -429,6 +443,7 @@ fn generate_compiled_template_with_root_source( &meta, adopted_stylesheet.as_deref(), shadow_dom, + auto_element, )) } @@ -437,6 +452,7 @@ fn emit_compiled_template_payload( meta: &TemplateMeta, adopted_stylesheet: Option<&str>, shadow_dom: bool, + auto_element: bool, ) -> CompiledTemplatePayload { let mut conditions = ConditionFunctionEmitter::new(128); let mut out = String::with_capacity(512 + html_content.len()); @@ -454,6 +470,10 @@ fn emit_compiled_template_payload( out.push_str(",\"sd\":1"); } + if auto_element { + out.push_str(",\"ae\":1"); + } + // re: root events if !meta.root_events.is_empty() { out.push_str(",\"re\":["); @@ -2681,6 +2701,7 @@ mod tests { html_content, html_content, false, + false, ) .expect("valid template compiles") } @@ -3105,6 +3126,7 @@ mod tests { css_fallback_chains: Vec::new(), source_path: std::path::PathBuf::new(), class_name: None, + has_script: false, }; plugin .register_component_template("test-el", &comp, &comp.html_content) @@ -3115,6 +3137,36 @@ mod tests { assert_eq!(plugin.take_component_templates().unwrap().len(), 1); } + #[test] + fn test_scriptless_component_template_is_auto_element_marked() { + let mut plugin = WebUIParserPlugin::new(); + let mut comp = Component { + tag_name: "test-el".to_string(), + html_content: "

hi

".to_string(), + css_content: None, + css_tokens: Vec::new(), + css_definitions: Vec::new(), + css_fallback_chains: Vec::new(), + source_path: std::path::PathBuf::new(), + class_name: None, + has_script: false, + }; + + plugin + .register_component_template("test-el", &comp, &comp.html_content) + .unwrap(); + let templates = plugin.take_component_templates().unwrap(); + assert!(templates[0].template_json.contains(r#","ae":1"#)); + + comp.has_script = true; + let mut plugin = WebUIParserPlugin::new(); + plugin + .register_component_template("test-el", &comp, &comp.html_content) + .unwrap(); + let templates = plugin.take_component_templates().unwrap(); + assert!(!templates[0].template_json.contains(r#","ae":1"#)); + } + #[test] fn test_compiled_template_preserves_link_node_in_static_html() { let result = generate_compiled_template( @@ -3158,6 +3210,7 @@ mod tests { css_fallback_chains: Vec::new(), source_path: std::path::PathBuf::new(), class_name: None, + has_script: false, }; plugin @@ -3191,6 +3244,7 @@ mod tests { css_fallback_chains: Vec::new(), source_path: std::path::PathBuf::new(), class_name: None, + has_script: false, }; plugin @@ -3220,6 +3274,7 @@ mod tests { css_fallback_chains: Vec::new(), source_path: std::path::PathBuf::new(), class_name: None, + has_script: false, }; plugin diff --git a/crates/webui-press/src/bundler.rs b/crates/webui-press/src/bundler.rs index 8c07d91e..a93f22ec 100644 --- a/crates/webui-press/src/bundler.rs +++ b/crates/webui-press/src/bundler.rs @@ -1765,13 +1765,12 @@ mod tests { } fs::create_dir_all(root.join(".webui-press"))?; let allowed_roots = allowed_script_roots(&root.join(".webui-press"), &root)?; + let absolute_import = root.join("secret.js").to_string_lossy().replace('\\', "/"); + let code = format!("console.log('before'); import \"{absolute_import}\";"); - let Err(err) = validate_inline_script_imports( - "console.log('before'); import \"/tmp/secret.js\";", - &root.join(".webui-press"), - &allowed_roots, - None, - ) else { + let Err(err) = + validate_inline_script_imports(&code, &root.join(".webui-press"), &allowed_roots, None) + else { panic!("absolute import should be rejected"); }; diff --git a/docs/ai.md b/docs/ai.md index af2355af..62166292 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -50,7 +50,7 @@ webui build + JSON state hydrate as islands takes over after hydration for user interactions. 4. **Static content never ships JavaScript.** Only components with event - handlers, reactive state, or user input need client-side code. + handlers, client-mutated state, or user input need client-side code. ## Project Structure @@ -317,13 +317,26 @@ export class MyComponent extends WebUIElement { MyComponent.define('my-component'); ``` +HTML-only components can omit the `.ts` file when they have no event handlers or +custom client logic. The compiler marks those scriptless templates in metadata, +and the framework automatically defines hydrating fallbacks when the metadata is +available. The fallback hydrates SSR output, observes template-relevant host +attributes, and accepts `setState()` for compiled binding roots. Routers and +asset loaders notify the framework about initial SSR or newly loaded metadata +with `webui:templates-registered`. + +Authored components can also omit `@observable` for values that are only read by +the template and supplied by SSR, router `setState()`, or component asset data. +The framework stores those template-only values internally. Use `@observable` +only when TypeScript code reads or mutates the value directly. + ### Decorator reference | Decorator | Purpose | SSR? | Triggers DOM update? | |-----------|---------|------|---------------------| | `@attr` | HTML attribute reflection | Yes (from JSON state) | Yes | | `@attr({ mode: 'boolean' })` | Boolean attribute (present/absent) | Yes | Yes | -| `@observable` | Reactive internal state | Yes (from JSON state) | Yes | +| `@observable` | Reactive state used by TypeScript code | Yes (from JSON state) | Yes | ### Component API @@ -923,7 +936,7 @@ The handler resolves `tokens.light` from the state, outputting: 7. **No computed getters for SSR state.** If a value appears in the template, it must be in the server state JSON. Use `@observable` - with explicit updates in event handlers. + only when event handlers or other TypeScript code read or change it. 8. **Components inside `` loops do NOT inherit loop variables.** Pass data explicitly via attributes. @@ -931,9 +944,9 @@ The handler resolves `tokens.light` from the state, outputting: 9. **No `import` or `require` in templates.** Components are discovered by file naming convention, not imports. -10. **No `this.querySelector()` for reactive state.** Use `@observable` and - template bindings. Use `w-ref` only for imperative DOM access (focus, - scroll, etc.). +10. **No `this.querySelector()` for reactive state.** Use `@observable` for + state your TypeScript changes, and use template bindings for DOM output. + Use `w-ref` only for imperative DOM access (focus, scroll, etc.). ## Common Patterns diff --git a/docs/guide/concepts/interactivity.md b/docs/guide/concepts/interactivity.md index 982c8d40..cad6dca8 100644 --- a/docs/guide/concepts/interactivity.md +++ b/docs/guide/concepts/interactivity.md @@ -15,7 +15,23 @@ my-counter/ - **HTML** defines what the component renders and where dynamic values appear - **CSS** styles the component in isolation - Shadow DOM prevents leaking -- **TypeScript** defines reactive properties, event handlers, and component logic +- **TypeScript** defines JS-visible reactive properties, event handlers, and component logic + +HTML-only components can omit the TypeScript file when they do not handle +events or run custom client logic: + +``` +product-card/ +├── product-card.html +└── product-card.css +``` + +The compiler marks scriptless templates in metadata, and the framework +automatically defines hydrating fallbacks for those tags when the metadata is +available. The fallback hydrates SSR output, observes attributes that correspond +to template bindings, and accepts router `setState()` data for those bindings. +Add a TypeScript class only when the component needs event handlers, custom +methods, custom lifecycle code, or state that TypeScript code reads or mutates. ## The Component Class @@ -130,6 +146,13 @@ Use `@observable` for internal state that changes over time. When an observable Observable changes are **synchronous and targeted** - only the specific DOM nodes bound to the changed property are updated. +You do not need `@observable` for values that only come from SSR, router +`setState()`, or component asset data and are only read by the template. The +framework stores those template-only values internally and updates the compiled +bindings without exposing public class fields. Add `@observable` when your +TypeScript code needs to read or mutate the value, for example in an event +handler. + ### Derived State For derived values like "has items?" or "total count", use template expressions directly instead of computed properties: diff --git a/docs/guide/concepts/routing.md b/docs/guide/concepts/routing.md index 04b0237f..b36c3d1a 100644 --- a/docs/guide/concepts/routing.md +++ b/docs/guide/concepts/routing.md @@ -214,7 +214,7 @@ The router provides four mechanisms for controlling how state flows to your comp | Need | Mechanism | What happens | |------|-----------|-------------| -| **Server provides all state** | Default (no changes) | `setState(state)` on every navigation | +| **Server provides all state** | Default (no changes) | `setState(state)` on every navigation. HTML-only route components use the explicit framework fallback when the app opts those tags in | | **I fetch my own data** | `static loader()` on component | Loader runs pre-commit, result passed to `setState()` | | **Preserve local state** | `keep-alive` on route | Params/query attrs updated, `setState()` skipped | | **Preserve DOM + refresh data** | `keep-alive` + `static loader()` | DOM preserved, loader result applied via `setState()` | @@ -230,6 +230,16 @@ app.get('*', async (req, res) => { }); ``` +Route components that only have `.html` and optional `.css` files do not need a +JavaScript loader or empty class. When initial SSR data or a later partial +response includes template metadata for an unregistered route tag, the router +publishes a `webui:templates-registered` event and stays otherwise platform +independent. The framework automatically claims compiler-marked HTML-only route +tags, so params, query attributes, and server state still update the template. +If no framework runtime claims the tag, the router keeps its legacy passive stub with a no-op +`setState()` so static server-rendered route content can still participate in +chain reconciliation. + ### Tagged Cache The router caches partial responses and tags them with server-provided cache tags for precise invalidation. Enable caching at startup: diff --git a/examples/app/calculator/src/calc-display/calc-display.ts b/examples/app/calculator/src/calc-display/calc-display.ts deleted file mode 100644 index ce23bc93..00000000 --- a/examples/app/calculator/src/calc-display/calc-display.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CalcDisplay extends WebUIElement { - @attr expression = ''; - @attr value = ''; - @attr error = ''; -} - -CalcDisplay.define('calc-display'); diff --git a/examples/app/calculator/src/index.ts b/examples/app/calculator/src/index.ts index 3229631e..3f14693b 100644 --- a/examples/app/calculator/src/index.ts +++ b/examples/app/calculator/src/index.ts @@ -5,7 +5,8 @@ * Calculator hydration entry point. * * The server pre-renders HTML with hydration markers via `webui build --plugin=webui`. - * The framework auto-hydrates registered custom elements and fires + * The framework auto-hydrates registered custom elements, auto-claims the + * HTML-only display from compiler metadata, and fires * `webui:hydration-complete` on `window` once they finish. */ @@ -20,7 +21,6 @@ function logHydrationTiming(): void { // Side-effect imports — register custom elements and trigger hydration import './calc-app/calc-app.js'; -import './calc-display/calc-display.js'; import './calc-button/calc-button.js'; // Fallback: if hydration already completed before the listener, log now diff --git a/examples/app/calculator/tests/calculator.spec.ts b/examples/app/calculator/tests/calculator.spec.ts index 59a3b582..075dbce0 100644 --- a/examples/app/calculator/tests/calculator.spec.ts +++ b/examples/app/calculator/tests/calculator.spec.ts @@ -7,7 +7,26 @@ test.describe('SSR rendering', () => { test('renders calculator display', async ({ page }) => { await page.goto('/'); await expect(page.locator('calc-app')).toBeVisible(); - await expect(page.locator('calc-display')).toBeVisible(); + const display = page.locator('calc-display'); + await expect(display).toBeVisible(); + + await expect( + display.evaluate((el) => { + const component = el as HTMLElement & { $ready?: boolean; setState?: unknown }; + + return { + ready: component.$ready === true, + setState: typeof component.setState === 'function', + }; + }), + ).resolves.toEqual({ ready: true, setState: true }); + + await display.evaluate((el) => { + const component = el as HTMLElement & { setState(state: unknown): void }; + component.setState({ expression: '1 + 1', value: '2' }); + }); + await expect(display).toContainText('1 + 1'); + await expect(display).toContainText('2'); }); test('renders calculator buttons', async ({ page }) => { diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index d1d6335f..f66bd75a 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -10,6 +10,7 @@ A full-featured commerce demo built with WebUI — server-side rendered with cli - **Nested routing** with `` and `` - **Client-side navigation** via `@microsoft/webui-router` - **SSR + hydration** with WebUI Framework +- **Declarative-only components** that auto-upgrade without authored TypeScript stubs - **View transitions** for smooth page changes - **Category filtering** and **sort options** - **Product gallery** with thumbnail navigation @@ -63,7 +64,7 @@ commerce/ │ ├── frontend.rs # WebUI protocol rendering │ └── state/ # State resolution per route ├── tests/ # Playwright E2E tests -│ └── commerce.spec.ts # 42 tests (desktop + mobile) +│ └── commerce.spec.ts # 43 tests (desktop + mobile) ├── dist/ # Built client bundle └── playwright.config.ts # Test config (chromium + mobile) ``` diff --git a/examples/app/commerce/src/atoms/mp-price/mp-price.ts b/examples/app/commerce/src/atoms/mp-price/mp-price.ts deleted file mode 100644 index 6dff6d99..00000000 --- a/examples/app/commerce/src/atoms/mp-price/mp-price.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class MpPrice extends WebUIElement { - @attr value = ''; - @attr size = 'md'; - @attr variant = 'pill'; - @attr({ attribute: 'currency-code' }) currencyCode = 'USD'; -} - -MpPrice.define('mp-price'); diff --git a/examples/app/commerce/src/atoms/mp-product-image/mp-product-image.ts b/examples/app/commerce/src/atoms/mp-product-image/mp-product-image.ts deleted file mode 100644 index fa0dd04c..00000000 --- a/examples/app/commerce/src/atoms/mp-product-image/mp-product-image.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class MpProductImage extends WebUIElement { - @attr gradient = ''; - @attr({ attribute: 'image-url' }) imageUrl = ''; - @attr alt = ''; - @attr interactive = ''; - @attr loading = 'lazy'; - @attr decoding = 'async'; - @attr({ attribute: 'fetch-priority' }) fetchPriority = 'auto'; - @attr({ attribute: 'proxy-width' }) proxyWidth = '640'; - @attr({ attribute: 'proxy-height' }) proxyHeight = '640'; -} - -MpProductImage.define('mp-product-image'); diff --git a/examples/app/commerce/src/index.ts b/examples/app/commerce/src/index.ts index aeefd93a..6a72bc7b 100644 --- a/examples/app/commerce/src/index.ts +++ b/examples/app/commerce/src/index.ts @@ -5,8 +5,9 @@ * WebUI Store — WebUI Framework hydration + client-side routing. * * The server pre-renders all HTML via WebUI's binary protocol (--plugin=webui). - * This script registers interactive custom elements, triggers hydration, - * and activates the WebUI Router for SPA page transitions. + * This script registers interactive custom elements, lets the framework + * auto-define HTML-only template elements, and activates the WebUI Router for + * SPA page transitions. * * Navigation flow: * 1. Initial page load → full SSR + WebUI Framework hydration @@ -17,6 +18,13 @@ import { Router } from '@microsoft/webui-router'; +// Shell and interactive children — eagerly loaded. HTML-only tags are +// auto-claimed from compiled template metadata. +import '#organisms/mp-app/mp-app.js'; +import '#organisms/mp-category-nav/mp-category-nav.js'; +import '#organisms/mp-filter-list/mp-filter-list.js'; +import '#organisms/mp-product-card/mp-product-card.js'; + // Listen for the framework's global hydration-complete event. // NOTE: ES module imports are hoisted, so hydration may complete before // this listener is registered. Check for the performance mark as a fallback. @@ -26,30 +34,16 @@ function onHydrationComplete(): void { const total = performance.getEntriesByName('webui:hydrate:total', 'measure')[0]; console.log(`WebUI Store hydration complete in ${total?.duration.toFixed(1)}ms`); - // Start client-side router after hydration — page components lazy-loaded + // Start client-side router after hydration. HTML-only routes use template + // fallback elements; the product page keeps its authored interactive class. Router.start({ preload: true, loaders: { - 'mp-page-home': () => import('#pages/mp-page-home/mp-page-home.js'), - 'mp-page-search': () => import('#pages/mp-page-search/mp-page-search.js'), - 'mp-product-grid': () => import('#organisms/mp-product-grid/mp-product-grid.js'), 'mp-page-product': () => import('#pages/mp-page-product/mp-page-product.js'), - 'mp-page-about': () => import('#pages/mp-page-about/mp-page-about.js'), - 'mp-page-terms': () => import('#pages/mp-page-terms/mp-page-terms.js'), - 'mp-page-shipping': () => import('#pages/mp-page-shipping/mp-page-shipping.js'), - 'mp-page-privacy': () => import('#pages/mp-page-privacy/mp-page-privacy.js'), - 'mp-page-faq': () => import('#pages/mp-page-faq/mp-page-faq.js'), }, }); } -// Shell component — eagerly loaded (child imports are co-located in each component) -import '#organisms/mp-app/mp-app.js'; - -// Search page components — eagerly loaded for SSR hydration of nested routes. -import '#pages/mp-page-search/mp-page-search.js'; -import '#organisms/mp-product-grid/mp-product-grid.js'; - // Fallback: if hydration already completed before the listener, log now if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) { onHydrationComplete(); diff --git a/examples/app/commerce/src/molecules/mp-product-label/mp-product-label.ts b/examples/app/commerce/src/molecules/mp-product-label/mp-product-label.ts deleted file mode 100644 index 0d43a6d5..00000000 --- a/examples/app/commerce/src/molecules/mp-product-label/mp-product-label.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -import '#atoms/mp-price/mp-price.js'; - -export class MpProductLabel extends WebUIElement { - @attr title = ''; - @attr price = ''; - @attr({ attribute: 'price-size' }) priceSize = 'sm'; -} - -MpProductLabel.define('mp-product-label'); diff --git a/examples/app/commerce/src/organisms/mp-app/mp-app.ts b/examples/app/commerce/src/organisms/mp-app/mp-app.ts index bcc952fe..34932e35 100644 --- a/examples/app/commerce/src/organisms/mp-app/mp-app.ts +++ b/examples/app/commerce/src/organisms/mp-app/mp-app.ts @@ -6,12 +6,6 @@ import { WebUIElement, observable } from '@microsoft/webui-framework'; import '#organisms/mp-navbar/mp-navbar.js'; import '#organisms/mp-mobile-menu/mp-mobile-menu.js'; import '#organisms/mp-cart-panel/mp-cart-panel.js'; -import '#organisms/mp-footer/mp-footer.js'; - -interface NavCategory { - handle: string; - title: string; -} interface MobileMenuController extends HTMLElement { openMenu(): void; @@ -28,15 +22,12 @@ interface CartStateDetail { } export class MpApp extends WebUIElement { - @observable storeName!: string; - @observable searchQuery!: string; @observable currentPath!: string; @observable cartOpen!: string; @observable cartHref!: string; @observable cartCloseHref!: string; @observable subtotal!: string; @observable taxes!: string; - @observable navCategories!: NavCategory[]; @observable cartItems!: any[]; mobileMenu!: MobileMenuController; diff --git a/examples/app/commerce/src/organisms/mp-carousel/mp-carousel.ts b/examples/app/commerce/src/organisms/mp-carousel/mp-carousel.ts deleted file mode 100644 index e0235952..00000000 --- a/examples/app/commerce/src/organisms/mp-carousel/mp-carousel.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -import '#organisms/mp-product-card/mp-product-card.js'; - -export class MpCarousel extends WebUIElement { - @observable products: { - handle: string; - title: string; - price: string; - gradient: string; - imageUrl?: string; - }[] = []; -} - -MpCarousel.define('mp-carousel'); diff --git a/examples/app/commerce/src/organisms/mp-cart-panel/mp-cart-panel.ts b/examples/app/commerce/src/organisms/mp-cart-panel/mp-cart-panel.ts index 77a092f2..eec8171f 100644 --- a/examples/app/commerce/src/organisms/mp-cart-panel/mp-cart-panel.ts +++ b/examples/app/commerce/src/organisms/mp-cart-panel/mp-cart-panel.ts @@ -1,28 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; - -import '#atoms/mp-price/mp-price.js'; -import '#atoms/mp-product-image/mp-product-image.js'; - -interface CartItem { - handle: string; - title: string; - color: string; - size: string; - variantLabel: string; - price: string; - quantity: number; - gradient: string; - imageUrl: string; - increaseTo: number; - decreaseTo: number; - redirectTo: string; -} +import { WebUIElement, attr } from '@microsoft/webui-framework'; export class MpCartPanel extends WebUIElement { - @observable cartItems!: CartItem[]; @attr subtotal!: string; @attr taxes!: string; @attr({ attribute: 'cart-open' }) cartOpen!: string; diff --git a/examples/app/commerce/src/organisms/mp-category-nav/mp-category-nav.ts b/examples/app/commerce/src/organisms/mp-category-nav/mp-category-nav.ts index e42b3e95..948acf56 100644 --- a/examples/app/commerce/src/organisms/mp-category-nav/mp-category-nav.ts +++ b/examples/app/commerce/src/organisms/mp-category-nav/mp-category-nav.ts @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; +import { WebUIElement, attr } from '@microsoft/webui-framework'; export class MpCategoryNav extends WebUIElement { @attr({ attribute: 'all-active', mode: 'boolean' }) allActive = false; @attr({ attribute: 'current-label' }) currentCategoryLabel = 'All'; - @observable categories: any[] = []; mobileDropdown!: HTMLDetailsElement; closeMobileDropdown(): void { diff --git a/examples/app/commerce/src/organisms/mp-filter-list/mp-filter-list.ts b/examples/app/commerce/src/organisms/mp-filter-list/mp-filter-list.ts index c2bc2136..b6c3fedd 100644 --- a/examples/app/commerce/src/organisms/mp-filter-list/mp-filter-list.ts +++ b/examples/app/commerce/src/organisms/mp-filter-list/mp-filter-list.ts @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { WebUIElement, observable } from '@microsoft/webui-framework'; +import { WebUIElement } from '@microsoft/webui-framework'; export class MpFilterList extends WebUIElement { - @observable sortOptions: any[] = []; mobileDropdown!: HTMLDetailsElement; closeMobileDropdown(): void { diff --git a/examples/app/commerce/src/organisms/mp-footer/mp-footer.ts b/examples/app/commerce/src/organisms/mp-footer/mp-footer.ts deleted file mode 100644 index bca3a72b..00000000 --- a/examples/app/commerce/src/organisms/mp-footer/mp-footer.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement } from '@microsoft/webui-framework'; - -export class MpFooter extends WebUIElement {} - -MpFooter.define('mp-footer'); diff --git a/examples/app/commerce/src/organisms/mp-hero-grid/mp-hero-grid.ts b/examples/app/commerce/src/organisms/mp-hero-grid/mp-hero-grid.ts deleted file mode 100644 index e4a86c0f..00000000 --- a/examples/app/commerce/src/organisms/mp-hero-grid/mp-hero-grid.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -import '#organisms/mp-product-card/mp-product-card.js'; - -export class MpHeroGrid extends WebUIElement { - @observable products: any[] = []; -} - -MpHeroGrid.define('mp-hero-grid'); diff --git a/examples/app/commerce/src/organisms/mp-mobile-menu/mp-mobile-menu.ts b/examples/app/commerce/src/organisms/mp-mobile-menu/mp-mobile-menu.ts index becae9a6..60e671d5 100644 --- a/examples/app/commerce/src/organisms/mp-mobile-menu/mp-mobile-menu.ts +++ b/examples/app/commerce/src/organisms/mp-mobile-menu/mp-mobile-menu.ts @@ -5,14 +5,8 @@ import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; import '#molecules/mp-search-bar/mp-search-bar.js'; -interface Category { - handle: string; - title: string; -} - export class MpMobileMenu extends WebUIElement { @attr({ attribute: 'search-query' }) searchQuery = ''; - @observable navCategories: Category[] = []; @observable open = false; panelEl!: HTMLElement; diff --git a/examples/app/commerce/src/organisms/mp-navbar/mp-navbar.ts b/examples/app/commerce/src/organisms/mp-navbar/mp-navbar.ts index 79267a04..40b3eb09 100644 --- a/examples/app/commerce/src/organisms/mp-navbar/mp-navbar.ts +++ b/examples/app/commerce/src/organisms/mp-navbar/mp-navbar.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; +import { WebUIElement, attr } from '@microsoft/webui-framework'; import '#molecules/mp-search-bar/mp-search-bar.js'; @@ -9,8 +9,6 @@ export class MpNavbar extends WebUIElement { @attr({ attribute: 'store-name' }) storeName = 'Acme Store'; @attr({ attribute: 'search-query' }) searchQuery = ''; @attr({ attribute: 'cart-href' }) cartHref = './?cart=open'; - @observable cartItems!: unknown[]; - @observable navCategories: { handle: string; title: string }[] = []; onCartClick(e: MouseEvent): void { e.preventDefault(); diff --git a/examples/app/commerce/src/organisms/mp-product-card/mp-product-card.ts b/examples/app/commerce/src/organisms/mp-product-card/mp-product-card.ts index 21a7a25a..0f2f3f85 100644 --- a/examples/app/commerce/src/organisms/mp-product-card/mp-product-card.ts +++ b/examples/app/commerce/src/organisms/mp-product-card/mp-product-card.ts @@ -3,9 +3,6 @@ import { WebUIElement, attr } from '@microsoft/webui-framework'; -import '#atoms/mp-product-image/mp-product-image.js'; -import '#molecules/mp-product-label/mp-product-label.js'; - export class MpProductCard extends WebUIElement { @attr handle = ''; @attr title = ''; diff --git a/examples/app/commerce/src/organisms/mp-product-gallery/mp-product-gallery.ts b/examples/app/commerce/src/organisms/mp-product-gallery/mp-product-gallery.ts index e2c75a35..1d37da49 100644 --- a/examples/app/commerce/src/organisms/mp-product-gallery/mp-product-gallery.ts +++ b/examples/app/commerce/src/organisms/mp-product-gallery/mp-product-gallery.ts @@ -3,8 +3,6 @@ import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; -import '#atoms/mp-product-image/mp-product-image.js'; - interface GalleryImage { index: number; gradient: string; diff --git a/examples/app/commerce/src/organisms/mp-product-grid/mp-product-grid.ts b/examples/app/commerce/src/organisms/mp-product-grid/mp-product-grid.ts deleted file mode 100644 index 62be3492..00000000 --- a/examples/app/commerce/src/organisms/mp-product-grid/mp-product-grid.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -import '#organisms/mp-product-card/mp-product-card.js'; - -export class MpProductGrid extends WebUIElement { - @observable products: any[] = []; - @observable query = ''; -} - -MpProductGrid.define('mp-product-grid'); diff --git a/examples/app/commerce/src/pages/mp-page-about/mp-page-about.ts b/examples/app/commerce/src/pages/mp-page-about/mp-page-about.ts deleted file mode 100644 index cbd5a69b..00000000 --- a/examples/app/commerce/src/pages/mp-page-about/mp-page-about.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement } from '@microsoft/webui-framework'; - -export class MpPageAbout extends WebUIElement {} - -MpPageAbout.define('mp-page-about'); diff --git a/examples/app/commerce/src/pages/mp-page-faq/mp-page-faq.ts b/examples/app/commerce/src/pages/mp-page-faq/mp-page-faq.ts deleted file mode 100644 index b269bb10..00000000 --- a/examples/app/commerce/src/pages/mp-page-faq/mp-page-faq.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement } from '@microsoft/webui-framework'; - -export class MpPageFaq extends WebUIElement {} - -MpPageFaq.define('mp-page-faq'); diff --git a/examples/app/commerce/src/pages/mp-page-home/mp-page-home.ts b/examples/app/commerce/src/pages/mp-page-home/mp-page-home.ts deleted file mode 100644 index 3a25d00f..00000000 --- a/examples/app/commerce/src/pages/mp-page-home/mp-page-home.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -import '#organisms/mp-hero-grid/mp-hero-grid.js'; -import '#organisms/mp-carousel/mp-carousel.js'; - -export class MpPageHome extends WebUIElement { - @observable featuredProducts!: any[]; - @observable carouselProducts!: any[]; -} - -MpPageHome.define('mp-page-home'); diff --git a/examples/app/commerce/src/pages/mp-page-privacy/mp-page-privacy.ts b/examples/app/commerce/src/pages/mp-page-privacy/mp-page-privacy.ts deleted file mode 100644 index 11a18ca9..00000000 --- a/examples/app/commerce/src/pages/mp-page-privacy/mp-page-privacy.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement } from '@microsoft/webui-framework'; - -export class MpPagePrivacy extends WebUIElement {} - -MpPagePrivacy.define('mp-page-privacy'); diff --git a/examples/app/commerce/src/pages/mp-page-product/mp-page-product.ts b/examples/app/commerce/src/pages/mp-page-product/mp-page-product.ts index ee513bf1..b554264c 100644 --- a/examples/app/commerce/src/pages/mp-page-product/mp-page-product.ts +++ b/examples/app/commerce/src/pages/mp-page-product/mp-page-product.ts @@ -3,32 +3,14 @@ import { WebUIElement, observable } from '@microsoft/webui-framework'; -import '#atoms/mp-price/mp-price.js'; import '#organisms/mp-product-gallery/mp-product-gallery.js'; import '#organisms/mp-variant-selector/mp-variant-selector.js'; import '#organisms/mp-add-to-cart/mp-add-to-cart.js'; import '#organisms/mp-product-card/mp-product-card.js'; export class MpPageProduct extends WebUIElement { - @observable handle!: string; - @observable productTitle!: string; - @observable price!: string; - @observable gradient!: string; - @observable gradientAlt!: string; - @observable imageUrl!: string; - @observable imageAltUrl!: string; - @observable compareAt!: string; - @observable hasCompareAt!: boolean; - @observable descriptionHtml!: string; - @observable defaultColor!: string; - @observable defaultSize!: string; @observable selectedColor!: string; @observable selectedSize!: string; - @observable currentPath!: string; - - @observable images!: any[]; - @observable optionGroups!: any[]; - @observable relatedProducts!: any[]; onVariantSelect(event: Event): void { const { group, value } = (event as CustomEvent).detail; diff --git a/examples/app/commerce/src/pages/mp-page-search/mp-page-search.ts b/examples/app/commerce/src/pages/mp-page-search/mp-page-search.ts deleted file mode 100644 index 048cd40e..00000000 --- a/examples/app/commerce/src/pages/mp-page-search/mp-page-search.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -import '#organisms/mp-category-nav/mp-category-nav.js'; -import '#organisms/mp-filter-list/mp-filter-list.js'; - -export class MpPageSearch extends WebUIElement { - @observable categories!: any[]; - @observable sortOptions!: any[]; - @observable allActive!: boolean; - @observable currentCategoryLabel!: string; -} - -MpPageSearch.define('mp-page-search'); diff --git a/examples/app/commerce/src/pages/mp-page-shipping/mp-page-shipping.ts b/examples/app/commerce/src/pages/mp-page-shipping/mp-page-shipping.ts deleted file mode 100644 index f5e9de96..00000000 --- a/examples/app/commerce/src/pages/mp-page-shipping/mp-page-shipping.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement } from '@microsoft/webui-framework'; - -export class MpPageShipping extends WebUIElement {} - -MpPageShipping.define('mp-page-shipping'); diff --git a/examples/app/commerce/src/pages/mp-page-terms/mp-page-terms.ts b/examples/app/commerce/src/pages/mp-page-terms/mp-page-terms.ts deleted file mode 100644 index fb9d6daf..00000000 --- a/examples/app/commerce/src/pages/mp-page-terms/mp-page-terms.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement } from '@microsoft/webui-framework'; - -export class MpPageTerms extends WebUIElement {} - -MpPageTerms.define('mp-page-terms'); diff --git a/examples/app/commerce/tests/commerce.spec.ts b/examples/app/commerce/tests/commerce.spec.ts index 22db7981..bc38342c 100644 --- a/examples/app/commerce/tests/commerce.spec.ts +++ b/examples/app/commerce/tests/commerce.spec.ts @@ -49,6 +49,32 @@ test.describe('SSR pages', () => { expect(count).toBeGreaterThanOrEqual(10); }); + test('declarative-only components auto-upgrade without authored stubs', async ({ page }) => { + await page.goto('/search/shirts'); + await expect(page.locator('mp-product-grid mp-product-card')).toHaveCount(3); + + const componentState = async (selector: string) => + page.locator(selector).first().evaluate((el) => { + const component = el as HTMLElement & { $ready?: boolean; setState?: unknown }; + + return { + ready: component.$ready === true, + setState: typeof component.setState === 'function', + }; + }); + + await expect(componentState('mp-page-search')).resolves.toEqual({ ready: true, setState: true }); + await expect(componentState('mp-product-grid')).resolves.toEqual({ ready: true, setState: true }); + await expect(componentState('mp-price')).resolves.toEqual({ ready: true, setState: true }); + + const grid = page.locator('mp-product-grid').first(); + await grid.evaluate((el) => { + const component = el as HTMLElement & { setState(state: unknown): void }; + component.setState({ products: [], query: 'fallback update' }); + }); + await expect(grid).toContainText('fallback update'); + }); + test('category page renders filtered products', async ({ page }) => { await page.goto('/search/shirts'); await expect(page.getByRole('heading', { name: 'Collections' })).toBeVisible(); diff --git a/examples/app/component-assets/src/app-shell/app-shell.ts b/examples/app/component-assets/src/app-shell/app-shell.ts index 93db3689..7cdbc222 100644 --- a/examples/app/component-assets/src/app-shell/app-shell.ts +++ b/examples/app/component-assets/src/app-shell/app-shell.ts @@ -7,7 +7,6 @@ import { defineComponentAssets } from '@microsoft/webui-framework/component-asse const assets = defineComponentAssets({ 'lazy-panel': { asset: './lazy-panel.webui.js', - module: () => import('../lazy-panel/lazy-panel.js'), data: async () => await (await fetch('./lazy-panel-data.json')).json(), }, }); diff --git a/examples/app/component-assets/src/asset-badge/asset-badge.ts b/examples/app/component-assets/src/asset-badge/asset-badge.ts deleted file mode 100644 index a7782df5..00000000 --- a/examples/app/component-assets/src/asset-badge/asset-badge.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class AssetBadge extends WebUIElement { - @attr label = ''; -} - -AssetBadge.define('asset-badge'); diff --git a/examples/app/component-assets/src/index.ts b/examples/app/component-assets/src/index.ts index 457f794b..b5a91d74 100644 --- a/examples/app/component-assets/src/index.ts +++ b/examples/app/component-assets/src/index.ts @@ -4,10 +4,9 @@ /** * Static component asset example. * - * The initial bundle registers and the shared . - * Clicking the button loads lazy-panel.webui.js, then imports the lazy - * component class chunk. + * The initial bundle registers . The framework auto-claims the + * shared , then claims when its static template asset + * is loaded. */ import './app-shell/app-shell.js'; -import './asset-badge/asset-badge.js'; diff --git a/examples/app/component-assets/src/lazy-panel/lazy-panel.ts b/examples/app/component-assets/src/lazy-panel/lazy-panel.ts deleted file mode 100644 index 6894024d..00000000 --- a/examples/app/component-assets/src/lazy-panel/lazy-panel.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; -import '../asset-badge/asset-badge.js'; - -export class LazyPanel extends WebUIElement { - @observable status = 'Loading'; - @observable heading = 'Loading panel data'; - @observable message = 'The component class is fetching its own JSON state.'; - @observable hasDetails = false; - @observable details = ''; -} - -LazyPanel.define('lazy-panel'); diff --git a/examples/app/component-assets/tests/component-assets.spec.ts b/examples/app/component-assets/tests/component-assets.spec.ts index 5c3f4c0e..2fcaed0d 100644 --- a/examples/app/component-assets/tests/component-assets.spec.ts +++ b/examples/app/component-assets/tests/component-assets.spec.ts @@ -44,24 +44,58 @@ test.describe('static component assets', () => { await page.goto('/'); await expect(page.getByRole('button', { name: 'Load lazy panel' })).toBeVisible(); await expect(page.locator('lazy-panel')).toHaveCount(0); + const badge = page.locator('asset-badge').first(); + await expect( + badge.evaluate((el) => { + const component = el as HTMLElement & { $ready?: boolean; setState?: unknown }; + + return { + ready: component.$ready === true, + setState: typeof component.setState === 'function', + }; + }), + ).resolves.toEqual({ ready: true, setState: true }); expect(lazyRequests).toEqual([]); expect(await loadedTemplateNames(page)).not.toContain('lazy-panel'); await page.getByRole('button', { name: 'Load lazy panel' }).click(); await expect(page.locator('lazy-panel')).toHaveCount(1); + const lazyPanel = page.locator('lazy-panel').first(); + await expect( + lazyPanel.evaluate((el) => { + const component = el as HTMLElement & { $ready?: boolean; setState?: unknown }; + + return { + ready: component.$ready === true, + setState: typeof component.setState === 'function', + }; + }), + ).resolves.toEqual({ ready: true, setState: true }); await expect(page.getByText('Static asset template is active')).toBeVisible(); await expect(page.getByText('Loaded from component fetch')).toBeVisible(); + await lazyPanel.evaluate((el) => { + const component = el as HTMLElement & { setState(state: unknown): void }; + component.setState({ + status: 'Updated', + heading: 'Fallback lazy panel', + message: 'Updated through setState()', + hasDetails: true, + details: 'No authored lazy panel class required.', + }); + }); + await expect(lazyPanel).toContainText('Fallback lazy panel'); + await expect(lazyPanel).toContainText('No authored lazy panel class required.'); + expect(await loadedTemplateNames(page)).toContain('lazy-panel'); expect(countLazyRequests(lazyRequests, 'asset')).toBe(1); - expect(countLazyRequests(lazyRequests, 'module')).toBe(1); + expect(countLazyRequests(lazyRequests, 'module')).toBe(0); expect(countLazyRequests(lazyRequests, 'data')).toBe(1); expect(countLazyRequests(lazyRequests, 'css')).toBeGreaterThanOrEqual(1); const firstLoadCounts = { asset: countLazyRequests(lazyRequests, 'asset'), - module: countLazyRequests(lazyRequests, 'module'), data: countLazyRequests(lazyRequests, 'data'), }; @@ -70,7 +104,7 @@ test.describe('static component assets', () => { await expect(page.getByText('Static asset template is active')).toBeVisible(); expect(countLazyRequests(lazyRequests, 'asset')).toBe(firstLoadCounts.asset); - expect(countLazyRequests(lazyRequests, 'module')).toBe(firstLoadCounts.module); + expect(countLazyRequests(lazyRequests, 'module')).toBe(0); expect(countLazyRequests(lazyRequests, 'data')).toBe(firstLoadCounts.data); }); }); diff --git a/examples/app/contact-book-manager/README.md b/examples/app/contact-book-manager/README.md index 40d6c802..64b548a3 100644 --- a/examples/app/contact-book-manager/README.md +++ b/examples/app/contact-book-manager/README.md @@ -1,6 +1,10 @@ # Contact Book Manager -A full-featured contact book manager built with **WebUI SSR** and WebUI Framework client hydration. Demonstrates Atomic Design component architecture, IndexedDB offline storage, client-side routing, and responsive layout - all rendered server-side with the `--plugin=webui` pipeline. +A full-featured contact book manager built with **WebUI SSR** and WebUI Framework client hydration. Demonstrates Atomic Design component architecture, IndexedDB offline storage, client-side routing, and responsive layout - all rendered server-side with the `--plugin=webui` pipeline. + +Only components with custom event handlers ship TypeScript. Declarative pages, +display atoms, and list/card components are HTML-only and are claimed by the +explicit framework fallback runtime from compiled template metadata. ## Quick Start @@ -21,4 +25,3 @@ cargo xtask dev contact-book-manager ``` Then open [http://localhost:3003](http://localhost:3003). - diff --git a/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts deleted file mode 100644 index be7b9f4a..00000000 --- a/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbAvatar extends WebUIElement { - @attr initials = ''; - @attr color = '#6B7280'; - @attr size = 'md'; -} - -CbAvatar.define('cb-avatar'); diff --git a/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts deleted file mode 100644 index f62af457..00000000 --- a/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbBadge extends WebUIElement { - @attr label = ''; - @attr variant = 'default'; -} - -CbBadge.define('cb-badge'); diff --git a/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts deleted file mode 100644 index 62eb1e94..00000000 --- a/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbButton extends WebUIElement { - @attr label = ''; - @attr variant = 'primary'; - @attr size = 'md'; -} - -CbButton.define('cb-button'); diff --git a/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts deleted file mode 100644 index 6ea914de..00000000 --- a/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbEmptyState extends WebUIElement { - @attr icon = '📭'; - @attr title = ''; - @attr message = ''; -} - -CbEmptyState.define('cb-empty-state'); diff --git a/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts deleted file mode 100644 index 8a6043a4..00000000 --- a/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbIconButton extends WebUIElement { - @attr icon = ''; - @attr title = ''; - @attr variant = 'default'; -} - -CbIconButton.define('cb-icon-button'); diff --git a/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts deleted file mode 100644 index e9ade67b..00000000 --- a/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbInput extends WebUIElement { - @attr placeholder = ''; - @attr value = ''; - @attr type = 'text'; - @attr name = ''; -} - -CbInput.define('cb-input'); diff --git a/examples/app/contact-book-manager/src/cb-app/cb-app.ts b/examples/app/contact-book-manager/src/cb-app/cb-app.ts index eac31812..3df56c7d 100644 --- a/examples/app/contact-book-manager/src/cb-app/cb-app.ts +++ b/examples/app/contact-book-manager/src/cb-app/cb-app.ts @@ -7,19 +7,14 @@ import { api } from '#api'; // Child components used in cb-app.html import '#organisms/cb-header/cb-header.js'; -import '#organisms/cb-sidebar/cb-sidebar.js'; type SearchChangeEvent = CustomEvent<{ value: string }>; type ContactEvent = CustomEvent<{ id: string }>; type FormSaveEvent = CustomEvent>; export class CbApp extends WebUIElement { - @observable page = ''; @observable searchQuery = ''; - @observable activeGroup = 'all'; - @observable totalContacts = '0'; @observable totalFavorites = '0'; - @observable groups: string[] = []; onSearch(e: SearchChangeEvent): void { this.searchQuery = e.detail.value; diff --git a/examples/app/contact-book-manager/src/index.ts b/examples/app/contact-book-manager/src/index.ts index 1604c89c..9a538e0c 100644 --- a/examples/app/contact-book-manager/src/index.ts +++ b/examples/app/contact-book-manager/src/index.ts @@ -8,6 +8,10 @@ import { Router } from '@microsoft/webui-router'; +// Shell component — eagerly loaded. HTML-only tags are auto-claimed from +// compiled template metadata. +import './cb-app/cb-app.js'; + // Listen for the framework's global hydration-complete event. window.addEventListener('webui:hydration-complete', onHydrationComplete); @@ -19,19 +23,12 @@ function onHydrationComplete(): void { // Page components use lazy loaders for code-split navigation. Router.start({ loaders: { - 'cb-page-dashboard': () => import('./pages/cb-page-dashboard/cb-page-dashboard.js'), - 'cb-page-contacts': () => import('./pages/cb-page-contacts/cb-page-contacts.js'), - 'cb-page-favorites': () => import('./pages/cb-page-favorites/cb-page-favorites.js'), - 'cb-page-group': () => import('./pages/cb-page-group/cb-page-group.js'), 'cb-contact-detail': () => import('./organisms/cb-contact-detail/cb-contact-detail.js'), 'cb-contact-form': () => import('./organisms/cb-contact-form/cb-contact-form.js'), }, }); } -// Shell component — eagerly loaded (child imports are co-located in each component) -import './cb-app/cb-app.js'; - // Fallback: if hydration already completed before the listener, log now if (performance.getEntriesByName('webui:hydrate:total', 'measure').length > 0) { onHydrationComplete(); diff --git a/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts deleted file mode 100644 index 27e53539..00000000 --- a/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbFormField extends WebUIElement { - @attr label = ''; - @attr name = ''; - @attr value = ''; - @attr placeholder = ''; - @attr type = 'text'; - @attr error = ''; -} - -CbFormField.define('cb-form-field'); diff --git a/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts deleted file mode 100644 index 65b3500a..00000000 --- a/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbStatCard extends WebUIElement { - @attr icon = ''; - @attr value = ''; - @attr label = ''; -} - -CbStatCard.define('cb-stat-card'); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts deleted file mode 100644 index ccfaf8bc..00000000 --- a/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr } from '@microsoft/webui-framework'; - -export class CbContactCard extends WebUIElement { - @attr id = ''; - @attr firstName = ''; - @attr lastName = ''; - @attr email = ''; - @attr phone = ''; - @attr company = ''; - @attr group = ''; - @attr favorite = 'false'; - @attr initials = ''; - @attr avatarColor = ''; - @attr notes = ''; - @attr address = ''; - - onClick(): void { - this.$emit('select-contact', { id: this.id }); - } -} - -CbContactCard.define('cb-contact-card'); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts index f73fc864..f32c4915 100644 --- a/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts @@ -5,17 +5,7 @@ import { WebUIElement, observable } from '@microsoft/webui-framework'; export class CbContactDetail extends WebUIElement { @observable id!: string; - @observable firstName!: string; - @observable lastName!: string; - @observable email!: string; - @observable phone!: string; - @observable company!: string; - @observable group!: string; @observable favorite!: boolean; - @observable initials!: string; - @observable avatarColor!: string; - @observable notes!: string; - @observable address!: string; onEdit(): void { this.$emit('edit-contact', { id: this.id }); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts deleted file mode 100644 index e20ef3da..00000000 --- a/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -// Child components used in cb-contact-list.html -import '#atoms/cb-empty-state/cb-empty-state.js'; -import '#organisms/cb-contact-card/cb-contact-card.js'; - -interface Contact { - id: string; - firstName: string; - lastName: string; - email: string; - phone: string; - company: string; - group: string; - favorite: boolean; - initials: string; - avatarColor: string; - notes: string; - address: string; -} - -export class CbContactList extends WebUIElement { - @observable contacts!: Contact[]; -} - -CbContactList.define('cb-contact-list'); diff --git a/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts deleted file mode 100644 index d09b8454..00000000 --- a/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, attr, observable } from '@microsoft/webui-framework'; - -export class CbSidebar extends WebUIElement { - @attr page = 'dashboard'; - @attr activeGroup = 'all'; - @attr totalContacts = '0'; - @attr totalFavorites = '0'; - @observable groups: string[] = []; -} - -CbSidebar.define('cb-sidebar'); diff --git a/examples/app/contact-book-manager/src/pages/cb-page-contacts/cb-page-contacts.ts b/examples/app/contact-book-manager/src/pages/cb-page-contacts/cb-page-contacts.ts deleted file mode 100644 index 978966c2..00000000 --- a/examples/app/contact-book-manager/src/pages/cb-page-contacts/cb-page-contacts.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; -import '#organisms/cb-contact-card/cb-contact-card.js'; -import { Contact } from '#api'; - -export class CbPageContacts extends WebUIElement { - @observable contacts: Contact[] = []; -} - -CbPageContacts.define('cb-page-contacts'); diff --git a/examples/app/contact-book-manager/src/pages/cb-page-dashboard/cb-page-dashboard.ts b/examples/app/contact-book-manager/src/pages/cb-page-dashboard/cb-page-dashboard.ts deleted file mode 100644 index 62062ab4..00000000 --- a/examples/app/contact-book-manager/src/pages/cb-page-dashboard/cb-page-dashboard.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -// Child components used in cb-page-dashboard.html -import '#organisms/cb-contact-card/cb-contact-card.js'; -import { Contact } from '#api'; - -export class CbPageDashboard extends WebUIElement { - @observable totalContacts = '0'; - @observable totalFavorites = '0'; - @observable totalGroups = '0'; - @observable recentContacts: Contact[] = []; -} - -CbPageDashboard.define('cb-page-dashboard'); diff --git a/examples/app/contact-book-manager/src/pages/cb-page-favorites/cb-page-favorites.ts b/examples/app/contact-book-manager/src/pages/cb-page-favorites/cb-page-favorites.ts deleted file mode 100644 index e7490909..00000000 --- a/examples/app/contact-book-manager/src/pages/cb-page-favorites/cb-page-favorites.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; -import '#organisms/cb-contact-card/cb-contact-card.js'; -import { Contact } from '#api'; - -export class CbPageFavorites extends WebUIElement { - @observable contacts: Contact[] = []; -} - -CbPageFavorites.define('cb-page-favorites'); diff --git a/examples/app/contact-book-manager/src/pages/cb-page-group/cb-page-group.ts b/examples/app/contact-book-manager/src/pages/cb-page-group/cb-page-group.ts deleted file mode 100644 index 45c3d797..00000000 --- a/examples/app/contact-book-manager/src/pages/cb-page-group/cb-page-group.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; -import '#organisms/cb-contact-card/cb-contact-card.js'; -import type { Contact } from '#api'; - -export class CbPageGroup extends WebUIElement { - @observable groupName = ''; - @observable contacts: Contact[] = []; -} - -CbPageGroup.define('cb-page-group'); diff --git a/examples/app/contact-book-manager/tests/contact-book.spec.ts b/examples/app/contact-book-manager/tests/contact-book.spec.ts index 6f524839..d4511dfb 100644 --- a/examples/app/contact-book-manager/tests/contact-book.spec.ts +++ b/examples/app/contact-book-manager/tests/contact-book.spec.ts @@ -72,6 +72,28 @@ test.describe('SSR pages', () => { // ── Client-side navigation tests ───────────────────────────────── test.describe('client-side navigation', () => { + test('HTML-only components hydrate and update through fallback elements', async ({ page }) => { + await page.goto('/contacts'); + + await expect(page.locator('cb-page-contacts cb-contact-card')).toHaveCount(15); + await expectSidebarGroupsStable(page); + + const autoDefined = await page.evaluate((tags) => { + const results: boolean[] = []; + for (let i = 0; i < tags.length; i++) { + results.push(customElements.get(tags[i] ?? '') !== undefined); + } + return results; + }, ['cb-sidebar', 'cb-page-contacts', 'cb-contact-card']); + + expect(autoDefined).toEqual([true, true, true]); + + await page.locator('cb-sidebar').getByRole('link', { name: 'Work' }).click(); + await expect(page).toHaveURL('/groups/Work'); + await expect(page.locator('cb-page-group .page-title')).toContainText('Work'); + await expectActiveSidebarNav(page, 'Work'); + }); + test('navigate dashboard to contacts', async ({ page }) => { await page.goto('/'); await expect(page.locator('cb-page-dashboard .page-title')).toHaveText('Dashboard'); diff --git a/examples/app/routes/src/index.ts b/examples/app/routes/src/index.ts index 8612a9a0..71a3ff99 100644 --- a/examples/app/routes/src/index.ts +++ b/examples/app/routes/src/index.ts @@ -26,7 +26,6 @@ function onHydrationComplete(): void { loaders: { 'section-page': () => import('./section-page/section-page.js'), 'topic-page': () => import('./topic-page/topic-page.js'), - 'lesson-page': () => import('./lesson-page/lesson-page.js'), }, }); } diff --git a/examples/app/routes/src/lesson-page/lesson-page.ts b/examples/app/routes/src/lesson-page/lesson-page.ts deleted file mode 100644 index 60b37990..00000000 --- a/examples/app/routes/src/lesson-page/lesson-page.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WebUIElement, observable } from '@microsoft/webui-framework'; - -export class LessonPage extends WebUIElement { - @observable id = ''; - @observable sectionName = ''; - @observable topicId = ''; - @observable topicName = ''; - @observable lessonId = ''; - @observable lessonName = ''; - @observable lessonContent = ''; -} - -LessonPage.define('lesson-page'); diff --git a/examples/app/routes/tests/routes-webui.spec.ts b/examples/app/routes/tests/routes-webui.spec.ts index 795f2d49..2d624ecd 100644 --- a/examples/app/routes/tests/routes-webui.spec.ts +++ b/examples/app/routes/tests/routes-webui.spec.ts @@ -60,6 +60,35 @@ test.describe('SSR routing', () => { ); }); + test('declarative-only lesson page auto-upgrades without an authored stub', async ({ page }) => { + await page.goto('/sections/frontend/topics/react/lessons/hooks'); + const lesson = page.locator('lesson-page'); + await expect(lesson).toBeVisible(); + + await expect( + lesson.evaluate((el) => { + const component = el as HTMLElement & { $ready?: boolean; setState?: unknown }; + + return { + ready: component.$ready === true, + setState: typeof component.setState === 'function', + }; + }), + ).resolves.toEqual({ ready: true, setState: true }); + + await lesson.evaluate((el) => { + const component = el as HTMLElement & { setState(state: unknown): void }; + component.setState({ + sectionName: 'Frontend', + topicName: 'React', + lessonName: 'Fallback lesson', + lessonContent: 'Updated through router fallback state.', + }); + }); + await expect(lesson).toContainText('Fallback lesson'); + await expect(lesson).toContainText('Updated through router fallback state.'); + }); + test('webui-route elements have correct active state in SSR', async ({ page }) => { const html = await (await page.goto('/sections/frontend'))!.text(); expect(html).toContain('path="/" component="routes-app" data-ri="0" active>'); diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 24969a6c..fdc395e0 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -87,6 +87,17 @@ Build with `--dom=shadow` (default) to wrap in a declarative shadow root, or `-- ``` +### HTML-only components + +If a component has no event handlers, custom lifecycle code, or client-only +methods, it can ship only `component.html` and optional `component.css`. + +The compiler marks scriptless templates in metadata, and the framework +automatically defines missing template tags after metadata is available. The +fallback hydrates SSR output, observes attributes for template binding roots, +and accepts `setState()` from routers or asset loaders. A developer-authored +class still wins whenever it calls `WebUIElement.define(tagName)` first. + ### Build with the WebUI plugin ```bash @@ -135,7 +146,7 @@ Base class for framework components. | `static define(tagName)` | Register the class as a custom element | | `$emit(name, detail?)` | Dispatch a bubbling, composed `CustomEvent` | | `$update()` | Force a reactive update (normally called automatically) | -| `setState(state)` | Populate `@observable` properties from router/server state | +| `setState(state)` | Apply router/server state to decorated properties and internal template state | | `disconnectedCallback()` | Override for cleanup (global listeners, etc.) | In most components you do not call `$update()` directly. Property changes through `@observable` and `@attr` trigger updates for you. @@ -177,8 +188,11 @@ when a component must wait briefly for state before mounting. ### `@observable` -Marks a property as reactive. When the value changes, the framework -re-evaluates the compiled bindings that reference it. +Marks a property as reactive. When the value changes, the framework +re-evaluates the compiled bindings that reference it. Use it for state that +TypeScript code reads or mutates; values supplied only by SSR, router +`setState()`, or component asset data can be omitted and stay in internal +template state. ```ts class SearchPanel extends WebUIElement { @@ -205,7 +219,7 @@ Notes: - default attribute names use kebab-case - attribute values arrive as strings -- use `@observable` for richer client-only state +- use `@observable` for state that client code reads or mutates ### `@volatile` @@ -257,11 +271,15 @@ Root-level events (e.g. `@toggle-item="{onToggleItem(e)}"`) can be declared on t ## Recommended Patterns -- Treat decorated properties as the source of truth. +- Treat decorated properties as the source of truth for state used by + TypeScript code. - Update state with property assignments such as `this.open = !this.open`. - Use `$emit()` for child-to-parent communication. - Use `w-ref` for true DOM-only concerns like focus or reading input values. -- Prefer `@observable someValue!: T;` when a value is expected to be seeded externally after construction. +- Omit `@observable` for values that are only read by the template and seeded + externally after construction. +- Omit the TypeScript class for HTML-only components that only need compiled + template bindings and router/server state. Avoid imperative DOM mutation for application state that can be represented by reactive properties. @@ -442,7 +460,7 @@ sequenceDiagram CE->>CE: attributeChangedCallback (pre-existing attrs) CE->>FW: connectedCallback() → $mount() FW->>FW: SSR DOM detected (shadow root or children exist) - FW->>FW: $applySSRState() — seed observables from __webui.state + FW->>FW: $applySSRState() — seed decorated + template state FW->>FW: $hydrate() — template-parallel path resolution FW->>FW: $resolveSSR() — match SSR nodes via ordinal traversal FW->>FW: $wireEvents() + $wireRefs() @@ -498,6 +516,7 @@ interface TemplateMeta { sa?: string; // Adopted stylesheet specifier sd?: boolean; // Shadow DOM flag for client-created re?: [event, handler, argSpecs][]; // Root-level events + ae?: 1; // Auto-element eligible (no script) } ``` @@ -558,15 +577,16 @@ sequenceDiagram ### Why Updates Are O(affected) After hydration, every dynamic value in the template is connected to a direct -DOM node reference stored in a binding array. A per-path index maps each -`@observable` property name to the subset of bindings that reference it. +DOM node reference stored in a binding array. A per-path index maps each +decorated property or compiled template root to the subset of bindings that +reference it. When `this.count = 5` fires, the `@observable` setter calls `$update('count')`, which looks up `'count'` in the index and only patches the bindings that actually depend on `count` — not every binding in the component. -Computed/volatile getters (paths not in the `@observable` set) are stored -under a wildcard key and always included in targeted updates. +Computed/volatile getters and other paths that are not known state roots are +stored under a wildcard key and always included in targeted updates. ```typescript // Targeted update (simplified): @@ -588,27 +608,29 @@ path index ensures only affected pointers are visited. ## SSR State Seeding -When the server renders `42` for `@observable count = 0`, the -browser sees `42` in the DOM but the JavaScript property `this.count` is still -`0` (the class default). Without seeding, the first `$update()` would -overwrite the SSR content with the wrong value. +When the server renders `42` for a template binding, the browser +sees `42` in the DOM before the component's JavaScript state exists. Without +seeding, the first `$update()` would overwrite the SSR content with the wrong +value. State seeding uses `window.__webui.state` — a JSON object loaded from the server-emitted `#webui-data` block. Like Preact's props, this delivers the -same data used for SSR rendering to the client. During `$mount()`, -`$applySSRState()` writes matching keys directly to observable backing fields -before any bindings are wired: +same data used for SSR rendering to the client. During `$mount()`, +`$applySSRState()` writes matching decorated keys directly to observable backing +fields and stores undecorated template roots in hidden framework state before +any bindings are wired: ```mermaid flowchart LR SCRIPT["<script type='application/json' id='webui-data'>
{ state: { count: 42, title: 'Hello' } }"] --> APPLY["$applySSRState()"] - APPLY --> SEED["Write to backing fields:
this._count = 42
this._title = 'Hello'"] + APPLY --> SEED["Write decorated fields + hidden template state"] SEED --> HYDRATE["$hydrate() — bindings match
server-rendered DOM"] ``` -`$applySSRState()` only sets properties that exist in the component's -`@observable` set — unknown keys are ignored. Writes go to the backing -field (`_prop`) directly, avoiding reactive updates before bindings are wired. +`$applySSRState()` only accepts keys that are decorated properties or compiled +template roots. Unknown keys are ignored. Decorated writes go to the backing +field (`_prop`) directly, and undecorated template roots stay internal, avoiding +reactive updates before bindings are wired. --- diff --git a/packages/webui-framework/package.json b/packages/webui-framework/package.json index ba8d1b54..4e9fdcca 100644 --- a/packages/webui-framework/package.json +++ b/packages/webui-framework/package.json @@ -4,7 +4,11 @@ "type": "module", "description": "WebUI Framework Next — Preact-inspired lightweight Web Component runtime with SSR hydration. 15KB minified, compiled-template path mapping, no hydration markers.", "license": "MIT", - "sideEffects": false, + "sideEffects": [ + "./dist/index.js", + "./src/index.js", + "./src/index.ts" + ], "exports": { ".": { "import": { @@ -26,6 +30,7 @@ "build": "tsc", "typecheck": "tsc --noEmit", "typecheck:e2e": "tsc -p tsconfig.test.json --noEmit", + "size": "node scripts/size-report.mjs", "test": "pnpm test:unit && pnpm test:e2e", "test:unit": "tsc -p tsconfig.unit-test.json && node --test \"dist/**/*.test.js\"", "test:e2e": "pnpm typecheck:e2e && playwright test", @@ -36,6 +41,7 @@ "@microsoft/webui-test-support": "workspace:*", "@playwright/test": "catalog:", "@types/node": "catalog:", + "esbuild": "catalog:", "typescript": "catalog:" } } diff --git a/packages/webui-framework/scripts/size-report.mjs b/packages/webui-framework/scripts/size-report.mjs new file mode 100644 index 00000000..6465cd9f --- /dev/null +++ b/packages/webui-framework/scripts/size-report.mjs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { brotliCompressSync, gzipSync } from 'node:zlib'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); + +const sharedOptions = { + bundle: true, + format: 'esm', + minify: true, + platform: 'browser', + target: 'es2022', + treeShaking: true, + write: false, +}; + +const probes = [ + { + name: 'root barrel', + entryPoints: [resolve(root, 'src/index.ts')], + }, + { + name: 'auto element internal', + entryPoints: [resolve(root, 'src/auto-element.ts')], + }, + { + name: 'authored probe', + stdin: { + contents: ` + import { WebUIElement, observable, attr } from './src/index.ts'; + class SizeProbe extends WebUIElement { + @attr label = ''; + @observable count = 0; + onClick = () => { this.count++; }; + } + SizeProbe.define('size-probe'); + `, + loader: 'ts', + resolveDir: root, + }, + }, + { + name: 'html-only probe', + stdin: { + contents: `import './src/index.ts';`, + loader: 'ts', + resolveDir: root, + }, + }, +]; + +function kb(bytes) { + return `${(bytes / 1024).toFixed(2)} KB`; +} + +const rows = []; +for (const probe of probes) { + const { name, ...options } = probe; + const result = await build({ + ...sharedOptions, + ...options, + }); + const code = result.outputFiles[0].contents; + rows.push({ + bundle: name, + minified: kb(code.length), + gzip: kb(gzipSync(code).length), + brotli: kb(brotliCompressSync(code).length), + }); +} + +console.table(rows); diff --git a/packages/webui-framework/src/auto-element.test.ts b/packages/webui-framework/src/auto-element.test.ts new file mode 100644 index 00000000..93aa0e7a --- /dev/null +++ b/packages/webui-framework/src/auto-element.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { strict as assert } from 'node:assert'; +import { describe, test } from 'node:test'; +import type { TemplateMeta } from './template.js'; + +const registry = new Map(); + +Object.defineProperty(globalThis, 'HTMLElement', { + value: class HTMLElement { + isConnected = false; + + get title(): string { + return 'native title'; + } + + hasAttribute(_name: string): boolean { + return false; + } + }, + configurable: true, +}); + +Object.defineProperty(globalThis, 'customElements', { + value: { + get(name: string): CustomElementConstructor | undefined { + return registry.get(name); + }, + define(name: string, ctor: CustomElementConstructor): void { + registry.set(name, ctor); + }, + }, + configurable: true, +}); + +Object.defineProperty(globalThis, 'document', { + value: { + readyState: 'complete', + getElementById() { + return null; + }, + }, + configurable: true, +}); + +Object.defineProperty(globalThis, 'window', { + value: { + __webui: { templates: {} }, + addEventListener() {}, + }, + configurable: true, +}); + +const { + installAutoElementRuntime, +} = await import('./auto-element.js'); + +type ObservedElementConstructor = CustomElementConstructor & { + readonly observedAttributes: readonly string[]; +}; + +function textTemplate(path: string, autoElement = true): TemplateMeta { + return { + h: '

', + ae: autoElement ? 1 : undefined, + tx: [[ + [[], 0], + [[path]], + ]], + }; +} + +function registerUnitTemplate(tag: string, meta: TemplateMeta): TemplateMeta { + const webui = window.__webui ?? (window.__webui = {}); + const templates = webui.templates ?? (webui.templates = {}); + templates[tag] = meta; + return meta; +} + +describe('auto element fallback', () => { + test('installAutoElementRuntime registers a CoreElement fallback for metadata roots', async () => { + const tag = `auto-unit-${Date.now()}`; + + registerUnitTemplate(tag, textTemplate('displayValue')); + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + + const ctor = registry.get(tag); + assert.ok(ctor); + assert.deepEqual((ctor as ObservedElementConstructor).observedAttributes, ['display-value']); + + const instance = new ctor() as HTMLElement & { + displayValue?: unknown; + setState(state: Record): void; + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; + $emit?: (name: string, detail?: unknown) => boolean; + }; + instance.setState({ displayValue: 'Loaded' }); + assert.equal(instance.displayValue, undefined); + + instance.attributeChangedCallback('display-value', 'Loaded', 'From attribute'); + assert.equal(instance.displayValue, undefined); + // Auto-elements extend the static CoreElement, so interactive helpers like + // $emit are tree-shaken away — an HTML-only fallback never needs them. + assert.equal(typeof instance.$emit, 'undefined'); + }); + + test('template state handles roots that match native HTMLElement properties', async () => { + const tag = `auto-native-title-${Date.now()}`; + + registerUnitTemplate(tag, textTemplate('title')); + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + + const ctor = registry.get(tag); + assert.ok(ctor); + + const instance = new ctor() as HTMLElement & { + setState(state: Record): void; + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; + $resolveValue(path: string): unknown; + }; + + instance.setState({ title: 'Loaded from state' }); + assert.equal(instance.$resolveValue('title'), 'Loaded from state'); + + instance.attributeChangedCallback('title', 'Loaded from state', 'Loaded from attr'); + assert.equal(instance.$resolveValue('title'), 'Loaded from attr'); + }); + + test('installAutoElementRuntime does not overwrite an existing custom element', async () => { + const tag = `existing-unit-${Date.now()}`; + const existing = class ExistingElement extends HTMLElement {}; + customElements.define(tag, existing); + + registerUnitTemplate(tag, textTemplate('title')); + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + assert.equal(customElements.get(tag), existing); + }); + + test('installAutoElementRuntime skips templates with event handlers', async () => { + const tag = `interactive-unit-${Date.now()}`; + + registerUnitTemplate(tag, { + h: '', + e: [['click', 'onClick', [], [0]]], + }); + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + assert.equal(customElements.get(tag), undefined); + }); + + test('installAutoElementRuntime registers each missing template', async () => { + const first = `auto-all-a-${Date.now()}`; + const second = `auto-all-b-${Date.now()}`; + + registerUnitTemplate(first, textTemplate('title')); + registerUnitTemplate(second, textTemplate('itemCount')); + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + + assert.ok(customElements.get(first)); + assert.ok(customElements.get(second)); + }); + + test('installAutoElementRuntime only claims compiler-marked auto elements', async () => { + const allowed = `runtime-allowed-${Date.now()}`; + const skipped = `runtime-skipped-${Date.now()}`; + registerUnitTemplate(allowed, textTemplate('title')); + registerUnitTemplate(skipped, textTemplate('title', false)); + + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + + assert.ok(customElements.get(allowed)); + assert.equal(customElements.get(skipped), undefined); + }); +}); diff --git a/packages/webui-framework/src/auto-element.ts b/packages/webui-framework/src/auto-element.ts new file mode 100644 index 00000000..57ed2e43 --- /dev/null +++ b/packages/webui-framework/src/auto-element.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { CoreElement } from './element.js'; +import { toKebabCase } from './decorators.js'; +import { getTemplateRegistry } from './template.js'; +import { templateHasEventHandlers } from './template-roots.js'; +import { + TEMPLATES_REGISTERED_EVENT, + templateRegistrationDetail, +} from './template-events.js'; +import type { TemplateMeta } from './template.js'; + +let runtimeInstalled = false; +let initialClaimQueued = false; + +function defineAutoElement(tag: string, meta: TemplateMeta): void { + const w = window as Window; + if (!w.__webui) w.__webui = {}; + if (!w.__webui.templates) w.__webui.templates = {}; + if (!w.__webui.templates[tag]) w.__webui.templates[tag] = meta; + + class AutoWebUIElement extends CoreElement { + protected $shouldApplyTemplateStateFromSSR(key: string): boolean { + return !this.hasAttribute(toKebabCase(key)); + } + } + + AutoWebUIElement.define(tag); +} + +/** + * Define a hydrating fallback element for one compiled template tag when safe. + * + * Developer-authored custom elements take precedence: when a tag is already + * registered, this function leaves it untouched and reports no fallback work. + */ +function defineMissingTemplateElement(tag: string, meta: TemplateMeta): boolean { + if ( + typeof customElements === 'undefined' || + typeof HTMLElement === 'undefined' || + !meta.ae || + customElements.get(tag) || + templateHasEventHandlers(meta) + ) { + return false; + } + defineAutoElement(tag, meta); + return true; +} + +function defineAutoTemplateElements(templates = getTemplateRegistry()): void { + if (!templates) return; + const tags = Object.keys(templates); + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + const meta = templates[tag]; + if (meta) defineMissingTemplateElement(tag, meta); + } +} + +function queueInitialAutoElementClaim(): void { + if (initialClaimQueued) return; + initialClaimQueued = true; + queueMicrotask(() => { + initialClaimQueued = false; + defineAutoTemplateElements(); + }); +} + +/** + * Install the fallback runtime for compiler-marked HTML-only compiled templates. + */ +export function installAutoElementRuntime(): void { + if (runtimeInstalled) { + queueInitialAutoElementClaim(); + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') return; + runtimeInstalled = true; + + window.addEventListener(TEMPLATES_REGISTERED_EVENT, (event: Event) => { + const templates = templateRegistrationDetail(event); + if (templates) defineAutoTemplateElements(templates); + }); + + if (document.readyState === 'loading') { + document.addEventListener( + 'DOMContentLoaded', + () => defineAutoTemplateElements(), + { once: true }, + ); + return; + } + + queueInitialAutoElementClaim(); +} diff --git a/packages/webui-framework/src/decorators.ts b/packages/webui-framework/src/decorators.ts index ca5d6f8d..1e52eccd 100644 --- a/packages/webui-framework/src/decorators.ts +++ b/packages/webui-framework/src/decorators.ts @@ -5,11 +5,20 @@ * Reactive decorators for WebUIElement properties. * * Uses TypeScript's `experimentalDecorators` emit, matching the FAST ecosystem - * conventions. + * conventions. This module holds both the reactive metadata registries (the + * read side that {@link WebUIElement} consults to route `setState`/SSR seeding) + * and the `@observable`/`@attr` decorators (the write side that populates them). + * + * Note on bundling: esbuild — the bundler every WebUI app and example uses — + * performs function-level dead-code elimination, so an HTML-only app that never + * references `@observable`/`@attr` already tree-shakes the write side away while + * keeping only the read helpers the engine imports. Splitting this module would + * add files without removing a single shipped byte, so it deliberately stays + * one unit. */ // --------------------------------------------------------------------------- -// Internal helpers +// kebab-case attribute naming // --------------------------------------------------------------------------- /** @@ -20,7 +29,6 @@ * with irregular mappings (concatenated lowercase) need explicit entries. */ const propertyToAttribute: Record = Object.assign(Object.create(null) as Record, { - // --- HTML global/element attributes --- accessKey: 'accesskey', autoCapitalize: 'autocapitalize', contentEditable: 'contenteditable', @@ -47,31 +55,10 @@ const propertyToAttribute: Record = Object.assign(Object.create( /** * Convert a camelCase DOM property name into its kebab-case HTML attribute form. * - * This function is optimized for framework-level hot paths where attribute - * normalization may run thousands of times per render. It performs three - * progressively cheaper checks: - * - * 1. **Direct lookup for irregular mappings** - * Many DOM properties (e.g., `readOnly`, `tabIndex`, `crossOrigin`) do not - * follow simple camelCase → kebab-case rules. These are resolved through a - * precomputed `propertyToAttribute` map for O(1) returns with no string - * processing. - * - * 2. **Fast path for ARIA attributes** - * ARIA properties always begin with `aria` followed by an uppercase letter - * (e.g., `ariaDescribedBy`). These map to `aria-` + the lowercase remainder. - * This branch avoids the general loop and uses the engine-optimized - * `.toLowerCase()` for the suffix. - * - * 3. **General camelCase → kebab-case conversion** - * For all other inputs, the function performs a tight ASCII-only scan: - * uppercase A–Z (65–90) are converted to lowercase and prefixed with `-`, - * while all other characters are copied as-is. This avoids regex engines, - * callback allocations, and match objects, producing predictable, - * allocation-minimal performance ideal for DOM attribute reflection. - * - * The result is a predictable, JIT-friendly transformation suitable for - * attribute diffing, SSR serialization, and runtime DOM patching. + * Optimized for framework-level hot paths where attribute normalization may run + * thousands of times per render. It performs three progressively cheaper checks: + * a direct lookup for irregular mappings, a fast path for ARIA attributes, then + * a tight ASCII-only scan. No regex engines, callbacks, or match objects. */ export function toKebabCase(str: string): string { const mapped = propertyToAttribute[str]; @@ -88,59 +75,109 @@ export function toKebabCase(str: string): string { return out; } -/** - * Shared logic for installing a reactive getter/setter on a class prototype. - * The backing value is stored in a private `_prop` field on the instance. - */ +// --------------------------------------------------------------------------- +// Reactive metadata registries (read side) +// --------------------------------------------------------------------------- + +type ReactiveInstance = Record; + interface AttrDefinition { attribute: string; property: string; boolean: boolean; } -type ReactiveInstance = Record; - +/** Marks the attribute currently being reflected so the reverse + * attributeChangedCallback path does not echo it back into the property. */ const reflectingAttribute = Symbol('webui.reflectingAttribute'); +const EMPTY_SET: Set = Object.freeze(new Set()) as Set; + function parentConstructor(ctor: Function): Function | null { const parent = Object.getPrototypeOf(ctor); return typeof parent === 'function' && parent !== Function.prototype ? parent : null; } -function createReactiveProperty( - proto: Record, - name: string, - attrDefinition?: AttrDefinition, -): void { - const backingKey = `_${name}`; - const changedKey = `${name}Changed`; +/** Per-class registry of @observable property names. */ +const observableRegistry = new WeakMap>(); - Object.defineProperty(proto, name, { - get(this: ReactiveInstance) { - return this[backingKey]; - }, - set(this: ReactiveInstance, newValue: unknown) { - const oldValue = this[backingKey]; - if (Object.is(oldValue, newValue)) return; - this[backingKey] = newValue; +/** Get the set of @observable property names registered for a class. */ +export function getObservableNames(ctor: Function): Set { + const names = observableRegistry.get(ctor); + if (names) return names; - if (attrDefinition && this['$ready'] === true) { - reflectPropertyToAttribute(this, attrDefinition, newValue); - } + const parent = parentConstructor(ctor); + return parent ? getObservableNames(parent) : EMPTY_SET; +} - const cb = this[changedKey]; - if (typeof cb === 'function') { - (cb as (old: unknown, next: unknown) => void).call(this, oldValue, newValue); - } +function registerObservableProperty(ctor: Function, name: string): void { + let names = observableRegistry.get(ctor); + if (!names) { + const parent = parentConstructor(ctor); + const inherited = parent ? getObservableNames(parent) : EMPTY_SET; + names = inherited.size > 0 ? new Set(inherited) : new Set(); + observableRegistry.set(ctor, names); + } + names.add(name); +} - if ((this as unknown as HTMLElement).isConnected) { - const upd = this['$update'] as ((path?: string) => void) | undefined; - if (upd) upd.call(this, name); - } - }, - enumerable: true, - configurable: true, - }); +/** Registry of attribute-name → property-name mappings per constructor. + * Used by `attributeChangedCallback` to route attribute changes to properties. */ +const attrByAttribute = new WeakMap>(); + +/** Registry of property-name → attribute metadata, used for mount-time sync. */ +const attrByProperty = new WeakMap>(); + +function inheritedAttrMap( + registry: WeakMap>, + ctor: Function, +): Map | undefined { + let current = parentConstructor(ctor); + while (current) { + const map = registry.get(current); + if (map) return map; + current = parentConstructor(current); + } + return undefined; +} + +function attrDefinitionFor( + ctor: Function, + attribute: string, +): AttrDefinition | undefined { + let current: Function | null = ctor; + while (current) { + const definition = attrByAttribute.get(current)?.get(attribute); + if (definition) return definition; + current = parentConstructor(current); + } + return undefined; +} + +function attrPropertyMapFor(ctor: Function): Map | undefined { + let current: Function | null = ctor; + while (current) { + const map = attrByProperty.get(current); + if (map) return map; + current = parentConstructor(current); + } + return undefined; +} + +export function isAttributeProperty(ctor: Function, property: string): boolean { + return attrPropertyMapFor(ctor)?.has(property) === true; +} + +// --------------------------------------------------------------------------- +// Attribute reflection +// --------------------------------------------------------------------------- + +function setReflectingAttribute(instance: ReactiveInstance, attrName: string): void { + instance[reflectingAttribute] = attrName; +} + +function restoreReflectingAttribute(instance: ReactiveInstance): void { + instance[reflectingAttribute] = undefined; } function reflectPropertyToAttribute( @@ -185,43 +222,63 @@ function reflectPropertyToAttribute( } } -function setReflectingAttribute(instance: ReactiveInstance, attrName: string): void { - instance[reflectingAttribute] = attrName; -} +/** Reflect every @attr property of an instance to its attribute at mount. */ +export function syncAttrProperties(instance: object, ctor: Function): void { + const attrs = attrPropertyMapFor(ctor); + if (!attrs) return; -function restoreReflectingAttribute(instance: ReactiveInstance): void { - instance[reflectingAttribute] = undefined; + const reactiveInstance = instance as ReactiveInstance; + for (const definition of attrs.values()) { + reflectPropertyToAttribute( + reactiveInstance, + definition, + reactiveInstance[definition.property], + ); + } } // --------------------------------------------------------------------------- -// @observable +// Decorators (write side) // --------------------------------------------------------------------------- -/** Per-class registry of @observable property names. */ -const observableRegistry = new WeakMap>(); - /** - * Get the set of @observable property names registered for a class. + * Shared logic for installing a reactive getter/setter on a class prototype. + * The backing value is stored in a private `_prop` field on the instance. */ -const EMPTY_SET: Set = Object.freeze(new Set()) as Set; +function createReactiveProperty( + proto: Record, + name: string, + attrDefinition?: AttrDefinition, +): void { + const backingKey = `_${name}`; + const changedKey = `${name}Changed`; -export function getObservableNames(ctor: Function): Set { - const names = observableRegistry.get(ctor); - if (names) return names; + Object.defineProperty(proto, name, { + get(this: ReactiveInstance) { + return this[backingKey]; + }, + set(this: ReactiveInstance, newValue: unknown) { + const oldValue = this[backingKey]; + if (Object.is(oldValue, newValue)) return; + this[backingKey] = newValue; - const parent = parentConstructor(ctor); - return parent ? getObservableNames(parent) : EMPTY_SET; -} + if (attrDefinition && this['$ready'] === true) { + reflectPropertyToAttribute(this, attrDefinition, newValue); + } -function registerObservableProperty(ctor: Function, name: string): void { - let names = observableRegistry.get(ctor); - if (!names) { - const parent = parentConstructor(ctor); - const inherited = parent ? getObservableNames(parent) : EMPTY_SET; - names = inherited.size > 0 ? new Set(inherited) : new Set(); - observableRegistry.set(ctor, names); - } - names.add(name); + const cb = this[changedKey]; + if (typeof cb === 'function') { + (cb as (old: unknown, next: unknown) => void).call(this, oldValue, newValue); + } + + if ((this as unknown as HTMLElement).isConnected) { + const upd = this['$update'] as ((path?: string) => void) | undefined; + if (upd) upd.call(this, name); + } + }, + enumerable: true, + configurable: true, + }); } /** @@ -236,59 +293,6 @@ export function observable(target: object, name: string): void { createReactiveProperty(target as Record, name); } -// --------------------------------------------------------------------------- -// @attr -// --------------------------------------------------------------------------- - -/** - * Registry of attribute-name → property-name mappings per constructor. - * Used by `attributeChangedCallback` to route attribute changes to properties. - */ -const attrByAttribute = new WeakMap>(); - -/** Registry of property-name → attribute metadata, used for mount-time sync. */ -const attrByProperty = new WeakMap>(); - -function inheritedAttrMap( - registry: WeakMap>, - ctor: Function, -): Map | undefined { - let current = parentConstructor(ctor); - while (current) { - const map = registry.get(current); - if (map) return map; - current = parentConstructor(current); - } - return undefined; -} - -function attrDefinitionFor( - ctor: Function, - attribute: string, -): AttrDefinition | undefined { - let current: Function | null = ctor; - while (current) { - const definition = attrByAttribute.get(current)?.get(attribute); - if (definition) return definition; - current = parentConstructor(current); - } - return undefined; -} - -function attrPropertyMapFor(ctor: Function): Map | undefined { - let current: Function | null = ctor; - while (current) { - const map = attrByProperty.get(current); - if (map) return map; - current = parentConstructor(current); - } - return undefined; -} - -export function isAttributeProperty(ctor: Function, property: string): boolean { - return attrPropertyMapFor(ctor)?.has(property) === true; -} - /** * Like {@link observable} but also reflects to/from an HTML attribute * (kebab-case). The decorator patches `observedAttributes` and @@ -393,23 +397,6 @@ function applyAttr( ctor._observedAttrs!.push(attrName); } -export function syncAttrProperties( - instance: object, - ctor: Function, -): void { - const attrs = attrPropertyMapFor(ctor); - if (!attrs) return; - - const reactiveInstance = instance as ReactiveInstance; - for (const definition of attrs.values()) { - reflectPropertyToAttribute( - reactiveInstance, - definition, - reactiveInstance[definition.property], - ); - } -} - export function attr(target: object, name: string): void; export function attr(options: AttrOptions): (target: object, name: string) => void; export function attr( diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index ce1ee519..71097553 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -71,6 +71,7 @@ import { ATTR_KIND_COMPLEX, ATTR_KIND_TEMPLATE, } from './element/types.js'; +import { getTemplateAttributeMap, getTemplateRootSet } from './template-roots.js'; import type { AttrBinding, CondBinding, @@ -115,6 +116,16 @@ function getTplOrdinals(tplNode: Node): Map { // ── Sentinels ─────────────────────────────────────────────────── const EMPTY_ARR: readonly never[] = []; +const EMPTY_SET: ReadonlySet = Object.freeze(new Set()) as ReadonlySet; +const EMPTY_ATTR_MAP: ReadonlyMap = Object.freeze(new Map()) as ReadonlyMap; +const WEBUI_SET_STATE_KEY = Symbol.for('microsoft.webui.setStateKey'); + +const templateAttributeMaps = new WeakMap>(); +const templateRootSets = new WeakMap>(); + +type TemplateObservedConstructor = CustomElementConstructor & { + readonly observedAttributes?: readonly string[]; +}; // ── Helper: snapshot child nodes into a pre-allocated array ────── @@ -137,15 +148,66 @@ function getTemplateDom(meta: TemplateBlockMeta): Element { return div; } +function installTemplateObservedAttributes(ctor: TemplateObservedConstructor, tagName: string): void { + const meta = getTemplate(tagName); + if (!meta) return; + + const attrMap = getTemplateAttributeMap(meta); + templateRootSets.set(ctor, getTemplateRootSet(meta)); + if (attrMap.size === 0) return; + templateAttributeMaps.set(ctor, attrMap); + + const existing = ctor.observedAttributes ?? EMPTY_ARR; + const merged = new Array(existing.length + attrMap.size); + let count = 0; + for (let i = 0; i < existing.length; i++) { + merged[count] = existing[i]; + count += 1; + } + for (const attrName of attrMap.keys()) { + let found = false; + for (let i = 0; i < count; i++) { + if (merged[i] === attrName) { + found = true; + break; + } + } + if (!found) { + merged[count] = attrName; + count += 1; + } + } + merged.length = count; + + Object.defineProperty(ctor, 'observedAttributes', { + get() { + return merged; + }, + configurable: true, + }); +} + +function hasAuthoredMember(instance: object, key: string): boolean { + if (Object.prototype.hasOwnProperty.call(instance, key)) return true; + + let proto = Object.getPrototypeOf(instance) as object | null; + while (proto && proto !== CoreElement.prototype) { + if (Object.prototype.hasOwnProperty.call(proto, key)) return true; + proto = Object.getPrototypeOf(proto) as object | null; + } + return false; +} + // ═══════════════════════════════════════════════════════════════════ -// WebUIElement +// CoreElement — static rendering core (no events / refs / emit) // ═══════════════════════════════════════════════════════════════════ -export class WebUIElement extends HTMLElement { +export class CoreElement extends HTMLElement { private $root: TemplateInstance | null = null; private $meta?: TemplateMeta; private $ready = false; private $hydrated = false; + private $templateState: Record | null = null; private $dirtyPaths: Set | null = null; private $pendingFlush = false; /** Cached condition resolver — avoids allocating a closure per evaluation. */ @@ -164,7 +226,12 @@ export class WebUIElement extends HTMLElement { repeats: RepeatBinding[]; } | null; + [WEBUI_SET_STATE_KEY](key: string, value: unknown): void { + this.$setStateKey(key, value); + } + static define(tagName: string): void { + installTemplateObservedAttributes(this as TemplateObservedConstructor, tagName); customElements.define(tagName, this); } @@ -322,37 +389,92 @@ export class WebUIElement extends HTMLElement { instance.repeats.length = 0; } - /** Dispatch a bubbling custom event. Uses composed:true when in shadow DOM. */ - $emit(name: string, detail?: unknown): boolean { - return this.dispatchEvent( - new CustomEvent(name, { - bubbles: true, - cancelable: true, - composed: !!this.shadowRoot, - detail, - }), - ); + attributeChangedCallback( + name: string, + oldValue: string | null, + newValue: string | null, + ): void { + if (Object.is(oldValue, newValue)) return; + const property = this.$templateAttributeMap().get(name); + if (property && this.$usesTemplateState(property)) { + this.$setTemplateState(property, newValue); + } } - /** Populate @observable properties from server or router state. + /** Populate component state from server or router state. * - * Each property is set through its reactive setter, which coalesces - * updates into a single pending microtask. We then synchronously - * flush those pending path updates so the DOM is current before any - * view-transition snapshot captures it. + * Decorated properties are set through their reactive setters. Template-only + * bindings are stored internally so app code does not need public + * `@observable` fields just to receive server state. */ setState(state: Record): void { - const names = getObservableNames(this.constructor as Function); const keys = Object.keys(state); for (let i = 0; i < keys.length; i++) { const key = keys[i]; - if (names.has(key)) { - (this as Record)[key] = state[key]; - } + this.$setStateKey(key, state[key]); } this.$flushUpdates(); } + protected $observableNames(): Set { + return getObservableNames(this.constructor as Function); + } + + protected $shouldApplySSRState(key: string): boolean { + return !isAttributeProperty(this.constructor as Function, key); + } + + protected $shouldApplyTemplateStateFromSSR(_key: string): boolean { + return true; + } + + protected $setTemplateState(key: string, value: unknown): void { + if (this.$writeTemplateState(key, value)) { + this.$update(key); + } + } + + private $writeTemplateState(key: string, value: unknown): boolean { + if (!this.$templateState) { + this.$templateState = Object.create(null) as Record; + } + if (Object.is(this.$templateState[key], value)) return false; + this.$templateState[key] = value; + return true; + } + + private $templateStateNames(): ReadonlySet { + const fromCtor = templateRootSets.get(this.constructor as Function); + if (fromCtor) return fromCtor; + if (this.$meta) return getTemplateRootSet(this.$meta); + const tagName = this.tagName; + if (!tagName) return EMPTY_SET; + const meta = getTemplate(tagName.toLowerCase()); + return meta ? getTemplateRootSet(meta) : EMPTY_SET; + } + + private $templateAttributeMap(): ReadonlyMap { + const fromCtor = templateAttributeMaps.get(this.constructor as Function); + if (fromCtor) return fromCtor; + if (!this.$meta) { + const meta = getTemplate(this.tagName.toLowerCase()); + return meta ? getTemplateAttributeMap(meta) : EMPTY_ATTR_MAP; + } + return getTemplateAttributeMap(this.$meta); + } + + private $usesTemplateState(key: string): boolean { + return this.$templateStateNames().has(key) && !hasAuthoredMember(this, key); + } + + private $setStateKey(key: string, value: unknown): void { + if (this.$observableNames().has(key)) { + (this as Record)[key] = value; + } else if (this.$usesTemplateState(key)) { + this.$setTemplateState(key, value); + } + } + /** * Apply SSR state from `window.__webui.state`. * @@ -367,12 +489,16 @@ export class WebUIElement extends HTMLElement { private $applySSRState(): void { const state = window.__webui?.state; if (!state || typeof state !== 'object') return; - const ctor = this.constructor as Function; - const names = getObservableNames(ctor); - for (const key of Object.keys(state)) { - if (names.has(key) && !isAttributeProperty(ctor, key)) { + const observableNames = this.$observableNames(); + const keys = Object.keys(state); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (observableNames.has(key)) { + if (!this.$shouldApplySSRState(key)) continue; // Write to backing field directly — no reactive update yet (this as Record)[`_${key}`] = state[key]; + } else if (this.$usesTemplateState(key) && this.$shouldApplyTemplateStateFromSSR(key)) { + this.$writeTemplateState(key, state[key]); } } } @@ -1122,99 +1248,19 @@ export class WebUIElement extends HTMLElement { } } - /** Wire events + root events + refs (shared by $wire and $hydrate). */ - private $finalize( - root: Node, - meta: TemplateBlockMeta, - resolver: (root: Node, path: TemplateNodePath) => Node | null, - scope?: ScopeFrame, - ): void { - this.$wireEvents(root, meta, resolver, scope); - if ((meta as TemplateMeta).re) this.$wireRoot((meta as TemplateMeta).re!); - this.$wireRefs(root); - } - - /** Wire events using a resolver function (works for both client and SSR). */ - private $wireEvents( - root: Node, - meta: TemplateBlockMeta, - resolver: (root: Node, path: TemplateNodePath) => Node | null, - scope?: ScopeFrame, - ): void { - if (!meta.e) return; - for (let i = 0; i < meta.e.length; i++) { - const [eventName, handlerName, args, target] = meta.e[i]; - const el = resolver(root, target); - if (!el || el.nodeType !== 1) continue; - this.$addEvent(el as Element, eventName, handlerName, args, scope); - } - } - - /** Wire root-level events on the host element (or shadow root when present). */ - private $wireRoot(re: [string, string, CompiledEventArgs][]): void { - const target = this.shadowRoot ?? this; - for (let i = 0; i < re.length; i++) { - this.$addEvent(target, re[i][0], re[i][1], re[i][2], undefined); - } - } - - /** Attach a single event listener. */ - private $addEvent( - target: EventTarget, - eventName: string, - handlerName: string, - args: CompiledEventArgs, - scope?: ScopeFrame, - ): void { - const method = (this as Record)[handlerName]; - if (typeof method !== 'function') return; - if (args.length === 0) { - target.addEventListener(eventName, () => { - (method as Function).call(this); - }); - return; - } - if (args.length === 1 && args[0][0] === 'e') { - target.addEventListener(eventName, (event) => { - (method as Function).call(this, event); - }); - return; - } - target.addEventListener(eventName, (event) => { - (method as Function).apply(this, this.$resolveEventArgs(args, event, scope)); - }); - } - - private $resolveEventArgs(args: CompiledEventArgs, event: Event, scope?: ScopeFrame): unknown[] { - const resolved: unknown[] = []; - for (let i = 0; i < args.length; i++) { - resolved.push(this.$resolveEventArg(args[i], event, scope)); - } - return resolved; - } - - private $resolveEventArg(arg: CompiledEventArg, event: Event, scope?: ScopeFrame): unknown { - switch (arg[0]) { - case 'e': return event; - case 'p': return this.$resolveValue(arg[1], scope); - case 's': return arg[1]; - case 'n': return arg[1]; - case 'b': return !!arg[1]; - case 'z': return null; - } - } + /** + * Hook for wiring interactivity (events + refs). The static rendering core + * does nothing here; the interactive {@link WebUIElement} subclass overrides + * it. Auto-elements — which can never carry event handlers — use this empty + * core hook and tree-shake every event/ref helper away. + */ + protected $finalize( + _root: Node, + _meta: TemplateBlockMeta, + _resolver: (root: Node, path: TemplateNodePath) => Node | null, + _scope?: ScopeFrame, + ): void {} - /** Find w-ref attributes and assign to component properties. */ - private $wireRefs(root: Node): void { - if (root.nodeType !== 1 && root.nodeType !== 11) return; - const refs = (root as Element).querySelectorAll('[w-ref]'); - for (let i = 0; i < refs.length; i++) { - const raw = refs[i].getAttribute('w-ref'); - if (!raw || raw.charCodeAt(0) !== 123) continue; - const name = raw.slice(1, -1); - if (name) (this as Record)[name] = refs[i]; - } - } /** Create an AttrBinding from compiled metadata. */ private $makeAttr(el: Element, entry: CompiledAttrMeta, scope?: ScopeFrame): AttrBinding { @@ -1270,7 +1316,7 @@ export class WebUIElement extends HTMLElement { private $buildPathIndex(): void { if (!this.$root) return; - const observableNames = getObservableNames(this.constructor as Function); + const observableNames = this.$observableNames(); const index = new Map { const dot = path.indexOf('.'); const root = dot > -1 ? path.slice(0, dot) : path; - return observableNames.has(root) ? root : '*'; + return observableNames.has(root) || this.$usesTemplateState(root) ? root : '*'; }; const isLocalPath = (path: string, scope?: ScopeFrame): boolean => { @@ -1395,13 +1441,15 @@ export class WebUIElement extends HTMLElement { switch (b.kind) { case ATTR_KIND_COMPLEX: { const v = this.$resolveValue(b.path!, b.scope); - (el as unknown as Record)[b.name] = v; - // If the target is a WebUIElement, flush its pending updates - // synchronously so child loops re-render immediately. - // Without this, the child's microtask-coalesced update runs - // too late for view transitions that snapshot the DOM. - const flush = (el as unknown as Record)['$flushUpdates']; - if (typeof flush === 'function') (flush as () => void).call(el); + const target = el as unknown as Record; + const setStateKey = target[WEBUI_SET_STATE_KEY]; + if (typeof setStateKey === 'function') { + (setStateKey as (key: string, value: unknown) => void).call(el, b.name, v); + const flush = target['$flushUpdates']; + if (typeof flush === 'function') (flush as () => void).call(el); + } else { + target[b.name] = v; + } break; } case ATTR_KIND_BOOLEAN: { @@ -1468,8 +1516,20 @@ export class WebUIElement extends HTMLElement { } // Resolve against component — fast path for single-segment (no dot) const dot = path.indexOf('.'); - if (dot === -1) return (this as Record)[path]; - return dotWalk((this as Record)[path.substring(0, dot)], path, dot + 1); + if (dot === -1) return this.$resolveComponentRoot(path); + return dotWalk(this.$resolveComponentRoot(path.substring(0, dot)), path, dot + 1); + } + + private $resolveComponentRoot(root: string): unknown { + const instance = this as Record; + if ( + this.$templateState && + Object.prototype.hasOwnProperty.call(this.$templateState, root) && + !hasAuthoredMember(this, root) + ) { + return this.$templateState[root]; + } + return instance[root]; } private $resolveParts(parts: CompiledAttrPart[], scope?: ScopeFrame): string { @@ -1535,3 +1595,122 @@ export class WebUIElement extends HTMLElement { return seen ? { path, prefix, suffix } : null; } } + +// ═══════════════════════════════════════════════════════════════════ +// WebUIElement — interactive superset (events + refs + emit) +// ═══════════════════════════════════════════════════════════════════ + +/** + * The interactive element base. Authored components extend this to gain event + * binding (`@click`, root events), `w-ref` wiring, and `$emit`. HTML-only + * components never reach this class: the auto-element runtime extends + * {@link CoreElement} directly, so a purely static app tree-shakes everything + * below out of its bundle. + */ +export class WebUIElement extends CoreElement { + /** Dispatch a bubbling custom event. Uses composed:true when in shadow DOM. */ + $emit(name: string, detail?: unknown): boolean { + return this.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + cancelable: true, + composed: !!this.shadowRoot, + detail, + }), + ); + } + + /** Wire events + root events + refs (shared by $wire and $hydrate). */ + protected override $finalize( + root: Node, + meta: TemplateBlockMeta, + resolver: (root: Node, path: TemplateNodePath) => Node | null, + scope?: ScopeFrame, + ): void { + this.$wireEvents(root, meta, resolver, scope); + if ((meta as TemplateMeta).re) this.$wireRoot((meta as TemplateMeta).re!); + this.$wireRefs(root); + } + + /** Wire events using a resolver function (works for both client and SSR). */ + private $wireEvents( + root: Node, + meta: TemplateBlockMeta, + resolver: (root: Node, path: TemplateNodePath) => Node | null, + scope?: ScopeFrame, + ): void { + if (!meta.e) return; + for (let i = 0; i < meta.e.length; i++) { + const [eventName, handlerName, args, target] = meta.e[i]; + const el = resolver(root, target); + if (!el || el.nodeType !== 1) continue; + this.$addEvent(el as Element, eventName, handlerName, args, scope); + } + } + + /** Wire root-level events on the host element (or shadow root when present). */ + private $wireRoot(re: [string, string, CompiledEventArgs][]): void { + const target = this.shadowRoot ?? this; + for (let i = 0; i < re.length; i++) { + this.$addEvent(target, re[i][0], re[i][1], re[i][2], undefined); + } + } + + /** Attach a single event listener. */ + private $addEvent( + target: EventTarget, + eventName: string, + handlerName: string, + args: CompiledEventArgs, + scope?: ScopeFrame, + ): void { + const method = (this as Record)[handlerName]; + if (typeof method !== 'function') return; + if (args.length === 0) { + target.addEventListener(eventName, () => { + (method as Function).call(this); + }); + return; + } + if (args.length === 1 && args[0][0] === 'e') { + target.addEventListener(eventName, (event) => { + (method as Function).call(this, event); + }); + return; + } + target.addEventListener(eventName, (event) => { + (method as Function).apply(this, this.$resolveEventArgs(args, event, scope)); + }); + } + + private $resolveEventArgs(args: CompiledEventArgs, event: Event, scope?: ScopeFrame): unknown[] { + const resolved: unknown[] = []; + for (let i = 0; i < args.length; i++) { + resolved.push(this.$resolveEventArg(args[i], event, scope)); + } + return resolved; + } + + private $resolveEventArg(arg: CompiledEventArg, event: Event, scope?: ScopeFrame): unknown { + switch (arg[0]) { + case 'e': return event; + case 'p': return this.$resolveValue(arg[1], scope); + case 's': return arg[1]; + case 'n': return arg[1]; + case 'b': return !!arg[1]; + case 'z': return null; + } + } + + /** Find w-ref attributes and assign to component properties. */ + private $wireRefs(root: Node): void { + if (root.nodeType !== 1 && root.nodeType !== 11) return; + const refs = (root as Element).querySelectorAll('[w-ref]'); + for (let i = 0; i < refs.length; i++) { + const raw = refs[i].getAttribute('w-ref'); + if (!raw || raw.charCodeAt(0) !== 123) continue; + const name = raw.slice(1, -1); + if (name) (this as Record)[name] = refs[i]; + } + } +} diff --git a/packages/webui-framework/src/index.ts b/packages/webui-framework/src/index.ts index 923e26e3..c9561bcd 100644 --- a/packages/webui-framework/src/index.ts +++ b/packages/webui-framework/src/index.ts @@ -21,8 +21,12 @@ * @packageDocumentation */ +import { installAutoElementRuntime } from './auto-element.js'; + export { WebUIElement } from './element.js'; export { observable, attr } from './decorators.js'; export { getTemplate, registerTemplateData } from './template.js'; export type { TemplateMeta } from './template.js'; export { hydrationStart, hydrationEnd } from './lifecycle.js'; + +installAutoElementRuntime(); diff --git a/packages/webui-framework/src/template-events.ts b/packages/webui-framework/src/template-events.ts new file mode 100644 index 00000000..e8d39e8f --- /dev/null +++ b/packages/webui-framework/src/template-events.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { TemplateMeta } from './template.js'; + +/** Event emitted when compiled template metadata becomes available. */ +export const TEMPLATES_REGISTERED_EVENT = 'webui:templates-registered'; + +/** Notify optional runtimes that compiled templates have been registered. */ +export function dispatchTemplatesRegistered(templates: Record): void { + if ( + typeof window === 'undefined' || + typeof CustomEvent !== 'function' || + typeof window.dispatchEvent !== 'function' + ) { + return; + } + + window.dispatchEvent(new CustomEvent(TEMPLATES_REGISTERED_EVENT, { + detail: { templates }, + })); +} + +/** Read a template registration event payload without trusting arbitrary detail. */ +export function templateRegistrationDetail(event: Event): Record | undefined { + const detail = (event as CustomEvent<{ templates?: unknown }>).detail; + const templates = detail?.templates; + return typeof templates === 'object' && templates !== null + ? templates as Record + : undefined; +} diff --git a/packages/webui-framework/src/template-roots.ts b/packages/webui-framework/src/template-roots.ts new file mode 100644 index 00000000..1460d057 --- /dev/null +++ b/packages/webui-framework/src/template-roots.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { toKebabCase } from './decorators.js'; +import type { + CompiledAttrPart, + TemplateBlockMeta, + TemplateMeta, +} from './template.js'; + +interface BlockVisit { + block: TemplateBlockMeta; + scopes?: ScopeName; +} + +interface ScopeName { + name: string; + parent?: ScopeName; +} + +const templateRootsCache = new WeakMap(); +const templateRootSetCache = new WeakMap>(); +const templateAttributeCache = new WeakMap>(); +const templateEventCache = new WeakMap(); + +function pathRoot(path: string): string { + const dot = path.indexOf('.'); + return dot === -1 ? path : path.slice(0, dot); +} + +function isScopedPath(path: string, scopes?: ScopeName): boolean { + const root = pathRoot(path); + let current = scopes; + while (current) { + if (current.name === root) return true; + current = current.parent; + } + return false; +} + +function addRoot(roots: Set, path: string, scopes?: ScopeName): void { + if (!path || isScopedPath(path, scopes)) return; + roots.add(pathRoot(path)); +} + +function addPartRoots(roots: Set, parts: CompiledAttrPart[], scopes?: ScopeName): void { + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (typeof part !== 'string') addRoot(roots, part[0], scopes); + } +} + +export function collectTemplateRoots(meta: TemplateMeta): readonly string[] { + const cached = templateRootsCache.get(meta); + if (cached) return cached; + + const roots = new Set(); + const stack: BlockVisit[] = [{ block: meta }]; + + while (stack.length > 0) { + const visit = stack.pop(); + if (!visit) continue; + + const { block, scopes } = visit; + if (block.tx) { + for (let i = 0; i < block.tx.length; i++) { + addPartRoots(roots, block.tx[i][1], scopes); + } + } + + if (block.a) { + for (let i = 0; i < block.a.length; i++) { + const attr = block.a[i]; + switch (attr[1]) { + case 0: + case 1: + addRoot(roots, attr[2], scopes); + break; + case 2: + for (let j = 0; j < attr[2][1].length; j++) { + addRoot(roots, attr[2][1][j], scopes); + } + break; + case 3: + addPartRoots(roots, attr[2], scopes); + break; + } + } + } + + if (block.c) { + for (let i = 0; i < block.c.length; i++) { + const [condition, blockIndex] = block.c[i]; + for (let j = 0; j < condition[1].length; j++) { + addRoot(roots, condition[1][j], scopes); + } + const child = meta.b?.[blockIndex]; + if (child) stack.push({ block: child, scopes }); + } + } + + if (block.r) { + for (let i = 0; i < block.r.length; i++) { + const [collection, itemVar, blockIndex] = block.r[i]; + addRoot(roots, collection, scopes); + const child = meta.b?.[blockIndex]; + if (child) stack.push({ block: child, scopes: { name: itemVar, parent: scopes } }); + } + } + } + + const collected = Array.from(roots); + templateRootsCache.set(meta, collected); + return collected; +} + +export function getTemplateRootSet(meta: TemplateMeta): ReadonlySet { + let cached = templateRootSetCache.get(meta); + if (cached) return cached; + cached = new Set(collectTemplateRoots(meta)); + templateRootSetCache.set(meta, cached); + return cached; +} + +export function getTemplateAttributeMap(meta: TemplateMeta): ReadonlyMap { + let cached = templateAttributeCache.get(meta); + if (cached) return cached; + + const roots = collectTemplateRoots(meta); + const attrs = new Map(); + for (let i = 0; i < roots.length; i++) { + attrs.set(toKebabCase(roots[i]), roots[i]); + } + templateAttributeCache.set(meta, attrs); + return attrs; +} + +export function templateHasEventHandlers(meta: TemplateMeta): boolean { + const cached = templateEventCache.get(meta); + if (cached !== undefined) return cached; + + if (meta.re && meta.re.length > 0) { + templateEventCache.set(meta, true); + return true; + } + + const stack: TemplateBlockMeta[] = [meta]; + while (stack.length > 0) { + const block = stack.pop(); + if (!block) continue; + if (block.e && block.e.length > 0) { + templateEventCache.set(meta, true); + return true; + } + const children = (block as TemplateMeta).b; + if (children) { + for (let i = 0; i < children.length; i++) stack.push(children[i]); + } + } + + templateEventCache.set(meta, false); + return false; +} diff --git a/packages/webui-framework/src/template-types.ts b/packages/webui-framework/src/template-types.ts index e9fe57eb..ed6b41b8 100644 --- a/packages/webui-framework/src/template-types.ts +++ b/packages/webui-framework/src/template-types.ts @@ -67,4 +67,6 @@ export interface TemplateMeta extends TemplateBlockMeta { re?: [string, string, CompiledEventArgs][]; /** Shadow DOM flag — when true, client-created components use shadow root. */ sd?: boolean; + /** Auto-element flag — true when the compiler found no authored script. */ + ae?: boolean | 1; } diff --git a/packages/webui-framework/src/template.ts b/packages/webui-framework/src/template.ts index b4913164..135f9c22 100644 --- a/packages/webui-framework/src/template.ts +++ b/packages/webui-framework/src/template.ts @@ -43,6 +43,7 @@ import type { TemplateCondition, TemplateMeta, } from './template-types.js'; +import { dispatchTemplatesRegistered } from './template-events.js'; const WEBUI_DATA_ID = 'webui-data'; const normalizedTemplates = new WeakSet(); @@ -70,6 +71,11 @@ export function getTemplate(name: string): TemplateMeta | undefined { return meta; } +export function getTemplateRegistry(): Record | undefined { + loadWebUIDataBlock(); + return window.__webui?.templates; +} + export function registerTemplateData( templates: Record, templateFns?: Record, @@ -86,12 +92,15 @@ export function registerTemplateData( } } const names = Object.keys(templates); + let hasTemplates = false; for (let i = 0; i < names.length; i++) { const tag = names[i]; const meta = templates[tag]; w.__webui.templates[tag] = meta; normalizeTemplate(tag, meta); + hasTemplates = true; } + if (hasTemplates) dispatchTemplatesRegistered(templates); } function loadWebUIDataBlock(): void { diff --git a/packages/webui-framework/tests/auto-elements.ts b/packages/webui-framework/tests/auto-elements.ts new file mode 100644 index 00000000..0a2a4ead --- /dev/null +++ b/packages/webui-framework/tests/auto-elements.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import '../src/index.js'; diff --git a/packages/webui-framework/tests/fixtures/html-only-auto-element/html-only-auto-element.spec.ts b/packages/webui-framework/tests/fixtures/html-only-auto-element/html-only-auto-element.spec.ts new file mode 100644 index 00000000..c8280261 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/html-only-auto-element/html-only-auto-element.spec.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +test.describe('html-only auto element fixture', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/html-only-auto-element/fixture.html'); + await page.waitForFunction(() => { + const el = document.querySelector('test-html-only'); + return el && (el as unknown as { $ready?: boolean }).$ready === true; + }); + }); + + test('hydrates an HTML-only component without a component stub', async ({ page }) => { + await expect(page.locator('script[src="/dist/html-only-auto-element/element.js"]')).toHaveCount(0); + + const hasFallback = await page.evaluate(() => { + const el = document.querySelector('test-html-only') as { + setState?: (state: Record) => void; + } | null; + return customElements.get('test-html-only') !== undefined && + typeof el?.setState === 'function'; + }); + expect(hasFallback).toBe(true); + + await expect(page.locator('test-html-only .heading')).toHaveText('Contacts'); + await expect(page.locator('test-html-only .filter')).toHaveText('all'); + await expect(page.locator('test-html-only .status')).toHaveText('Ready'); + await expect(page.locator('test-html-only .detail-link')).toHaveAttribute('href', '/items/42'); + await expect(page.locator('test-html-only .item')).toHaveText(['Ada', 'Grace']); + await expect(page.locator('test-html-only .details')).toHaveCount(0); + }); + + test('updates template bindings through setState', async ({ page }) => { + await page.evaluate(() => { + const host = document.querySelector('test-html-only') as { + setState(state: Record): void; + } | null; + host?.setState({ + heading: 'Updated contacts', + status: 'Loaded', + selectedId: '99', + items: [ + { name: 'Linus' }, + { name: 'Margaret' }, + { name: 'Radia' }, + ], + showDetails: true, + details: 'Loaded from state', + }); + }); + + await expect(page.locator('test-html-only .heading')).toHaveText('Updated contacts'); + await expect(page.locator('test-html-only .status')).toHaveText('Loaded'); + await expect(page.locator('test-html-only .detail-link')).toHaveAttribute('href', '/items/99'); + await expect(page.locator('test-html-only .item')).toHaveText(['Linus', 'Margaret', 'Radia']); + await expect(page.locator('test-html-only .details')).toHaveText('Loaded from state'); + }); + + test('updates template bindings when host attributes change', async ({ page }) => { + await page.evaluate(() => { + const host = document.querySelector('test-html-only'); + host?.setAttribute('filter', 'favorites'); + host?.setAttribute('heading', 'Attribute heading'); + }); + + await expect(page.locator('test-html-only .filter')).toHaveText('favorites'); + await expect(page.locator('test-html-only .heading')).toHaveText('Attribute heading'); + }); +}); diff --git a/packages/webui-framework/tests/fixtures/html-only-auto-element/src/index.html b/packages/webui-framework/tests/fixtures/html-only-auto-element/src/index.html new file mode 100644 index 00000000..f6f408ee --- /dev/null +++ b/packages/webui-framework/tests/fixtures/html-only-auto-element/src/index.html @@ -0,0 +1,10 @@ + + + + + HTML-only Auto Element Fixture + + + + + diff --git a/packages/webui-framework/tests/fixtures/html-only-auto-element/src/test-html-only/test-html-only.html b/packages/webui-framework/tests/fixtures/html-only-auto-element/src/test-html-only/test-html-only.html new file mode 100644 index 00000000..800b3b0e --- /dev/null +++ b/packages/webui-framework/tests/fixtures/html-only-auto-element/src/test-html-only/test-html-only.html @@ -0,0 +1,12 @@ +

{{heading}}

+

{{filter}}

+

{{status}}

+Details +
    + +
  • {{item.name}}
  • +
    +
+ +

{{details}}

+
diff --git a/packages/webui-framework/tests/fixtures/html-only-auto-element/state.json b/packages/webui-framework/tests/fixtures/html-only-auto-element/state.json new file mode 100644 index 00000000..03983f7b --- /dev/null +++ b/packages/webui-framework/tests/fixtures/html-only-auto-element/state.json @@ -0,0 +1,12 @@ +{ + "heading": "Contacts", + "filter": "from-state", + "status": "Ready", + "selectedId": "42", + "items": [ + { "name": "Ada" }, + { "name": "Grace" } + ], + "showDetails": false, + "details": "Initial details" +} diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/element.ts b/packages/webui-framework/tests/fixtures/optional-template-state/element.ts new file mode 100644 index 00000000..a739e867 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/optional-template-state/element.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebUIElement, observable } from '../../../src/index.js'; + +export class TestOptionalState extends WebUIElement { + @observable selected = 'off'; + + toggle(): void { + this.selected = this.selected === 'off' ? 'on' : 'off'; + } +} + +TestOptionalState.define('test-optional-state'); diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts b/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts new file mode 100644 index 00000000..a1df5892 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +test.describe('optional template state fixture', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/optional-template-state/fixture.html'); + await page.waitForFunction(() => { + const el = document.querySelector('test-optional-state'); + return el && (el as unknown as { $ready?: boolean }).$ready === true; + }); + }); + + test('hydrates template-only bindings without public observables', async ({ page }) => { + await expect(page.locator('test-optional-state .heading')).toHaveText('Server heading'); + await expect(page.locator('test-optional-state .count')).toHaveText('Count: 2'); + await expect(page.locator('test-optional-state .selected')).toHaveText('Selected: off'); + await expect(page.locator('test-optional-state .details-link')).toHaveAttribute('href', '/items/42'); + await expect(page.locator('test-optional-state .item')).toHaveText(['Ada', 'Grace']); + await expect(page.locator('test-optional-state .details')).toHaveCount(0); + + const exposesTemplateOnlyState = await page.evaluate(() => { + const host = document.querySelector('test-optional-state') as unknown as Record; + return Object.prototype.hasOwnProperty.call(host, 'heading') || + Object.prototype.hasOwnProperty.call(host, 'items') || + Object.prototype.hasOwnProperty.call(host, 'showDetails'); + }); + expect(exposesTemplateOnlyState).toBe(false); + }); + + test('updates omitted template bindings through setState', async ({ page }) => { + await page.evaluate(() => { + const host = document.querySelector('test-optional-state') as { + setState(state: Record): void; + } | null; + host?.setState({ + heading: 'Updated heading', + count: 3, + selectedId: '99', + items: [ + { name: 'Linus' }, + { name: 'Radia' }, + { name: 'Margaret' }, + ], + showDetails: true, + details: 'Loaded from hidden state', + }); + }); + + await expect(page.locator('test-optional-state .heading')).toHaveText('Updated heading'); + await expect(page.locator('test-optional-state .count')).toHaveText('Count: 3'); + await expect(page.locator('test-optional-state .details-link')).toHaveAttribute('href', '/items/99'); + await expect(page.locator('test-optional-state .item')).toHaveText(['Linus', 'Radia', 'Margaret']); + await expect(page.locator('test-optional-state .details')).toHaveText('Loaded from hidden state'); + }); + + test('updates omitted template bindings when host attributes change', async ({ page }) => { + await page.evaluate(() => { + const host = document.querySelector('test-optional-state'); + host?.setAttribute('heading', 'Attribute heading'); + host?.setAttribute('selected-id', '123'); + }); + + await expect(page.locator('test-optional-state .heading')).toHaveText('Attribute heading'); + await expect(page.locator('test-optional-state .details-link')).toHaveAttribute('href', '/items/123'); + }); + + test('keeps observables for state used by component code', async ({ page }) => { + await page.locator('test-optional-state .toggle').click(); + await expect(page.locator('test-optional-state .selected')).toHaveText('Selected: on'); + }); +}); diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/src/index.html b/packages/webui-framework/tests/fixtures/optional-template-state/src/index.html new file mode 100644 index 00000000..33c667e4 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/optional-template-state/src/index.html @@ -0,0 +1,10 @@ + + + + + Optional Template State Fixture + + + + + diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html b/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html new file mode 100644 index 00000000..381eb856 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html @@ -0,0 +1,15 @@ +
+

{{heading}}

+

Count: {{count}}

+

Selected: {{selected}}

+ + Details +
    + +
  • {{item.name}}
  • +
    +
+ +

{{details}}

+
+
diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/state.json b/packages/webui-framework/tests/fixtures/optional-template-state/state.json new file mode 100644 index 00000000..7ea745cc --- /dev/null +++ b/packages/webui-framework/tests/fixtures/optional-template-state/state.json @@ -0,0 +1,12 @@ +{ + "heading": "Server heading", + "count": 2, + "selected": "off", + "selectedId": "42", + "items": [ + { "name": "Ada" }, + { "name": "Grace" } + ], + "showDetails": false, + "details": "SSR details" +} diff --git a/packages/webui-framework/tests/server.ts b/packages/webui-framework/tests/server.ts index 67569fe2..b79a0aa5 100644 --- a/packages/webui-framework/tests/server.ts +++ b/packages/webui-framework/tests/server.ts @@ -24,6 +24,16 @@ await buildFixtureEntries({ outDir, tsconfig, emptyMessage: `No fixture entry points found in ${fixturesRoot}`, + extraBuilds: [{ + entryPoints: [resolve(here, 'auto-elements.ts')], + bundle: true, + format: 'iife', + outfile: resolve(outDir, 'auto-elements.js'), + platform: 'browser', + target: 'es2022', + supported: { 'import-attributes': true }, + tsconfig, + }], }); startFixtureServer({ diff --git a/packages/webui-router/README.md b/packages/webui-router/README.md index 46a6a433..3af7873d 100644 --- a/packages/webui-router/README.md +++ b/packages/webui-router/README.md @@ -83,7 +83,14 @@ window.addEventListener('webui:hydration-complete', () => { }); ``` -Components in `loaders` are lazy-loaded on first navigation. Components not listed are assumed eagerly loaded. +Components in `loaders` are lazy-loaded on first navigation. Components not +listed are assumed eagerly loaded when already registered. The router has no +dependency on `@microsoft/webui-framework`; it publishes +`webui:templates-registered` for initial SSR templates and later partial +response templates. The framework can claim compiler-marked HTML-only tags from +that event, but the router remains platform-independent. If no loader or runtime +registers a tag, the router uses a passive no-op stub for static server-rendered +route content. ## Nested Routes diff --git a/packages/webui-router/package.json b/packages/webui-router/package.json index 37db7a19..76566fbf 100644 --- a/packages/webui-router/package.json +++ b/packages/webui-router/package.json @@ -1,7 +1,6 @@ { "description": "Lightweight client-side router for WebUI apps. Intercepts navigation, matches routes locally or via server route-data, and hydrates components.", "devDependencies": { - "@microsoft/webui-framework": "workspace:*", "@microsoft/webui-router": "workspace:*", "@playwright/test": "catalog:", "@types/dom-navigation": "catalog:", diff --git a/packages/webui-router/src/loaders.ts b/packages/webui-router/src/loaders.ts index 70f545fd..835e3a54 100644 --- a/packages/webui-router/src/loaders.ts +++ b/packages/webui-router/src/loaders.ts @@ -6,7 +6,6 @@ * modules and resolution of static `loader()` methods on route * component constructors. */ - import type { RouteLoaderContext } from './types.js'; import type { RouteChainEntry } from './cache.js'; @@ -23,10 +22,10 @@ export const NOOP_SIGNAL: AbortSignal = new AbortController().signal; * most once. * * When no loader exists and the tag is not yet registered, a passive - * stub element is auto-defined. This implements the islands - * architecture pattern: only interactive components need explicit - * class definitions — passive route targets (pages with no client-side - * logic) are handled automatically by the framework. + * stub element is auto-defined. Framework runtimes can register richer + * template-backed elements before this point; the router stays platform + * independent and only falls back to a no-op host when no runtime claimed + * the tag. */ export async function ensureComponentLoaded( tag: string, diff --git a/packages/webui-router/src/router.test.ts b/packages/webui-router/src/router.test.ts index 8411a8c0..9b6b42fe 100644 --- a/packages/webui-router/src/router.test.ts +++ b/packages/webui-router/src/router.test.ts @@ -109,7 +109,9 @@ describe('WebUIRouter', () => { const origQuerySelectorAll = (globalThis as any).document.querySelectorAll; const origAddEventListener = (globalThis as any).document.addEventListener; const origRemoveEventListener = (globalThis as any).document.removeEventListener; + const origDispatchEvent = (globalThis as any).window.dispatchEvent; let removed = false; + let notifiedTemplates: Record | undefined; globals().__webui = { templateFns: { greeting: [() => true] }, @@ -126,6 +128,12 @@ describe('WebUIRouter', () => { (globalThis as any).document.querySelectorAll = () => []; (globalThis as any).document.addEventListener = () => {}; (globalThis as any).document.removeEventListener = () => {}; + (globalThis as any).window.dispatchEvent = (event: Event) => { + if (event.type === 'webui:templates-registered') { + notifiedTemplates = (event as CustomEvent<{ templates: Record }>).detail.templates; + } + return true; + }; try { const router = new WebUIRouter(); @@ -136,6 +144,11 @@ describe('WebUIRouter', () => { assert.deepEqual(globals().__webui!.state, { title: 'Hello' }); assert.ok(globals().__webui!.templates?.greeting, 'template metadata should be loaded'); assert.ok(globals().__webui!.templateFns?.greeting, 'existing templateFns should be preserved'); + assert.deepEqual( + notifiedTemplates, + { greeting: { h: '

' } }, + 'initial SSR templates should notify optional framework runtimes', + ); assert.equal(removed, true); router.destroy(); } finally { @@ -144,6 +157,7 @@ describe('WebUIRouter', () => { (globalThis as any).document.querySelectorAll = origQuerySelectorAll; (globalThis as any).document.addEventListener = origAddEventListener; (globalThis as any).document.removeEventListener = origRemoveEventListener; + (globalThis as any).window.dispatchEvent = origDispatchEvent; } }); }); @@ -245,6 +259,29 @@ describe('WebUIRouter', () => { } }); + test('template registration notifies optional framework runtimes', () => { + const origDispatchEvent = (globalThis as any).window.dispatchEvent; + let notifiedTemplates: Record | undefined; + + (globalThis as any).window.dispatchEvent = (event: Event) => { + if (event.type === 'webui:templates-registered') { + notifiedTemplates = (event as CustomEvent<{ templates: Record }>).detail.templates; + } + return true; + }; + + try { + const template = { h: '

Notified

' }; + registerTemplatesAndStyles({ + templates: { 'notified-comp': template }, + }, '', new Set(), () => {}); + + assert.deepEqual(notifiedTemplates, { 'notified-comp': template }); + } finally { + (globalThis as any).window.dispatchEvent = origDispatchEvent; + } + }); + test('template string payloads that are not HTML are rejected', () => { const origCreateElement = (globalThis as any).document.createElement; const origHead = (globalThis as any).document.head; diff --git a/packages/webui-router/src/router.ts b/packages/webui-router/src/router.ts index ecad1f11..f0c86765 100644 --- a/packages/webui-router/src/router.ts +++ b/packages/webui-router/src/router.ts @@ -40,7 +40,12 @@ import { import { NavigationCache } from './cache.js'; import type { PartialResponse, RouteChainEntry } from './cache.js'; -import { registerTemplatesAndStyles, injectCssLinks, fetchComponentTemplates } from './templates.js'; +import { + registerTemplatesAndStyles, + injectCssLinks, + fetchComponentTemplates, + notifyTemplatesRegistered, +} from './templates.js'; import { readStreamingPartial, applyDeferredStates } from './streaming.js'; import type { StreamingContext } from './streaming.js'; import { setupFormInterception } from './actions.js'; @@ -125,6 +130,7 @@ export class WebUIRouter { if (!meta.css) meta.css = []; if (!meta.styles) meta.styles = []; if (!meta.templates) meta.templates = {}; + notifyTemplatesRegistered(meta.templates); // Build O(1) lookup Sets from the global arrays, then free the arrays — // they were one-shot SSR data; the Sets are the live lookup structure. diff --git a/packages/webui-router/src/templates.ts b/packages/webui-router/src/templates.ts index 36fa151e..4184e61f 100644 --- a/packages/webui-router/src/templates.ts +++ b/packages/webui-router/src/templates.ts @@ -6,6 +6,8 @@ * `ensureLoaded()`. */ +const TEMPLATES_REGISTERED_EVENT = 'webui:templates-registered'; + /** * Register templates + inject CSS from a server response. * Shared by fetchPartial and fetchComponentTemplates. @@ -69,6 +71,7 @@ export function registerTemplatesAndStyles( } let executableTemplateBody = ''; + let registeredTemplates: Record | undefined; // 2. Template closures: execute only the component-local condition arrays. // TRUST BOUNDARY: closure scripts come from the same-origin server @@ -115,6 +118,8 @@ export function registerTemplatesAndStyles( } } else { w.__webui.templates[tag] = template; + if (!registeredTemplates) registeredTemplates = {}; + registeredTemplates[tag] = template; } } } @@ -126,6 +131,8 @@ export function registerTemplatesAndStyles( document.head.appendChild(script); document.head.removeChild(script); } + + notifyTemplatesRegistered(registeredTemplates); } /** Inject CSS stylesheet links from a partial response. */ @@ -169,3 +176,18 @@ export async function fetchComponentTemplates( // Register using the same pipeline as partial navigation registerTemplatesAndStyles(data, nonce, injectedStyles, updateInventory); } + +export function notifyTemplatesRegistered(templates: Record | undefined): void { + if ( + !templates || + typeof window === 'undefined' || + typeof CustomEvent !== 'function' || + typeof window.dispatchEvent !== 'function' + ) { + return; + } + + window.dispatchEvent(new CustomEvent(TEMPLATES_REGISTERED_EVENT, { + detail: { templates }, + })); +} diff --git a/packages/webui-test-support/src/fixture-render.ts b/packages/webui-test-support/src/fixture-render.ts index 2f0335e5..9d6fc269 100644 --- a/packages/webui-test-support/src/fixture-render.ts +++ b/packages/webui-test-support/src/fixture-render.ts @@ -64,7 +64,10 @@ function renderOne(fixturePath: string, name: string): RenderedFixture | null { let html = render(result.protocol, state, { plugin: 'webui' }); - const scriptTag = ``; + const scriptPath = existsSync(resolve(fixturePath, 'element.ts')) + ? `/dist/${name}/element.js` + : '/dist/auto-elements.js'; + const scriptTag = ``; const bodyEnd = html.lastIndexOf(''); if (bodyEnd !== -1) { html = html.slice(0, bodyEnd) + scriptTag + html.slice(bodyEnd); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66206c56..10a3ebb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,6 +411,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.5 + esbuild: + specifier: 'catalog:' + version: 0.28.1 typescript: specifier: 'catalog:' version: 5.9.3 @@ -421,9 +424,6 @@ importers: packages/webui-router: devDependencies: - '@microsoft/webui-framework': - specifier: workspace:* - version: link:../webui-framework '@microsoft/webui-router': specifier: workspace:* version: 'link:' From e7b67306c1ee052cf85046a3b79fa5e2bbc40545 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 30 Jun 2026 23:36:24 -0700 Subject: [PATCH 2/7] Refine auto-element docs and parser state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/webui-parser/src/component_registry.rs | 47 +++++++++-- crates/webui-parser/src/plugin/webui.rs | 20 ++--- docs/ai.md | 82 ++++++++----------- docs/guide/concepts/interactivity.md | 47 ++++------- docs/guide/concepts/routing.md | 58 ++++++------- packages/webui-framework/README.md | 49 ++++++----- packages/webui-router/README.md | 41 ++++------ 7 files changed, 169 insertions(+), 175 deletions(-) diff --git a/crates/webui-parser/src/component_registry.rs b/crates/webui-parser/src/component_registry.rs index d9c4345a..ee7024f0 100644 --- a/crates/webui-parser/src/component_registry.rs +++ b/crates/webui-parser/src/component_registry.rs @@ -16,8 +16,6 @@ use std::path::PathBuf; use walkdir::WalkDir; type ProcessedCss = (String, Vec, Vec, Vec); -#[cfg(feature = "fs")] -const COMPONENT_SCRIPT_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs"]; /// Represents a web component in the registry. #[derive(Debug, Clone)] @@ -64,12 +62,7 @@ pub struct ComponentRegistry { #[cfg(feature = "fs")] fn component_has_script(html_path: &Path) -> bool { - for extension in COMPONENT_SCRIPT_EXTENSIONS { - if html_path.with_extension(extension).exists() { - return true; - } - } - false + html_path.with_extension("ts").exists() || html_path.with_extension("js").exists() } impl Default for ComponentRegistry { @@ -338,7 +331,7 @@ mod tests { } #[test] - fn test_register_component_detects_sibling_script() { + fn test_register_component_detects_ts_sibling_script() { let mut fs = TestFileSystem::new(); let html_path = fs.add_file("components/scripted-card.html", "

Scripted

"); std::fs::write(html_path.with_extension("ts"), "export {};") @@ -355,6 +348,42 @@ mod tests { assert!(component.has_script); } + #[test] + fn test_register_component_detects_js_sibling_script() { + let mut fs = TestFileSystem::new(); + let html_path = fs.add_file("components/scripted-card.html", "

Scripted

"); + std::fs::write(html_path.with_extension("js"), "export {};") + .expect("Failed to write sibling script"); + + let mut registry = ComponentRegistry::new(); + registry + .register_component_from_paths(&html_path, None::<&str>) + .expect("register failed"); + + let component = registry + .get("scripted-card") + .expect("Failed to retrieve registered component"); + assert!(component.has_script); + } + + #[test] + fn test_register_component_ignores_tsx_sibling_script() { + let mut fs = TestFileSystem::new(); + let html_path = fs.add_file("components/scripted-card.html", "

Scripted

"); + std::fs::write(html_path.with_extension("tsx"), "export {};") + .expect("Failed to write sibling script"); + + let mut registry = ComponentRegistry::new(); + registry + .register_component_from_paths(&html_path, None::<&str>) + .expect("register failed"); + + let component = registry + .get("scripted-card") + .expect("Failed to retrieve registered component"); + assert!(!component.has_script); + } + #[test] fn test_component_name_validation() { let html_content = "

Invalid

"; diff --git a/crates/webui-parser/src/plugin/webui.rs b/crates/webui-parser/src/plugin/webui.rs index 1090e8f1..e102aba2 100644 --- a/crates/webui-parser/src/plugin/webui.rs +++ b/crates/webui-parser/src/plugin/webui.rs @@ -77,7 +77,7 @@ struct TrackedComponent { tag_name: String, template_html: String, root_event_source: String, - auto_element: bool, + has_script: bool, } /// WebUI Framework parser plugin. @@ -131,7 +131,7 @@ impl WebUIParserPlugin { &c.template_html, &c.root_event_source, use_shadow, - c.auto_element, + !c.has_script, )?; out.push(ComponentTemplateArtifact::webui( c.tag_name.clone(), @@ -147,21 +147,21 @@ impl WebUIParserPlugin { tag_name: &str, template_html: &str, root_event_source: &str, - auto_element: bool, + has_script: bool, ) { if let Some(component) = self.components.iter_mut().find(|c| c.tag_name == tag_name) { component.template_html.clear(); component.template_html.push_str(template_html); component.root_event_source.clear(); component.root_event_source.push_str(root_event_source); - component.auto_element = auto_element; + component.has_script = has_script; return; } self.components.push(TrackedComponent { tag_name: tag_name.to_string(), template_html: template_html.to_string(), root_event_source: root_event_source.to_string(), - auto_element, + has_script, }); } } @@ -200,7 +200,7 @@ impl ParserPlugin for WebUIParserPlugin { tag_name, processed_template, &component.html_content, - !component.has_script, + component.has_script, ); Ok(()) } @@ -431,7 +431,7 @@ fn generate_compiled_template_with_root_source( html_content: &str, root_event_source: &str, shadow_dom: bool, - auto_element: bool, + emit_auto_element: bool, ) -> Result { let trimmed = html_content.trim(); let root_events = extract_root_events(tag_name, root_event_source.trim())?; @@ -443,7 +443,7 @@ fn generate_compiled_template_with_root_source( &meta, adopted_stylesheet.as_deref(), shadow_dom, - auto_element, + emit_auto_element, )) } @@ -452,7 +452,7 @@ fn emit_compiled_template_payload( meta: &TemplateMeta, adopted_stylesheet: Option<&str>, shadow_dom: bool, - auto_element: bool, + emit_auto_element: bool, ) -> CompiledTemplatePayload { let mut conditions = ConditionFunctionEmitter::new(128); let mut out = String::with_capacity(512 + html_content.len()); @@ -470,7 +470,7 @@ fn emit_compiled_template_payload( out.push_str(",\"sd\":1"); } - if auto_element { + if emit_auto_element { out.push_str(",\"ae\":1"); } diff --git a/docs/ai.md b/docs/ai.md index 62166292..8b28ae32 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -242,9 +242,9 @@ In the TypeScript class: `searchInput!: HTMLInputElement;` All attributes are validated at build time. Referencing a non-existent `pending` or `error` component is a compile error. **State flow:** -- `keep-alive` — preserves DOM and local state. On reactivation, only param/query attrs are updated - `setState()` is NOT called +- `keep-alive` preserves DOM and local state. On reactivation, only param/query attrs are updated - Route loaders: `static loader({ params, query, signal })` on component class - fetches custom data instead of server state. Runs pre-commit. Falls back to server state on failure -- Keep-alive + loader: DOM preserved, loader provides fresh data via `setState()` on reactivation +- Keep-alive + loader: DOM preserved, loader refreshes data on reactivation - Route actions: `static action({ formData, params, signal })` on component class - handles `
`. Returns `{ invalidateTags?, state? }`. Auto-invalidates cache with merged tags **Cache & preload:** @@ -318,17 +318,12 @@ MyComponent.define('my-component'); ``` HTML-only components can omit the `.ts` file when they have no event handlers or -custom client logic. The compiler marks those scriptless templates in metadata, -and the framework automatically defines hydrating fallbacks when the metadata is -available. The fallback hydrates SSR output, observes template-relevant host -attributes, and accepts `setState()` for compiled binding roots. Routers and -asset loaders notify the framework about initial SSR or newly loaded metadata -with `webui:templates-registered`. - -Authored components can also omit `@observable` for values that are only read by -the template and supplied by SSR, router `setState()`, or component asset data. -The framework stores those template-only values internally. Use `@observable` -only when TypeScript code reads or mutates the value directly. +custom client logic. Create a custom element only for an Interactive Island: +events, custom lifecycle code, imperative methods, or JavaScript-owned state. + +`@observable` and `@attr` are optional. Use them when TypeScript code reads or +mutates the value directly, or when the value is part of the component's public +API. ### Decorator reference @@ -346,7 +341,6 @@ only when TypeScript code reads or mutates the value directly. | `this.$emit(name, detail?)` | Dispatch a CustomEvent that bubbles up | | `this.$update()` | Force a reactive update cycle | | `this.$flushUpdates()` | Synchronously flush pending updates | -| `setState(state)` | Populate from router navigation state | | `static define(tagName)` | Register as a custom element | | `defineComponentAssets(manifest)` | Define lazy component assets and load asset/module/data work in parallel | @@ -371,15 +365,15 @@ onItemSelected(e: CustomEvent): void { ### Dynamic Component Loading -Components like dialogs, overlays, and drawers are declared as routes but -loaded on demand — not during initial navigation. Declare them in the route -tree so the build compiles them into the protocol: +Components like dialogs, overlays, and drawers are declared as routes but loaded +on demand, not during initial navigation. Declare them in the route tree so they +can be loaded dynamically: ```html - + ``` @@ -387,7 +381,7 @@ tree so the build compiles them into the protocol: Then load on demand with `Router.ensureLoaded` before creating the element: ```typescript -// Fetches template + CSS from /_webui/templates — no FOUC +// Fetches template + CSS from /_webui/templates before showing UI. await Router.ensureLoaded('settings-dialog'); this.showSettings = true; @@ -396,11 +390,10 @@ await Router.ensureLoaded('modal-a', 'modal-b', 'drawer-c'); ``` The component's template is **not** sent during initial SSR or partial -navigation — `collect_inventoryable_components` only walks the matched -route, not siblings. Zero cost until requested. +navigation. It has zero client cost until requested. If a user navigates directly to `/settings` (deep link), the component -renders normally in the outlet — it works both ways. +renders normally in the outlet. It works both ways. Configure a custom endpoint if needed: @@ -425,11 +418,11 @@ are returned in `BuildResult::component_asset_files` and written by `componentAssetFiles` (`[filename, content, ...]`) from `build()`. `webui serve` accepts the same `--emit-component-assets` flag and validates each -root on every dev build — so HTML and theme-token errors in lazily loaded -components (which are not part of the SSR tree) fail the build instead of being -silently skipped — then serves the compiled `.webui.js` from memory, -rebuilding it on change under `--watch`. No separate `webui build`/`--out` step -is needed during development. +root on every dev build. HTML and theme-token errors in lazily loaded components +fail the build instead of being missed because the component is outside the +initial route tree. The dev server serves `.webui.js` from memory and +rebuilds it on change under `--watch`. No separate `webui build`/`--out` step is +needed during development. ```typescript import { settingsAssets } from './lazy-assets.js'; @@ -453,17 +446,13 @@ export const settingsAssets = defineComponentAssets({ }); ``` -`defineComponentAssets()` uses the current page nonce from `window.__webui.nonce` -or `` when it needs to append CSS module importmaps. -The `.webui.js` asset is a browser-native ESM module that default-exports -template/style metadata and compiled condition functions in one request. If the -root template is already in `window.__webui.templates`, the loader skips -importing. Concurrent calls for the same URL share one in-flight request, and -CSS module styles are deduped against `window.__webui.styles`. +`defineComponentAssets()` uses the current page CSP nonce when it needs to append +CSS module importmaps. The `.webui.js` asset is a browser-native ESM module that +carries the component template and style payload in one request. Concurrent calls +for the same URL share one in-flight request, and CSS module styles are deduped. The manifest helper lets the shell start the template asset, JS chunk, and data fetch in parallel as soon as the user expresses intent; `create(tag)` waits for -only the template asset and JS module by default, creates the element, then -applies data later with `setState()`. Use +only the template asset and JS module by default, then creates the element. Use `create(tag, { awaitData: true, dataTimeoutMs: 150 })` only when a component must wait briefly for state before mounting. @@ -624,11 +613,10 @@ webui build ./src --out ./dist --plugin=webui \ --emit-component-assets mail-thread,compose-page ``` -This writes `mail-thread.webui.js`. Requested roots are compiled through -synthetic non-entry fragments, so they are not part of initial SSR unless the -entry template also references them. The asset is standard ESM that carries -template/style data and compiled condition functions with no inventory field. -Load it with `defineComponentAssets()` before mounting the component. Use +This writes `mail-thread.webui.js`. Requested roots stay outside initial SSR +unless the entry template also references them. The asset is standard ESM that +carries the component template and styles. Load it with +`defineComponentAssets()` before mounting the component. Use `--asset-file-name-template "[name]-[hash].[ext]"` for CDN-cacheable filenames. Do not reference the lazy component tag from an SSR-reachable template unless you intentionally want it eligible for initial SSR. @@ -998,7 +986,7 @@ button:not([data-active]) { background: transparent; } Declare the component as a route (so it's compiled), then load dynamically: ```html - + @@ -1006,7 +994,7 @@ Declare the component as a route (so it's compiled), then load dynamically: ``` ```typescript -// Shell component — load template + CSS on demand +// Shell component - load template + CSS on demand async onOpenSettings(): Promise { await Router.ensureLoaded('settings-dialog'); await import('./settings-dialog/settings-dialog.js'); @@ -1015,7 +1003,7 @@ async onOpenSettings(): Promise { ``` ```html - + @@ -1176,6 +1164,6 @@ import { renderComponentTemplates } from '@microsoft/webui'; const result = renderComponentTemplates(protocolBuf, ['settings-dialog'], invHex); ``` -The JSON response contains `templates` as component-tag-keyed metadata, `templateFunctions` -as component-tag-keyed condition closure arrays, `templateStyles` for CSS module importmaps, -and `inventory` with the updated component bitmask. +The JSON response contains component-tag-keyed `templates`, matching +`templateFunctions`, `templateStyles` for CSS module importmaps, and `inventory` +with the updated component bitmask. diff --git a/docs/guide/concepts/interactivity.md b/docs/guide/concepts/interactivity.md index cad6dca8..fdf5af53 100644 --- a/docs/guide/concepts/interactivity.md +++ b/docs/guide/concepts/interactivity.md @@ -17,8 +17,7 @@ my-counter/ - **CSS** styles the component in isolation - Shadow DOM prevents leaking - **TypeScript** defines JS-visible reactive properties, event handlers, and component logic -HTML-only components can omit the TypeScript file when they do not handle -events or run custom client logic: +Components that do not need client-side behavior can omit the TypeScript file: ``` product-card/ @@ -26,12 +25,10 @@ product-card/ └── product-card.css ``` -The compiler marks scriptless templates in metadata, and the framework -automatically defines hydrating fallbacks for those tags when the metadata is -available. The fallback hydrates SSR output, observes attributes that correspond -to template bindings, and accepts router `setState()` data for those bindings. -Add a TypeScript class only when the component needs event handlers, custom -methods, custom lifecycle code, or state that TypeScript code reads or mutates. +Create a custom element only for an Interactive Island: event handlers, custom +lifecycle code, imperative methods, or state that TypeScript code reads or +mutates. `@observable` and `@attr` are optional; add them when JavaScript needs +to access the value or when the value is part of the component's public API. ## The Component Class @@ -146,12 +143,9 @@ Use `@observable` for internal state that changes over time. When an observable Observable changes are **synchronous and targeted** - only the specific DOM nodes bound to the changed property are updated. -You do not need `@observable` for values that only come from SSR, router -`setState()`, or component asset data and are only read by the template. The -framework stores those template-only values internally and updates the compiled -bindings without exposing public class fields. Add `@observable` when your -TypeScript code needs to read or mutate the value, for example in an event -handler. +You do not need `@observable` for values that are only read by the template. +Add `@observable` when TypeScript code needs to read or mutate the value, for +example in an event handler. ### Derived State @@ -384,9 +378,8 @@ webui build ./src --out ./dist --plugin=webui \ ``` Each requested root writes one ESM module such as `.webui.js` next to -`protocol.bin`. The module carries template/style data, compiled condition -functions, and the dependency closure for the root component; it does not contain -inventory state. +`protocol.bin`. The module carries the component's template, styles, and +dependency closure; it does not contain route inventory state. During development, pass the same flag to `webui serve` so these roots are validated and served without a separate build step: @@ -396,10 +389,10 @@ webui serve ./src --state ./data/state.json --plugin=webui \ --emit-component-assets settings-dialog,mail-thread --watch ``` -The dev server parses and validates each root on every build — HTML and +The dev server parses and validates each root on every build. HTML and theme-token errors in a lazily loaded component fail the build instead of being -skipped because it is outside the SSR tree — and serves the compiled -`.webui.js` from memory, rebuilding it on change. +missed because the component is outside the initial route tree. The dev server +serves `.webui.js` from memory and rebuilds it on change. Load the asset before creating or revealing the component: @@ -430,15 +423,11 @@ export const settingsAssets = defineComponentAssets({ }); ``` -`defineComponentAssets()` imports the asset module, registers template metadata, -and applies the current page CSP nonce to CSS module importmaps when needed. -Components can then fetch their own data in their class code and attach it -through observables or `setState()`. The loader skips importing when the root -template is already present in `window.__webui.templates`, shares concurrent -requests for the same URL, and dedupes CSS module styles against -`window.__webui.styles`. `create(tag)` creates the element after template/module -work is ready, then applies loaded data with `setState()` when the data promise -resolves, matching the router state handoff model. Use +`defineComponentAssets()` loads the component's template, styles, JavaScript +module, and optional data together. Components can then fetch their own data in +their class code and expose it through `@observable` fields when JavaScript needs +to read or mutate it. Concurrent requests for the same asset share one in-flight +load. `create(tag)` creates the element after template/module work is ready. Use `create(tag, { awaitData: true, dataTimeoutMs: 150 })` only when a component must wait briefly for state before mounting. Use a manifest helper when you want the fastest path: it lets the shell start the template asset, JS chunk, and data diff --git a/docs/guide/concepts/routing.md b/docs/guide/concepts/routing.md index b36c3d1a..9c2f160e 100644 --- a/docs/guide/concepts/routing.md +++ b/docs/guide/concepts/routing.md @@ -146,7 +146,7 @@ Preserve a component's DOM and state across navigations instead of destroying an ``` When navigating from Mail to Calendar and back: -- **`mail-view` (keep-alive):** Hidden on deactivation, shown instantly on return. The folder pane, email list, and all local state survive the round trip. Route param and query param attributes are updated, but `setState()` is **not called** — your component's `@observable` properties are preserved. +- **`mail-view` (keep-alive):** Hidden on deactivation, shown instantly on return. The folder pane, email list, and all local state survive the round trip. Route param and query param attributes are updated, and your component's `@observable` properties are preserved. - **`settings-page` (no keep-alive):** Destroyed on deactivation, recreated fresh on each visit. | Behavior | With `keep-alive` | Without | @@ -154,7 +154,7 @@ When navigating from Mail to Calendar and back: | Deactivate | `display: none` (stays in DOM) | `display: none` (stays in DOM) | | Reactivate | Reuses existing component — params updated, state preserved | Destroys old, creates new component | | Local state | ✅ Preserved (scroll, input, timers, observables) | Lost | -| Server state | **Skipped** — use a [loader](#route-loaders) to refresh | Applied on mount via `setState()` | +| Server state | **Skipped** - use a [loader](#route-loaders) to refresh | Loaded with the new component | @@ -164,7 +164,7 @@ Use on routes with expensive UI (lists, grids, trees) that users switch between -If a keep-alive component needs fresh data when reactivated, define a static `loader()` method. The router calls it on every navigation (including reactivation) and applies the result via `setState()`. +If a keep-alive component needs fresh data when reactivated, define a static `loader()` method. The router calls it on every navigation, including reactivation, and uses the returned data to refresh the component. @@ -214,10 +214,10 @@ The router provides four mechanisms for controlling how state flows to your comp | Need | Mechanism | What happens | |------|-----------|-------------| -| **Server provides all state** | Default (no changes) | `setState(state)` on every navigation. HTML-only route components use the explicit framework fallback when the app opts those tags in | -| **I fetch my own data** | `static loader()` on component | Loader runs pre-commit, result passed to `setState()` | -| **Preserve local state** | `keep-alive` on route | Params/query attrs updated, `setState()` skipped | -| **Preserve DOM + refresh data** | `keep-alive` + `static loader()` | DOM preserved, loader result applied via `setState()` | +| **Server provides all state** | Default (no changes) | Fresh route state is applied when the component mounts. HTML-only route components do not need empty classes | +| **I fetch my own data** | `static loader()` on component | Loader runs before the route commits and supplies route data | +| **Preserve local state** | `keep-alive` on route | Params/query attrs update while local state is preserved | +| **Preserve DOM + refresh data** | `keep-alive` + `static loader()` | DOM is preserved and loader data refreshes the component | ```typescript // Express example — render_partial returns chain + templates (no state). @@ -231,14 +231,9 @@ app.get('*', async (req, res) => { ``` Route components that only have `.html` and optional `.css` files do not need a -JavaScript loader or empty class. When initial SSR data or a later partial -response includes template metadata for an unregistered route tag, the router -publishes a `webui:templates-registered` event and stays otherwise platform -independent. The framework automatically claims compiler-marked HTML-only route -tags, so params, query attributes, and server state still update the template. -If no framework runtime claims the tag, the router keeps its legacy passive stub with a no-op -`setState()` so static server-rendered route content can still participate in -chain reconciliation. +JavaScript loader or empty class. Create a custom element only when the route +component is interactive: event handlers, custom lifecycle code, imperative +methods, or JavaScript-owned state. ### Tagged Cache @@ -332,7 +327,7 @@ The router intercepts `` submissions via a delegated listene | **2. Guard** | Skips forms with external `action` URLs or `target` other than `_self` | | **3. Call** | Invokes `static action({ formData, params, signal })` on the component class | | **4. Invalidate** | Merges the action's returned tags with the route's build-time `invalidates` attribute | -| **5. Update** | Applies optimistic `result.state` via `setState()` if provided | +| **5. Update** | Applies optimistic `result.state` if provided | | **6. Event** | Dispatches `webui:route:action-complete` on `window` | ### Pending UI @@ -394,7 +389,7 @@ If no `error` attribute is declared on the route, the router falls back to its d 2. Server matches the full route chain: `app-shell - section-page - topic-page` 3. Renders all matched components nested at their outlets 4. Browser displays fully rendered HTML - no JavaScript needed yet -5. JavaScript loads, hydration runs, router starts and reads `window.__webui` +5. JavaScript loads, hydration runs, and the router starts from the SSR bootstrap data #### SSR Output @@ -410,11 +405,9 @@ The server renders `` elements with these DOM attributes: | `error` | Error component tag (if declared) | | `data-ri` | Route index for O(1) element binding during hydration | -Build-time attributes like `query`, `keep-alive`, `cache-tags`, and `invalidates` are **not** emitted as DOM attributes on `` elements. They are compiled into the binary protocol and delivered to the client via `window.__webui.chain` JSON data. The `` source attributes remain valid and unchanged - the compiler just delivers them through JSON instead of the DOM. +Build-time attributes like `query`, `keep-alive`, `cache-tags`, and `invalidates` are **not** emitted as DOM attributes on `` elements. The `` source attributes remain valid and unchanged, while the rendered route elements expose only the runtime attributes needed for navigation. -The server also emits an inert `webui-data` JSON block containing the SSR chain, template inventory, CSS metadata, and state. The client packages first read any existing `window.__webui`, then lazily parse and remove that block into `window.__webui` when metadata is needed. - -When using the WebUI framework plugin, `webui-data` also includes JSON-safe component template metadata and a small executable side-channel installs component-local condition closures in `window.__webui.templateFns`. FAST plugins emit their own `` tags, so they use the same router metadata but do not emit WebUI `templates` or `templateFns`. +The server also emits inert route bootstrap data so the client router can start without walking the DOM. ```html ``` -With the WebUI framework plugin, the same data block can also include JSON-safe `templates`, and a small executable side-channel installs component-local condition closures in `window.__webui.templateFns`. FAST plugins use `` tags instead and only need the shared router metadata. - The router reads this at startup, eliminating DOM walking and URLPattern usage. ## Exports @@ -559,8 +554,8 @@ The package exports the following: | `WebUIRouteElement` | class | `` custom element | | `parseQuery` | function | Parse URL query string into a record | | `filterQuery` | function | Filter query params by an allowlist | -| `isStateful` | function | Type guard - checks if an element implements `setState()` | -| `StatefulElement` | type | Interface for elements with `setState()` support | +| `isStateful` | function | Type guard - checks if an element accepts route state | +| `StatefulElement` | type | Interface for elements that accept route state | | `RouterConfig` | type | Configuration for `Router.start()` | | `RouteLoaderContext` | type | Context passed to `static loader()` methods | | `RouteActionContext` | type | Context passed to `static action()` methods | From 3d1a4fecf94e37fd2150c8c62e59e8d338a82795 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 30 Jun 2026 23:57:52 -0700 Subject: [PATCH 3/7] Document auto-element runtime intent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/webui-parser/src/component_registry.rs | 5 +++ crates/webui-parser/src/plugin/webui.rs | 2 + .../webui-framework/scripts/size-report.mjs | 13 ++++++ packages/webui-framework/src/auto-element.ts | 31 +++++++++++-- packages/webui-framework/src/element.ts | 43 +++++++++++++++++++ .../webui-framework/src/template-events.ts | 17 +++++++- .../webui-framework/src/template-roots.ts | 39 +++++++++++++++++ .../webui-framework/src/template-types.ts | 6 ++- packages/webui-framework/src/template.ts | 18 ++++++++ .../webui-framework/tests/auto-elements.ts | 8 ++++ .../optional-template-state/element.ts | 7 +++ packages/webui-router/src/loaders.ts | 22 +++++----- packages/webui-router/src/templates.ts | 12 ++++++ .../webui-test-support/src/fixture-render.ts | 3 ++ 14 files changed, 208 insertions(+), 18 deletions(-) diff --git a/crates/webui-parser/src/component_registry.rs b/crates/webui-parser/src/component_registry.rs index ee7024f0..aab5be81 100644 --- a/crates/webui-parser/src/component_registry.rs +++ b/crates/webui-parser/src/component_registry.rs @@ -61,6 +61,11 @@ pub struct ComponentRegistry { } #[cfg(feature = "fs")] +/// Return true when a component has an authored browser module next to its HTML. +/// +/// Only `.ts` and `.js` are recognized component implementations. Other +/// extensions are intentionally ignored so auto-element eligibility is +/// deterministic and matches the documented authoring model. fn component_has_script(html_path: &Path) -> bool { html_path.with_extension("ts").exists() || html_path.with_extension("js").exists() } diff --git a/crates/webui-parser/src/plugin/webui.rs b/crates/webui-parser/src/plugin/webui.rs index e102aba2..af3109cb 100644 --- a/crates/webui-parser/src/plugin/webui.rs +++ b/crates/webui-parser/src/plugin/webui.rs @@ -77,6 +77,8 @@ struct TrackedComponent { tag_name: String, template_html: String, root_event_source: String, + /// Source fact from the component registry. Auto-element metadata is derived + /// as `!has_script` only when the final template payload is emitted. has_script: bool, } diff --git a/packages/webui-framework/scripts/size-report.mjs b/packages/webui-framework/scripts/size-report.mjs index 6465cd9f..3ce30186 100644 --- a/packages/webui-framework/scripts/size-report.mjs +++ b/packages/webui-framework/scripts/size-report.mjs @@ -1,6 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +/** + * Bundle-size harness for framework reachability checks. + * + * The probes intentionally model the important client graphs: + * - root barrel: what a normal app import keeps alive + * - auto element: the HTML-only runtime path + * - authored probe: a component that uses decorators and events + * - html-only probe: a static app that only imports the framework root + * + * This lets framework refactors prove they changed shipped bytes, not just + * source layout. The script is dev-only and never participates in app bundles. + */ + import { brotliCompressSync, gzipSync } from 'node:zlib'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/packages/webui-framework/src/auto-element.ts b/packages/webui-framework/src/auto-element.ts index 57ed2e43..2ca0adb5 100644 --- a/packages/webui-framework/src/auto-element.ts +++ b/packages/webui-framework/src/auto-element.ts @@ -1,6 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +/** + * Automatic HTML-only component runtime. + * + * The parser marks component templates that have no sibling `.ts` / `.js` + * implementation. Importing the framework root installs this runtime so those + * scriptless templates still hydrate as real WebUI elements when server or route + * state changes. Authored custom elements always win, and templates with event + * metadata are refused because event handlers require developer code. + * + * Keep this module dependent on `CoreElement`, not `WebUIElement`: the whole + * point is that HTML-only pages can tree-shake event, ref, and `$emit` support. + */ + import { CoreElement } from './element.js'; import { toKebabCase } from './decorators.js'; import { getTemplateRegistry } from './template.js'; @@ -14,6 +27,7 @@ import type { TemplateMeta } from './template.js'; let runtimeInstalled = false; let initialClaimQueued = false; +/** Define the smallest hydrating element for a scriptless template. */ function defineAutoElement(tag: string, meta: TemplateMeta): void { const w = window as Window; if (!w.__webui) w.__webui = {}; @@ -30,10 +44,10 @@ function defineAutoElement(tag: string, meta: TemplateMeta): void { } /** - * Define a hydrating fallback element for one compiled template tag when safe. + * Define a hydrating auto-element for one compiled template tag when safe. * * Developer-authored custom elements take precedence: when a tag is already - * registered, this function leaves it untouched and reports no fallback work. + * registered, this function leaves it untouched and reports no work. */ function defineMissingTemplateElement(tag: string, meta: TemplateMeta): boolean { if ( @@ -49,6 +63,7 @@ function defineMissingTemplateElement(tag: string, meta: TemplateMeta): boolean return true; } +/** Claim every eligible template in a registry snapshot. */ function defineAutoTemplateElements(templates = getTemplateRegistry()): void { if (!templates) return; const tags = Object.keys(templates); @@ -59,6 +74,13 @@ function defineAutoTemplateElements(templates = getTemplateRegistry()): void { } } +/** + * Defer the first page-wide claim by one microtask. + * + * This gives authored component modules in the same import graph a chance to + * call `customElements.define()` first, while router-delivered templates still + * claim synchronously through the registration event below. + */ function queueInitialAutoElementClaim(): void { if (initialClaimQueued) return; initialClaimQueued = true; @@ -69,7 +91,10 @@ function queueInitialAutoElementClaim(): void { } /** - * Install the fallback runtime for compiler-marked HTML-only compiled templates. + * Install the runtime for compiler-marked HTML-only templates. + * + * This is called by the package root as a side effect, so app authors do not + * maintain tag lists or import an auto-element subpath. */ export function installAutoElementRuntime(): void { if (runtimeInstalled) { diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index 71097553..37d25369 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -9,6 +9,12 @@ * template path mapping. Client-created components use exact childNode * indices from the compiled template HTML. * + * The runtime is split into `CoreElement` and `WebUIElement` for Interactive + * Islands. `CoreElement` owns hydration, template-only state, and DOM updates. + * `WebUIElement` adds authored interactivity: event handlers, root events, + * `w-ref`, and `$emit`. HTML-only auto-elements extend `CoreElement` directly + * so static components do not pull interactive code into their bundles. + * * ## SSR hydration markers * * The server-side handler plugin emits lightweight HTML comment markers @@ -118,6 +124,8 @@ function getTplOrdinals(tplNode: Node): Map { const EMPTY_ARR: readonly never[] = []; const EMPTY_SET: ReadonlySet = Object.freeze(new Set()) as ReadonlySet; const EMPTY_ATTR_MAP: ReadonlyMap = Object.freeze(new Map()) as ReadonlyMap; + +/** Branded single-key state writer used by framework bindings, not public duck typing. */ const WEBUI_SET_STATE_KEY = Symbol.for('microsoft.webui.setStateKey'); const templateAttributeMaps = new WeakMap>(); @@ -148,6 +156,13 @@ function getTemplateDom(meta: TemplateBlockMeta): Element { return div; } +/** + * Merge authored `observedAttributes` with template-read roots. + * + * This is the key that makes `@attr` optional for HTML-only values: if a template + * reads `title`, then a host `title="..."` mutation can update the hidden + * template state even when the class never declared an `@attr title` property. + */ function installTemplateObservedAttributes(ctor: TemplateObservedConstructor, tagName: string): void { const meta = getTemplate(tagName); if (!meta) return; @@ -187,6 +202,13 @@ function installTemplateObservedAttributes(ctor: TemplateObservedConstructor, ta }); } +/** + * Return true for properties intentionally provided by component code. + * + * Hidden template state must not shadow authored fields, accessors, or methods. + * The scan stops at `CoreElement.prototype`, so native `HTMLElement` properties + * like `title` and `id` do not block template-only state roots with those names. + */ function hasAuthoredMember(instance: object, key: string): boolean { if (Object.prototype.hasOwnProperty.call(instance, key)) return true; @@ -202,6 +224,14 @@ function hasAuthoredMember(instance: object, key: string): boolean { // CoreElement — static rendering core (no events / refs / emit) // ═══════════════════════════════════════════════════════════════════ +/** + * Static WebUI rendering core. + * + * This class hydrates SSR output, creates client-side template instances, keeps + * template-only state for omitted `@observable` / `@attr` fields, and updates + * DOM bindings. It deliberately contains no event, ref, or custom-event emitter + * code so HTML-only auto-elements have the smallest reachable runtime. + */ export class CoreElement extends HTMLElement { private $root: TemplateInstance | null = null; private $meta?: TemplateMeta; @@ -226,10 +256,18 @@ export class CoreElement extends HTMLElement { repeats: RepeatBinding[]; } | null; + /** Internal single-key state hook used by compiled parent-to-child bindings. */ [WEBUI_SET_STATE_KEY](key: string, value: unknown): void { this.$setStateKey(key, value); } + /** + * Register this constructor for a tag and install template-derived observers. + * + * Authored components call this directly. Auto-elements also use it so + * scriptless templates observe only the host attributes referenced by their + * bindings. + */ static define(tagName: string): void { installTemplateObservedAttributes(this as TemplateObservedConstructor, tagName); customElements.define(tagName, this); @@ -420,14 +458,17 @@ export class CoreElement extends HTMLElement { return getObservableNames(this.constructor as Function); } + /** Decide whether a decorated property should be initialized from SSR state. */ protected $shouldApplySSRState(key: string): boolean { return !isAttributeProperty(this.constructor as Function, key); } + /** Decide whether hidden template state should be initialized from SSR state. */ protected $shouldApplyTemplateStateFromSSR(_key: string): boolean { return true; } + /** Write hidden template state and update bindings that read this root. */ protected $setTemplateState(key: string, value: unknown): void { if (this.$writeTemplateState(key, value)) { this.$update(key); @@ -467,6 +508,7 @@ export class CoreElement extends HTMLElement { return this.$templateStateNames().has(key) && !hasAuthoredMember(this, key); } + /** Route one external state key to an authored observable or hidden template state. */ private $setStateKey(key: string, value: unknown): void { if (this.$observableNames().has(key)) { (this as Record)[key] = value; @@ -1522,6 +1564,7 @@ export class CoreElement extends HTMLElement { private $resolveComponentRoot(root: string): unknown { const instance = this as Record; + // Template-only state wins only when the component did not author the member. if ( this.$templateState && Object.prototype.hasOwnProperty.call(this.$templateState, root) && diff --git a/packages/webui-framework/src/template-events.ts b/packages/webui-framework/src/template-events.ts index e8d39e8f..b5c807f9 100644 --- a/packages/webui-framework/src/template-events.ts +++ b/packages/webui-framework/src/template-events.ts @@ -1,12 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +/** + * Template registration bridge shared by framework and router. + * + * `@microsoft/webui-router` must stay platform independent and cannot import the + * framework. It dispatches this DOM event after registering WebUI template data; + * the framework listens for the event and can claim HTML-only auto-elements. + */ + import type { TemplateMeta } from './template.js'; -/** Event emitted when compiled template metadata becomes available. */ +/** DOM event emitted when WebUI template data becomes available at runtime. */ export const TEMPLATES_REGISTERED_EVENT = 'webui:templates-registered'; -/** Notify optional runtimes that compiled templates have been registered. */ +/** + * Notify optional runtimes that templates have been registered. + * + * The payload is intentionally just the template map so consumers can decide + * what to do without creating package dependencies between router and framework. + */ export function dispatchTemplatesRegistered(templates: Record): void { if ( typeof window === 'undefined' || diff --git a/packages/webui-framework/src/template-roots.ts b/packages/webui-framework/src/template-roots.ts index 1460d057..1c73094c 100644 --- a/packages/webui-framework/src/template-roots.ts +++ b/packages/webui-framework/src/template-roots.ts @@ -1,6 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +/** + * Template root analysis helpers. + * + * The compiled template tells the runtime which state roots are actually read by + * bindings. `CoreElement` uses that information to keep template-only state + * hidden when app code omits `@observable` / `@attr`, and auto-elements use it + * to observe only attributes that can affect DOM output. + */ + import { toKebabCase } from './decorators.js'; import type { CompiledAttrPart, @@ -23,11 +32,13 @@ const templateRootSetCache = new WeakMap>(); const templateAttributeCache = new WeakMap>(); const templateEventCache = new WeakMap(); +/** Return the top-level state key for a binding path. */ function pathRoot(path: string): string { const dot = path.indexOf('.'); return dot === -1 ? path : path.slice(0, dot); } +/** Ignore repeat item aliases so they do not become component state roots. */ function isScopedPath(path: string, scopes?: ScopeName): boolean { const root = pathRoot(path); let current = scopes; @@ -38,11 +49,13 @@ function isScopedPath(path: string, scopes?: ScopeName): boolean { return false; } +/** Add a binding path root unless it belongs to the current repeat scope. */ function addRoot(roots: Set, path: string, scopes?: ScopeName): void { if (!path || isScopedPath(path, scopes)) return; roots.add(pathRoot(path)); } +/** Add all dynamic roots from a mixed text or attribute binding. */ function addPartRoots(roots: Set, parts: CompiledAttrPart[], scopes?: ScopeName): void { for (let i = 0; i < parts.length; i++) { const part = parts[i]; @@ -50,6 +63,13 @@ function addPartRoots(roots: Set, parts: CompiledAttrPart[], scopes?: Sc } } +/** + * Collect component-level state roots referenced by a template. + * + * This walks nested condition/repeat block metadata iteratively so deeply nested + * templates cannot overflow the stack. Repeat item variables are tracked as + * lexical scopes and excluded from the returned component root set. + */ export function collectTemplateRoots(meta: TemplateMeta): readonly string[] { const cached = templateRootsCache.get(meta); if (cached) return cached; @@ -114,6 +134,12 @@ export function collectTemplateRoots(meta: TemplateMeta): readonly string[] { return collected; } +/** + * Return a cached `Set` view of template roots for fast membership checks. + * + * `CoreElement` calls this on SSR state and attribute updates to decide whether + * a key can live in hidden template state. + */ export function getTemplateRootSet(meta: TemplateMeta): ReadonlySet { let cached = templateRootSetCache.get(meta); if (cached) return cached; @@ -122,6 +148,13 @@ export function getTemplateRootSet(meta: TemplateMeta): ReadonlySet { return cached; } +/** + * Map observed host attribute names back to template state roots. + * + * HTML-only components do not declare `@attr`, so this lets their host + * attributes still update template bindings. Authored members still take + * precedence in `CoreElement`. + */ export function getTemplateAttributeMap(meta: TemplateMeta): ReadonlyMap { let cached = templateAttributeCache.get(meta); if (cached) return cached; @@ -135,6 +168,12 @@ export function getTemplateAttributeMap(meta: TemplateMeta): ReadonlyMap | undefined { loadWebUIDataBlock(); return window.__webui?.templates; } +/** + * Register template metadata and optional condition closures at runtime. + * + * Used by component assets and tests. After registration, a DOM event is + * dispatched so auto-elements can claim newly available scriptless templates. + */ export function registerTemplateData( templates: Record, templateFns?: Record, diff --git a/packages/webui-framework/tests/auto-elements.ts b/packages/webui-framework/tests/auto-elements.ts index 0a2a4ead..a01c5e47 100644 --- a/packages/webui-framework/tests/auto-elements.ts +++ b/packages/webui-framework/tests/auto-elements.ts @@ -1,4 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +/** + * Shared fixture bootstrap for HTML-only auto-element tests. + * + * These fixtures intentionally have no `element.ts`; importing the framework + * root is enough to install the auto-element runtime and prove scriptless + * templates hydrate without authored component stubs. + */ + import '../src/index.js'; diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/element.ts b/packages/webui-framework/tests/fixtures/optional-template-state/element.ts index a739e867..a2a68f4a 100644 --- a/packages/webui-framework/tests/fixtures/optional-template-state/element.ts +++ b/packages/webui-framework/tests/fixtures/optional-template-state/element.ts @@ -3,6 +3,13 @@ import { WebUIElement, observable } from '../../../src/index.js'; +/** + * Test component for optional template state. + * + * `selected` is JS-owned interactive state because the click handler mutates it. + * Other template values in the fixture are intentionally omitted from the class + * to prove `@observable` / `@attr` are not required for template-only bindings. + */ export class TestOptionalState extends WebUIElement { @observable selected = 'off'; diff --git a/packages/webui-router/src/loaders.ts b/packages/webui-router/src/loaders.ts index 835e3a54..7e5402de 100644 --- a/packages/webui-router/src/loaders.ts +++ b/packages/webui-router/src/loaders.ts @@ -22,10 +22,10 @@ export const NOOP_SIGNAL: AbortSignal = new AbortController().signal; * most once. * * When no loader exists and the tag is not yet registered, a passive - * stub element is auto-defined. Framework runtimes can register richer - * template-backed elements before this point; the router stays platform - * independent and only falls back to a no-op host when no runtime claimed - * the tag. + * stub element is auto-defined as a last resort. Framework runtimes can + * register richer template-backed elements before this point; the router stays + * platform independent and only falls back to a no-op host when no runtime + * claimed the tag. */ export async function ensureComponentLoaded( tag: string, @@ -51,14 +51,12 @@ export async function ensureComponentLoaded( } /** - * Auto-define a passive stub custom element for tags that have no - * registered class and no lazy loader. The stub extends HTMLElement - * directly (no hydration, no template, no bindings) and exposes a - * no-op `setState()` so the router's `isStateful()` check passes. - * - * This is the core of the islands architecture: app code only defines - * components that need interactivity. Everything else is server- - * rendered static HTML with zero client-side overhead. + * Auto-define a passive stub custom element for tags that have no registered + * class, no lazy loader, and no framework runtime claim. The stub extends + * `HTMLElement` directly: no hydration, no template binding, no dependency on + * a specific component framework. This preserves router compatibility for + * non-framework consumers while allowing WebUI Framework auto-elements to own + * HTML-only hydration when present. */ function definePassiveStub(tag: string): void { if (customElements.get(tag)) return; diff --git a/packages/webui-router/src/templates.ts b/packages/webui-router/src/templates.ts index 4184e61f..88adf5e5 100644 --- a/packages/webui-router/src/templates.ts +++ b/packages/webui-router/src/templates.ts @@ -4,13 +4,24 @@ /** * Template & CSS registration — shared by partial navigation and * `ensureLoaded()`. + * + * This module intentionally knows nothing about `@microsoft/webui-framework`. + * It only stores templates and announces that new WebUI template data exists; + * framework runtimes can listen and claim tags without making the router + * platform-specific. */ +/** Shared event name understood by optional framework runtimes. */ const TEMPLATES_REGISTERED_EVENT = 'webui:templates-registered'; /** * Register templates + inject CSS from a server response. * Shared by fetchPartial and fetchComponentTemplates. + * + * WebUI JSON template payloads are stored directly. FAST string templates are + * materialized as DOM. After JSON templates are registered, a DOM event lets + * auto-element runtimes upgrade scriptless component tags before the router + * falls back to passive stubs. */ export function registerTemplatesAndStyles( data: { @@ -177,6 +188,7 @@ export async function fetchComponentTemplates( registerTemplatesAndStyles(data, nonce, injectedStyles, updateInventory); } +/** Announce newly registered WebUI templates without importing a framework. */ export function notifyTemplatesRegistered(templates: Record | undefined): void { if ( !templates || diff --git a/packages/webui-test-support/src/fixture-render.ts b/packages/webui-test-support/src/fixture-render.ts index 9d6fc269..ca4a44c4 100644 --- a/packages/webui-test-support/src/fixture-render.ts +++ b/packages/webui-test-support/src/fixture-render.ts @@ -64,6 +64,9 @@ function renderOne(fixturePath: string, name: string): RenderedFixture | null { let html = render(result.protocol, state, { plugin: 'webui' }); + // Fixtures with an authored element entry exercise interactive islands. + // Fixtures without one use the shared framework bootstrap to prove HTML-only + // templates do not need empty component stubs. const scriptPath = existsSync(resolve(fixturePath, 'element.ts')) ? `/dist/${name}/element.js` : '/dist/auto-elements.js'; From 7636c680a0e068a01ad520338e31305db82b0068 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 1 Jul 2026 00:03:33 -0700 Subject: [PATCH 4/7] Remove framework size harness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/webui-framework/package.json | 2 - .../webui-framework/scripts/size-report.mjs | 90 ------------------- pnpm-lock.yaml | 3 - 3 files changed, 95 deletions(-) delete mode 100644 packages/webui-framework/scripts/size-report.mjs diff --git a/packages/webui-framework/package.json b/packages/webui-framework/package.json index 4e9fdcca..3687520b 100644 --- a/packages/webui-framework/package.json +++ b/packages/webui-framework/package.json @@ -30,7 +30,6 @@ "build": "tsc", "typecheck": "tsc --noEmit", "typecheck:e2e": "tsc -p tsconfig.test.json --noEmit", - "size": "node scripts/size-report.mjs", "test": "pnpm test:unit && pnpm test:e2e", "test:unit": "tsc -p tsconfig.unit-test.json && node --test \"dist/**/*.test.js\"", "test:e2e": "pnpm typecheck:e2e && playwright test", @@ -41,7 +40,6 @@ "@microsoft/webui-test-support": "workspace:*", "@playwright/test": "catalog:", "@types/node": "catalog:", - "esbuild": "catalog:", "typescript": "catalog:" } } diff --git a/packages/webui-framework/scripts/size-report.mjs b/packages/webui-framework/scripts/size-report.mjs deleted file mode 100644 index 3ce30186..00000000 --- a/packages/webui-framework/scripts/size-report.mjs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/** - * Bundle-size harness for framework reachability checks. - * - * The probes intentionally model the important client graphs: - * - root barrel: what a normal app import keeps alive - * - auto element: the HTML-only runtime path - * - authored probe: a component that uses decorators and events - * - html-only probe: a static app that only imports the framework root - * - * This lets framework refactors prove they changed shipped bytes, not just - * source layout. The script is dev-only and never participates in app bundles. - */ - -import { brotliCompressSync, gzipSync } from 'node:zlib'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { build } from 'esbuild'; - -const here = dirname(fileURLToPath(import.meta.url)); -const root = resolve(here, '..'); - -const sharedOptions = { - bundle: true, - format: 'esm', - minify: true, - platform: 'browser', - target: 'es2022', - treeShaking: true, - write: false, -}; - -const probes = [ - { - name: 'root barrel', - entryPoints: [resolve(root, 'src/index.ts')], - }, - { - name: 'auto element internal', - entryPoints: [resolve(root, 'src/auto-element.ts')], - }, - { - name: 'authored probe', - stdin: { - contents: ` - import { WebUIElement, observable, attr } from './src/index.ts'; - class SizeProbe extends WebUIElement { - @attr label = ''; - @observable count = 0; - onClick = () => { this.count++; }; - } - SizeProbe.define('size-probe'); - `, - loader: 'ts', - resolveDir: root, - }, - }, - { - name: 'html-only probe', - stdin: { - contents: `import './src/index.ts';`, - loader: 'ts', - resolveDir: root, - }, - }, -]; - -function kb(bytes) { - return `${(bytes / 1024).toFixed(2)} KB`; -} - -const rows = []; -for (const probe of probes) { - const { name, ...options } = probe; - const result = await build({ - ...sharedOptions, - ...options, - }); - const code = result.outputFiles[0].contents; - rows.push({ - bundle: name, - minified: kb(code.length), - gzip: kb(gzipSync(code).length), - brotli: kb(brotliCompressSync(code).length), - }); -} - -console.table(rows); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10a3ebb1..98122109 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,9 +411,6 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.5 - esbuild: - specifier: 'catalog:' - version: 0.28.1 typescript: specifier: 'catalog:' version: 5.9.3 From 7b124da1e421a45b626b053e8c6661ef04e48641 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 1 Jul 2026 01:11:28 -0700 Subject: [PATCH 5/7] Harden auto-elements and event delegation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 38 +++-- crates/webui-parser/src/component_registry.rs | 7 +- .../webui-framework/src/auto-element.test.ts | 58 ++++++- packages/webui-framework/src/auto-element.ts | 18 +- packages/webui-framework/src/element.ts | 157 +++++++++++++++--- packages/webui-framework/src/element/types.ts | 7 + .../webui-framework/src/template-events.ts | 25 ++- .../webui-framework/src/template-roots.ts | 37 +++++ .../optional-template-state/element.ts | 15 ++ .../optional-template-state.spec.ts | 2 + .../test-nonobservable-child.html | 1 + .../test-optional-state.html | 1 + .../optional-template-state/state.json | 1 + .../fixtures/repeat-conditional/element.ts | 7 +- .../repeat-conditional.spec.ts | 7 + .../test-repeat-conditional.html | 3 +- packages/webui-router/package.json | 1 + packages/webui-router/src/loaders.ts | 10 +- packages/webui-router/src/router.test.ts | 11 +- packages/webui-router/src/router.ts | 21 ++- packages/webui-router/src/streaming.ts | 9 +- packages/webui-router/src/templates.ts | 13 +- pnpm-lock.yaml | 3 + 23 files changed, 385 insertions(+), 67 deletions(-) create mode 100644 packages/webui-framework/tests/fixtures/optional-template-state/src/test-nonobservable-child/test-nonobservable-child.html diff --git a/DESIGN.md b/DESIGN.md index 47fe150a..7f9d60a2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1544,29 +1544,41 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m with `ae: 1` when the component has no sibling client script. The framework root runtime listens for template metadata and installs a static `CoreElement` subclass only for compiler-marked tags when the tag is not already registered - and the compiled template contains no event handler metadata. `CoreElement` is - the static rendering core (hydration, template state, bindings, repeats, - conditionals, attribute reflection); the interactive `WebUIElement` superset - adds event wiring, `w-ref` wiring, and `$emit` on top. Because auto-elements - extend `CoreElement` — never `WebUIElement` — a purely static / HTML-only app - tree-shakes all event/ref/emit code out of its bundle. + and the compiled template has dynamic bindings but no event handler metadata. + Fully static scriptless templates remain plain SSR DOM and are not upgraded. + `CoreElement` is the static rendering core (hydration, template state, + bindings, repeats, conditionals, attribute reflection); the interactive + `WebUIElement` superset adds event wiring, `w-ref` wiring, and `$emit` on top. + Because auto-elements extend `CoreElement` — never `WebUIElement` — a purely + static / HTML-only app tree-shakes all event/ref/emit code out of its bundle. The fallback derives reactive roots from `tx`, `a`, `c`, and `r` metadata, observes the corresponding host attributes, seeds non-attribute state from `window.__webui.state`, and supports router `setState()` updates without developer-authored `@observable` / `@attr` stubs. The framework root - entrypoint stays side-effect free and tree-shakeable. Developer-authored - classes and lazy loaders own templates that contain event handlers. + entrypoint installs this runtime as its one side effect; other framework + subpaths stay tree-shakeable. Developer-authored classes, lazy loaders, and + templates with event handlers own their custom element definitions. - Developer-authored `WebUIElement` classes also treat compiled template roots as stateful. `setState()` and SSR seeding store any undecorated template-bound roots in hidden framework state, so `@observable` is only required when TypeScript code reads or mutates the property directly. - Template producers that bootstrap initial SSR metadata or load metadata after initial SSR, including `@microsoft/webui-router`, publish a synchronous - `webui:templates-registered` event with `{ templates }` in `detail`. The - framework runtime listens for this optional platform-neutral event and claims - compiler-marked template-backed HTML-only tags before routers or asset loaders - create them. -- 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. + `webui:templates-registered` event with `{ templates, blockedTags? }` in + `detail`. `blockedTags` are custom-element tags owned by lazy component + loaders; automatic runtimes must not claim them before the loader module + defines the authored element. The framework runtime listens for this optional + platform-neutral event and claims compiler-marked template-backed HTML-only + tags synchronously before routers fall back to passive stubs. The initial + document-wide auto-element scan is deferred one microtask at DOMContentLoaded + so authored modules and router loader exclusions can register first. +- Events are resolved from compiled `e[]` metadata entries using path indices. + The runtime groups element events by event name and installs one delegated + listener per event name on the component render root, resolving handler + arguments against the scope captured when that block was rendered. Nested + conditional/repeat instances unregister their delegated listeners when removed + so detached DOM is not retained. Root events from `re[]` attach directly to the + host element or shadow root. - 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. Detailed component examples, decorators, and package entrypoint guidance live in [packages/webui-framework/README.md](packages/webui-framework/README.md) rather than being duplicated in this design spec. diff --git a/crates/webui-parser/src/component_registry.rs b/crates/webui-parser/src/component_registry.rs index aab5be81..cd3bf77f 100644 --- a/crates/webui-parser/src/component_registry.rs +++ b/crates/webui-parser/src/component_registry.rs @@ -242,7 +242,11 @@ impl ComponentRegistry { css_fallback_chains, source_path: PathBuf::new(), // Empty path since it's not from a file class_name: None, - has_script: false, + // Virtual registrations do not have a filesystem sibling scan. + // Treat them as authored so only real `.html` files discovered + // without a sibling `.ts` / `.js` are marked for browser + // auto-elements. + has_script: true, }; // Register the component @@ -443,6 +447,7 @@ mod tests { .expect("Failed to retrieve registered component"); assert_eq!(component.html_content, html_content); assert_eq!(component.css_content.as_deref(), Some(css_content)); + assert!(component.has_script); } #[test] diff --git a/packages/webui-framework/src/auto-element.test.ts b/packages/webui-framework/src/auto-element.test.ts index 93aa0e7a..5189df50 100644 --- a/packages/webui-framework/src/auto-element.test.ts +++ b/packages/webui-framework/src/auto-element.test.ts @@ -6,6 +6,7 @@ import { describe, test } from 'node:test'; import type { TemplateMeta } from './template.js'; const registry = new Map(); +const windowListeners = new Map void>>(); Object.defineProperty(globalThis, 'HTMLElement', { value: class HTMLElement { @@ -44,10 +45,35 @@ Object.defineProperty(globalThis, 'document', { configurable: true, }); +Object.defineProperty(globalThis, 'CustomEvent', { + value: class CustomEvent extends Event { + detail: T; + + constructor(type: string, init?: CustomEventInit) { + super(type); + this.detail = init?.detail as T; + } + }, + configurable: true, +}); + Object.defineProperty(globalThis, 'window', { value: { __webui: { templates: {} }, - addEventListener() {}, + addEventListener(type: string, listener: (event: Event) => void) { + const listeners = windowListeners.get(type); + if (listeners) { + listeners.push(listener); + } else { + windowListeners.set(type, [listener]); + } + }, + dispatchEvent(event: Event): boolean { + const listeners = windowListeners.get(event.type); + if (!listeners) return true; + for (let i = 0; i < listeners.length; i++) listeners[i](event); + return true; + }, }, configurable: true, }); @@ -145,6 +171,7 @@ describe('auto element fallback', () => { registerUnitTemplate(tag, { h: '', + ae: 1, e: [['click', 'onClick', [], [0]]], }); installAutoElementRuntime(); @@ -177,4 +204,33 @@ describe('auto element fallback', () => { assert.ok(customElements.get(allowed)); assert.equal(customElements.get(skipped), undefined); }); + + test('installAutoElementRuntime skips fully static scriptless templates', async () => { + const tag = `static-only-${Date.now()}`; + registerUnitTemplate(tag, { + h: '

Static content

', + ae: 1, + }); + + installAutoElementRuntime(); + await new Promise(resolve => queueMicrotask(resolve)); + + assert.equal(customElements.get(tag), undefined); + }); + + test('template registration event blocks loader-owned tags', async () => { + const tag = `loader-owned-${Date.now()}`; + const meta = registerUnitTemplate(tag, textTemplate('message')); + + installAutoElementRuntime(); + window.dispatchEvent(new CustomEvent('webui:templates-registered', { + detail: { + templates: { [tag]: meta }, + blockedTags: [tag], + }, + })); + await new Promise(resolve => queueMicrotask(resolve)); + + assert.equal(customElements.get(tag), undefined); + }); }); diff --git a/packages/webui-framework/src/auto-element.ts b/packages/webui-framework/src/auto-element.ts index 2ca0adb5..359f6ab2 100644 --- a/packages/webui-framework/src/auto-element.ts +++ b/packages/webui-framework/src/auto-element.ts @@ -17,15 +17,17 @@ import { CoreElement } from './element.js'; import { toKebabCase } from './decorators.js'; import { getTemplateRegistry } from './template.js'; -import { templateHasEventHandlers } from './template-roots.js'; +import { templateNeedsAutoElement } from './template-roots.js'; import { TEMPLATES_REGISTERED_EVENT, templateRegistrationDetail, + templateRegistrationBlockedTags, } from './template-events.js'; import type { TemplateMeta } from './template.js'; let runtimeInstalled = false; let initialClaimQueued = false; +const blockedAutoElementTags = new Set(); /** Define the smallest hydrating element for a scriptless template. */ function defineAutoElement(tag: string, meta: TemplateMeta): void { @@ -53,9 +55,9 @@ function defineMissingTemplateElement(tag: string, meta: TemplateMeta): boolean if ( typeof customElements === 'undefined' || typeof HTMLElement === 'undefined' || - !meta.ae || - customElements.get(tag) || - templateHasEventHandlers(meta) + !templateNeedsAutoElement(meta) || + blockedAutoElementTags.has(tag) || + customElements.get(tag) ) { return false; } @@ -105,6 +107,12 @@ export function installAutoElementRuntime(): void { runtimeInstalled = true; window.addEventListener(TEMPLATES_REGISTERED_EVENT, (event: Event) => { + const blockedTags = templateRegistrationBlockedTags(event); + if (blockedTags) { + for (let i = 0; i < blockedTags.length; i++) { + blockedAutoElementTags.add(blockedTags[i]); + } + } const templates = templateRegistrationDetail(event); if (templates) defineAutoTemplateElements(templates); }); @@ -112,7 +120,7 @@ export function installAutoElementRuntime(): void { if (document.readyState === 'loading') { document.addEventListener( 'DOMContentLoaded', - () => defineAutoTemplateElements(), + () => queueInitialAutoElementClaim(), { once: true }, ); return; diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index 37d25369..737ba3cd 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -88,6 +88,15 @@ import type { TextBinding, } from './element/types.js'; +type DelegatedEventEntry = { + target: Element; + method: EventHandler; + args: CompiledEventArgs; + scope?: ScopeFrame; +}; + +type EventHandler = (...args: unknown[]) => unknown; + // ── Caches ────────────────────────────────────────────────────── /** Parsed template cache — cloneNode(true) is faster than re-parsing. */ @@ -257,8 +266,8 @@ export class CoreElement extends HTMLElement { } | null; /** Internal single-key state hook used by compiled parent-to-child bindings. */ - [WEBUI_SET_STATE_KEY](key: string, value: unknown): void { - this.$setStateKey(key, value); + [WEBUI_SET_STATE_KEY](key: string, value: unknown): boolean { + return this.$setStateKey(key, value); } /** @@ -409,6 +418,7 @@ export class CoreElement extends HTMLElement { /** Break all DOM references held by a binding instance and its nested blocks. */ private $teardown(instance: TemplateInstance): void { + this.$teardownInstanceCleanups(instance); for (const c of instance.conds) { if (c.instance) this.$teardown(c.instance); c.instance = null; @@ -427,6 +437,14 @@ export class CoreElement extends HTMLElement { instance.repeats.length = 0; } + /** Run listener cleanup attached directly to one template instance. */ + private $teardownInstanceCleanups(instance: TemplateInstance): void { + const cleanups = instance.cleanups; + if (!cleanups) return; + for (let i = 0; i < cleanups.length; i++) cleanups[i](); + cleanups.length = 0; + } + attributeChangedCallback( name: string, oldValue: string | null, @@ -509,12 +527,16 @@ export class CoreElement extends HTMLElement { } /** Route one external state key to an authored observable or hidden template state. */ - private $setStateKey(key: string, value: unknown): void { + private $setStateKey(key: string, value: unknown): boolean { if (this.$observableNames().has(key)) { (this as Record)[key] = value; - } else if (this.$usesTemplateState(key)) { + return true; + } + if (this.$usesTemplateState(key)) { this.$setTemplateState(key, value); + return true; } + return false; } /** @@ -805,7 +827,7 @@ export class CoreElement extends HTMLElement { // Events + refs — resolve BEFORE anchors shift childNode indices. // Events target element nodes (not text/comment positions), but anchor // insertions still shift childNode indices for sibling elements. - this.$finalize(root, meta, (r, p) => this.$resolve(r, p), scope); + this.$finalize(instance, root, meta, (r, p) => this.$resolve(r, p), scope); // Now insert anchors using pre-resolved references @@ -1102,7 +1124,7 @@ export class CoreElement extends HTMLElement { } // Events + refs — this is the last phase that uses $resolveSSR. - this.$finalize(ssrRoot, meta, (r, p) => this.$resolveSSR(r, tplDom, p, pathStart), scope); + this.$finalize(instance, ssrRoot, meta, (r, p) => this.$resolveSSR(r, tplDom, p, pathStart), scope); // All path-based resolution is complete. Remove the SSR markers that // were kept alive for structural-block skipping. Start markers @@ -1297,6 +1319,7 @@ export class CoreElement extends HTMLElement { * core hook and tree-shake every event/ref helper away. */ protected $finalize( + _instance: TemplateInstance, _root: Node, _meta: TemplateBlockMeta, _resolver: (root: Node, path: TemplateNodePath) => Node | null, @@ -1486,12 +1509,13 @@ export class CoreElement extends HTMLElement { const target = el as unknown as Record; const setStateKey = target[WEBUI_SET_STATE_KEY]; if (typeof setStateKey === 'function') { - (setStateKey as (key: string, value: unknown) => void).call(el, b.name, v); - const flush = target['$flushUpdates']; - if (typeof flush === 'function') (flush as () => void).call(el); - } else { - target[b.name] = v; + if ((setStateKey as (key: string, value: unknown) => boolean).call(el, b.name, v)) { + const flush = target['$flushUpdates']; + if (typeof flush === 'function') (flush as () => void).call(el); + break; + } } + target[b.name] = v; break; } case ATTR_KIND_BOOLEAN: { @@ -1607,6 +1631,7 @@ export class CoreElement extends HTMLElement { } $removeInstance(instance: TemplateInstance): void { + this.$teardownInstanceCleanups(instance); for (const n of instance.nodes) n.parentNode?.removeChild(n); for (const c of instance.conds) { if (c.instance) this.$removeInstance(c.instance); @@ -1665,42 +1690,116 @@ export class WebUIElement extends CoreElement { /** Wire events + root events + refs (shared by $wire and $hydrate). */ protected override $finalize( + instance: TemplateInstance, root: Node, meta: TemplateBlockMeta, resolver: (root: Node, path: TemplateNodePath) => Node | null, scope?: ScopeFrame, ): void { - this.$wireEvents(root, meta, resolver, scope); - if ((meta as TemplateMeta).re) this.$wireRoot((meta as TemplateMeta).re!); + this.$wireEvents(instance, root, meta, resolver, scope); + if ((meta as TemplateMeta).re) this.$wireRoot(instance, (meta as TemplateMeta).re!); this.$wireRefs(root); } - /** Wire events using a resolver function (works for both client and SSR). */ + /** Wire element events as one delegated listener per event name. */ private $wireEvents( + instance: TemplateInstance, root: Node, meta: TemplateBlockMeta, resolver: (root: Node, path: TemplateNodePath) => Node | null, scope?: ScopeFrame, ): void { if (!meta.e) return; + const eventNames: string[] = []; + const buckets: DelegatedEventEntry[][] = []; for (let i = 0; i < meta.e.length; i++) { const [eventName, handlerName, args, target] = meta.e[i]; const el = resolver(root, target); if (!el || el.nodeType !== 1) continue; - this.$addEvent(el as Element, eventName, handlerName, args, scope); + const method = (this as Record)[handlerName]; + if (typeof method !== 'function') continue; + let bucketIndex = -1; + for (let j = 0; j < eventNames.length; j++) { + if (eventNames[j] === eventName) { + bucketIndex = j; + break; + } + } + if (bucketIndex < 0) { + bucketIndex = eventNames.length; + eventNames.push(eventName); + buckets.push([]); + } + buckets[bucketIndex].push({ + target: el as Element, + method: method as EventHandler, + args, + scope, + }); + } + const delegateTarget = this.$eventDelegateTarget(); + for (let i = 0; i < eventNames.length; i++) { + this.$addDelegatedEvent(instance, delegateTarget, eventNames[i], buckets[i]); } } /** Wire root-level events on the host element (or shadow root when present). */ - private $wireRoot(re: [string, string, CompiledEventArgs][]): void { + private $wireRoot(instance: TemplateInstance, re: [string, string, CompiledEventArgs][]): void { const target = this.shadowRoot ?? this; for (let i = 0; i < re.length; i++) { - this.$addEvent(target, re[i][0], re[i][1], re[i][2], undefined); + this.$addEvent(instance, target, re[i][0], re[i][1], re[i][2], undefined); + } + } + + /** Stable delegation target for SSR and detached client-created blocks. */ + private $eventDelegateTarget(): EventTarget { + return this.shadowRoot ?? this; + } + + /** Attach one listener for all bindings of the same event name in an instance. */ + private $addDelegatedEvent( + instance: TemplateInstance, + target: EventTarget, + eventName: string, + entries: DelegatedEventEntry[], + ): void { + if (entries.length === 0) return; + const listener = (event: Event): void => { + const path = typeof event.composedPath === 'function' ? event.composedPath() : null; + if (path) { + this.$dispatchDelegatedPath(entries, path, event); + return; + } + this.$dispatchDelegatedFallback(entries, event); + }; + target.addEventListener(eventName, listener); + this.$addCleanup(instance, () => target.removeEventListener(eventName, listener)); + } + + private $dispatchDelegatedPath(entries: DelegatedEventEntry[], path: EventTarget[], event: Event): void { + for (let p = 0; p < path.length; p++) { + const target = path[p]; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.target === target) this.$callEventHandler(entry.method, entry.args, event, entry.scope); + } } } - /** Attach a single event listener. */ + private $dispatchDelegatedFallback(entries: DelegatedEventEntry[], event: Event): void { + let current = event.target as Node | null; + while (current) { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.target === current) this.$callEventHandler(entry.method, entry.args, event, entry.scope); + } + current = current.parentNode; + } + } + + /** Attach a direct listener for root-level event bindings. */ private $addEvent( + instance: TemplateInstance, target: EventTarget, eventName: string, handlerName: string, @@ -1709,21 +1808,25 @@ export class WebUIElement extends CoreElement { ): void { const method = (this as Record)[handlerName]; if (typeof method !== 'function') return; + const listener = (event: Event): void => this.$callEventHandler(method as EventHandler, args, event, scope); + target.addEventListener(eventName, listener); + this.$addCleanup(instance, () => target.removeEventListener(eventName, listener)); + } + + private $addCleanup(instance: TemplateInstance, cleanup: () => void): void { + (instance.cleanups ??= []).push(cleanup); + } + + private $callEventHandler(method: EventHandler, args: CompiledEventArgs, event: Event, scope?: ScopeFrame): void { if (args.length === 0) { - target.addEventListener(eventName, () => { - (method as Function).call(this); - }); + method.call(this); return; } if (args.length === 1 && args[0][0] === 'e') { - target.addEventListener(eventName, (event) => { - (method as Function).call(this, event); - }); + method.call(this, event); return; } - target.addEventListener(eventName, (event) => { - (method as Function).apply(this, this.$resolveEventArgs(args, event, scope)); - }); + method.apply(this, this.$resolveEventArgs(args, event, scope)); } private $resolveEventArgs(args: CompiledEventArgs, event: Event, scope?: ScopeFrame): unknown[] { diff --git a/packages/webui-framework/src/element/types.ts b/packages/webui-framework/src/element/types.ts index 60d2fd3c..1e7e1d82 100644 --- a/packages/webui-framework/src/element/types.ts +++ b/packages/webui-framework/src/element/types.ts @@ -70,6 +70,13 @@ export interface TemplateInstance { attrs: AttrBinding[]; conds: CondBinding[]; repeats: RepeatBinding[]; + /** + * Per-instance listener cleanup. Delegated event listeners attach to the + * component render root for correctness while detached blocks are moved into + * place, so nested conditional/repeat instances must explicitly unregister + * when their block leaves the DOM. + */ + cleanups?: Array<() => void>; } /** Direct reference to a conditional block with anchor + nested compiled block. */ diff --git a/packages/webui-framework/src/template-events.ts b/packages/webui-framework/src/template-events.ts index b5c807f9..92080b4a 100644 --- a/packages/webui-framework/src/template-events.ts +++ b/packages/webui-framework/src/template-events.ts @@ -17,10 +17,16 @@ export const TEMPLATES_REGISTERED_EVENT = 'webui:templates-registered'; /** * Notify optional runtimes that templates have been registered. * - * The payload is intentionally just the template map so consumers can decide - * what to do without creating package dependencies between router and framework. + * The payload is intentionally generic so consumers can decide what to do + * without creating package dependencies between router and framework. Routers + * may include `blockedTags` for tags owned by lazy component loaders; automatic + * template runtimes must not claim those tags before the loader module defines + * the authored element. */ -export function dispatchTemplatesRegistered(templates: Record): void { +export function dispatchTemplatesRegistered( + templates: Record, + blockedTags?: readonly string[], +): void { if ( typeof window === 'undefined' || typeof CustomEvent !== 'function' || @@ -30,7 +36,7 @@ export function dispatchTemplatesRegistered(templates: Record : undefined; } + +/** Read the optional loader-owned tag list from a template registration event. */ +export function templateRegistrationBlockedTags(event: Event): readonly string[] | undefined { + const detail = (event as CustomEvent<{ blockedTags?: unknown }>).detail; + const blockedTags = detail?.blockedTags; + if (!Array.isArray(blockedTags)) return undefined; + for (let i = 0; i < blockedTags.length; i++) { + if (typeof blockedTags[i] !== 'string') return undefined; + } + return blockedTags as readonly string[]; +} diff --git a/packages/webui-framework/src/template-roots.ts b/packages/webui-framework/src/template-roots.ts index 1c73094c..c721bb5a 100644 --- a/packages/webui-framework/src/template-roots.ts +++ b/packages/webui-framework/src/template-roots.ts @@ -31,6 +31,7 @@ const templateRootsCache = new WeakMap(); const templateRootSetCache = new WeakMap>(); const templateAttributeCache = new WeakMap>(); const templateEventCache = new WeakMap(); +const templateDynamicCache = new WeakMap(); /** Return the top-level state key for a binding path. */ function pathRoot(path: string): string { @@ -200,3 +201,39 @@ export function templateHasEventHandlers(meta: TemplateMeta): boolean { templateEventCache.set(meta, false); return false; } + +/** + * Return true when a scriptless template needs a hydrating auto-element. + * + * Static HTML-only templates can stay as plain SSR DOM. Templates with dynamic + * text, attributes, conditionals, or repeats need `CoreElement` so server/router + * state and host attribute changes can update rendered output. + */ +export function templateNeedsAutoElement(meta: TemplateMeta): boolean { + if (!meta.ae || templateHasEventHandlers(meta)) return false; + + const cached = templateDynamicCache.get(meta); + if (cached !== undefined) return cached; + + const stack: TemplateBlockMeta[] = [meta]; + while (stack.length > 0) { + const block = stack.pop(); + if (!block) continue; + if ( + (block.tx && block.tx.length > 0) || + (block.a && block.a.length > 0) || + (block.c && block.c.length > 0) || + (block.r && block.r.length > 0) + ) { + templateDynamicCache.set(meta, true); + return true; + } + const children = (block as TemplateMeta).b; + if (children) { + for (let i = 0; i < children.length; i++) stack.push(children[i]); + } + } + + templateDynamicCache.set(meta, false); + return false; +} diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/element.ts b/packages/webui-framework/tests/fixtures/optional-template-state/element.ts index a2a68f4a..0a9f7492 100644 --- a/packages/webui-framework/tests/fixtures/optional-template-state/element.ts +++ b/packages/webui-framework/tests/fixtures/optional-template-state/element.ts @@ -19,3 +19,18 @@ export class TestOptionalState extends WebUIElement { } TestOptionalState.define('test-optional-state'); + +/** + * Child component with an authored setter but no decorator. + * + * The parent binds a complex property into this setter. That must fall back to + * direct assignment when the child template does not read the property itself, + * otherwise authored non-observable APIs silently miss parent data. + */ +export class TestNonobservableChild extends WebUIElement { + set payload(value: { label?: string }) { + this.setAttribute('data-payload-label', value.label ?? ''); + } +} + +TestNonobservableChild.define('test-nonobservable-child'); diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts b/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts index a1df5892..7b2f9c60 100644 --- a/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts +++ b/packages/webui-framework/tests/fixtures/optional-template-state/optional-template-state.spec.ts @@ -43,6 +43,7 @@ test.describe('optional template state fixture', () => { { name: 'Radia' }, { name: 'Margaret' }, ], + childPayload: { label: 'Updated child payload' }, showDetails: true, details: 'Loaded from hidden state', }); @@ -51,6 +52,7 @@ test.describe('optional template state fixture', () => { await expect(page.locator('test-optional-state .heading')).toHaveText('Updated heading'); await expect(page.locator('test-optional-state .count')).toHaveText('Count: 3'); await expect(page.locator('test-optional-state .details-link')).toHaveAttribute('href', '/items/99'); + await expect(page.locator('test-optional-state test-nonobservable-child')).toHaveAttribute('data-payload-label', 'Updated child payload'); await expect(page.locator('test-optional-state .item')).toHaveText(['Linus', 'Radia', 'Margaret']); await expect(page.locator('test-optional-state .details')).toHaveText('Loaded from hidden state'); }); diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/src/test-nonobservable-child/test-nonobservable-child.html b/packages/webui-framework/tests/fixtures/optional-template-state/src/test-nonobservable-child/test-nonobservable-child.html new file mode 100644 index 00000000..06389e35 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/optional-template-state/src/test-nonobservable-child/test-nonobservable-child.html @@ -0,0 +1 @@ +Child shell diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html b/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html index 381eb856..dfb957dd 100644 --- a/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html +++ b/packages/webui-framework/tests/fixtures/optional-template-state/src/test-optional-state/test-optional-state.html @@ -4,6 +4,7 @@

{{heading}}

Selected: {{selected}}

Details +
  • {{item.name}}
  • diff --git a/packages/webui-framework/tests/fixtures/optional-template-state/state.json b/packages/webui-framework/tests/fixtures/optional-template-state/state.json index 7ea745cc..4cf61a50 100644 --- a/packages/webui-framework/tests/fixtures/optional-template-state/state.json +++ b/packages/webui-framework/tests/fixtures/optional-template-state/state.json @@ -7,6 +7,7 @@ { "name": "Ada" }, { "name": "Grace" } ], + "childPayload": { "label": "Server child payload" }, "showDetails": false, "details": "SSR details" } diff --git a/packages/webui-framework/tests/fixtures/repeat-conditional/element.ts b/packages/webui-framework/tests/fixtures/repeat-conditional/element.ts index 4e689ac8..618d5c67 100644 --- a/packages/webui-framework/tests/fixtures/repeat-conditional/element.ts +++ b/packages/webui-framework/tests/fixtures/repeat-conditional/element.ts @@ -11,6 +11,8 @@ interface RepeatConditionalItem { } export class TestRepeatConditional extends WebUIElement { + @observable selectedTitle = ''; + @observable items: RepeatConditionalItem[] = [ { title: 'Shirts', @@ -77,7 +79,10 @@ export class TestRepeatConditional extends WebUIElement { }, ]; } + + selectItem(title: string): void { + this.selectedTitle = title; + } } TestRepeatConditional.define('test-repeat-conditional'); - diff --git a/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts b/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts index e112037c..b568cd56 100644 --- a/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts +++ b/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts @@ -50,4 +50,11 @@ test.describe('repeat conditional fixture', () => { await expect(page.locator('test-repeat-conditional .link').nth(1)).toBeEnabled(); await expect(page.locator('test-repeat-conditional .link').first()).toHaveAttribute('data-href', '/search/shirts'); }); + + test('delegates repeat-scoped event handlers after structural updates', async ({ page }) => { + await page.locator('test-repeat-conditional .switch').click(); + await page.locator('test-repeat-conditional .link').first().click(); + + await expect(page.locator('test-repeat-conditional .selected-title')).toHaveText('Shirts'); + }); }); diff --git a/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html b/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html index 46e4559b..c0be95b1 100644 --- a/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html +++ b/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html @@ -1,6 +1,7 @@
    + {{selectedTitle}}
      @@ -9,7 +10,7 @@

      {{item.title}}

      - +
      diff --git a/packages/webui-router/package.json b/packages/webui-router/package.json index 76566fbf..37db7a19 100644 --- a/packages/webui-router/package.json +++ b/packages/webui-router/package.json @@ -1,6 +1,7 @@ { "description": "Lightweight client-side router for WebUI apps. Intercepts navigation, matches routes locally or via server route-data, and hydrates components.", "devDependencies": { + "@microsoft/webui-framework": "workspace:*", "@microsoft/webui-router": "workspace:*", "@playwright/test": "catalog:", "@types/dom-navigation": "catalog:", diff --git a/packages/webui-router/src/loaders.ts b/packages/webui-router/src/loaders.ts index 7e5402de..a4c23806 100644 --- a/packages/webui-router/src/loaders.ts +++ b/packages/webui-router/src/loaders.ts @@ -17,9 +17,10 @@ export const NOOP_SIGNAL: AbortSignal = new AbortController().signal; /** * Ensure a component's JS module is loaded. If a lazy loader is - * configured for this tag and the element isn't already registered, - * invoke the loader. The promise is cached so each loader runs at - * most once. + * configured for this tag, invoke the loader before consulting the registry. + * This gives authored lazy components precedence over compiler-marked + * auto-elements if template metadata is conservative or stale. The promise is + * cached so each loader runs at most once. * * When no loader exists and the tag is not yet registered, a passive * stub element is auto-defined as a last resort. Framework runtimes can @@ -32,10 +33,9 @@ export async function ensureComponentLoaded( loaders: Record Promise>, loaderPromises: Map>, ): Promise { - if (customElements.get(tag)) return; - const loader = loaders[tag]; if (!loader) { + if (customElements.get(tag)) return; // No loader and not registered — auto-define a passive stub so // the router can create/query this element during SPA navigation. definePassiveStub(tag); diff --git a/packages/webui-router/src/router.test.ts b/packages/webui-router/src/router.test.ts index 9b6b42fe..61ecc1b0 100644 --- a/packages/webui-router/src/router.test.ts +++ b/packages/webui-router/src/router.test.ts @@ -262,10 +262,16 @@ describe('WebUIRouter', () => { test('template registration notifies optional framework runtimes', () => { const origDispatchEvent = (globalThis as any).window.dispatchEvent; let notifiedTemplates: Record | undefined; + let blockedTags: readonly string[] | undefined; (globalThis as any).window.dispatchEvent = (event: Event) => { if (event.type === 'webui:templates-registered') { - notifiedTemplates = (event as CustomEvent<{ templates: Record }>).detail.templates; + const detail = (event as CustomEvent<{ + templates: Record; + blockedTags?: readonly string[]; + }>).detail; + notifiedTemplates = detail.templates; + blockedTags = detail.blockedTags; } return true; }; @@ -274,9 +280,10 @@ describe('WebUIRouter', () => { const template = { h: '

      Notified

      ' }; registerTemplatesAndStyles({ templates: { 'notified-comp': template }, - }, '', new Set(), () => {}); + }, '', new Set(), () => {}, ['lazy-owned']); assert.deepEqual(notifiedTemplates, { 'notified-comp': template }); + assert.deepEqual(blockedTags, ['lazy-owned']); } finally { (globalThis as any).window.dispatchEvent = origDispatchEvent; } diff --git a/packages/webui-router/src/router.ts b/packages/webui-router/src/router.ts index f0c86765..d702b049 100644 --- a/packages/webui-router/src/router.ts +++ b/packages/webui-router/src/router.ts @@ -66,6 +66,7 @@ export class WebUIRouter { private cleanupFns: Array<() => void> = []; private isInitialNavigation = true; private loaders: Record Promise> = {}; + private loaderTags: readonly string[] | undefined; private loaderPromises = new Map>(); private activeChain: RouteChainEntry[] = []; private basePath = ''; @@ -102,6 +103,8 @@ export class WebUIRouter { this.started = true; this.config = config; this.loaders = config.loaders ?? {}; + const loaderTags = Object.keys(this.loaders); + this.loaderTags = loaderTags.length === 0 ? undefined : loaderTags; this.basePath = document.querySelector('base')?.getAttribute('href')?.replace(/\/+$/, '') ?? ''; this.excludePaths = config.excludePaths ?? []; @@ -130,7 +133,7 @@ export class WebUIRouter { if (!meta.css) meta.css = []; if (!meta.styles) meta.styles = []; if (!meta.templates) meta.templates = {}; - notifyTemplatesRegistered(meta.templates); + notifyTemplatesRegistered(meta.templates, this.loaderOwnedTags()); // Build O(1) lookup Sets from the global arrays, then free the arrays — // they were one-shot SSR data; the Sets are the live lookup structure. @@ -230,6 +233,7 @@ export class WebUIRouter { const fetchPromise = fetchComponentTemplates( missing, inv, endpoint, window.__webui!.nonce!, this.stylesSet, (inv) => this.updateInventory(inv), + this.loaderOwnedTags(), ).finally(() => { for (const tag of missing) this.loadPromises.delete(tag); }); @@ -267,6 +271,7 @@ export class WebUIRouter { this.loaderPromises.clear(); this.loadPromises.clear(); this.loaders = {}; + this.loaderTags = undefined; this.activeChain = []; for (const fn of this.cleanupFns) fn(); this.cleanupFns = []; @@ -425,7 +430,13 @@ export class WebUIRouter { const data = await resp.json() as PartialResponse & { inventory?: string }; if (signal?.aborted) return null; - registerTemplatesAndStyles(data, window.__webui!.nonce!, this.stylesSet, (inv) => this.updateInventory(inv)); + registerTemplatesAndStyles( + data, + window.__webui!.nonce!, + this.stylesSet, + (inv) => this.updateInventory(inv), + this.loaderOwnedTags(), + ); injectCssLinks(data, this.cssSet); return data; } @@ -490,6 +501,7 @@ export class WebUIRouter { get nonce() { return window.__webui!.nonce!; }, get injectedStyles() { return self.stylesSet; }, get injectedCss() { return self.cssSet; }, + get blockedAutoElementTags() { return self.loaderOwnedTags(); }, setDeferredReader(r) { self.deferredReader = r; }, setDeferredGeneration(g) { self.deferredGeneration = g; }, updateInventory(inv) { self.updateInventory(inv); }, @@ -528,6 +540,11 @@ export class WebUIRouter { } } + /** Tags with lazy component modules are owned by those modules, not auto-elements. */ + private loaderOwnedTags(): readonly string[] | undefined { + return this.loaderTags; + } + private clearSsrPreloads(): void { if (this.ssrPreloadsCleared) return; this.ssrPreloadsCleared = true; diff --git a/packages/webui-router/src/streaming.ts b/packages/webui-router/src/streaming.ts index 493770f8..ee934dca 100644 --- a/packages/webui-router/src/streaming.ts +++ b/packages/webui-router/src/streaming.ts @@ -23,6 +23,7 @@ export interface StreamingContext { readonly nonce: string; readonly injectedStyles: Set; readonly injectedCss: Set; + readonly blockedAutoElementTags?: readonly string[]; setDeferredReader(reader: Promise | null): void; setDeferredGeneration(gen: number): void; updateInventory(inv: string): void; @@ -97,7 +98,13 @@ export async function readStreamingPartial( } // Register templates/styles from Chunk 1 - registerTemplatesAndStyles(chunk1, ctx.nonce, ctx.injectedStyles, ctx.updateInventory); + registerTemplatesAndStyles( + chunk1, + ctx.nonce, + ctx.injectedStyles, + ctx.updateInventory, + ctx.blockedAutoElementTags, + ); injectCssLinks(chunk1, ctx.injectedCss); // Spawn background reader for remaining chunks (Chunk 2 state) diff --git a/packages/webui-router/src/templates.ts b/packages/webui-router/src/templates.ts index 88adf5e5..1962678b 100644 --- a/packages/webui-router/src/templates.ts +++ b/packages/webui-router/src/templates.ts @@ -33,6 +33,7 @@ export function registerTemplatesAndStyles( nonce: string, injectedStyles: Set, updateInventory: (inv: string) => void, + blockedAutoElementTags?: readonly string[], ): void { if (data.inventory) { updateInventory(data.inventory); @@ -143,7 +144,7 @@ export function registerTemplatesAndStyles( document.head.removeChild(script); } - notifyTemplatesRegistered(registeredTemplates); + notifyTemplatesRegistered(registeredTemplates, blockedAutoElementTags); } /** Inject CSS stylesheet links from a partial response. */ @@ -176,6 +177,7 @@ export async function fetchComponentTemplates( nonce: string, injectedStyles: Set, updateInventory: (inv: string) => void, + blockedAutoElementTags?: readonly string[], ): Promise { const url = `${templateEndpoint}?t=${tags.join(',')}&inv=${encodeURIComponent(inventoryHex)}`; const resp = await fetch(url); @@ -185,11 +187,14 @@ export async function fetchComponentTemplates( const data = await resp.json(); // Register using the same pipeline as partial navigation - registerTemplatesAndStyles(data, nonce, injectedStyles, updateInventory); + registerTemplatesAndStyles(data, nonce, injectedStyles, updateInventory, blockedAutoElementTags); } /** Announce newly registered WebUI templates without importing a framework. */ -export function notifyTemplatesRegistered(templates: Record | undefined): void { +export function notifyTemplatesRegistered( + templates: Record | undefined, + blockedTags?: readonly string[], +): void { if ( !templates || typeof window === 'undefined' || @@ -200,6 +205,6 @@ export function notifyTemplatesRegistered(templates: Record | u } window.dispatchEvent(new CustomEvent(TEMPLATES_REGISTERED_EVENT, { - detail: { templates }, + detail: { templates, blockedTags }, })); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98122109..66206c56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,6 +421,9 @@ importers: packages/webui-router: devDependencies: + '@microsoft/webui-framework': + specifier: workspace:* + version: link:../webui-framework '@microsoft/webui-router': specifier: workspace:* version: 'link:' From 7020d935adef0bd19c408adcd1d47bf4deedb174 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 1 Jul 2026 01:37:32 -0700 Subject: [PATCH 6/7] Trim framework runtime size Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/webui-framework/src/auto-element.ts | 15 ++++------ packages/webui-framework/src/element.ts | 26 ++--------------- .../webui-framework/src/template-events.ts | 28 +++++++++---------- .../webui-framework/src/template-roots.ts | 27 +----------------- packages/webui-router/src/router.ts | 13 +++------ packages/webui-router/src/streaming.ts | 4 +-- packages/webui-router/src/templates.ts | 8 +++--- 7 files changed, 33 insertions(+), 88 deletions(-) diff --git a/packages/webui-framework/src/auto-element.ts b/packages/webui-framework/src/auto-element.ts index 359f6ab2..4337fe4e 100644 --- a/packages/webui-framework/src/auto-element.ts +++ b/packages/webui-framework/src/auto-element.ts @@ -21,7 +21,6 @@ import { templateNeedsAutoElement } from './template-roots.js'; import { TEMPLATES_REGISTERED_EVENT, templateRegistrationDetail, - templateRegistrationBlockedTags, } from './template-events.js'; import type { TemplateMeta } from './template.js'; @@ -51,18 +50,15 @@ function defineAutoElement(tag: string, meta: TemplateMeta): void { * Developer-authored custom elements take precedence: when a tag is already * registered, this function leaves it untouched and reports no work. */ -function defineMissingTemplateElement(tag: string, meta: TemplateMeta): boolean { +function defineMissingTemplateElement(tag: string, meta: TemplateMeta): void { if ( - typeof customElements === 'undefined' || - typeof HTMLElement === 'undefined' || !templateNeedsAutoElement(meta) || blockedAutoElementTags.has(tag) || customElements.get(tag) ) { - return false; + return; } defineAutoElement(tag, meta); - return true; } /** Claim every eligible template in a registry snapshot. */ @@ -107,14 +103,15 @@ export function installAutoElementRuntime(): void { runtimeInstalled = true; window.addEventListener(TEMPLATES_REGISTERED_EVENT, (event: Event) => { - const blockedTags = templateRegistrationBlockedTags(event); + const detail = templateRegistrationDetail(event); + if (!detail) return; + const blockedTags = detail.blockedTags; if (blockedTags) { for (let i = 0; i < blockedTags.length; i++) { blockedAutoElementTags.add(blockedTags[i]); } } - const templates = templateRegistrationDetail(event); - if (templates) defineAutoTemplateElements(templates); + if (detail.templates) defineAutoTemplateElements(detail.templates); }); if (document.readyState === 'loading') { diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index 737ba3cd..8e27dd7d 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -1737,7 +1737,7 @@ export class WebUIElement extends CoreElement { scope, }); } - const delegateTarget = this.$eventDelegateTarget(); + const delegateTarget = this.shadowRoot ?? this; for (let i = 0; i < eventNames.length; i++) { this.$addDelegatedEvent(instance, delegateTarget, eventNames[i], buckets[i]); } @@ -1751,11 +1751,6 @@ export class WebUIElement extends CoreElement { } } - /** Stable delegation target for SSR and detached client-created blocks. */ - private $eventDelegateTarget(): EventTarget { - return this.shadowRoot ?? this; - } - /** Attach one listener for all bindings of the same event name in an instance. */ private $addDelegatedEvent( instance: TemplateInstance, @@ -1765,28 +1760,13 @@ export class WebUIElement extends CoreElement { ): void { if (entries.length === 0) return; const listener = (event: Event): void => { - const path = typeof event.composedPath === 'function' ? event.composedPath() : null; - if (path) { - this.$dispatchDelegatedPath(entries, path, event); - return; - } - this.$dispatchDelegatedFallback(entries, event); + this.$dispatchDelegatedEvent(entries, event); }; target.addEventListener(eventName, listener); this.$addCleanup(instance, () => target.removeEventListener(eventName, listener)); } - private $dispatchDelegatedPath(entries: DelegatedEventEntry[], path: EventTarget[], event: Event): void { - for (let p = 0; p < path.length; p++) { - const target = path[p]; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (entry.target === target) this.$callEventHandler(entry.method, entry.args, event, entry.scope); - } - } - } - - private $dispatchDelegatedFallback(entries: DelegatedEventEntry[], event: Event): void { + private $dispatchDelegatedEvent(entries: DelegatedEventEntry[], event: Event): void { let current = event.target as Node | null; while (current) { for (let i = 0; i < entries.length; i++) { diff --git a/packages/webui-framework/src/template-events.ts b/packages/webui-framework/src/template-events.ts index 92080b4a..f1644be6 100644 --- a/packages/webui-framework/src/template-events.ts +++ b/packages/webui-framework/src/template-events.ts @@ -41,21 +41,19 @@ export function dispatchTemplatesRegistered( } /** Read a template registration event payload without trusting arbitrary detail. */ -export function templateRegistrationDetail(event: Event): Record | undefined { - const detail = (event as CustomEvent<{ templates?: unknown }>).detail; +export function templateRegistrationDetail(event: Event): { + templates?: Record; + blockedTags?: readonly string[]; +} | undefined { + const detail = (event as CustomEvent<{ templates?: unknown; blockedTags?: unknown }>).detail; + if (!detail || typeof detail !== 'object') return undefined; const templates = detail?.templates; - return typeof templates === 'object' && templates !== null - ? templates as Record - : undefined; -} - -/** Read the optional loader-owned tag list from a template registration event. */ -export function templateRegistrationBlockedTags(event: Event): readonly string[] | undefined { - const detail = (event as CustomEvent<{ blockedTags?: unknown }>).detail; const blockedTags = detail?.blockedTags; - if (!Array.isArray(blockedTags)) return undefined; - for (let i = 0; i < blockedTags.length; i++) { - if (typeof blockedTags[i] !== 'string') return undefined; - } - return blockedTags as readonly string[]; + const payload = { + templates: typeof templates === 'object' && templates !== null + ? templates as Record + : undefined, + blockedTags: Array.isArray(blockedTags) ? blockedTags as readonly string[] : undefined, + }; + return payload.templates || payload.blockedTags ? payload : undefined; } diff --git a/packages/webui-framework/src/template-roots.ts b/packages/webui-framework/src/template-roots.ts index c721bb5a..f85cd119 100644 --- a/packages/webui-framework/src/template-roots.ts +++ b/packages/webui-framework/src/template-roots.ts @@ -31,7 +31,6 @@ const templateRootsCache = new WeakMap(); const templateRootSetCache = new WeakMap>(); const templateAttributeCache = new WeakMap>(); const templateEventCache = new WeakMap(); -const templateDynamicCache = new WeakMap(); /** Return the top-level state key for a binding path. */ function pathRoot(path: string): string { @@ -211,29 +210,5 @@ export function templateHasEventHandlers(meta: TemplateMeta): boolean { */ export function templateNeedsAutoElement(meta: TemplateMeta): boolean { if (!meta.ae || templateHasEventHandlers(meta)) return false; - - const cached = templateDynamicCache.get(meta); - if (cached !== undefined) return cached; - - const stack: TemplateBlockMeta[] = [meta]; - while (stack.length > 0) { - const block = stack.pop(); - if (!block) continue; - if ( - (block.tx && block.tx.length > 0) || - (block.a && block.a.length > 0) || - (block.c && block.c.length > 0) || - (block.r && block.r.length > 0) - ) { - templateDynamicCache.set(meta, true); - return true; - } - const children = (block as TemplateMeta).b; - if (children) { - for (let i = 0; i < children.length; i++) stack.push(children[i]); - } - } - - templateDynamicCache.set(meta, false); - return false; + return collectTemplateRoots(meta).length !== 0; } diff --git a/packages/webui-router/src/router.ts b/packages/webui-router/src/router.ts index d702b049..2909a210 100644 --- a/packages/webui-router/src/router.ts +++ b/packages/webui-router/src/router.ts @@ -133,7 +133,7 @@ export class WebUIRouter { if (!meta.css) meta.css = []; if (!meta.styles) meta.styles = []; if (!meta.templates) meta.templates = {}; - notifyTemplatesRegistered(meta.templates, this.loaderOwnedTags()); + notifyTemplatesRegistered(meta.templates, this.loaderTags); // Build O(1) lookup Sets from the global arrays, then free the arrays — // they were one-shot SSR data; the Sets are the live lookup structure. @@ -233,7 +233,7 @@ export class WebUIRouter { const fetchPromise = fetchComponentTemplates( missing, inv, endpoint, window.__webui!.nonce!, this.stylesSet, (inv) => this.updateInventory(inv), - this.loaderOwnedTags(), + this.loaderTags, ).finally(() => { for (const tag of missing) this.loadPromises.delete(tag); }); @@ -435,7 +435,7 @@ export class WebUIRouter { window.__webui!.nonce!, this.stylesSet, (inv) => this.updateInventory(inv), - this.loaderOwnedTags(), + this.loaderTags, ); injectCssLinks(data, this.cssSet); return data; @@ -501,7 +501,7 @@ export class WebUIRouter { get nonce() { return window.__webui!.nonce!; }, get injectedStyles() { return self.stylesSet; }, get injectedCss() { return self.cssSet; }, - get blockedAutoElementTags() { return self.loaderOwnedTags(); }, + get blockedTags() { return self.loaderTags; }, setDeferredReader(r) { self.deferredReader = r; }, setDeferredGeneration(g) { self.deferredGeneration = g; }, updateInventory(inv) { self.updateInventory(inv); }, @@ -540,11 +540,6 @@ export class WebUIRouter { } } - /** Tags with lazy component modules are owned by those modules, not auto-elements. */ - private loaderOwnedTags(): readonly string[] | undefined { - return this.loaderTags; - } - private clearSsrPreloads(): void { if (this.ssrPreloadsCleared) return; this.ssrPreloadsCleared = true; diff --git a/packages/webui-router/src/streaming.ts b/packages/webui-router/src/streaming.ts index ee934dca..f28ca014 100644 --- a/packages/webui-router/src/streaming.ts +++ b/packages/webui-router/src/streaming.ts @@ -23,7 +23,7 @@ export interface StreamingContext { readonly nonce: string; readonly injectedStyles: Set; readonly injectedCss: Set; - readonly blockedAutoElementTags?: readonly string[]; + readonly blockedTags?: readonly string[]; setDeferredReader(reader: Promise | null): void; setDeferredGeneration(gen: number): void; updateInventory(inv: string): void; @@ -103,7 +103,7 @@ export async function readStreamingPartial( ctx.nonce, ctx.injectedStyles, ctx.updateInventory, - ctx.blockedAutoElementTags, + ctx.blockedTags, ); injectCssLinks(chunk1, ctx.injectedCss); diff --git a/packages/webui-router/src/templates.ts b/packages/webui-router/src/templates.ts index 1962678b..9f2188f0 100644 --- a/packages/webui-router/src/templates.ts +++ b/packages/webui-router/src/templates.ts @@ -33,7 +33,7 @@ export function registerTemplatesAndStyles( nonce: string, injectedStyles: Set, updateInventory: (inv: string) => void, - blockedAutoElementTags?: readonly string[], + blockedTags?: readonly string[], ): void { if (data.inventory) { updateInventory(data.inventory); @@ -144,7 +144,7 @@ export function registerTemplatesAndStyles( document.head.removeChild(script); } - notifyTemplatesRegistered(registeredTemplates, blockedAutoElementTags); + notifyTemplatesRegistered(registeredTemplates, blockedTags); } /** Inject CSS stylesheet links from a partial response. */ @@ -177,7 +177,7 @@ export async function fetchComponentTemplates( nonce: string, injectedStyles: Set, updateInventory: (inv: string) => void, - blockedAutoElementTags?: readonly string[], + blockedTags?: readonly string[], ): Promise { const url = `${templateEndpoint}?t=${tags.join(',')}&inv=${encodeURIComponent(inventoryHex)}`; const resp = await fetch(url); @@ -187,7 +187,7 @@ export async function fetchComponentTemplates( const data = await resp.json(); // Register using the same pipeline as partial navigation - registerTemplatesAndStyles(data, nonce, injectedStyles, updateInventory, blockedAutoElementTags); + registerTemplatesAndStyles(data, nonce, injectedStyles, updateInventory, blockedTags); } /** Announce newly registered WebUI templates without importing a framework. */ From 79734be22e3e72778f0a4d7d879f90e4e459c55f Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 1 Jul 2026 02:20:49 -0700 Subject: [PATCH 7/7] Preserve delegated event currentTarget Keep delegated event dispatch on the shared handler invocation path while exposing the bound element as event.currentTarget for handlers that receive e. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/webui-framework/src/element.ts | 46 ++++++++++++++++++- .../tests/fixtures/list/element.ts | 3 +- .../tests/fixtures/list/list.spec.ts | 2 +- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/webui-framework/src/element.ts b/packages/webui-framework/src/element.ts index 8e27dd7d..dee72bda 100644 --- a/packages/webui-framework/src/element.ts +++ b/packages/webui-framework/src/element.ts @@ -1771,7 +1771,9 @@ export class WebUIElement extends CoreElement { while (current) { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; - if (entry.target === current) this.$callEventHandler(entry.method, entry.args, event, entry.scope); + if (entry.target === current) { + this.$callEventHandler(entry.method, entry.args, event, entry.scope, entry.target); + } } current = current.parentNode; } @@ -1797,11 +1799,21 @@ export class WebUIElement extends CoreElement { (instance.cleanups ??= []).push(cleanup); } - private $callEventHandler(method: EventHandler, args: CompiledEventArgs, event: Event, scope?: ScopeFrame): void { + private $callEventHandler( + method: EventHandler, + args: CompiledEventArgs, + event: Event, + scope?: ScopeFrame, + currentTarget?: EventTarget, + ): void { if (args.length === 0) { method.call(this); return; } + if (currentTarget && this.$eventArgsUseEvent(args)) { + this.$callEventHandlerWithCurrentTarget(method, args, event, scope, currentTarget); + return; + } if (args.length === 1 && args[0][0] === 'e') { method.call(this, event); return; @@ -1809,6 +1821,36 @@ export class WebUIElement extends CoreElement { method.apply(this, this.$resolveEventArgs(args, event, scope)); } + private $eventArgsUseEvent(args: CompiledEventArgs): boolean { + for (let i = 0; i < args.length; i++) { + if (args[i][0] === 'e') return true; + } + return false; + } + + private $callEventHandlerWithCurrentTarget( + method: EventHandler, + args: CompiledEventArgs, + event: Event, + scope: ScopeFrame | undefined, + currentTarget: EventTarget, + ): void { + const descriptor = Object.getOwnPropertyDescriptor(event, 'currentTarget'); + Object.defineProperty(event, 'currentTarget', { + configurable: true, + value: currentTarget, + }); + try { + this.$callEventHandler(method, args, event, scope); + } finally { + if (descriptor) { + Object.defineProperty(event, 'currentTarget', descriptor); + } else { + delete (event as { currentTarget?: EventTarget | null }).currentTarget; + } + } + } + private $resolveEventArgs(args: CompiledEventArgs, event: Event, scope?: ScopeFrame): unknown[] { const resolved: unknown[] = []; for (let i = 0; i < args.length; i++) { diff --git a/packages/webui-framework/tests/fixtures/list/element.ts b/packages/webui-framework/tests/fixtures/list/element.ts index c69cc5ac..42ce1838 100644 --- a/packages/webui-framework/tests/fixtures/list/element.ts +++ b/packages/webui-framework/tests/fixtures/list/element.ts @@ -76,7 +76,8 @@ export class TestList extends WebUIElement { } selectItemWithEvent(id: string, e: Event): void { - this.lastLoopArg = `arg=${id} event=${e.type} args.length=${arguments.length}`; + const currentTarget = e.currentTarget as Element | null; + this.lastLoopArg = `arg=${id} event=${e.type} current=${currentTarget?.className ?? ''} args.length=${arguments.length}`; } } diff --git a/packages/webui-framework/tests/fixtures/list/list.spec.ts b/packages/webui-framework/tests/fixtures/list/list.spec.ts index 5f805931..a89c0f9c 100644 --- a/packages/webui-framework/tests/fixtures/list/list.spec.ts +++ b/packages/webui-framework/tests/fixtures/list/list.spec.ts @@ -131,7 +131,7 @@ test.describe('list fixture', () => { await expect(page.locator('test-list .last-loop-arg')).toHaveText('arg=2 typeof=string args.length=1'); await page.locator('test-list .loop-arg-event').nth(0).click(); - await expect(page.locator('test-list .last-loop-arg')).toHaveText('arg=1 event=click args.length=2'); + await expect(page.locator('test-list .last-loop-arg')).toHaveText('arg=1 event=click current=loop-arg-event args.length=2'); }); test('hydrates empty text slots after repeat content at the correct position', async ({ page }) => {