diff --git a/DESIGN.md b/DESIGN.md index 3f7bf038..e5a60427 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -283,6 +283,13 @@ pub struct WebUIFragmentAttribute { } ``` +`attr_start` opens the component attribute collection window and `attr_skip` +excludes individual attributes from it. The window closes when the matching +component fragment is entered. Attributes on native elements never carry +`attr_start`, so they render directly to HTML and never enter component +attribute state — a native attribute cannot become a local variable of a +later component. + ##### Attribute Name Mapping Some HTML attributes use concatenated lowercase names that do not follow diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index ffbfd208..77d177d2 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -417,6 +417,10 @@ pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> { pub(crate) local_vars: HashMap, /// Accumulates component attribute values between attrStart and the component fragment. pub(crate) component_attrs: HashMap, + /// True only while parser-produced component opening-tag attributes are + /// being accumulated. Native element attributes render directly and never + /// enter `component_attrs`. + collecting_component_attrs: bool, /// URL path for server-side route matching. Borrowed from /// `RenderOptions<'a>::request_path` — zero-copy. pub(crate) request_path: &'protocol str, @@ -2078,6 +2082,7 @@ impl WebUIHandler { take_scope_map(&mut context.scope_pool), ); context.local_vars = saved_component_attrs; + context.collecting_component_attrs = false; if let Some(p) = &mut context.plugin { p.push_scope(); @@ -2688,16 +2693,18 @@ impl WebUIHandler { attr: &webui_protocol::WebUIFragmentAttribute, context: &mut WebUIProcessContext, ) -> Result<()> { - // Initialize component attribute accumulator on attrStart + // Initialize component attribute accumulator on attrStart. Clearing the + // pooled map keeps its bucket capacity instead of allocating a fresh one. if attr.attr_start { - context.component_attrs = HashMap::new(); + context.component_attrs.clear(); + context.collecting_component_attrs = true; } // Boolean attribute with condition tree if let Some(condition) = &attr.condition_tree { let condition_met = self.evaluate_condition(condition, context)?; - if !attr.attr_skip { + if context.collecting_component_attrs && !attr.attr_skip { let name = component_attr_name(&attr.name); context .component_attrs @@ -2717,7 +2724,7 @@ impl WebUIHandler { let escaped = crate::html_encode::encode_safe(&raw_value); write_attr(context.writer, &attr.name, &escaped)?; - if !attr.attr_skip { + if context.collecting_component_attrs && !attr.attr_skip { let name = component_attr_name(&attr.name); context .component_attrs @@ -2731,7 +2738,7 @@ impl WebUIHandler { if attr.raw_value { // Static attribute — value is the literal string write_attr(context.writer, &attr.name, &attr.value)?; - if !attr.attr_skip { + if context.collecting_component_attrs && !attr.attr_skip { let name = component_attr_name(&attr.name); context .component_attrs @@ -2740,7 +2747,7 @@ impl WebUIHandler { } else if attr.complex { // Complex attribute — resolve value, don't render to HTML, store as state if let Some(value) = self.resolve_value(&attr.value, context) { - if !attr.attr_skip { + if context.collecting_component_attrs && !attr.attr_skip { let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name); let name = component_attr_name(stripped); context.component_attrs.insert(name, value); @@ -2772,7 +2779,7 @@ impl WebUIHandler { } } - if !attr.attr_skip { + if context.collecting_component_attrs && !attr.attr_skip { let name = component_attr_name(&attr.name); context .component_attrs @@ -2860,6 +2867,7 @@ impl WebUIHandler { writer, local_vars: HashMap::new(), component_attrs: HashMap::new(), + collecting_component_attrs: false, request_path: options.request_path, route_base: Cow::Borrowed("/"), rendered_components: HashSet::new(), @@ -3232,6 +3240,56 @@ mod tests { ); } + #[test] + fn native_dynamic_attribute_does_not_leak_into_next_component() { + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::component("my-comp"), + WebUIFragment::raw(""), + ], + contains_boundary: false, + }, + ); + fragments.insert( + "my-comp".to_string(), + FragmentList { + fragments: vec![WebUIFragment::signal("title", false)], + contains_boundary: false, + }, + ); + let protocol = WebUIProtocol::new(fragments); + let state = test_json!({ + "nativeTitle": "native", + "title": "global" + }); + let mut writer = TestWriter::new(); + handle( + &protocol, + &state, + &RenderOptions::new("index.html", "/"), + &mut writer, + ) + .unwrap_or_else(|error| panic!("render failed: {error}")); + assert_eq!( + writer.get_content(), + "
global" + ); + } + #[test] fn test_handle_component() { // Create a protocol with a component diff --git a/crates/webui-handler/src/streaming/inventory.rs b/crates/webui-handler/src/streaming/inventory.rs index 8ab0a973..27f16fbe 100644 --- a/crates/webui-handler/src/streaming/inventory.rs +++ b/crates/webui-handler/src/streaming/inventory.rs @@ -410,6 +410,7 @@ mod tests { writer: &mut writer, local_vars: HashMap::new(), component_attrs: HashMap::new(), + collecting_component_attrs: false, request_path: "/account/details", route_base: std::borrow::Cow::Borrowed("/account"), rendered_components: std::collections::HashSet::new(), diff --git a/crates/webui-handler/src/streaming/session.rs b/crates/webui-handler/src/streaming/session.rs index cb80074f..b76d6597 100644 --- a/crates/webui-handler/src/streaming/session.rs +++ b/crates/webui-handler/src/streaming/session.rs @@ -539,6 +539,7 @@ impl SessionCore { writer, local_vars: std::mem::take(&mut self.local_vars), component_attrs: std::mem::take(&mut self.component_attrs), + collecting_component_attrs: false, request_path: options.request_path, route_base: self .route_base diff --git a/crates/webui-handler/src/streaming/vm.rs b/crates/webui-handler/src/streaming/vm.rs index 7f9a76db..ba95be4f 100644 --- a/crates/webui-handler/src/streaming/vm.rs +++ b/crates/webui-handler/src/streaming/vm.rs @@ -725,6 +725,7 @@ impl ContinuationVm { crate::take_scope_map(&mut context.scope_pool), ); context.local_vars = saved_component_attrs; + context.collecting_component_attrs = false; if let Some(plugin) = context.plugin.as_mut() { plugin.push_scope(); } @@ -749,6 +750,7 @@ impl ContinuationVm { let used_locals = std::mem::replace(&mut context.local_vars, frame.saved_local_vars); crate::recycle_scope_map(&mut context.scope_pool, used_locals); context.component_attrs.clear(); + context.collecting_component_attrs = false; if frame.owns_css_tree { let component = protocol .fragment_id(frame.component_slot) diff --git a/crates/webui-handler/tests/streaming_v2.rs b/crates/webui-handler/tests/streaming_v2.rs index f876fffd..92aa4636 100644 --- a/crates/webui-handler/tests/streaming_v2.rs +++ b/crates/webui-handler/tests/streaming_v2.rs @@ -102,6 +102,40 @@ fn component_local_boundary_suspends_and_emits_v2_span_contract() { assert!(tail.contains("")); } +#[test] +fn native_attribute_does_not_leak_into_next_streamed_component() { + let protocol = parsed_protocol( + &document(concat!( + r#""#, + r#"
"#, + "", + "
", + )), + &[("leaf-box", "{{title}}")], + ); + let mut session = new_session(protocol, "/"); + + let state = test_json!({ "nativeTitle": "native", "title": "global" }); + let start = session.start(&state).unwrap(); + let boundary = start.boundary.unwrap(); + let committed = session + .resume(boundary.instance_id, &state, BoundaryMode::Final) + .unwrap(); + let html = String::from_utf8(committed.bytes).unwrap(); + assert!( + html.contains(r#"
"#), + "native attribute should still render: {html}" + ); + assert!( + html.contains("global"), + "component must resolve `title` from global state, not the leaked native attribute: {html}" + ); + assert!( + !html.contains("native"), + "native attribute leaked into component state: {html}" + ); +} + #[test] fn resume_writes_only_the_committed_boundary_and_advance_writes_the_tail() { let protocol = parsed_protocol(