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
53 changes: 46 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,22 @@ pub struct WebUIFragmentSignal {
pub value: String,
/// Determines if the value should be rendered as raw content.
pub raw: bool,
/// Whether the signal is inside an HTML raw-text context such as `<style>`.
/// Raw-text signals never own replaceable sibling ranges.
pub raw_text_context: bool,
}
```
`raw_text_context` governs marker ownership only; it does not change escaping.
`raw` keeps its usual meaning (`false` HTML-encodes, `true` writes verbatim) in
every context, including inside `<script>`/`<style>`/`<xmp>` (HTML raw-text,
never decodes character references) and `<title>`/`<textarea>` (RCDATA, does
decode them). An escaped (`{{value}}`) binding inside a raw-text element is
therefore an authoring footgun: an HTML-encoded value such as `&amp;` is
emitted as literal text and is never decoded back by the browser, which can
corrupt CSS/JS. Authors binding values that may contain `&`, `<`, `>`, or
quotes inside `<script>`/`<style>`/`<xmp>` must use the raw (`{{{value}}}`)
form. The parser does not currently reject an escaped binding in these
contexts.
#### Conditional Fragment
```rust
pub struct WebUIFragmentIf {
Expand Down Expand Up @@ -1050,8 +1064,18 @@ completion work such as rendered-component template emission stays in handler co
pub trait HandlerPlugin: Send {
fn push_scope(&mut self);
fn pop_scope(&mut self);
fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()>;
fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()>;
fn on_binding_start(
&mut self,
name: &str,
raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()>;
fn on_binding_end(
&mut self,
name: &str,
raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()>;
fn on_repeat_item_start(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()>;
fn on_repeat_item_end(&mut self, index: usize, writer: &mut dyn ResponseWriter) -> Result<()>;
fn on_element_data(&mut self, data: &[u8], writer: &mut dyn ResponseWriter) -> Result<()>;
Expand Down Expand Up @@ -1080,7 +1104,9 @@ retain thread-affine state such as `Rc`; use owned state, `Arc`, or another
sendable handle instead.

**Hook invocation points:**
- **Signal**: `on_binding_start` before, `on_binding_end` after (same scope)
- **Signal**: every signal calls `on_binding_start/end`. The `raw` argument is
`true` only for authored raw HTML signals that own replaceable sibling ranges;
escaped signals and marker-free signals in HTML raw-text contexts pass `false`.
- **For loop**: `on_binding_start/end` around entire loop; `on_repeat_item_start/end` + `push_scope/pop_scope` per item
- **If condition**: `on_binding_start/end` around condition; `push_scope/pop_scope` if condition is true
- **Component**: `push_scope/pop_scope` around component body
Expand Down Expand Up @@ -1654,6 +1680,13 @@ trimmed body is exactly one handlebars expression:

Bare handlebars expressions in CSS are raw text. Dynamic CSS fragments must use
the comment wrapper so the CSS parser can distinguish them from invalid CSS.
`<style>` is an HTML raw-text element, so the browser never decodes character
references in it: an escaped (`raw: false`) CSS signal whose value contains
`&`, `<`, `>`, or quotes is HTML-encoded (e.g. `&amp;`) exactly like any other
escaped signal, and that encoded text is emitted verbatim into the stylesheet
rather than decoded back — corrupting the CSS for those values. Prefer
`/*{{{tokens.light}}}*/` (raw) for CSS custom-property/token values, which are
expected to be plain CSS syntax rather than pre-escaped text.

### Design Token Resolution (`webui-tokens`)

Expand Down Expand Up @@ -1757,7 +1790,7 @@ update hot paths still call the function directly.
| Field | Type | Description |
|-------|-----------------------------------|----------------------------------------------------|
| `h` | `string` | Marker-free static HTML for client-created DOM, including baked-in `<link>` / `<style>` nodes for link/style CSS strategies |
| `tx` | `[slot, parts][]` | Client text runs inserted at precompiled slots |
| `tx` | `[slot, parts, raw?][]` | Client text runs inserted at precompiled slots; `raw = 1` identifies unescaped HTML ranges |
| `a` | `CompiledAttrMeta[]` | Attribute binding metadata |
| `ag` | `[elementIndex, start, count][]` | Attribute-target groups for `a[]` |
| `c` | `[ConditionRef, blockIndex, slot][]` | Conditional blocks |
Expand Down Expand Up @@ -4045,7 +4078,7 @@ strict missing-fragment failure.

**Machine-readable diagnostics.** `webui-cli` accepts a global `--format <human|json>` flag. In `json` mode the colorized terminal output is suppressed and each error is emitted as a single JSON object on **stdout** (`{severity, code, message, file, line, column, snippet, help, chain}`), so editors, CI, and AI assistants consume diagnostics without scraping ANSI text. The process exit code follows BSD `sysexits.h` so callers can branch on the cause: `65` (`EX_DATAERR`) for a template/authoring error, `66` (`EX_NOINPUT`) for a missing app folder / state file / serve dir / entry, `69` (`EX_UNAVAILABLE`) for an occupied port, `74` (`EX_IOERR`) for other I/O failures, `2` for argument/usage errors (clap), and `1` otherwise.

`tx[]` stores text runs as `[slot, parts]`, where `parts` reuse the compact attribute-part encoding (`string` for static text, `[path]` for dynamic text). Client-created DOM inserts one runtime `Text` node per run instead of scanning compiled marker comments.
`tx[]` stores text runs as `[slot, parts, raw?]`, where `parts` reuse the compact attribute-part encoding (`string` for static text, `[path]` for dynamic text). Escaped text omits `raw` and client-created DOM inserts one runtime `Text` node per run. Triple-brace bindings set `raw` to `1` and own the sibling-safe DOM range between paired `<!--wN-->` and `<!--/wN-->` markers.

**Element addressing.** Every locator - the `slot` in `tx` / `c` / `r`, the target in `ag`, and the event target in `eg` - names an element by its **pre-order index** within its own compiled section: `0` is the section root and elements are numbered `1..N` in the order a depth-first walk of `h` meets them. The root template and each `<if>` / `<for>` block number independently, matching the `b[]` split. A `slot` is `[parentIndex, beforeIndex, order?]`, where `beforeIndex` remains a child offset within that parent. Both runtime paths rebuild the same numbering in one walk - client-created DOM by walking the cloned `h`, SSR by walking the server output while skipping structural block ranges - so a binding resolves by array index rather than by descending a chain of child offsets.

Expand Down Expand Up @@ -4079,16 +4112,22 @@ WebUI SSR marker formats are:
| Repeat item | `<!--wi-->` | Marks each iteration boundary inside a repeat |
| Conditional start | `<!--wc-->` | Opens an `<if>` block |
| Conditional end | `<!--/wc-->` | Closes the `<if>` block |
| Raw HTML start | `<!--wN-->` | Opens raw range `N` owned by a triple-brace binding |
| Raw HTML end | `<!--/wN-->` | Closes the same raw range `N` |

The WebUI handler plugin emits only these five comment markers. Text bindings, attribute bindings, and event handlers are resolved from compiled pre-order element indices at hydration time - no DOM attribute markers are needed. The handler only emits markers in active child scopes; the root page scope remains marker-free. During hydration the framework keeps `<!--wr-->` and `<!--wc-->` as runtime anchors and removes `<!--/wr-->`, `<!--/wc-->`, and `<!--wi-->` markers.
The WebUI handler plugin emits these seven comment marker roles. Escaped text bindings, attribute bindings, and event handlers are resolved from compiled pre-order element indices at hydration time - no DOM attribute markers are needed. Raw HTML is the exception because its rendered value can contain any number of top-level nodes and therefore needs explicit ownership boundaries. Raw markers carry a decimal pair identifier so adjacent bindings cannot claim each other's ranges. Exact `<!--wN-->` / `<!--/wN-->` comments are framework-reserved and trusted raw HTML must not emit a marker matching its surrounding range. During hydration the framework keeps `<!--wr-->`, `<!--wc-->`, `<!--wN-->`, and `<!--/wN-->` as runtime anchors and removes `<!--/wr-->`, `<!--/wc-->`, and `<!--wi-->` markers.

WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled metadata were generated by the same trusted WebUI compiler/handler version. Hand-authored or partially modified marker streams are unsupported; missing structural closing markers are invalid input, not a recoverable runtime condition.

### Runtime contract

`@microsoft/webui-framework` consumes the metadata object above plus the SSR markers emitted by `WebUIHydrationPlugin`. This follows an Islands Architecture approach: the server delivers fully-rendered HTML, authored Web Components hydrate on startup or explicitly opt into visibility-driven activation, and compiler-owned scriptless hosts remain dormant until browser code actually writes state.

- SSR hydration performs one pre-order walk per component that pairs each template element with the server-rendered element it hydrates and collects `<!--wc-->` / `<!--wr-->` markers in document order. Because the compiler emits `c` / `r` in source order and the server renders in source order, the two line up by index, so each block's anchor is unambiguous. Bindings then resolve by lookup rather than by rescanning, keeping hydration linear in subtree size instead of proportional to bindings times sibling count. The walk skips whole `<!--wc-->` / `<!--wr-->` ranges — that content belongs to the block's own metadata — and stops at child components, which contribute no children to the parent's `h`. `<!--wi-->` item markers and SSR-only closing markers are removed afterwards.
- SSR hydration performs one pre-order walk per component that pairs each template element with the server-rendered element it hydrates and collects structural markers and raw HTML ranges in document order. Because compiler metadata and server output share source order, each block and raw range is unambiguous. Bindings then resolve by lookup rather than by rescanning, keeping hydration linear in subtree size instead of proportional to bindings times sibling count. The walk skips complete conditional, repeat, and raw HTML ranges - their rendered elements are not static children owned by the enclosing section - and stops at child components, which contribute no children to the parent's `h`. `<!--wi-->` item markers and structural closing markers are removed afterwards; raw HTML boundaries remain for targeted updates.
- Reactive triple-brace updates delete only the nodes between the binding's retained
`<!--wN-->` / `<!--/wN-->` anchors, parse the new trusted HTML in the parent
element's context, and insert the resulting fragment before the end anchor.
Static, conditional, and repeated siblings outside that range are preserved.
- Authored browser entries execute only after every SSR instance they may
upgrade has complete markup. Parser-inserted, non-async ES module scripts and
classic `defer` scripts satisfy this automatically; blocking classic scripts
Expand Down
26 changes: 22 additions & 4 deletions crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1639,7 +1639,7 @@ impl WebUIHandler {
if signal.raw {
self.process_raw_signal(signal, context)
} else {
self.process_state_signal(signal, context)
self.process_state_signal(signal, false, context)
}
}

Expand All @@ -1650,7 +1650,7 @@ impl WebUIHandler {
context: &mut WebUIProcessContext<'data, '_, '_>,
) -> Result<()> {
let Some(structural_value) = structural_signal_value(signal) else {
return self.process_state_signal(signal, context);
return self.process_state_signal(signal, !signal.raw_text_context, context);
};

if context.streaming.is_some()
Expand Down Expand Up @@ -1949,28 +1949,46 @@ impl WebUIHandler {
Ok(())
}

/// Process a plain (non-structural) signal by resolving its value and
/// writing it to the response, HTML-encoded unless `signal.raw` is set.
///
/// `owns_html_range` only controls whether the active plugin emits
/// replaceable sibling markers around the value (`on_binding_start/end`);
/// it never affects escaping. In particular, `signal.raw_text_context`
/// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
/// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
/// at the same time. See `WebUIFragment::raw_text_signal` for why an
/// escaped binding inside an HTML raw-text element is an authoring
/// footgun rather than something this function can safely correct.
#[inline]
fn process_state_signal(
&self,
signal: &webui_protocol::WebUIFragmentSignal,
owns_html_range: bool,
context: &mut WebUIProcessContext,
) -> Result<()> {
if let Some(p) = &mut context.plugin {
p.on_binding_start(&signal.value, context.writer)?;
p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
}

if let Some(value) = self.resolve_value(&signal.value, context) {
self.write_signal_value(&value, signal.raw, context.writer)?;
}
Comment thread
mohamedmansour marked this conversation as resolved.

if let Some(p) = &mut context.plugin {
p.on_binding_end(&signal.value, context.writer)?;
p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
}
Ok(())
}

/// Write a signal value directly to the writer, avoiding intermediate String allocation.
/// For HTML-escaped output, writes the Cow from `encode_safe` directly.
///
/// `raw` here is purely the authored escaping choice (`{{value}}` vs.
/// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
/// context: it does not know whether it is writing into a raw-text
/// element (`<script>`, `<style>`, `<xmp>`, which never decode character
/// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
fn write_signal_value(
&self,
value: &Value,
Expand Down
4 changes: 3 additions & 1 deletion crates/webui-handler/src/plugin/fast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ mod tests {
let mut plugin = FastHydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
assert!(plugin.on_binding_start("userName", &mut writer).is_ok());
assert!(plugin
.on_binding_start("userName", false, &mut writer)
.is_ok());
assert_eq!(writer.output, "<!--fe-b$$start$$0$$userName$$fe-b-->");
}

Expand Down
34 changes: 24 additions & 10 deletions crates/webui-handler/src/plugin/fast_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ impl HandlerPlugin for FastV2HydrationPlugin {
self.scopes.pop();
}

fn on_binding_start(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
fn on_binding_start(
&mut self,
name: &str,
_raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if !self.is_active() {
return Ok(());
}
Expand All @@ -142,7 +147,12 @@ impl HandlerPlugin for FastV2HydrationPlugin {
writer.write(&self.buffer)
}

fn on_binding_end(&mut self, name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
fn on_binding_end(
&mut self,
name: &str,
_raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if !self.is_active() {
return Ok(());
}
Expand Down Expand Up @@ -232,8 +242,12 @@ mod tests {
let mut plugin = FastV2HydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
plugin.on_binding_start("userName", &mut writer).unwrap();
plugin.on_binding_end("userName", &mut writer).unwrap();
plugin
.on_binding_start("userName", false, &mut writer)
.unwrap();
plugin
.on_binding_end("userName", false, &mut writer)
.unwrap();
assert_eq!(
writer.output,
"<!--fe-b$$start$$0$$userName$$fe-b--><!--fe-b$$end$$0$$userName$$fe-b-->"
Expand All @@ -245,10 +259,10 @@ mod tests {
let mut plugin = FastV2HydrationPlugin::new();
plugin.push_scope();
let mut writer = TestWriter::new();
plugin.on_binding_start("a", &mut writer).unwrap();
plugin.on_binding_end("a", &mut writer).unwrap();
plugin.on_binding_start("a", false, &mut writer).unwrap();
plugin.on_binding_end("a", false, &mut writer).unwrap();
writer.output.clear();
plugin.on_binding_start("b", &mut writer).unwrap();
plugin.on_binding_start("b", false, &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$1$$b$$fe-b-->");
}

Expand Down Expand Up @@ -291,16 +305,16 @@ mod tests {
plugin.on_element_data(&three, &mut writer).unwrap();

writer.output.clear();
plugin.on_binding_start("next", &mut writer).unwrap();
plugin.on_binding_start("next", false, &mut writer).unwrap();
assert_eq!(writer.output, "<!--fe-b$$start$$3$$next$$fe-b-->");
}

#[test]
fn test_fast_v2_root_scope_disabled() {
let mut plugin = FastV2HydrationPlugin::new();
let mut writer = TestWriter::new();
plugin.on_binding_start("x", &mut writer).unwrap();
plugin.on_binding_end("x", &mut writer).unwrap();
plugin.on_binding_start("x", false, &mut writer).unwrap();
plugin.on_binding_end("x", false, &mut writer).unwrap();
plugin.on_repeat_item_start(0, &mut writer).unwrap();
plugin.on_repeat_item_end(0, &mut writer).unwrap();
let data = 3u32.to_le_bytes();
Expand Down
Loading
Loading