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
122 changes: 100 additions & 22 deletions crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@ struct LocalValueSources<'ctx, 'protocol, 'state> {
borrowed: &'ctx BorrowedScope<'protocol, 'state>,
}

/// Route level an `<outlet />` matches against.
pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;

#[derive(Default)]
struct BorrowedScope<'protocol, 'state> {
inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
Expand Down Expand Up @@ -825,6 +828,27 @@ fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) ->
}
}

/// Route level one step below `children[index]`.
///
/// A borrowed level yields a borrowed sublevel, so descending through a route
/// tree during a render never copies a `WebUiFragmentRoute`. A level a
/// streaming continuation already materialized transfers its child level.
fn descend_into<'protocol>(
children: &mut RouteChildren<'protocol>,
index: usize,
) -> RouteChildren<'protocol> {
match children {
Cow::Borrowed(routes) => match routes.get(index) {
Some(route) => Cow::Borrowed(&route.children),
None => Cow::Borrowed(&[]),
},
Cow::Owned(routes) => match routes.get_mut(index) {
Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
None => Cow::Borrowed(&[]),
},
}
}

/// Fragment ID a fragment descends into, or `None` when it renders inline.
fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
match fragment.fragment.as_ref()? {
Expand Down Expand Up @@ -884,7 +908,12 @@ pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
/// Current position in the route tree for outlet-based rendering.
/// Contains the children of the currently matched route fragment.
pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
///
/// A matched route's `children` is a recursive prost subtree, so the
/// borrowed variant avoids deep-cloning the whole remaining route tree on
/// every matched route of every request. Only a suspended streaming
/// continuation, which outlives the borrow, materializes the owned variant.
pub(crate) route_children: RouteChildren<'protocol>,
/// Entry fragment ID — used to compute the initial inventory at head_end.
/// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
pub(crate) entry_id: &'protocol str,
Expand Down Expand Up @@ -2338,7 +2367,15 @@ impl WebUIHandler {
/// Matches children from the currently active route's `children` field
/// against the request path, renders the matched child `<webui-route>`
/// elements directly at this position (no wrapper element).
fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
fn process_outlet<'protocol>(
&self,
context: &mut WebUIProcessContext<'protocol, '_, '_>,
) -> Result<()> {
// Moved out so the matched child can render with the context pointing
// at its grandchildren. The level is deliberately not put back, which
// preserves the previous behavior exactly: a second `<outlet />` at
// this level renders nothing. That is a latent bug tracked by #515, not
// a property this function needs; fixing it belongs in its own change.
let mut children = std::mem::take(&mut context.route_children);
if children.is_empty() {
return Ok(());
Expand All @@ -2364,17 +2401,11 @@ impl WebUIHandler {
}
}

// Extract grandchildren from the matched child to avoid cloning.
// We swap out the children vec so we can move it into context without
// cloning, then swap an empty vec back for the sibling rendering pass.
let grandchildren = if let Some((idx, _)) = &best {
std::mem::take(&mut children[*idx].children)
} else {
Vec::new()
};

if let Some((idx, ref rm)) = best {
let matched_child = &children[idx];
let descended = descend_into(&mut children, idx);
let Some(matched_child) = children.get(idx) else {
return Ok(());
};
let comp = &matched_child.fragment_id;

if !comp.is_empty() {
Expand All @@ -2385,9 +2416,7 @@ impl WebUIHandler {
);
std::mem::replace(&mut context.route_base, Cow::Owned(base))
});
let saved_route_children = std::mem::take(&mut context.route_children);

context.route_children = grandchildren;
context.route_children = descended;

// Emit matched <webui-route>
context.writer.write("<webui-route")?;
Expand Down Expand Up @@ -2438,7 +2467,10 @@ impl WebUIHandler {
if let Some(saved) = saved_route_base {
context.route_base = saved;
}
context.route_children = saved_route_children;
// Restores the empty level the matched child was rendered
// against, rather than the level this outlet matched. See the
// note at the top of this function and #515.
context.route_children = Cow::Borrowed(&[]);
}
}

Expand Down Expand Up @@ -2836,11 +2868,11 @@ impl WebUIHandler {
}

/// Process a route fragment — renders `<webui-route>` with matched/hidden state.
fn process_route(
fn process_route<'protocol>(
&self,
route_frag: &webui_protocol::WebUiFragmentRoute,
route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
best_route: &Option<(String, route_matcher::RouteMatch)>,
context: &mut WebUIProcessContext,
context: &mut WebUIProcessContext<'protocol, '_, '_>,
) -> Result<()> {
let is_matched = best_route
.as_ref()
Expand Down Expand Up @@ -2873,8 +2905,10 @@ impl WebUIHandler {
route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
std::mem::replace(&mut context.route_base, Cow::Owned(base))
});
let saved_route_children = std::mem::take(&mut context.route_children);
context.route_children = route_frag.children.clone();
let saved_route_children = std::mem::replace(
&mut context.route_children,
Cow::Borrowed(&route_frag.children),
);

if !route_frag.content_fragment_id.is_empty() {
self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
Expand Down Expand Up @@ -3975,7 +4009,7 @@ impl WebUIHandler {
route_base: Cow::Borrowed("/"),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
route_children: Cow::Borrowed(&[]),
entry_id: options.entry_id,
// Same defensive normalisation as `handle()`. See the
// doc-comment there for the CSP-outage rationale.
Expand Down Expand Up @@ -4147,6 +4181,50 @@ mod tests {
)
}

#[test]
fn route_descent_borrows_protocol_children() {
let routes = vec![WebUiFragmentRoute {
children: vec![WebUiFragmentRoute {
fragment_id: "child".to_string(),
..Default::default()
}],
..Default::default()
}];
let mut level = RouteChildren::Borrowed(&routes);

let descended = descend_into(&mut level, 0);

let Cow::Borrowed(children) = descended else {
panic!("protocol-owned route children should remain borrowed");
};
assert!(std::ptr::eq(children, routes[0].children.as_slice()));
}

#[test]
fn route_descent_moves_parked_children() {
let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
children: vec![WebUiFragmentRoute {
fragment_id: "child".to_string(),
..Default::default()
}],
..Default::default()
}]);

