diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index 7b836b4b..02dd6206 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -3428,19 +3428,19 @@ impl WebUIHandler { // round-trip. The graph walker follows conditional and loop branches // unconditionally, but only descends into the matched route chain — // components on other routes are delivered via SPA partial navigation. - let reachable = context - .reachable_components - .take() - .unwrap_or_else(|| { - crate::route_handler::collect_reachable_component_order_for_request( - context.protocol, - context.entry_id, - context.request_path, - context.route_index, - ) - }) - .into_iter() - .collect::>(); + // Kept as the traversal-ordered `Vec` produced upstream (already + // deduplicated) rather than collected into a `HashSet`: downstream + // `` CSS ``/style-module emission order must stay + // deterministic across renders, not vary with the process's + // randomized hash seed. + let reachable = context.reachable_components.take().unwrap_or_else(|| { + crate::route_handler::collect_reachable_component_order_for_request( + context.protocol, + context.entry_id, + context.request_path, + context.route_index, + ) + }); let state_selection = collect_hydration_state(context.protocol, reachable.iter().map(String::as_str)); @@ -10117,6 +10117,80 @@ mod tests { ); } + #[test] + fn link_strategy_emits_stylesheets_in_deterministic_document_order() { + // Regression: `css_hrefs`/`style_specs` must follow the traversal-ordered + // `reachable` list (a `Vec`), never a `HashSet` — whose + // iteration order depends on the process's randomized hash seed and can + // silently reorder `` tags between renders/process restarts, which + // in turn can flip cascade-order-sensitive CSS (e.g. same-specificity + // `order`/`position` rules) and move on-page elements around. + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("".to_string()), + structural_fragment("head_end"), + WebUIFragment::raw( + "" + .to_string(), + ), + WebUIFragment::component("comp-a"), + WebUIFragment::component("comp-b"), + WebUIFragment::component("comp-c"), + structural_fragment("body_end"), + WebUIFragment::raw("".to_string()), + ], + contains_boundary: false, + }, + ); + for tag in ["comp-a", "comp-b", "comp-c"] { + fragments.insert( + tag.to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw(format!("
{tag}
"))], + contains_boundary: false, + }, + ); + } + + let mut protocol = WebUIProtocol::new(fragments); + protocol.set_css_strategy(webui_protocol::CssStrategy::Link); + for tag in ["comp-a", "comp-b", "comp-c"] { + let comp = protocol.components.entry(tag.to_string()).or_default(); + comp.css_href = format!("{tag}.css"); + comp.template_json = format!(r#"{{"h":"
{tag}
"}}"#); + } + protocol.populate_style_closures(&["index.html"]); + + let state = test_json!({}); + let mut writer = TestWriter::new(); + handle( + &protocol, + &state, + &RenderOptions::new("index.html", "/"), + &mut writer, + ) + .unwrap(); + let html = writer.get_content(); + + let pos_a = html + .find(r#"href="comp-a.css""#) + .expect("comp-a.css link missing"); + let pos_b = html + .find(r#"href="comp-b.css""#) + .expect("comp-b.css link missing"); + let pos_c = html + .find(r#"href="comp-c.css""#) + .expect("comp-c.css link missing"); + assert!( + pos_a < pos_b && pos_b < pos_c, + "stylesheet tags must follow document/traversal order \ + (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}" + ); + } + #[test] fn link_strategy_preloads_static_shadow_css_in_head() { let mut fragments = HashMap::new(); diff --git a/crates/webui-handler/src/plugin/mod.rs b/crates/webui-handler/src/plugin/mod.rs index e3d73b5d..0b14a089 100644 --- a/crates/webui-handler/src/plugin/mod.rs +++ b/crates/webui-handler/src/plugin/mod.rs @@ -12,7 +12,6 @@ pub mod fast_v3; pub mod webui; use crate::{ResponseWriter, Result}; -use std::collections::HashSet; use webui_protocol::WebUIProtocol; /// Split WebUI component template payload used by SSR bootstrap emission. @@ -29,8 +28,10 @@ pub struct WebUiTemplatePayload<'a> { pub struct BootstrapExtensionContext<'a> { /// Full protocol for plugins that need additional component metadata. pub protocol: &'a WebUIProtocol, - /// Route-reachable component tags for this render. - pub components: &'a HashSet, + /// Route-reachable component tags for this render, in deterministic + /// traversal order (not a `HashSet`, whose iteration order varies with + /// the process's randomized hash seed). + pub components: &'a [String], /// Split WebUI template payloads collected for this render. pub payloads: &'a [WebUiTemplatePayload<'a>], /// CSP nonce for executable scripts, when configured. @@ -145,7 +146,7 @@ pub trait HandlerPlugin: Send { fn emit_templates( &self, protocol: &WebUIProtocol, - components: &HashSet, + components: &[String], _nonce: Option<&str>, writer: &mut dyn ResponseWriter, ) -> Result<()> { @@ -161,7 +162,7 @@ pub trait HandlerPlugin: Send { fn collect_template_payloads<'a>( &self, _protocol: &'a WebUIProtocol, - _components: &HashSet, + _components: &[String], ) -> Option>> { None } @@ -170,7 +171,7 @@ pub trait HandlerPlugin: Send { /// /// The streaming checkpoint path captures the exact component tags rendered /// since the previous checkpoint as a borrowed `&[&str]`, avoiding the owned - /// `HashSet` the ordinary body-end path builds. The default forwards + /// `Vec` the ordinary body-end path builds. The default forwards /// to [`emit_component_templates_slice`] (verbatim FAST `` /// emission). fn emit_templates_slice( @@ -200,7 +201,7 @@ pub trait HandlerPlugin: Send { /// path, given only the already-collected template payloads. /// /// Unlike [`HandlerPlugin::emit_bootstrap_extension`], this takes no - /// `HashSet` component set — the streaming checkpoint has already + /// `Vec` component set — the streaming checkpoint has already /// projected the exact per-checkpoint payloads. The default is a no-op. fn emit_bootstrap_extension_payloads( &self, @@ -230,7 +231,7 @@ pub trait HandlerPlugin: Send { /// Used by FAST parser plugins for `` tags. pub(crate) fn emit_component_templates( protocol: &WebUIProtocol, - components: &HashSet, + components: &[String], writer: &mut dyn ResponseWriter, ) -> Result<()> { for name in components { diff --git a/crates/webui-handler/src/plugin/webui.rs b/crates/webui-handler/src/plugin/webui.rs index 3915cdad..2c6d3875 100644 --- a/crates/webui-handler/src/plugin/webui.rs +++ b/crates/webui-handler/src/plugin/webui.rs @@ -12,7 +12,6 @@ use super::{BootstrapExtensionContext, HandlerPlugin}; use crate::{ResponseWriter, Result}; -use std::collections::HashSet; use webui_protocol::WebUIProtocol; const REPEAT_START: &str = ""; @@ -162,7 +161,7 @@ impl HandlerPlugin for WebUIHydrationPlugin { fn emit_templates( &self, protocol: &WebUIProtocol, - components: &HashSet, + components: &[String], nonce: Option<&str>, writer: &mut dyn ResponseWriter, ) -> Result<()> { @@ -188,7 +187,7 @@ impl HandlerPlugin for WebUIHydrationPlugin { fn collect_template_payloads<'a>( &self, protocol: &'a WebUIProtocol, - components: &HashSet, + components: &[String], ) -> Option>> { webui_collect_payloads(protocol, components.iter().map(String::as_str)) } @@ -221,7 +220,7 @@ impl HandlerPlugin for WebUIHydrationPlugin { /// Emit non-split WebUI component templates inside a single `