Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1540,7 +1540,45 @@ 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.
- 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.
- 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 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 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, 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.
Expand Down
75 changes: 75 additions & 0 deletions crates/webui-parser/src/component_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ pub struct Component {

/// The class name that implements this component (if available)
pub class_name: Option<String>,

/// Whether this component has an authored client script next to its HTML.
pub has_script: bool,
}

/// Registry of web components.
Expand All @@ -57,6 +60,16 @@ pub struct ComponentRegistry {
legal_comments: LegalComments,
}

#[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()
}

impl Default for ComponentRegistry {
fn default() -> Self {
Self::new()
Expand Down Expand Up @@ -180,6 +193,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);
Expand Down Expand Up @@ -228,6 +242,11 @@ impl ComponentRegistry {
css_fallback_chains,
source_path: PathBuf::new(), // Empty path since it's not from a file
class_name: None,
// 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
Expand Down Expand Up @@ -317,6 +336,61 @@ 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_ts_sibling_script() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
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]
fn test_register_component_detects_js_sibling_script() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
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", "<p>Scripted</p>");
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]
Expand Down Expand Up @@ -373,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]
Expand Down
1 change: 1 addition & 0 deletions crates/webui-parser/src/plugin/fast_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ mod tests {
css_fallback_chains: Vec::new(),
source_path: PathBuf::from("/test"),
class_name: None,
has_script: false,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/webui-parser/src/plugin/fast_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ mod tests {
css_fallback_chains: Vec::new(),
source_path: PathBuf::from("/test"),
class_name: None,
has_script: false,
}
}

Expand Down
69 changes: 63 additions & 6 deletions crates/webui-parser/src/plugin/webui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ 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,
}

/// WebUI Framework parser plugin.
Expand Down Expand Up @@ -130,6 +133,7 @@ impl WebUIParserPlugin {
&c.template_html,
&c.root_event_source,
use_shadow,
!c.has_script,
)?;
out.push(ComponentTemplateArtifact::webui(
c.tag_name.clone(),
Expand All @@ -145,19 +149,21 @@ impl WebUIParserPlugin {
tag_name: &str,
template_html: &str,
root_event_source: &str,
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.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(),
has_script,
});
}
}
Expand Down Expand Up @@ -192,7 +198,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(())
}

Expand Down Expand Up @@ -407,17 +418,22 @@ 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<String> {
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(
tag_name: &str,
html_content: &str,
root_event_source: &str,
shadow_dom: bool,
emit_auto_element: bool,
) -> Result<CompiledTemplatePayload> {
let trimmed = html_content.trim();
let root_events = extract_root_events(tag_name, root_event_source.trim())?;
Expand All @@ -429,6 +445,7 @@ fn generate_compiled_template_with_root_source(
&meta,
adopted_stylesheet.as_deref(),
shadow_dom,
emit_auto_element,
))
}

Expand All @@ -437,6 +454,7 @@ fn emit_compiled_template_payload(
meta: &TemplateMeta,
adopted_stylesheet: Option<&str>,
shadow_dom: bool,
emit_auto_element: bool,
) -> CompiledTemplatePayload {
let mut conditions = ConditionFunctionEmitter::new(128);
let mut out = String::with_capacity(512 + html_content.len());
Expand All @@ -454,6 +472,10 @@ fn emit_compiled_template_payload(
out.push_str(",\"sd\":1");
}

if emit_auto_element {
out.push_str(",\"ae\":1");
}

// re: root events
if !meta.root_events.is_empty() {
out.push_str(",\"re\":[");
Expand Down Expand Up @@ -2681,6 +2703,7 @@ mod tests {
html_content,
html_content,
false,
false,
)
.expect("valid template compiles")
}
Expand Down Expand Up @@ -3105,6 +3128,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)
Expand All @@ -3115,6 +3139,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: "<p>hi</p>".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(
Expand Down Expand Up @@ -3158,6 +3212,7 @@ mod tests {
css_fallback_chains: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
has_script: false,
};

plugin
Expand Down Expand Up @@ -3191,6 +3246,7 @@ mod tests {
css_fallback_chains: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
has_script: false,
};

plugin
Expand Down Expand Up @@ -3220,6 +3276,7 @@ mod tests {
css_fallback_chains: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
has_script: false,
};

plugin
Expand Down
11 changes: 5 additions & 6 deletions crates/webui-press/src/bundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
};

Expand Down
Loading
Loading