Skip to content

Commit 5dd9988

Browse files
akroshgCopilotmohamedmansour
authored
fix: omit render-only tokens from client state (#351)
* Omit render-only tokens from client state Keep design-token CSS available during SSR, but omit the render-only tokens field from the emitted webui-data client state in CLI and FFI render paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: strip render-only tokens from client state Strip top-level tokens in the handler when serializing the WebUI bootstrap state instead of exposing a generic RenderOptions omit-list or wiring special cases through CLI and FFI callers. SSR still receives the full state so token CSS signals resolve during render, while the emitted client state drops the render-only token payload. Keep coverage at the handler boundary and the serve pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove serve token filtering test Rely on handler and FFI coverage for render-only token omission and remove the webui-cli serve-specific regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Mohamed Mansour <hello@mohamedmansour.com>
1 parent 1d8ef0d commit 5dd9988

3 files changed

Lines changed: 82 additions & 3 deletions

File tree

DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1257,7 +1257,7 @@ inject_token_css(state, css) → state["tokens"]["light"] = "..."
12571257
1. **Filter**: Only tokens in `protocol.tokens` are kept.
12581258
2. **Dependency closure**: Token values referencing other tokens via `var(--x)` trigger transitive inclusion. Uses an iterative BFS expansion followed by DFS cycle detection.
12591259
3. **CSS generation**: Sorted `--name: value;` declarations. Output is deterministic.
1260-
4. **State injection**: Per-theme CSS strings are set on `state.tokens.<theme>`, where `/*{{{tokens.<theme>}}}*/` signals resolve them during rendering.
1260+
4. **State injection**: Per-theme CSS strings are set on `state.tokens.<theme>`, where `/*{{{tokens.<theme>}}}*/` signals resolve them during rendering. These render-only token strings are omitted from the emitted `webui-data` client bootstrap.
12611261

12621262
#### Package Resolution (`resolve_theme_path`)
12631263

crates/webui-handler/src/lib.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use plugin::BootstrapExtensionContext;
2828
use plugin::HandlerPlugin;
2929
use plugin::WebUiTemplatePayload;
3030
use route_matcher::CompiledRouteCache;
31+
use serde::ser::SerializeMap;
3132
use serde::Serialize;
3233
use serde_json::Value;
3334
use std::borrow::Cow;
@@ -37,6 +38,8 @@ use webui_expressions::{evaluate_with_resolver, ExpressionError};
3738
use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol};
3839
use webui_state::find_value_by_dotted_path_ref;
3940

41+
const CLIENT_STATE_TOKEN_KEY: &str = "tokens";
42+
4043
/// Error types for the WebUI handler.
4144
#[derive(Debug, Error)]
4245
pub enum HandlerError {
@@ -360,6 +363,34 @@ where
360363
write_script_safe_json(writer, value)
361364
}
362365

366+
struct ClientState<'a> {
367+
value: &'a Value,
368+
}
369+
370+
impl Serialize for ClientState<'_> {
371+
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
372+
where
373+
S: serde::Serializer,
374+
{
375+
let Value::Object(map) = self.value else {
376+
return self.value.serialize(serializer);
377+
};
378+
379+
if !map.contains_key(CLIENT_STATE_TOKEN_KEY) {
380+
return self.value.serialize(serializer);
381+
}
382+
383+
let mut out = serializer.serialize_map(None)?;
384+
for (key, value) in map {
385+
if key == CLIENT_STATE_TOKEN_KEY {
386+
continue;
387+
}
388+
out.serialize_entry(key, value)?;
389+
}
390+
out.end()
391+
}
392+
}
393+
363394
fn write_webui_bootstrap(
364395
writer: &mut dyn ResponseWriter,
365396
bootstrap: WebUiBootstrap<'_>,
@@ -377,7 +408,14 @@ fn write_webui_bootstrap(
377408
if let Some(nonce) = bootstrap.nonce {
378409
write_json_field(writer, &mut wrote_field, "nonce", nonce)?;
379410
}
380-
write_json_field(writer, &mut wrote_field, "state", bootstrap.state)?;
411+
write_json_field(
412+
writer,
413+
&mut wrote_field,
414+
"state",
415+
&ClientState {
416+
value: bootstrap.state,
417+
},
418+
)?;
381419
if !bootstrap.style_specs.is_empty() {
382420
write_json_field(writer, &mut wrote_field, "styles", bootstrap.style_specs)?;
383421
}
@@ -7091,6 +7129,46 @@ mod tests {
70917129
);
70927130
}
70937131

7132+
#[test]
7133+
fn client_state_strips_tokens_after_ssr_resolution() -> Result<()> {
7134+
let mut fragments = HashMap::new();
7135+
fragments.insert(
7136+
"index.html".to_string(),
7137+
FragmentList {
7138+
fragments: vec![
7139+
WebUIFragment::raw("<html><body><style>".to_string()),
7140+
WebUIFragment::signal("tokens.light", true),
7141+
WebUIFragment::raw("</style>".to_string()),
7142+
WebUIFragment::signal("body_end", true),
7143+
WebUIFragment::raw("</body></html>".to_string()),
7144+
],
7145+
},
7146+
);
7147+
let protocol = WebUIProtocol::new(fragments);
7148+
let state = test_json!({
7149+
"name": "Alice",
7150+
"tokens": {
7151+
"light": "--color-brand: red;"
7152+
}
7153+
});
7154+
let mut writer = TestWriter::new();
7155+
let handler = WebUIHandler::with_plugin(|| {
7156+
Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7157+
});
7158+
handler.handle(
7159+
&protocol,
7160+
&state,
7161+
&RenderOptions::new("index.html", "/"),
7162+
&mut writer,
7163+
)?;
7164+
let output = writer.get_content();
7165+
7166+
assert!(output.contains("--color-brand: red;"));
7167+
assert!(output.contains(r#""name":"Alice""#));
7168+
assert!(!output.contains(r#""tokens""#));
7169+
Ok(())
7170+
}
7171+
70947172
#[test]
70957173
fn test_component_attr_name_aria() {
70967174
// component_attr_name correctly maps ARIA attributes via the shared table

docs/ai.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,8 @@ property declarations into the render state. Only available on `webui serve`.
716716
including nested `var()` fallbacks)
717717
3. Expands transitive `var()` references and detects cycles
718718
4. Generates CSS declaration strings per theme
719-
5. Injects into state as `state.tokens.light`, `state.tokens.dark`, etc.
719+
5. Injects into SSR state as `state.tokens.light`, `state.tokens.dark`, etc.
720+
These render-only token strings are omitted from the emitted client state.
720721

721722
**Multi-theme format:**
722723
```json

0 commit comments

Comments
 (0)