let descended = descend_into(&mut level, 0);

let Cow::Owned(children) = descended else {
panic!("parked route children must not borrow session-owned storage");
};
assert_eq!(children[0].fragment_id, "child");
let Cow::Owned(source) = level else {
panic!("the parked source level must stay owned");
};
assert!(
source[0].children.is_empty(),
"owned route children must move out instead of being cloned"
);
}

#[test]
fn borrowed_collection_reuses_state_array() {
let state = test_json!({
Expand Down
2 changes: 1 addition & 1 deletion crates/webui-handler/src/streaming/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ mod tests {
route_base: std::borrow::Cow::Borrowed("/account"),
rendered_components: std::collections::HashSet::new(),
plugin: None,
route_children: Vec::new(),
route_children: std::borrow::Cow::Borrowed(&[]),
entry_id: "index.html",
nonce: None,
component_index,
Expand Down
42 changes: 39 additions & 3 deletions crates/webui-handler/src/streaming/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ pub(crate) struct SessionCore {
document_style_resources: HashSet<String>,
shadow_style_roots: Vec<crate::ShadowStyleRoot>,
plugin: Option<Box<dyn HandlerPlugin>>,
route_children: Vec<webui_protocol::WebUiFragmentRoute>,
route_children: crate::RouteChildren<'static>,
head_end_emitted: bool,
body_start_emitted: bool,
component_asset_styles_emitted: bool,
Expand Down Expand Up @@ -408,7 +408,7 @@ impl SessionCore {
document_style_resources: HashSet::new(),
shadow_style_roots: Vec::new(),
plugin: handler.plugin_factory.map(|factory| factory()),
route_children: Vec::new(),
route_children: Cow::Owned(Vec::new()),
head_end_emitted: false,
body_start_emitted: false,
component_asset_styles_emitted: false,
Expand Down Expand Up @@ -730,7 +730,7 @@ impl SessionCore {
};
self.rendered_components = std::mem::take(&mut context.rendered_components);
self.plugin = context.plugin.take();
self.route_children = std::mem::take(&mut context.route_children);
self.route_children = Cow::Owned(std::mem::take(&mut context.route_children).into_owned());
self.head_end_emitted = context.head_end_emitted;
self.body_start_emitted = context.body_start_emitted;
self.component_asset_styles_emitted = context.component_asset_styles_emitted;
Expand Down Expand Up @@ -953,6 +953,42 @@ mod tests {
RenderOptions::new("index.html", "/")
}

#[test]
fn session_parks_owned_routes_and_retains_scope_pool() -> Result<()> {
let protocol = boundary_protocol(2, StateProjectionMode::Keys);
let handler = WebUIHandler::new();
let state = test_json!({ "count": 3, "title": "pooled" });
let render_options = options();
let mut sink = TestSink {
output: String::new(),
};
let mut response = handler.stream_response(&protocol, &render_options, &mut sink)?;

let first = response.start(&state)?;
assert!(matches!(response.core.route_children, Cow::Owned(_)));
let Some(boundary) = first.boundary else {
panic!("the first boundary should suspend");
};

response.resume(boundary.instance_id, &state, BoundaryMode::Final)?;
assert!(matches!(response.core.route_children, Cow::Owned(_)));
let pooled_capacity = response
.core
.scope_pool
.first()
.map(HashMap::capacity)
.unwrap_or_else(|| panic!("the completed component should recycle its scope map"));

response.advance()?;
assert!(matches!(response.core.route_children, Cow::Owned(_)));
assert_eq!(
response.core.scope_pool.first().map(HashMap::capacity),
Some(pooled_capacity),
"a suspension step must preserve the scope-map pool"
);
Ok(())
}

#[test]
fn render_streaming_projects_full_state_once_per_response() -> Result<()> {
// Full-state protocols retain the caller's whole tree. Committing each
Expand Down
11 changes: 7 additions & 4 deletions crates/webui-handler/src/streaming/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ impl ContinuationVm {
} => {
context.writer.write("</webui-route>")?;
context.route_base = Cow::Owned(saved_route_base.into_string());
context.route_children = saved_route_children;
context.route_children = Cow::Owned(saved_route_children);
}
Frame::Outlet(frame) => self.step_outlet(frame, handler, protocol, context)?,
}
Expand Down Expand Up @@ -1319,8 +1319,11 @@ impl ContinuationVm {
)
.into_owned()
.into_boxed_str();
let saved_route_children =
std::mem::replace(&mut context.route_children, route.children.clone());
let saved_route_children = std::mem::replace(
&mut context.route_children,
Cow::Owned(route.children.clone()),
)
.into_owned();
self.push(Frame::RouteEnd {
saved_route_base,
saved_route_children,
Expand All @@ -1338,7 +1341,7 @@ impl ContinuationVm {
}

fn begin_outlet(&mut self, context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
let routes = std::mem::take(&mut context.route_children);
let routes = std::mem::take(&mut context.route_children).into_owned();
if routes.is_empty() {
return Ok(());
}
Expand Down
42 changes: 42 additions & 0 deletions crates/webui-handler/tests/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,48 @@ fn selected_route_component_can_suspend_inside_generated_host() {
.contains(r#"<!--ws:0--><route-page data-ws data-ws-span="0">"#));
}

#[test]
fn boundary_free_component_descends_owned_nested_routes() {
let protocol = parsed_protocol(
&document(concat!(
r#"<route path="/" component="route-shell">"#,
r#"<route path="sections/:sectionId" component="section-page">"#,
r#"<route path="topics/:topicId" component="topic-page">"#,
r#"<route path="lessons/:lessonId" component="lesson-page"></route>"#,
"</route></route></route>",
)),
&[
(
"route-shell",
concat!(
"<layout-shell></layout-shell>",
r#"<boundary name="ready"><p>ready</p></boundary>"#,
),
),
("layout-shell", "<main><outlet /></main>"),
(
"section-page",
"<h2>Section</h2><section><outlet /></section>",
),
("topic-page", "<h3>Topic</h3><article><outlet /></article>"),
("lesson-page", "<p>Lesson</p>"),
],
);
let mut session = new_session(protocol, "/sections/alpha/topics/beta/lessons/gamma");

let start = session.start(&test_json!({})).unwrap();

assert_eq!(start.boundary.as_ref().unwrap().name.as_ref(), "ready");
let html = String::from_utf8(start.bytes).unwrap();
let section = html.find("<h2>Section</h2>").unwrap();
let topic = html.find("<h3>Topic</h3>").unwrap();
let lesson = html.find("<p>Lesson</p>").unwrap();
assert!(section < topic && topic < lesson, "{html}");
assert!(html.contains(r#"<webui-route path="sections/:sectionId""#));
assert!(html.contains(r#"<webui-route path="topics/:topicId""#));
assert!(html.contains(r#"<webui-route path="lessons/:lessonId""#));
}

#[test]
fn selected_route_component_hydration_keys_survive_frozen_state() {
let entry = document(concat!(
Expand Down
Loading