diff --git a/crates/webui-handler/benches/handler_bench.rs b/crates/webui-handler/benches/handler_bench.rs
index 2fca20ad..37377822 100644
--- a/crates/webui-handler/benches/handler_bench.rs
+++ b/crates/webui-handler/benches/handler_bench.rs
@@ -525,12 +525,88 @@ fn handler_state_depth_bench(c: &mut Criterion) {
group.finish();
}
+/// Build a document whose fragment graph is wide enough that per-fragment
+/// preparation cost is visible, including a large sibling route table.
+fn build_construction_document(fragment_count: usize, routes_per_page: usize) -> WebUIProtocol {
+ let mut fragments = HashMap::new();
+
+ let mut root = Vec::with_capacity(fragment_count + routes_per_page);
+ for route in 0..routes_per_page {
+ root.push(WebUIFragment::route(
+ format!("/section-{route}"),
+ format!("route-{route}.html"),
+ ));
+ fragments.insert(
+ format!("route-{route}.html"),
+ FragmentList {
+ fragments: vec![WebUIFragment::raw("
route body
")],
+ contains_boundary: false,
+ },
+ );
+ }
+ for page in 0..fragment_count {
+ root.push(WebUIFragment::component(format!("card-{page}")));
+ fragments.insert(
+ format!("card-{page}"),
+ FragmentList {
+ fragments: vec![
+ WebUIFragment::attribute("data-card-label", format!("card {page}")),
+ WebUIFragment::attribute(":card-detail-text", format!("detail {page}")),
+ WebUIFragment::signal(format!("cards.{page}.title"), false),
+ WebUIFragment::raw(""),
+ ],
+ contains_boundary: false,
+ },
+ );
+ }
+
+ fragments.insert(
+ "index.html".to_string(),
+ FragmentList {
+ fragments: root,
+ contains_boundary: false,
+ },
+ );
+
+ WebUIProtocol::new(fragments)
+}
+
+/// Control for the load-time half of the render fragment index: a render-time
+/// win must not be paid for by a disproportionate `Protocol::new` regression.
+fn handler_protocol_construction_bench(c: &mut Criterion) {
+ let mut group = c.benchmark_group("handler_protocol_construction");
+
+ let cases = [
+ ("small_16_fragments", 16usize, 0usize),
+ ("medium_128_fragments", 128, 0),
+ ("wide_128_routes", 32, 128),
+ ("large_512_fragments", 512, 0),
+ ];
+
+ for (label, fragment_count, routes) in cases {
+ let document = build_construction_document(fragment_count, routes);
+ group.throughput(Throughput::Elements(
+ u64::try_from(document.fragments.len()).unwrap_or(u64::MAX),
+ ));
+ group.bench_function(label, |b| {
+ b.iter_batched(
+ || document.clone(),
+ |document| Protocol::new(black_box(document)),
+ criterion::BatchSize::SmallInput,
+ );
+ });
+ }
+
+ group.finish();
+}
+
criterion_group!(
benches,
handler_plugin_fast_bench,
handler_loop_scaling_bench,
handler_condition_variety_bench,
handler_nested_components_bench,
- handler_state_depth_bench
+ handler_state_depth_bench,
+ handler_protocol_construction_bench
);
criterion_main!(benches);
diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs
index ea576a27..161b11d0 100644
--- a/crates/webui-handler/src/lib.rs
+++ b/crates/webui-handler/src/lib.rs
@@ -42,7 +42,9 @@ use serde::ser::SerializeMap;
use serde::Serialize;
use serde_json::Value;
use std::borrow::Cow;
+use std::cell::{Cell, OnceCell};
use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
use streaming::{
consume_streaming_component_root, ensure_no_pending_streaming_root,
prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
@@ -57,7 +59,7 @@ pub use streaming::{
use thiserror::Error;
use webui_expressions::{evaluate_with_resolver, ExpressionError};
use webui_protocol::{
- web_ui_fragment::Fragment, ComponentAssetStylePreload, InitialStateStrategy,
+ web_ui_fragment::Fragment, ComponentAssetStylePreload, FragmentList, InitialStateStrategy,
StateProjectionMode, WebUIFragment, WebUIProtocol,
};
use webui_state::find_value_by_dotted_path_ref;
@@ -427,9 +429,391 @@ pub(crate) struct ShadowStyleRoot {
routed_resources: Vec,
}
+struct LoopBinding<'protocol, 'state> {
+ name: &'protocol str,
+ value: &'state Value,
+}
+
+#[derive(Clone, Copy)]
+struct VisibleLoopScope {
+ start: usize,
+ end: usize,
+}
+
+impl VisibleLoopScope {
+ const EMPTY: Self = Self { start: 0, end: 0 };
+}
+
+#[derive(Clone, Copy)]
+struct LocalValueSources<'ctx, 'protocol, 'state> {
+ owned: &'ctx HashMap,
+ borrowed: &'ctx BorrowedScope<'protocol, 'state>,
+}
+
+#[derive(Default)]
+struct BorrowedScope<'protocol, 'state> {
+ inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
+ inline_len: usize,
+ overflow: Vec<(&'protocol str, &'state Value)>,
+}
+
+impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
+ fn get(&self, name: &str) -> Option<&'state Value> {
+ if self.inline_len == 0 {
+ return None;
+ }
+ if let Some((entry_name, value)) = self.inline[0].as_ref() {
+ if *entry_name == name {
+ return Some(*value);
+ }
+ }
+ for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
+ if *entry_name == name {
+ return Some(*value);
+ }
+ }
+ self.overflow
+ .iter()
+ .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
+ }
+
+ fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
+ for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
+ if *entry_name == name {
+ return Some(std::mem::replace(current, value));
+ }
+ }
+ for (entry_name, current) in &mut self.overflow {
+ if *entry_name == name {
+ return Some(std::mem::replace(current, value));
+ }
+ }
+ if self.inline_len < INLINE_SCOPE_SLOTS {
+ self.inline[self.inline_len] = Some((name, value));
+ self.inline_len += 1;
+ } else {
+ self.overflow.push((name, value));
+ }
+ None
+ }
+
+ fn remove(&mut self, name: &str) -> Option<&'state Value> {
+ if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
+ entry
+ .as_ref()
+ .is_some_and(|(entry_name, _)| *entry_name == name)
+ }) {
+ let removed = self.inline[index].take().map(|(_, value)| value);
+ if let Some(entry) = self.overflow.pop() {
+ self.inline[index] = Some(entry);
+ } else {
+ self.inline_len -= 1;
+ self.inline[index] = self.inline[self.inline_len].take();
+ }
+ return removed;
+ }
+ let index = self
+ .overflow
+ .iter()
+ .position(|(entry_name, _)| *entry_name == name)?;
+ Some(self.overflow.swap_remove(index).1)
+ }
+
+ fn clear(&mut self) {
+ for entry in &mut self.inline[..self.inline_len] {
+ *entry = None;
+ }
+ self.inline_len = 0;
+ self.overflow.clear();
+ }
+
+ fn clone_into_owned(&self, target: &mut HashMap) {
+ for (name, value) in self.inline[..self.inline_len].iter().flatten() {
+ target.insert((*name).to_owned(), (*value).clone());
+ }
+ for (name, value) in &self.overflow {
+ target.insert((*name).to_owned(), (*value).clone());
+ }
+ }
+}
+
+/// A fragment list paired with the render metadata prepared for it when the
+/// runtime [`Protocol`] was loaded.
+///
+/// `fragments` still points at the protocol's own storage, so passing this by
+/// value never copies or clones a fragment graph.
+#[derive(Clone, Copy)]
+pub(crate) struct RenderFragmentList<'protocol> {
+ fragments: &'protocol [WebUIFragment],
+ metadata: &'protocol [RenderFragmentMetadata],
+ attr_names: &'protocol str,
+ /// True when this list contains at least one `` fragment.
+ /// Renders skip the sibling route pre-scan entirely when it is false.
+ has_routes: bool,
+}
+
+impl<'protocol> RenderFragmentList<'protocol> {
+ /// Render slot the fragment at `index` descends into, if any.
+ fn target(self, index: usize) -> Option {
+ let target = self.metadata.get(index)?.target;
+ (target != NO_RENDER_SLOT).then_some(target as usize)
+ }
+
+ /// Canonical camelCase component prop name prepared for an attribute fragment.
+ fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
+ let prepared = self.metadata.get(index)?;
+ if prepared.attr_start == NO_ATTR_NAME {
+ return None;
+ }
+ let start = prepared.attr_start as usize;
+ self.attr_names
+ .get(start..start + prepared.attr_len as usize)
+ }
+}
+
+/// Sentinel for "this fragment does not descend into another fragment list".
+const NO_RENDER_SLOT: u32 = u32::MAX;
+/// Sentinel for "this fragment is not an attribute fragment".
+const NO_ATTR_NAME: u32 = u32::MAX;
+
+/// Per-fragment values hoisted out of the render loop at protocol load time.
+///
+/// Deliberately a flat 12-byte `Copy` record with no owned allocations: a large
+/// protocol keeps one contiguous arena instead of one heap block per fragment.
+#[derive(Clone, Copy)]
+struct RenderFragmentMetadata {
+ /// Numeric slot of the fragment list this fragment renders into, avoiding a
+ /// string hash lookup per component, loop, condition, and template attribute.
+ /// [`NO_RENDER_SLOT`] when the fragment renders inline.
+ target: u32,
+ /// Offset into the index's shared attribute-name arena, or [`NO_ATTR_NAME`].
+ attr_start: u32,
+ attr_len: u32,
+}
+
+/// Build-time render plan for every fragment list in a protocol.
+///
+/// Built once when a [`Protocol`] is created and shared immutably by every
+/// render. Fragment IDs are the same `Arc` values the protocol already
+/// interns, metadata lives in one flat arena, and the fragment graphs
+/// themselves are never duplicated.
+pub(crate) struct RenderFragmentIndex {
+ ids: Box<[Arc]>,
+ metadata: Box<[RenderFragmentMetadata]>,
+ /// Prefix offsets into `metadata`; length is `ids.len() + 1`.
+ ranges: Box<[u32]>,
+ /// Every prepared component prop name concatenated into one allocation.
+ attr_names: Box,
+ /// One bit per fragment list: does it contain a route fragment?
+ route_presence: Box<[u64]>,
+}
+
+/// A [`RenderFragmentIndex`] bound to the protocol document for one render.
+///
+/// Fragment lists are borrowed lazily and memoized in an inline slot cache, so
+/// a large protocol is never eagerly walked to serve a small render.
+pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
+ cache: [Cell