diff --git a/DESIGN.md b/DESIGN.md
index 5833001e..cf1dbd7e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -319,7 +319,7 @@ without a server round-trip.
**Partial response:** `render_partial()` returns `{ templateStyles, templates, inventory, path, chain, cacheTags, cacheControl }`. The caller adds application state to the response (e.g. as a top-level `state` field for non-streaming, or as an NDJSON Chunk 2 for streaming):
- `state`: (added by caller) route-scoped application data — the router applies it to components via `setState()`
-- `templateStyles`: module CSS definition tags (``) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root ``. Components inside false `` blocks or empty `` loops that were not rendered during SSR get their module style definitions emitted at `body_end`, so client-side activation can adopt them. When a CSP nonce is configured (via `RenderOptions::with_nonce` / `webui_handler_set_nonce`), the SSR-emitted ``.
+ /// Requires Multiple Import Maps (Chrome 133+); each call emits an
+ /// independent importmap that the browser merges at the document
+ /// level. The per-render CSP nonce is applied when set (importmap
+ /// scripts honor `script-src`).
///
- /// This keeps SSR output lean — only components actually rendered on the current
- /// route get their style definitions. Components on other routes receive their
- /// definitions via `templateStyles` during SPA partial navigation.
+ /// Example for `my-comp` with CSS `span{color:blue;}`:
+ /// ``
+ fn emit_css_module_importmap(
+ &self,
+ specifier: &str,
+ css: &str,
+ context: &mut WebUIProcessContext,
+ ) -> Result<()> {
+ let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
+ context.writer.write(&tag)?;
+ Ok(())
+ }
+
+ /// Emit a component's CSS module importmap on its first render
+ /// (deduped by `rendered_components`) into the component's light DOM,
+ /// so the browser registers it under the component's specifier
+ /// before the shadow root template is parsed. See
+ /// [`Self::emit_css_module_importmap`] for the emitted shape.
+ ///
+ /// Only components rendered on the current route get inline
+ /// definitions; others receive theirs via `templateStyles` during
+ /// SPA partial navigation.
fn emit_css_module(
&self,
component: &webui_protocol::WebUIFragmentComponent,
@@ -749,17 +771,7 @@ impl WebUIHandler {
.map(|c| c.css.as_str())
.filter(|s| !s.is_empty())
{
- context.writer.write("")?;
+ self.emit_css_module_importmap(&component.fragment_id, css, context)?;
}
}
Ok(())
@@ -846,11 +858,8 @@ impl WebUIHandler {
component: &webui_protocol::WebUIFragmentComponent,
context: &mut WebUIProcessContext,
) -> Result<()> {
- // Emit CSS module into the component's light DOM on first encounter.
- // Only rendered components get their
+ // Emit the component's CSS module importmap into its light DOM
+ // on first encounter (see `emit_css_module`).
if !context.rendered_components.contains(&component.fragment_id) {
self.emit_css_module(component, context)?;
}
@@ -1016,15 +1025,12 @@ impl WebUIHandler {
}
// Emit CSS tags in for Link-strategy components.
- //
- // The protocol carries css_strategy and dom_strategy set at build
- // time. For components with a non-empty css_href:
+ // For components with a non-empty css_href:
// Link + Shadow → (stylesheet is in shadow root)
// Link + Light → (no shadow root)
//
- // Style-strategy embeds CSS inside the shadow DOM template.
- // Module-strategy emits ")?;
+ self.emit_css_module_importmap(name, css, context)?;
}
}
}
@@ -6262,13 +6256,15 @@ mod tests {
let html = writer.get_content();
- // CSS module should appear exactly once
- let count = html
- .matches(r#""#
- ),
- "Route component should have CSS module: {html}"
+ html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
+ "Route component should have CSS module importmap with data: URI: {html}"
);
assert!(
html.contains("
Dashboard
"),
@@ -6866,8 +6872,8 @@ mod tests {
// product-card CSS module IS in the output — reachable components need
// their stylesheet definitions for client-side activation.
assert!(
- html.contains(r#"specifier="product-card""#),
- "reachable product-card CSS module should be emitted: {html}"
+ html.contains(r#""product-card":"data:text/css,"#),
+ "reachable product-card CSS module importmap should be emitted: {html}"
);
// app-shell and cart-panel SHOULD be in the output (they were rendered)
@@ -6881,16 +6887,14 @@ mod tests {
);
}
- // ── CSP nonce on "#
+ r#""#
),
- "CSS module tag should include nonce attribute in canonical order: {html}"
+ "CSS module importmap tag should include nonce attribute in canonical order: {html}"
);
}
@@ -7019,9 +7023,9 @@ mod tests {
assert!(
html.contains(
- r#""#
+ r#""#
),
- "Unrendered (body_end) CSS module tag should include nonce attribute in canonical order: {html}"
+ "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
);
}
diff --git a/crates/webui-handler/src/route_handler.rs b/crates/webui-handler/src/route_handler.rs
index 7ecaaa82..0b938c74 100644
--- a/crates/webui-handler/src/route_handler.rs
+++ b/crates/webui-handler/src/route_handler.rs
@@ -1029,13 +1029,11 @@ fn collect_component_assets(protocol: &WebUIProtocol, tags: &[&str]) -> (Vec");
- s.push_str(&component.css);
- s.push_str("");
- style_array.push(Value::String(s));
+ // No nonce here — the per-request CSP nonce is attached
+ // client-side by the router when it materializes each
+ // importmap script tag into the DOM.
+ let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
+ style_array.push(Value::String(tag_html));
}
tmpl_array.push(Value::String(component.template.clone()));
}
@@ -1810,19 +1808,15 @@ mod tests {
.as_array()
.expect("templateStyles should be an array");
assert_eq!(styles.len(), 1);
+ let style_html = styles[0].as_str().unwrap_or_default();
assert!(
- styles[0]
- .as_str()
- .unwrap_or_default()
- .contains("specifier=\"my-page\""),
- "module style should carry the component specifier"
+ style_html.starts_with(r#"` tags that register each component's CSS under a data URI, and adds `shadowrootadoptedstylesheets` to `` tags. The browser shares a single `CSSStyleSheet` across all shadow roots that adopt it. No separate CSS files are written. Based on the [Import Maps](https://html.spec.whatwg.org/multipage/webappapis.html#import-maps) and [CSS Module Scripts](https://github.com/whatwg/html/issues/9572) proposals. |
For long-lived CDN/browser caching in `link` mode, include `[hash]` in the CSS
filename template. `[hash]` is the component CSS file's SHA-256 content hash
diff --git a/docs/guide/concepts/routing.md b/docs/guide/concepts/routing.md
index d56b7a72..86b5329e 100644
--- a/docs/guide/concepts/routing.md
+++ b/docs/guide/concepts/routing.md
@@ -622,7 +622,7 @@ When `Accept: application/json` or `application/x-ndjson`:
```json
{
"state": { "name": "Alice", "email": "alice@example.com" },
- "templateStyles": [""],
+ "templateStyles": [""],
"templates": ["(function(){var w=window.__webui.templates||...})();"],
"inventory": "04000400...",
"path": "/users/42",
diff --git a/docs/guide/integrations/ffi.md b/docs/guide/integrations/ffi.md
index 3c79f6ce..473a7f0e 100644
--- a/docs/guide/integrations/ffi.md
+++ b/docs/guide/integrations/ffi.md
@@ -136,7 +136,7 @@ Destroy a handler instance created by `webui_handler_create`. Passing `NULL` is
void webui_handler_set_nonce(void *handler_ptr, const char *nonce);
```
-Set the CSP nonce for inline tags on a handler instance. When set, all subsequent renders include `nonce="VALUE"` on both inline `` in the HTML payload registers the CSS as a module under `tag-name`. The framework imports it via `import(tag, { with: { type: 'css' } })` and applies the resulting `CSSStyleSheet` via `adoptedStyleSheets` for shadow DOM isolation |
CSS module stylesheets are cached so each component instance adopts the same
parsed sheet without re-parsing CSS. The `meta.sa` field specifies the
diff --git a/packages/webui-framework/RENDERING.md b/packages/webui-framework/RENDERING.md
index b18550d4..13abb951 100644
--- a/packages/webui-framework/RENDERING.md
+++ b/packages/webui-framework/RENDERING.md
@@ -322,7 +322,7 @@ Three delivery modes, set by the compiler from `` / `',
- '',
+ '',
+ '',
],
templates: [
'(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w["alpha"]={h:"
a
"};})();',
@@ -226,13 +228,18 @@ describe('WebUIRouter', () => {
(globalThis as any).document.querySelector = () => null;
(globalThis as any).document.head = {
appendChild(el: Record) {
- if (el.tagName === 'style') {
- order.push(`style:${(el.attributes as Record).specifier}`);
+ if (el.tagName === 'script' && el.type === 'importmap') {
+ const body = el.textContent as string;
+ const parsed = JSON.parse(body) as { imports: Record };
+ const specifier = Object.keys(parsed.imports)[0];
+ order.push(`importmap:${specifier}`);
+ importmapBodies.push(body);
+ importmapNonces.push(el.nonce as string);
return el;
}
order.push('script');
- scriptNonces.push(el.nonce as string);
- scriptBodies.push(el.textContent as string);
+ templateScriptNonces.push(el.nonce as string);
+ templateScriptBodies.push(el.textContent as string);
// eslint-disable-next-line no-new-func
Function(el.textContent as string)();
return el;
@@ -254,14 +261,49 @@ describe('WebUIRouter', () => {
const result = await fetchPartial('/test');
assert.ok(result, 'should return partial data');
- // Styles must be appended BEFORE scripts
- assert.deepEqual(order, ['style:alpha', 'style:beta', 'script']);
+ // SSR and SPA paths emit ONE ');
if (openTagEnd < 0 || closeTagStart <= openTagEnd) continue;
- const specifierToken = 'specifier="';
- const specStart = trimmed.indexOf(specifierToken);
- let specifier: string | null = null;
- if (specStart >= 0) {
- const valStart = specStart + specifierToken.length;
- const valEnd = trimmed.indexOf('"', valStart);
- if (valEnd > valStart) specifier = trimmed.substring(valStart, valEnd);
- }
-
- if (specifier && injectedStyles.has(specifier)) {
+ const jsonBody = trimmed.substring(openTagEnd + 1, closeTagStart);
+ let parsed: { imports?: Record };
+ try {
+ parsed = JSON.parse(jsonBody) as { imports?: Record };
+ } catch {
continue;
}
+ if (!parsed.imports || typeof parsed.imports !== 'object') continue;
- const style = document.createElement('style');
- style.type = 'module';
- if (specifier) {
- style.setAttribute('specifier', specifier);
+ const newImports: Record = {};
+ let hasNew = false;
+ for (const [specifier, uri] of Object.entries(parsed.imports)) {
+ if (typeof uri !== 'string' || !uri.startsWith('data:text/css,')) continue;
+ if (injectedStyles.has(specifier)) continue;
+ newImports[specifier] = uri;
injectedStyles.add(specifier);
+ hasNew = true;
}
- style.textContent = trimmed.substring(openTagEnd + 1, closeTagStart);
- document.head.appendChild(style);
+ if (!hasNew) continue;
+
+ const script = document.createElement('script');
+ script.type = 'importmap';
+ if (nonce) script.nonce = nonce;
+ script.textContent = JSON.stringify({ imports: newImports });
+ document.head.appendChild(script);
}
}