From f7c8a80a88f116738004bb6b45af7347e5c501ee Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 19 Jun 2026 15:47:41 -0700 Subject: [PATCH 1/6] Add static component asset loading Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + DESIGN.md | 54 ++ crates/webui-cli/Cargo.toml | 1 + crates/webui-cli/src/commands/build.rs | 145 ++++- crates/webui-cli/src/commands/common.rs | 2 + .../src/commands/component_assets.rs | 426 ++++++++++++++ crates/webui-cli/src/commands/mod.rs | 1 + crates/webui-node/src/lib.rs | 1 + crates/webui/src/lib.rs | 37 ++ docs/ai.md | 62 ++ docs/guide/cli/index.md | 52 +- docs/guide/concepts/interactivity.md | 63 +++ examples/app/calculator/tsconfig.json | 25 +- examples/app/commerce/tsconfig.json | 14 +- examples/app/component-assets/data/state.json | 6 + examples/app/component-assets/demo.toml | 13 + examples/app/component-assets/package.json | 22 + .../src/app-shell/app-shell.css | 54 ++ .../src/app-shell/app-shell.html | 14 + .../src/app-shell/app-shell.ts | 25 + .../src/asset-badge/asset-badge.css | 13 + .../src/asset-badge/asset-badge.html | 1 + .../src/asset-badge/asset-badge.ts | 10 + examples/app/component-assets/src/index.html | 45 ++ examples/app/component-assets/src/index.ts | 13 + .../component-assets/src/lazy-panel-data.json | 7 + .../src/lazy-panel/lazy-panel.css | 29 + .../src/lazy-panel/lazy-panel.html | 8 + .../src/lazy-panel/lazy-panel.ts | 15 + examples/app/component-assets/tsconfig.json | 8 + .../contact-book-manager/src/cb-app/cb-app.ts | 7 +- .../app/contact-book-manager/tsconfig.json | 26 +- examples/app/service-worker/tsconfig.json | 22 +- .../app/service-worker/tsconfig.worker.json | 21 +- examples/app/todo-fast/tsconfig.json | 25 +- examples/app/todo-webui/tsconfig.json | 16 +- packages/webui-framework/README.md | 37 ++ packages/webui-framework/package.json | 7 + .../src/component-asset.test.ts | 535 ++++++++++++++++++ .../webui-framework/src/component-asset.ts | 356 ++++++++++++ pnpm-lock.yaml | 15 + tsconfig.json | 3 +- 42 files changed, 2121 insertions(+), 116 deletions(-) create mode 100644 crates/webui-cli/src/commands/component_assets.rs create mode 100644 examples/app/component-assets/data/state.json create mode 100644 examples/app/component-assets/demo.toml create mode 100644 examples/app/component-assets/package.json create mode 100644 examples/app/component-assets/src/app-shell/app-shell.css create mode 100644 examples/app/component-assets/src/app-shell/app-shell.html create mode 100644 examples/app/component-assets/src/app-shell/app-shell.ts create mode 100644 examples/app/component-assets/src/asset-badge/asset-badge.css create mode 100644 examples/app/component-assets/src/asset-badge/asset-badge.html create mode 100644 examples/app/component-assets/src/asset-badge/asset-badge.ts create mode 100644 examples/app/component-assets/src/index.html create mode 100644 examples/app/component-assets/src/index.ts create mode 100644 examples/app/component-assets/src/lazy-panel-data.json create mode 100644 examples/app/component-assets/src/lazy-panel/lazy-panel.css create mode 100644 examples/app/component-assets/src/lazy-panel/lazy-panel.html create mode 100644 examples/app/component-assets/src/lazy-panel/lazy-panel.ts create mode 100644 examples/app/component-assets/tsconfig.json create mode 100644 packages/webui-framework/src/component-asset.test.ts create mode 100644 packages/webui-framework/src/component-asset.ts diff --git a/Cargo.lock b/Cargo.lock index 381f94d7..6233ac3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1828,6 +1828,7 @@ dependencies = [ "microsoft-webui-protocol", "microsoft-webui-tokens", "mime_guess", + "rayon", "serde_json", "tempfile", "tokio", diff --git a/DESIGN.md b/DESIGN.md index c223eee5..f4c7a0dd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -339,6 +339,59 @@ emit WebUI `templates` or `templateFns`. **Cache control:** The server can include `cacheControl: { staleTime: number }` in the partial response to override the client's default stale time for this specific route. +**Static component assets:** `webui build --emit-component-assets mail-thread,compose-page` +emits CDN-loadable component asset files next to `protocol.bin`. The flag is a +strict comma-separated allowlist of root component tags; every tag must be a +discovered lowercase kebab-case component with WebUI template metadata. Static +component asset runtimes are framework-owned: the WebUI Framework loader lives at +`@microsoft/webui-framework/component-asset.js`; a FAST runtime should define its +own asset loader rather than making the core `@microsoft/webui` package know +plugin details. Asset roots are parsed into the protocol through synthetic +non-entry fragments, so they do not become reachable from the SSR entry tree and +are not included in the initial SSR bootstrap unless the entry graph also +references them. Asset generation is parallelized across requested roots. Each +root produces +`.webui.json` and, only when compiled conditions exist, +`.webui-fns.js`. The JSON file contains: + +```json +{ + "type": "webui-component-asset", + "version": 1, + "components": ["mail-thread", "mail-message"], + "templateStyles": [], + "templates": {}, + "templateFunctionModule": "mail-thread.webui-fns.js" +} +``` + +The component list is the conservative dependency closure for the requested root: +component edges, ``, ``, attribute-template edges, and all nested +`` branches are followed without evaluating runtime state. The JSON file +is inert data and intentionally omits `inventory`: a build-time static asset does +not know the page's current loaded bitset, so consumers must not replace +`window.__webui.inventory` with asset-local state. The optional function module +registers component-local condition closure arrays into +`window.__webui.templateFns` as normal ES module code, avoiding large executable +template payloads and avoiding nonce-bound inline function scripts. CSS module +importmaps still use the page's current CSP nonce when materialized by the +optional `@microsoft/webui-framework/component-asset.js` `defineComponentAssets()` +manifest loader. This loader is not re-exported from the framework root package +entrypoint, keeping it out of normal framework bundles unless an app imports the +optional subpath. The loader first derives the root component name from +`.webui.json` and checks `window.__webui.templates` via `getTemplate(tag)`; +when the template is already registered, it skips the fetch entirely. Otherwise +it deduplicates in-flight fetches by resolved asset URL and deduplicates +module-style importmaps against `window.__webui.styles` plus previously injected +asset styles. `defineComponentAssets().create(tag)` waits for the asset/module, +mounts without blocking on data by default, and applies data later with +`setState()`; callers can opt into bounded data blocking with +`{ awaitData: true, dataTimeoutMs }`. + +FAST plugin builds can emit the same asset envelope with `plugin: "fast-v3"` and +trusted `` payloads in `templates`; those assets require a FAST-owned +runtime loader. + **Navigation cache:** The client router maintains a tagged navigation cache. Partial responses are stored keyed by request path and tagged with `cacheTags`. On revisit within `staleTime`, the cache is used and the network fetch is skipped. After a mutation action, `Router.invalidateTags()` evicts all entries whose tags overlap with the invalidated tags. Configuration: `Router.start({ cache: { staleTime, gcTime, maxEntries } })`. **Mutation actions:** Components can declare `static action(ctx: RouteActionContext)` as the write counterpart to `static loader()`. The router intercepts `
` submissions, finds the nearest route component's `static action()`, calls it, and auto-invalidates the cache using both the action's returned tags and the route's build-time `invalidates` attribute. This ensures the compiler-declared invalidation graph is always respected — developers cannot forget. @@ -1049,6 +1102,7 @@ parser.parse("index.html", &html)?; ```bash webui build ./templates --out ./dist --plugin= webui build ./templates --out ./dist --css-file-name-template="[name]-[hash].[ext]" --css-public-base="https://cdn.example.com/assets" +webui build ./templates --out ./dist --plugin=webui --emit-component-assets mail-thread,compose-page webui serve ./templates --state ./data/state.json --plugin= ``` diff --git a/crates/webui-cli/Cargo.toml b/crates/webui-cli/Cargo.toml index b36960a9..7dccaf1c 100644 --- a/crates/webui-cli/Cargo.toml +++ b/crates/webui-cli/Cargo.toml @@ -34,6 +34,7 @@ bytes = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true } log = { workspace = true } +rayon = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/webui-cli/src/commands/build.rs b/crates/webui-cli/src/commands/build.rs index 00857b78..d135087c 100644 --- a/crates/webui-cli/src/commands/build.rs +++ b/crates/webui-cli/src/commands/build.rs @@ -9,6 +9,7 @@ use std::fs; use std::path::{Path, PathBuf}; use super::common::*; +use super::component_assets; use crate::utils::error::CliError; use crate::utils::output; @@ -22,6 +23,10 @@ pub struct BuildArgs { /// is written with that filename and CSS files are emitted next to it. #[arg(long)] pub out: PathBuf, + + /// Comma-separated root component tags to emit as static CDN-loadable assets + #[arg(long, value_delimiter = ',', value_name = "TAGS")] + pub emit_component_assets: Vec, } /// Resolve the `--out` argument into `(output_directory, protocol_filename)`. @@ -94,9 +99,13 @@ fn run(args: &BuildArgs) -> Result<()> { if !args.app_args.components.is_empty() { output::field("Components", &args.app_args.components.join(", ")); } + if !args.emit_component_assets.is_empty() { + output::field("Component assets", &args.emit_component_assets.join(", ")); + } eprintln!(); - let build_options = args.app_args.to_build_options(&app); + let mut build_options = args.app_args.to_build_options(&app); + build_options.component_asset_roots = args.emit_component_assets.clone(); let result = webui::build(build_options).with_context(|| "Build failed")?; fs::create_dir_all(&out_dir) @@ -107,6 +116,11 @@ fn run(args: &BuildArgs) -> Result<()> { fs::write(out_dir.join(name), content) .with_context(|| format!("Failed to write {name} to {}", out_dir.display()))?; } + let component_asset_stats = component_assets::emit_component_assets( + &result.protocol, + &args.emit_component_assets, + &out_dir, + )?; let stats = result.stats; output::success(&format!( @@ -130,7 +144,19 @@ fn run(args: &BuildArgs) -> Result<()> { )); } - let files_written = 1 + stats.css_file_count; + if component_asset_stats.root_count > 0 { + output::success(&format!( + "Emitted {} component asset{}", + console::style(component_asset_stats.root_count).bold(), + if component_asset_stats.root_count == 1 { + "" + } else { + "s" + } + )); + } + + let files_written = 1 + stats.css_file_count + component_asset_stats.file_count; output::success(&format!( "Wrote {}", console::style(Path::new(&protocol_name).display()).bold() @@ -162,6 +188,7 @@ pub fn build(app: &std::path::Path, out: &std::path::Path, entry: &str) -> Resul legal_comments: LegalComments::Inline, }, out: out.to_path_buf(), + emit_component_assets: Vec::new(), }) } @@ -268,6 +295,7 @@ mod tests { legal_comments: LegalComments::Inline, }, out: out_dir.path().to_path_buf(), + emit_component_assets: Vec::new(), }) .unwrap(); @@ -276,6 +304,114 @@ mod tests { assert!(!out_dir.path().join("my-card.css").exists()); } + #[test] + fn test_build_emits_static_component_assets() { + let app_dir = create_app_dir(&[ + ("index.html", ""), + ("app-shell.html", r#"
"#), + ( + "mail-thread.html", + r#""#, + ), + ("mail-message.html", "

{{title}}

"), + ]); + let out_dir = TempDir::new().unwrap(); + + run(&BuildArgs { + app_args: AppArgs { + app: app_dir.path().to_path_buf(), + entry: "index.html".to_string(), + css: CssStrategy::Link, + dom: DomStrategy::Shadow, + plugin: Some(Plugin::WebUI), + components: Vec::new(), + css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(), + css_public_base: None, + legal_comments: LegalComments::Inline, + }, + out: out_dir.path().to_path_buf(), + emit_component_assets: vec!["mail-thread".to_string()], + }) + .unwrap(); + + let asset_path = out_dir.path().join("mail-thread.webui.json"); + let function_path = out_dir.path().join("mail-thread.webui-fns.js"); + assert!(asset_path.exists()); + assert!(function_path.exists()); + + let bytes = fs::read(out_dir.path().join("protocol.bin")).unwrap(); + let protocol = WebUIProtocol::from_protobuf(&bytes).unwrap(); + let index_fragments = &protocol.fragments["index.html"].fragments; + assert!( + !index_fragments.iter().any(|fragment| matches!( + fragment.fragment.as_ref(), + Some(Fragment::Component(component)) if component.fragment_id == "mail-thread" + )), + "mail-thread must not be reachable from the SSR entry fragment" + ); + + let asset: serde_json::Value = + serde_json::from_str(&fs::read_to_string(asset_path).unwrap()).unwrap(); + assert_eq!(asset["type"], "webui-component-asset"); + assert_eq!(asset["version"], 1); + assert!(asset.get("plugin").is_none()); + assert_eq!(asset["templateFunctionModule"], "mail-thread.webui-fns.js"); + assert!(asset.get("templateFunctions").is_none()); + assert!(asset.get("inventory").is_none()); + let components = asset["components"].as_array().unwrap(); + assert!(components.contains(&serde_json::Value::String("mail-thread".to_string()))); + assert!(components.contains(&serde_json::Value::String("mail-message".to_string()))); + let templates = asset["templates"].as_object().unwrap(); + assert!(templates.contains_key("mail-thread")); + assert!(templates.contains_key("mail-message")); + + let functions = fs::read_to_string(function_path).unwrap(); + assert!(functions.contains(r#"f["mail-thread"]="#)); + } + + #[test] + fn test_build_emits_fast_component_assets() { + let app_dir = create_app_dir(&[ + ("index.html", ""), + ("app-shell.html", "
"), + ("fast-card.html", "

{{title}}

"), + ]); + let out_dir = TempDir::new().unwrap(); + + run(&BuildArgs { + app_args: AppArgs { + app: app_dir.path().to_path_buf(), + entry: "index.html".to_string(), + css: CssStrategy::Link, + dom: DomStrategy::Shadow, + plugin: Some(Plugin::FastV3), + components: Vec::new(), + css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(), + css_public_base: None, + legal_comments: LegalComments::Inline, + }, + out: out_dir.path().to_path_buf(), + emit_component_assets: vec!["fast-card".to_string()], + }) + .unwrap(); + + let asset_path = out_dir.path().join("fast-card.webui.json"); + assert!(asset_path.exists()); + assert!(!out_dir.path().join("fast-card.webui-fns.js").exists()); + + let asset: serde_json::Value = + serde_json::from_str(&fs::read_to_string(asset_path).unwrap()).unwrap(); + assert_eq!(asset["type"], "webui-component-asset"); + assert_eq!(asset["version"], 1); + assert!(asset.get("plugin").is_none()); + assert!(asset.get("templateFunctionModule").is_none()); + let templates = asset["templates"].as_object().unwrap(); + assert!(templates["fast-card"] + .as_str() + .unwrap() + .contains("Not index")]); @@ -387,6 +523,7 @@ mod tests { legal_comments: LegalComments::Inline, }, out: out_dir.path().to_path_buf(), + emit_component_assets: Vec::new(), }) .unwrap(); @@ -470,6 +607,7 @@ mod tests { legal_comments: LegalComments::Inline, }, out: out_dir.path().to_path_buf(), + emit_component_assets: Vec::new(), }) .unwrap(); @@ -550,6 +688,7 @@ mod tests { legal_comments: LegalComments::Inline, }, out: out_dir.path().to_path_buf(), + emit_component_assets: Vec::new(), }) .unwrap(); @@ -599,6 +738,7 @@ mod tests { legal_comments: LegalComments::Inline, }, out: custom_path.clone(), + emit_component_assets: Vec::new(), }) .unwrap(); @@ -634,6 +774,7 @@ mod tests { legal_comments: LegalComments::Inline, }, out: nested.clone(), + emit_component_assets: Vec::new(), }) .unwrap(); diff --git a/crates/webui-cli/src/commands/common.rs b/crates/webui-cli/src/commands/common.rs index 7e9d85bc..bfd667c1 100644 --- a/crates/webui-cli/src/commands/common.rs +++ b/crates/webui-cli/src/commands/common.rs @@ -59,6 +59,7 @@ impl AppArgs { dom: self.dom, plugin: self.plugin, components: self.components.clone(), + component_asset_roots: Vec::new(), css_file_name_template: self.css_file_name_template.clone(), css_public_base: self.css_public_base.clone(), legal_comments: self.legal_comments, @@ -90,6 +91,7 @@ mod tests { options.css_public_base.as_deref(), Some("https://cdn.example.com/assets") ); + assert!(options.component_asset_roots.is_empty()); assert_eq!(options.legal_comments, LegalComments::None); } } diff --git a/crates/webui-cli/src/commands/component_assets.rs b/crates/webui-cli/src/commands/component_assets.rs new file mode 100644 index 00000000..fbbaa70e --- /dev/null +++ b/crates/webui-cli/src/commands/component_assets.rs @@ -0,0 +1,426 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use anyhow::{bail, Context, Result}; +use rayon::prelude::*; +use serde_json::{Map, Value}; +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use webui_handler::route_handler::{render_component_templates, ProtocolIndex}; +use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol}; + +const ASSET_TYPE: &str = "webui-component-asset"; +const ASSET_VERSION: u64 = 1; + +/// Summary of emitted component asset files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ComponentAssetStats { + /// Number of requested root component assets emitted. + pub root_count: usize, + /// Number of physical files written. + pub file_count: usize, +} + +struct AssetFile { + name: String, + content: String, +} + +struct ComponentAssetPlan { + root: String, + components: Vec, +} + +/// Emit static CDN-loadable component assets for the requested root components. +pub fn emit_component_assets( + protocol: &WebUIProtocol, + roots: &[String], + out_dir: &Path, +) -> Result { + let plans = plan_component_assets(protocol, roots)?; + if plans.is_empty() { + return Ok(ComponentAssetStats { + root_count: 0, + file_count: 0, + }); + } + + let files_by_root: Vec>> = plans + .par_iter() + .map(|plan| render_asset_files(protocol, plan)) + .collect(); + + let mut files = Vec::with_capacity(plans.len() * 2); + for root_files in files_by_root { + files.extend(root_files?); + } + + files.par_iter().try_for_each(|file| { + let path = out_dir.join(&file.name); + fs::write(&path, &file.content) + .with_context(|| format!("Failed to write component asset {}", path.display())) + })?; + + Ok(ComponentAssetStats { + root_count: plans.len(), + file_count: files.len(), + }) +} + +fn plan_component_assets( + protocol: &WebUIProtocol, + roots: &[String], +) -> Result> { + let roots = validate_roots(protocol, roots)?; + let mut plans = Vec::with_capacity(roots.len()); + for root in roots { + plans.push(ComponentAssetPlan { + components: collect_component_asset_closure(protocol, &root), + root, + }); + } + Ok(plans) +} + +fn render_asset_files( + protocol: &WebUIProtocol, + plan: &ComponentAssetPlan, +) -> Result> { + let tag_refs: Vec<&str> = plan.components.iter().map(String::as_str).collect(); + let mut index = ProtocolIndex::new(protocol); + let payload = render_component_templates(protocol, &tag_refs, "", &mut index) + .with_context(|| format!("Failed to render component asset for <{}>", plan.root))?; + let mut object = into_object(payload, &plan.root)?; + let functions = object + .remove("templateFunctions") + .unwrap_or_else(|| Value::Object(Map::new())); + let templates = remove_object(&mut object, "templates", &plan.root)?; + let template_styles = remove_array(&mut object, "templateStyles", &plan.root)?; + + let function_module = build_function_module_file(&plan.root, &functions)?; + let mut asset = Map::with_capacity(7); + asset.insert("type".into(), Value::String(ASSET_TYPE.to_string())); + asset.insert("version".into(), Value::from(ASSET_VERSION)); + asset.insert( + "components".into(), + Value::Array( + plan.components + .iter() + .map(|tag| Value::String(tag.clone())) + .collect(), + ), + ); + asset.insert("templateStyles".into(), Value::Array(template_styles)); + asset.insert("templates".into(), Value::Object(templates)); + + let mut files = Vec::with_capacity(if function_module.is_some() { 2 } else { 1 }); + if let Some(function_file) = function_module { + asset.insert( + "templateFunctionModule".into(), + Value::String(function_file.name.clone()), + ); + files.push(function_file); + } + + let json = serde_json::to_string(&Value::Object(asset)) + .with_context(|| format!("Failed to serialize component asset for <{}>", plan.root))?; + files.push(AssetFile { + name: asset_json_file_name(&plan.root), + content: append_newline(json), + }); + Ok(files) +} + +fn into_object(payload: Value, root: &str) -> Result> { + match payload { + Value::Object(object) => Ok(object), + _ => bail!("component asset for <{root}> was not a JSON object"), + } +} + +fn remove_object( + object: &mut Map, + field: &str, + root: &str, +) -> Result> { + match object.remove(field) { + Some(Value::Object(value)) => Ok(value), + Some(_) => bail!("component asset field '{field}' for <{root}> was not an object"), + None => Ok(Map::new()), + } +} + +fn remove_array(object: &mut Map, field: &str, root: &str) -> Result> { + match object.remove(field) { + Some(Value::Array(value)) => Ok(value), + Some(_) => bail!("component asset field '{field}' for <{root}> was not an array"), + None => Ok(Vec::new()), + } +} + +fn build_function_module_file(root: &str, functions: &Value) -> Result> { + let Value::Object(functions) = functions else { + bail!("component asset field 'templateFunctions' for <{root}> was not an object"); + }; + if functions.is_empty() { + return Ok(None); + } + + let mut tags: Vec<&str> = functions.keys().map(String::as_str).collect(); + tags.sort_unstable(); + + let mut js = String::with_capacity(128); + js.push_str("const f=window.__webui.templateFns||(window.__webui.templateFns={});\n"); + for tag in tags { + let Some(function_array) = functions.get(tag).and_then(Value::as_str) else { + bail!("templateFunctions entry for <{tag}> in <{root}> asset was not a string"); + }; + js.push_str("f["); + js.push_str( + &serde_json::to_string(tag) + .with_context(|| format!("Failed to encode template function tag <{tag}>"))?, + ); + js.push_str("]="); + js.push_str(function_array); + js.push_str(";\n"); + } + js.push_str("export {};\n"); + + Ok(Some(AssetFile { + name: asset_function_file_name(root), + content: js, + })) +} + +fn validate_roots(protocol: &WebUIProtocol, roots: &[String]) -> Result> { + let mut seen = HashSet::with_capacity(roots.len()); + let mut normalized = Vec::with_capacity(roots.len()); + for raw in roots { + let tag = raw.trim(); + if tag.is_empty() { + bail!("--emit-component-assets contains an empty component tag"); + } + if !is_component_tag_name(tag) { + bail!( + "--emit-component-assets component '{tag}' must be a lowercase kebab-case custom element tag" + ); + } + if !seen.insert(tag.to_string()) { + bail!("--emit-component-assets contains duplicate component <{tag}>"); + } + if !protocol.fragments.contains_key(tag) { + bail!( + "--emit-component-assets requested unknown component <{tag}>. Add a discovered {tag}.html component or remove it from the allowlist." + ); + } + if !protocol + .components + .get(tag) + .is_some_and(has_template_payload) + { + bail!( + "--emit-component-assets requested <{tag}>, but it has no compiled template metadata. Build with a plugin that emits component templates and ensure the component has a template." + ); + } + normalized.push(tag.to_string()); + } + Ok(normalized) +} + +fn is_component_tag_name(tag: &str) -> bool { + let bytes = tag.as_bytes(); + !bytes.is_empty() + && bytes.contains(&b'-') + && bytes[0].is_ascii_lowercase() + && bytes[bytes.len() - 1].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') +} + +fn has_template_payload(component: &webui_protocol::ComponentData) -> bool { + !component.template_json.is_empty() || !component.template.is_empty() +} + +fn collect_component_asset_closure(protocol: &WebUIProtocol, root: &str) -> Vec { + let mut visited_fragments = HashSet::new(); + let mut components = HashSet::new(); + let mut stack = vec![root.to_string()]; + + while let Some(fragment_id) = stack.pop() { + if fragment_id.is_empty() || !visited_fragments.insert(fragment_id.clone()) { + continue; + } + + if protocol + .components + .get(&fragment_id) + .is_some_and(has_template_payload) + { + components.insert(fragment_id.clone()); + } + + let Some(fragment_list) = protocol.fragments.get(&fragment_id) else { + continue; + }; + + for fragment in &fragment_list.fragments { + match fragment.fragment.as_ref() { + Some(Fragment::Component(component)) => { + stack.push(component.fragment_id.clone()); + } + Some(Fragment::ForLoop(for_loop)) => { + stack.push(for_loop.fragment_id.clone()); + } + Some(Fragment::IfCond(if_cond)) => { + stack.push(if_cond.fragment_id.clone()); + } + Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => { + stack.push(attr.template.clone()); + } + Some(Fragment::Route(route)) => { + push_route_component_ids(route, &mut stack); + } + _ => {} + } + } + } + + let mut ordered: Vec = components.into_iter().collect(); + ordered.sort_unstable(); + ordered +} + +fn push_route_component_ids(route: &WebUIFragmentRoute, stack: &mut Vec) { + let mut routes = vec![route]; + while let Some(current) = routes.pop() { + if !current.fragment_id.is_empty() { + stack.push(current.fragment_id.clone()); + } + if !current.pending_component.is_empty() { + stack.push(current.pending_component.clone()); + } + if !current.error_component.is_empty() { + stack.push(current.error_component.clone()); + } + routes.extend(current.children.iter()); + } +} + +fn asset_json_file_name(root: &str) -> String { + let mut name = String::with_capacity(root.len() + ".webui.json".len()); + name.push_str(root); + name.push_str(".webui.json"); + name +} + +fn asset_function_file_name(root: &str) -> String { + let mut name = String::with_capacity(root.len() + ".webui-fns.js".len()); + name.push_str(root); + name.push_str(".webui-fns.js"); + name +} + +fn append_newline(mut value: String) -> String { + value.push('\n'); + value +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] +mod tests { + use super::*; + use webui_protocol::{FragmentList, WebUIFragment}; + + fn protocol_with_component(tag: &str) -> WebUIProtocol { + let mut fragments = std::collections::HashMap::new(); + fragments.insert( + tag.to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("

")], + }, + ); + let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new()); + protocol + .components + .entry(tag.to_string()) + .or_default() + .template_json = r#"{"h":"

"}"#.to_string(); + protocol + } + + #[test] + fn validates_lowercase_kebab_component_tags() { + assert!(is_component_tag_name("mail-thread")); + assert!(is_component_tag_name("mail-thread2")); + assert!(!is_component_tag_name("mail")); + assert!(!is_component_tag_name("Mail-thread")); + assert!(!is_component_tag_name("mail_thread")); + assert!(!is_component_tag_name("mail-thread-")); + } + + #[test] + fn validate_roots_rejects_duplicate_tags() { + let protocol = protocol_with_component("mail-thread"); + let err = validate_roots( + &protocol, + &["mail-thread".to_string(), "mail-thread".to_string()], + ) + .unwrap_err() + .to_string(); + assert!(err.contains("duplicate")); + } + + #[test] + fn closure_follows_components_and_all_route_branches() { + let mut fragments = std::collections::HashMap::new(); + fragments.insert( + "app-shell".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::component("mail-list"), + WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute { + path: "compose".to_string(), + fragment_id: "compose-page".to_string(), + exact: true, + children: vec![webui_protocol::WebUiFragmentRoute { + path: "preview".to_string(), + fragment_id: "compose-preview".to_string(), + exact: true, + ..Default::default() + }], + ..Default::default() + }), + ], + }, + ); + for tag in ["mail-list", "compose-page", "compose-preview"] { + fragments.insert( + tag.to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("

")], + }, + ); + } + let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new()); + for tag in ["app-shell", "mail-list", "compose-page", "compose-preview"] { + protocol + .components + .entry(tag.to_string()) + .or_default() + .template_json = r#"{"h":"

"}"#.to_string(); + } + + let closure = collect_component_asset_closure(&protocol, "app-shell"); + assert_eq!( + closure, + vec![ + "app-shell".to_string(), + "compose-page".to_string(), + "compose-preview".to_string(), + "mail-list".to_string(), + ] + ); + } +} diff --git a/crates/webui-cli/src/commands/mod.rs b/crates/webui-cli/src/commands/mod.rs index b90f444a..89c6b49a 100644 --- a/crates/webui-cli/src/commands/mod.rs +++ b/crates/webui-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod build; pub mod common; +pub mod component_assets; pub mod inspect; pub mod serve; diff --git a/crates/webui-node/src/lib.rs b/crates/webui-node/src/lib.rs index 9d1bd261..e56232ae 100644 --- a/crates/webui-node/src/lib.rs +++ b/crates/webui-node/src/lib.rs @@ -129,6 +129,7 @@ pub fn build(options: JsBuildOptions) -> napi::Result { dom, plugin, components: options.components.unwrap_or_default(), + component_asset_roots: Vec::new(), css_file_name_template: options .css_file_name_template .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()), diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs index 58187793..47e59080 100644 --- a/crates/webui/src/lib.rs +++ b/crates/webui/src/lib.rs @@ -72,6 +72,12 @@ pub struct BuildOptions { pub plugin: Option, /// Additional component sources (npm packages or local paths). pub components: Vec, + /// Additional root components to compile for static component asset emission. + /// + /// These roots are parsed into the protocol so their templates, styles, and + /// dependency closures can be emitted later, but they are not connected to + /// the entry fragment and therefore are not rendered during initial SSR. + pub component_asset_roots: Vec, /// Link-mode CSS filename template using `[name]`, `[hash]`, and `[ext]`. /// Ignored unless `css == CssStrategy::Link`. pub css_file_name_template: String, @@ -91,6 +97,7 @@ impl Default for BuildOptions { dom: DomStrategy::Shadow, plugin: None, components: Vec::new(), + component_asset_roots: Vec::new(), css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(), css_public_base: None, legal_comments: LegalComments::default(), @@ -302,6 +309,8 @@ fn build_protocol_inner(options: &BuildOptions) -> Result = parser .component_registry() .get_all() @@ -397,6 +406,34 @@ fn build_protocol_inner(options: &BuildOptions) -> Result Result<(), WebUIError> { + for (index, root) in roots.iter().enumerate() { + if parser.has_fragment(root) { + continue; + } + // Compile the requested lazy root without connecting it to the entry + // graph, so it can be emitted as a static asset but stays out of SSR. + let mut synthetic = String::with_capacity(root.len() * 2 + 5); + synthetic.push('<'); + synthetic.push_str(root); + synthetic.push_str(">'); + + let fragment_id = format!("__webui_asset_root_{index}"); + parser + .parse(&fragment_id, &synthetic) + .map_err(|source| WebUIError::Parse { + context: format!("Failed to parse component asset root <{root}>"), + source, + })?; + } + Ok(()) +} + #[cfg(test)] #[allow(clippy::disallowed_methods)] mod tests { diff --git a/docs/ai.md b/docs/ai.md index 4d0b1da2..4f35cae1 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -335,6 +335,7 @@ MyComponent.define('my-component'); | `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 | ### Emitting custom events @@ -397,6 +398,50 @@ Router.start({ }); ``` +Without `@microsoft/webui-router`, prebuild static assets and load them from a +CDN or the app's static folder: + +```bash +webui build ./src --out ./dist --plugin=webui \ + --emit-component-assets settings-dialog +``` + +```typescript +import { settingsAssets } from './lazy-assets.js'; + +async onOpenSettings(): Promise { + settingsAssets.preload('settings-dialog'); + this.panelSlot.replaceChildren(await settingsAssets.create('settings-dialog')); +} +``` + +```typescript +// lazy-assets.ts +import { defineComponentAssets } from '@microsoft/webui-framework/component-asset.js'; + +export const settingsAssets = defineComponentAssets({ + 'settings-dialog': { + asset: '/settings-dialog.webui.json', + module: () => import('./settings-dialog/settings-dialog.js'), + data: async () => await (await fetch('/settings-dialog-data.json')).json(), + }, +}); +``` + +`defineComponentAssets()` uses the current page nonce from `window.__webui.nonce` +or `` when it needs to append CSS module importmaps. +The optional `.webui-fns.js` module registers compiled condition functions as an +ES module, so large template metadata stays as JSON data. If the root template +from `.webui.json` is already in `window.__webui.templates`, the loader +skips fetching. Concurrent calls for the same URL share one in-flight request, +and CSS module styles are deduped against `window.__webui.styles`. +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 +`create(tag, { awaitData: true, dataTimeoutMs: 150 })` only when a component +must wait briefly for state before mounting. + ## Component CSS CSS is scoped per component via Shadow DOM. No CSS-in-JS. @@ -521,6 +566,7 @@ webui build ./src --out ./dist --plugin=webui | `--dom ` | `shadow` | `shadow` or `light` | | `--plugin ` | none | Plugin identifier (e.g. `webui`) | | `--components ` | none | Extra component sources (repeatable) | +| `--emit-component-assets ` | none | Comma-separated root component tags emitted as static `.webui.json` assets in `--out` | | `--css-file-name-template