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
11 changes: 7 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,11 @@ When the handler encounters `Fragment::Outlet`:
3. Emit `<webui-outlet>` containing matched child `<webui-route>` with component, and hidden stubs for siblings.

The handler also emits `<meta name="webui-inventory">` in `<head>` with the initial component
inventory bitmask, so the client router can tell the server which templates it already has
on the first client-side navigation.
inventory bitmask (covering only **rendered** components), so the client router can tell the server
which templates it already has on the first client-side navigation. Note that **templates** and
**CSS module definitions** are emitted for all **reachable** components (including those in false
`<if>` blocks), not just rendered ones — this ensures client-side conditional activation works
without a server round-trip.

**Key elements:**
- `<webui-route>` — light DOM custom element, structural routing wrapper with no shadow DOM
Expand Down Expand Up @@ -660,7 +663,7 @@ pub enum CssStrategy {

- **Link** (default): Emits `<link>` tags referencing external `.css` files only for components whose discovery/registration data included CSS. Used by the CLI for production builds where CSS files are served separately.
- **Style**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
- **Module**: Uses the [Declarative CSS Module Scripts](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md) proposal. During SSR, emits a `<style type="module" specifier="component-name">` definition in each component's light DOM on first render (e.g., `<my-comp><style type="module" ...>CSS</style><template ...>`) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root `<template>`. The browser registers the CSS module globally and shares a single `CSSStyleSheet` across all shadow roots that adopt it. No external CSS files are produced. During SPA partial navigation, module style definitions for newly needed components are sent in the `templateStyles` array; the router appends them to `<head>` before executing template scripts. WebUI Framework compiled metadata carries the adopted stylesheet specifier (`sa`) so client-created components can adopt the registered stylesheet on their shadow root.
- **Module**: Uses the [Declarative CSS Module Scripts](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md) proposal. During SSR, emits a `<style type="module" specifier="component-name">` definition in each component's light DOM on first render (e.g., `<my-comp><style type="module" ...>CSS</style><template ...>`) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root `<template>`. Components inside false `<if>` blocks or empty `<for>` loops that were not rendered during SSR get their module style definitions emitted at `body_end`, so client-side activation can adopt them. The browser registers the CSS module globally and shares a single `CSSStyleSheet` across all shadow roots that adopt it. No external CSS files are produced. During SPA partial navigation, module style definitions for newly needed components are sent in the `templateStyles` array; the router appends them to `<head>` before executing template scripts. WebUI Framework compiled metadata carries the adopted stylesheet specifier (`sa`) so client-created components can adopt the registered stylesheet on their shadow root.

Set via `parser.set_css_strategy(CssStrategy::Style)`.

Expand Down Expand Up @@ -707,7 +710,7 @@ pub trait ParserPlugin {
**Built-in plugin: `WebUIParserPlugin`**
- Skips WebUI Framework runtime attributes (`@click`, `@keydown`, etc.) without counting them as attribute bindings
- Tracks per-element event count; emits 12-byte `WebUIElementData` `Plugin` fragments encoding `[binding_count, event_start, event_count]`
- Tracks components and compiles templates into raw JS IIFE strings registered in `window.__webui_templates`. During SSR the handler wraps them in a single `<script>` tag; during SPA navigation the router appends any `templateStyles` first, then evaluates the batched template scripts in one nonce-friendly `<script>` tag.
- Tracks components and compiles templates into raw JS IIFE strings registered in `window.__webui_templates`. During SSR the handler emits templates for all reachable components on the active route (including those inside false `<if>` and empty `<for>` blocks) in a single `<script>` tag. During SPA navigation the router appends any `templateStyles` first, then evaluates the batched template scripts in one nonce-friendly `<script>` tag.
- Public framework authoring, decorators, and package entrypoints live in [packages/webui-framework/README.md](packages/webui-framework/README.md)

**Usage:**
Expand Down
78 changes: 58 additions & 20 deletions crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -769,12 +769,6 @@ impl WebUIHandler {
}

// Hook: emit component templates before body_end when hydration is enabled.
// Only emit templates for components that were actually rendered during SSR.
// This keeps the inventory aligned with what's truly in the document:
// if a component's template IIFE is emitted, its <style type="module">
// definition was also emitted inline (via emit_css_module). Components
// inside unrendered conditional blocks are excluded — they'll be delivered
// via templateStyles[] + templates[] during SPA partial navigation.
if signal.raw && signal.value == "body_end" && context.plugin.is_some() {
// Build the component → index map for the inventory bitfield.
let comp_index = crate::route_handler::build_component_index(context.protocol);
Expand All @@ -794,10 +788,51 @@ impl WebUIHandler {
context.writer.write(&inventory_hex)?;
context.writer.write("\">")?;

// Emit templates for all REACHABLE components on the current route,
// not just those rendered in this SSR pass. Components inside false
// <if> blocks or empty <for> loops are reachable via client-side
// state changes and need their templates available without a server
// 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_names, _) = crate::route_handler::get_needed_components_for_request(
context.protocol,
&context.entry_id,
&context.request_path,
"",
);
let reachable: std::collections::HashSet<String> =
reachable_names.into_iter().collect();

// Emit CSS module definitions for reachable-but-unrendered components.
// Rendered components already got their <style type="module"> inline
// during the render pass (via emit_css_module). Unrendered components
// need their definitions here so the framework can adopt them when
// the <if> condition flips true client-side.
for name in &reachable {
if !context.rendered_components.contains(name) {
if let Some(css) = context
.protocol
.components
.get(name)
.map(|c| c.css.as_str())
.filter(|s| !s.is_empty())
{
context
.writer
.write("<style type=\"module\" specifier=\"")?;
context.writer.write(name)?;
context.writer.write("\">")?;
context.writer.write(css)?;
context.writer.write("</style>")?;
}
}
}

if let Some(ref p) = context.plugin {
p.emit_templates(
context.protocol,
&context.rendered_components,
&reachable,
context.nonce.as_deref(),
context.writer,
)?;
Expand Down Expand Up @@ -6135,13 +6170,14 @@ mod tests {
}

#[test]
fn test_unrendered_components_excluded_from_templates_and_inventory() {
// Simulates the commerce about page: app-shell renders cart-panel,
// but cart-panel contains an <if> block with product-card inside.
// When the condition is false (empty cart), product-card is NOT rendered.
// Its template IIFE must NOT be in the <script> output, and its bit
// must NOT be set in the inventory — otherwise the client thinks it
// has the component but lacks the <style type="module"> definition.
fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
// Simulates a page where app-shell renders cart-panel, but cart-panel
// contains an <if> block with product-card inside. When the condition
// is false (empty cart), product-card is NOT rendered — but it IS
// reachable from the fragment graph. Its template IIFE and CSS module
// definition must be in the output so the client can mount it when
// the <if> flips true. However, its bit must NOT be set in the
// inventory — the inventory tracks what was actually rendered.
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
Expand Down Expand Up @@ -6219,16 +6255,18 @@ mod tests {

let html = writer.get_content();

// product-card template IIFE must NOT be in the output
// product-card template IS in the output — it's a known component
// whose template must be available for client-side <if> activation.
assert!(
!html.contains("w['product-card']"),
"unrendered product-card template should not be emitted: {html}"
html.contains("w['product-card']"),
"product-card template should be emitted even when unrendered: {html}"
);

// product-card style must NOT be in the output
// product-card CSS module IS in the output — reachable components need
// their stylesheet definitions for client-side <if> activation.
assert!(
!html.contains(r#"specifier="product-card""#),
"unrendered product-card CSS module should not be emitted: {html}"
html.contains(r#"specifier="product-card""#),
"reachable product-card CSS module should be emitted: {html}"
);

// app-shell and cart-panel SHOULD be in the output (they were rendered)
Expand Down
10 changes: 5 additions & 5 deletions crates/webui-handler/src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,22 +92,22 @@ pub trait HandlerPlugin {
fn emit_templates(
&self,
protocol: &WebUIProtocol,
rendered_components: &HashSet<String>,
components: &HashSet<String>,
_nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
emit_rendered_component_templates(protocol, rendered_components, writer)
emit_component_templates(protocol, components, writer)
}
}

/// Default template emission: write each non-empty template verbatim.
/// Used by the FAST plugin for `<f-template>` tags.
pub(crate) fn emit_rendered_component_templates(
pub(crate) fn emit_component_templates(
protocol: &WebUIProtocol,
rendered_components: &HashSet<String>,
components: &HashSet<String>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
for name in rendered_components {
for name in components {
if let Some(template) = protocol
.components
.get(name)
Expand Down
22 changes: 11 additions & 11 deletions crates/webui-handler/src/plugin/webui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,13 @@ impl HandlerPlugin for WebUIHydrationPlugin {
fn emit_templates(
&self,
protocol: &WebUIProtocol,
rendered_components: &HashSet<String>,
components: &HashSet<String>,
nonce: Option<&str>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let mut templates: Vec<&str> = Vec::with_capacity(rendered_components.len());
let mut templates: Vec<&str> = Vec::with_capacity(components.len());

for name in rendered_components {
for name in components {
if let Some(template) = protocol
.components
.get(name)
Expand Down Expand Up @@ -330,7 +330,7 @@ mod tests {
}

#[test]
fn test_on_render_complete_only_emits_rendered_components() {
fn test_emit_templates_only_emits_provided_components() {
let mut writer = TestWriter::new();

let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
Expand All @@ -350,12 +350,12 @@ mod tests {
.or_default()
.template = iife_template("comp-c", "h:\"c\"");

let mut rendered = std::collections::HashSet::new();
rendered.insert("comp-a".to_string());
let mut components = std::collections::HashSet::new();
components.insert("comp-a".to_string());

let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
.emit_templates(&protocol, &components, None, &mut writer)
.unwrap();

assert!(
Expand All @@ -376,20 +376,20 @@ mod tests {
}

#[test]
fn test_on_render_complete_empty_rendered_set() {
fn test_emit_templates_empty_set() {
let mut writer = TestWriter::new();
let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
protocol
.components
.entry("comp-a".to_string())
.or_default()
.template = iife_template("comp-a", "h:\"a\"");
let rendered = std::collections::HashSet::new();
let components = std::collections::HashSet::new();
let plugin = WebUIHydrationPlugin::new();
plugin
.emit_templates(&protocol, &rendered, None, &mut writer)
.emit_templates(&protocol, &components, None, &mut writer)
.unwrap();
assert_eq!(writer.output, "", "empty rendered set should emit nothing");
assert_eq!(writer.output, "", "empty set should emit nothing");
}

#[test]
Expand Down
Loading
Loading