Skip to content
Merged
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
100 changes: 87 additions & 13 deletions crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<HashSet<_>>();
// Kept as the traversal-ordered `Vec` produced upstream (already
// deduplicated) rather than collected into a `HashSet`: downstream
// `<head>` CSS `<link>`/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));

Expand Down Expand Up @@ -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<String>`), never a `HashSet<String>` — whose
// iteration order depends on the process's randomized hash seed and can
// silently reorder `<link>` 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("<html><head>".to_string()),
structural_fragment("head_end"),
WebUIFragment::raw(
"</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
.to_string(),
),
WebUIFragment::component("comp-a"),
WebUIFragment::component("comp-b"),
WebUIFragment::component("comp-c"),
structural_fragment("body_end"),
WebUIFragment::raw("</body></html>".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!("<div>{tag}</div>"))],
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":"<div>{tag}</div>"}}"#);
}
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 <link> 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();
Expand Down
17 changes: 9 additions & 8 deletions crates/webui-handler/src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String>,
/// 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.
Expand Down Expand Up @@ -145,7 +146,7 @@ pub trait HandlerPlugin: Send {
fn emit_templates(
&self,
protocol: &WebUIProtocol,
components: &HashSet<String>,
components: &[String],
_nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
Expand All @@ -161,7 +162,7 @@ pub trait HandlerPlugin: Send {
fn collect_template_payloads<'a>(
&self,
_protocol: &'a WebUIProtocol,
_components: &HashSet<String>,
_components: &[String],
) -> Option<Vec<WebUiTemplatePayload<'a>>> {
None
}
Expand All @@ -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<String>` the ordinary body-end path builds. The default forwards
/// `Vec<String>` the ordinary body-end path builds. The default forwards
/// to [`emit_component_templates_slice`] (verbatim FAST `<f-template>`
/// emission).
fn emit_templates_slice(
Expand Down Expand Up @@ -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<String>` component set — the streaming checkpoint has already
/// `Vec<String>` 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,
Expand Down Expand Up @@ -230,7 +231,7 @@ pub trait HandlerPlugin: Send {
/// Used by FAST parser plugins for `<f-template>` tags.
pub(crate) fn emit_component_templates(
protocol: &WebUIProtocol,
components: &HashSet<String>,
components: &[String],
writer: &mut dyn ResponseWriter,
) -> Result<()> {
for name in components {
Expand Down
24 changes: 9 additions & 15 deletions crates/webui-handler/src/plugin/webui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

use super::{BootstrapExtensionContext, HandlerPlugin};
use crate::{ResponseWriter, Result};
use std::collections::HashSet;
use webui_protocol::WebUIProtocol;

const REPEAT_START: &str = "<!--wr-->";
Expand Down Expand Up @@ -162,7 +161,7 @@ impl HandlerPlugin for WebUIHydrationPlugin {
fn emit_templates(
&self,
protocol: &WebUIProtocol,
components: &HashSet<String>,
components: &[String],
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
Expand All @@ -188,7 +187,7 @@ impl HandlerPlugin for WebUIHydrationPlugin {
fn collect_template_payloads<'a>(
&self,
protocol: &'a WebUIProtocol,
components: &HashSet<String>,
components: &[String],
) -> Option<Vec<super::WebUiTemplatePayload<'a>>> {
webui_collect_payloads(protocol, components.iter().map(String::as_str))
}
Expand Down Expand Up @@ -221,7 +220,7 @@ impl HandlerPlugin for WebUIHydrationPlugin {

/// Emit non-split WebUI component templates inside a single `<script>` tag.
///
/// Shared by the `HashSet`-based ordinary path and the `&[&str]`-based streaming
/// Shared by the ordinary slice-based path and the `&[&str]`-based streaming
/// path; the lookup-key lifetime `'b` is independent of the protocol so both
/// callers pass borrowed tags without cloning.
fn webui_emit_templates<'b>(
Expand Down Expand Up @@ -265,7 +264,7 @@ fn webui_emit_templates<'b>(
/// Collect split WebUI template payloads for the given component tags.
///
/// Returned payloads borrow the protocol (`'a`); the lookup-key lifetime `'b`
/// is independent so both the `HashSet` and slice callers share this helper.
/// is independent so both the ordinary slice and streaming callers share this helper.
fn webui_collect_payloads<'a, 'b>(
protocol: &'a WebUIProtocol,
tags: impl Iterator<Item = &'b str>,
Expand Down Expand Up @@ -551,8 +550,7 @@ mod tests {
.or_default()
.template = iife_template("comp-c", "h:\"c\"");

let mut components = std::collections::HashSet::new();
components.insert("comp-a".to_string());
let components = vec!["comp-a".to_string()];

let plugin = WebUIHydrationPlugin::new();
plugin
Expand Down Expand Up @@ -585,7 +583,7 @@ mod tests {
.entry("comp-a".to_string())
.or_default()
.template = iife_template("comp-a", "h:\"a\"");
let components = std::collections::HashSet::new();
let components: Vec<String> = Vec::new();
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &components, None, &mut writer)
Expand All @@ -609,9 +607,7 @@ mod tests {
.or_default()
.template = iife_template("comp-b", "h:\"b\"");

let mut rendered = std::collections::HashSet::new();
rendered.insert("comp-a".to_string());
rendered.insert("comp-b".to_string());
let rendered = vec!["comp-a".to_string(), "comp-b".to_string()];

let plugin = WebUIHydrationPlugin::new();
plugin
Expand All @@ -637,8 +633,7 @@ mod tests {
.entry("comp-a".to_string())
.or_default()
.template = String::new();
let mut rendered = std::collections::HashSet::new();
rendered.insert("comp-a".to_string());
let rendered = vec!["comp-a".to_string()];
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
Expand All @@ -650,8 +645,7 @@ mod tests {
fn test_on_render_complete_unknown_component() {
let mut writer = TestWriter::new();
let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
let mut rendered = std::collections::HashSet::new();
rendered.insert("nonexistent-comp".to_string());
let rendered = vec!["nonexistent-comp".to_string()];
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
Expand Down
Loading