feat: make Light DOM the component invariant - #429
feat: make Light DOM the component invariant#429Mohamed Mansour (mohamedmansour) wants to merge 26 commits into
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR makes Light DOM the default rendering mode across WebUI, introduces a required componentStyles catalog (resources + ordered closures) as the unified CSS delivery contract, and updates the full stack (parser → protocol → handler → router/framework → assets → docs/tests/examples) to match the new default and style-delivery model.
Changes:
- Switch default DOM strategy to Light, while preserving explicit
--dom=shadow/dom: "shadow"and per-component Shadow opt-in via a sole top-level<template shadowrootmode="open">. - Replace legacy
templateStyles/ inference with required, versionedcomponentStyles(resources + closures) and plumb it through SSR, streaming checkpoints, partial navigation, and component assets (v3). - Update tests, fixtures, examples, and documentation to align with the new default and new CSS/style installation behavior.
Reviewed changes
Copilot reviewed 111 out of 111 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/webui/test/integration.test.ts | Update integration expectations for Light default and componentStyles. |
| packages/webui/src/index.ts | Add dom build option and default it to "light" in Node API wrapper. |
| packages/webui/README.md | Document Light default + Shadow opt-in + componentStyles return shape. |
| packages/webui-test-support/src/fixture-render.ts | Add per-fixture dom override behavior (shadow vs default vs explicit light). |
| packages/webui-router/src/types.ts | Define ComponentStyles and expose registration bridge types. |
| packages/webui-router/src/templates.ts | Switch router registration flow to componentStyles and bridge integration. |
| packages/webui-router/src/streaming.ts | Remove legacy injected module-style set from streaming context usage. |
| packages/webui-router/src/router.ts | Remove router-level module-style tracking; keep SSR styles array for framework lazy dedupe. |
| packages/webui-router/src/router.test.ts | Update tests for componentStyles + bridge ordering and SSR styles retention. |
| packages/webui-router/src/index.ts | Re-export ComponentStyles and related public types. |
| packages/webui-router/src/cache.ts | Replace templateStyles with required componentStyles in partial response shape. |
| packages/webui-router/README.md | Update partial/streaming response contract docs to componentStyles. |
| packages/webui-framework/tests/fixtures/slot-shadow/webui.config.json | Add fixture-level global Shadow config for slot coverage. |
| packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-btn/test-slot-btn.html | Wrap slot component in Shadow opt-in template. |
| packages/webui-framework/tests/fixtures/slot-shadow/slot-shadow.spec.ts | Add regression asserting explicit global Shadow still wraps templates. |
| packages/webui-framework/tests/fixtures/README.md | Update fixture authoring guidance for new default + Shadow opt-in rules. |
| packages/webui-framework/tests/fixtures/light-dom/webui.config.json | Configure fixture to test product default without passing dom option. |
| packages/webui-framework/tests/fixtures/light-dom/state.json | Add fixture state for Light pipeline coverage. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-shadow-opt-in/test-shadow-opt-in.html | Add component-level Shadow opt-in fixture component. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-shadow-opt-in/test-shadow-opt-in.css | Add Shadow-scoped ::slotted styling in fixture. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-shadow-light-child/test-shadow-light-child.html | Add nested Light child component markup for mixed-mode test. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-shadow-light-child/test-shadow-light-child.css | Add nested Light child styling for mixed-mode test. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-light-dom/test-light-dom.html | Add Light root fixture with client-side child spawning and Shadow opt-in. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-light-dom/test-light-dom.css | Add scoped Light CSS validation in fixture. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-light-child/test-light-child.html | Add Light child fixture component markup. |
| packages/webui-framework/tests/fixtures/light-dom/src/test-light-child/test-light-child.css | Add Light child fixture styling. |
| packages/webui-framework/tests/fixtures/light-dom/src/index.html | Add fixture entry document for real-pipeline Light rendering. |
| packages/webui-framework/tests/fixtures/light-dom/light-dom.spec.ts | Rewrite E2E to validate Light default, scoped CSS, closures order, Shadow opt-in, and slot projection. |
| packages/webui-framework/tests/fixtures/light-dom/element.ts | Switch fixture to real pipeline (define components + behaviors) instead of manual registration. |
| packages/webui-framework/tests/fixtures/css-module/css-module.spec.ts | Update comment to reflect new module install function naming/behavior. |
| packages/webui-framework/src/template.ts | Add componentStyles registration + bridge integration into template registry. |
| packages/webui-framework/src/template.test.ts | Update SSR bootstrap parsing test data to include componentStyles. |
| packages/webui-framework/src/template-types.ts | Remove sa from template metadata surface. |
| packages/webui-framework/src/template-events.ts | Include componentStyles in templates-registered event payload. |
| packages/webui-framework/src/template-element.ts | Install component style closures during hydration and add Light host marker for client-created Light elements. |
| packages/webui-framework/src/template-element.test.ts | Adjust HTMLElement/document mocks for root detection and ownership. |
| packages/webui-framework/src/streaming-protocol.ts | Make streaming bootstrap require componentStyles. |
| packages/webui-framework/src/streaming-pipeline.test.ts | Add coverage for halting stream when checkpoint omits componentStyles. |
| packages/webui-framework/src/streaming-bootstrap.ts | Register componentStyles during boundary bootstrap and avoid merging it into ephemeral state. |
| packages/webui-framework/src/index.ts | Export componentStyles APIs/types from framework package surface. |
| packages/webui-framework/src/element/styles.test.ts | Add unit test suite for componentStyles catalogs (Document/ShadowRoot install, module importmaps, nonce, dedupe). |
| packages/webui-framework/src/element/markers.ts | Ensure ordinal walker skips compiler-emitted style fallback markers. |
| packages/webui-framework/src/element/markers.test.ts | Add test ensuring data-webui-resource elements are skipped in ordinal counting. |
| packages/webui-framework/src/component-asset/resources.ts | Remove legacy component-asset templateStyles importmap injection implementation. |
| packages/webui-framework/src/component-asset/loader.ts | Switch component-asset loading to required componentStyles + graph validation. |
| packages/webui-framework/src/component-asset/asset.ts | Bump component assets to v3 and require componentStyles. |
| packages/webui-framework/src/component-asset.test.ts | Update component asset tests to validate v3 + componentStyles installation/validation. |
| packages/webui-framework/RENDERING.md | Update rendering spec docs for componentStyles/closures and Light default. |
| packages/webui-framework/README.md | Update authoring + DOM selection docs and remove sa references. |
| examples/app/service-worker/scripts/check-render.ts | Update render check to assert new data-webui-resource style markers. |
| examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.html | Wrap slot usage in Shadow opt-in template. |
| examples/app/contact-book-manager/src/atoms/cb-button/cb-button.html | Wrap slot usage in Shadow opt-in template. |
| examples/app/commerce/server/src/server.rs | Update server tests to validate new componentStyles resource shapes. |
| docs/guide/why.md | Refresh platform-primitive messaging for Light default + scoped CSS. |
| docs/guide/integrations/wasm.md | Update sample component to wrap <slot> in Shadow opt-in template. |
| docs/guide/integrations/rust.md | Document dom default Light and Shadow selection rules. |
| docs/guide/integrations/node.md | Update Node integration defaults + Shadow opt-in guidance. |
| docs/guide/index.md | Update guide intro to reflect Light default + optional Shadow. |
| docs/guide/concepts/routing.md | Update routing payload examples/field table to componentStyles. |
| docs/guide/concepts/react-comparison.md | Update styling comparison for scoped Light + Shadow opt-in. |
| docs/guide/concepts/plugins/index.md | Update plugin contract to include ComponentTemplateContext with effective DOM mode. |
| docs/guide/concepts/performance.md | Update DOM-mode guidance to reflect Light default and Shadow selection. |
| docs/guide/concepts/interactivity.md | Update template wrapper guidance and styling section for new model. |
| docs/guide/concepts/how-it-works.md | Update SSR/hydration description for Light default + component style installation. |
| docs/guide/concepts/components/index.md | Update component authoring docs for Light default and Shadow opt-in rules. |
| docs/guide/concepts/best-practices.md | Update best practices for Light default and slot-in-Shadow constraint. |
| docs/guide/cli/index.md | Update CLI docs for --dom default and new CSS resource delivery contract. |
| docs/ai/SKILL.md | Update AI authoring guidance for Light default + slot-in-Shadow rule. |
| docs/.webui-press/components/code-comparison/code-comparison.html | Wrap slots in Shadow opt-in template for docs component. |
| crates/webui/src/server.rs | Update Rust server tests to validate componentStyles resources across CSS strategies. |
| crates/webui/src/component_assets/serialize.rs | Emit v3 component assets with componentStyles resources + closures. |
| crates/webui/src/component_assets/render.rs | Thread protocol context into asset rendering options. |
| crates/webui/src/component_assets/payload.rs | Render style resources based on CssStrategy instead of legacy templateStyles importmap. |
| crates/webui/src/component_assets.rs | Prune/validate style closures when retaining entry protocol; add tests. |
| crates/webui/README.md | Update Rust crate README for Light default and doc table updates. |
| crates/webui-wasm/src/parser.rs | Snapshot/apply effective component DOM strategies and style closure metadata in WASM parser output. |
| crates/webui-wasm/src/lib.rs | Update WASM tests for slot-in-Shadow and new style marker output. |
| crates/webui-protocol/src/gen_webui.rs | Add effective_dom_strategy, style_closures, and flip DomStrategy enum values (Light=0). |
| crates/webui-protocol/proto/webui.proto | Update proto schema for effective_dom_strategy + style_closures; flip DomStrategy enum ordering. |
| crates/webui-press/README.md | Update docs generator narrative for Light default + Shadow opt-in. |
| crates/webui-press/components/webui-press-tabs/webui-press-tabs.html | Wrap slots in Shadow opt-in template. |
| crates/webui-press/components/webui-press-tab/webui-press-tab.html | Wrap slots in Shadow opt-in template. |
| crates/webui-press/components/webui-press-tab-panel/webui-press-tab-panel.html | Wrap slots in Shadow opt-in template. |
| crates/webui-press/components/webui-blockquote/webui-blockquote.html | Wrap slots in Shadow opt-in template. |
| crates/webui-press/components/code-block/code-block.html | Wrap slot component in Shadow opt-in template. |
| crates/webui-parser/src/plugin/webui.rs | Resolve/store per-component effective DOM strategy and remove sa emission. |
| crates/webui-parser/src/plugin/mod.rs | Extend plugin contract with ComponentTemplateContext and require effective_dom_strategy in artifacts. |
| crates/webui-parser/src/plugin/fast_v3.rs | Plumb effective DOM strategy through FAST v3 artifacts. |
| crates/webui-parser/src/plugin/fast_v2.rs | Plumb effective DOM strategy through FAST v2 artifacts. |
| crates/webui-parser/src/diagnostic.rs | Add diagnostic codes for Light DOM slot, invalid Shadow wrapper, unsupported light CSS, etc. |
| crates/webui-parser/src/component_registry.rs | Preserve authored CSS for diagnostics when processed CSS is replaced. |
| crates/webui-parser/benches/parser_bench.rs | Update benches for new parser options + add Light CSS boundary benchmark. |
| crates/webui-node/src/lib.rs | Update Node binding docs/tests for Light default and slot constraints. |
| crates/webui-handler/src/streaming/session.rs | Track CSS strategy + style closure roots during streaming; ensure style metadata present. |
| crates/webui-handler/src/streaming/checkpoint.rs | Emit componentStyles at checkpoints; gate module importmap emission by strategy. |
| crates/webui-handler/src/html_encode.rs | Add safe CSS style text writer to prevent premature </style> termination. |
| crates/webui-handler/README.md | Update handler README for componentStyles return shape. |
| crates/webui-handler/benches/bootstrap_state_bench.rs | Update benchmark payload shapes to include componentStyles. |
| crates/webui-cli/src/commands/serve.rs | Update comment for JSON partial contents. |
| crates/webui-cli/src/commands/common.rs | Default CLI --dom to Light and add parsing tests. |
| crates/webui-cli/src/commands/build.rs | Default build command to Light; update tests and asset version assertions. |
| .github/skills/webui-dev/SKILL.md | Update skill guidance to reflect Light default + Shadow opt-in wrapper rule. |
| .github/skills/testing/SKILL.md | Update fixture guidance for Light default and Shadow opt-in patterns. |
…vestigate-light-dom
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 111 out of 111 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/webui/src/component_assets.rs:210
- This new unit test asserts on a specific error string fragment ("requires missing ..."), which is brittle and will break on harmless wording changes. Prefer asserting on the error variant and (optionally) a stable substring describing the condition, rather than the full rendered error text.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Associate nested route dependencies with the component that owns their outlet so Light styles are precomputed for the CSS tree where SSR renders them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove the global DOM strategy from every build surface. Derive Shadow ownership only from a sole authored open declarative root while retaining compact per-component metadata for rendering, hydration, and CSS closures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Avoid repeated parser source clones, graph walks, asset closure duplication, partial and streaming CSS payloads, and browser registry/DOM work while preserving inventory correctness and deterministic style ordering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 164 out of 164 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/webui-framework/tests/fixtures/README.md:97
- The fixtures README says
webui.config.jsonsupports adomkey, but the fixture renderer only readscssandscript(see packages/webui-test-support/src/fixture-render.ts:62-83 and :113-120). Keepingdomhere is misleading and suggests a build option that no longer exists.
Document only the fixture build keys consumed by the renderer now that global DOM selection has been removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 166 out of 170 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/webui-router/src/router.ts:67
- isRouteStyleMarker requires a
data-webui-strategyattribute, but the client runtime’s style installer (packages/webui-framework/src/element/styles.ts, appendResource) only setsdata-webui-resourceon inserted<link>/<style>markers (SSR may include the strategy attribute, but client-installed markers often won’t). As a result,mountedRouteComponent()/clearRouteContent()can treat real style markers as ordinary content and remove them when remounting routes, breaking route-scoped CSS on navigation.
`owns_component_styles` asked plugins a policy question the core had to act on, putting FAST's runtime model inside the generic `ParserPlugin` trait and making the parser inject markup on a plugin's behalf. Replace it with data. `ComponentTemplateContext` now carries the resolved `ComponentStyleDelivery` (Link href, inline CSS, or adopted specifier) alongside `uses_shadow_dom`, and the parser injects nothing. WebUI ignores the field because the handler installs its precomputed closures; FAST reads it and places the snippet inside the Shadow template its runtime uses to build roots. Delivery is reported only for authored Shadow components: Light CSS is Document-owned and its `@scope` root cannot match from inside a runtime-created root. Fold the duplicated f-template serializers in fast_v2/fast_v3 into a single `build_f_template`, so the on-demand route path and the captured template share one injection site. Also revert the dotted-tag CSS escaping: component names must contain a hyphen and dotted names are out of contract, so the escape was dead weight on a build-time path. The length-delimited keyframe naming stays. Evidence: `webui inspect --format json` of the todo-fast build (two authored-Shadow FAST components with CSS) is byte-identical across the change at 12,565 B, and both emitted stylesheets match by SHA-256. Raw protocol.bin bytes differ run-to-run with an unchanged binary, so they are not a signal here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 165 out of 170 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/webui-cli/src/utils/output.rs:312
- After switching force_colors() to an RAII guard, these tests should stop manually toggling console::set_colors_enabled(prev) and just keep the guard alive for the duration of the assertion block.
let (_guard, prev) = force_colors();
let (_display, message) = build_error_renderings(&template_error());
console::set_colors_enabled(prev);
crates/webui-cli/src/utils/output.rs:328
- After switching force_colors() to an RAII guard, this test can just bind the guard for the scope and avoid manually restoring console color state.
let (_guard, prev) = force_colors();
let (display, _message) = build_error_renderings(&template_error());
console::set_colors_enabled(prev);
crates/webui-cli/src/utils/output.rs:288
- force_colors() relies on callers to restore console::set_colors_enabled(prev). If the test panics between enabling and restoring, the process-global color setting can leak into later tests (even though the mutex prevents concurrent runs). Prefer an RAII guard that restores the previous state in Drop so restoration happens even on panic.
This issue also appears in the following locations of the same file:
- line 310
- line 326
fn force_colors() -> (MutexGuard<'static, ()>, bool) {
let guard = COLOR_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = console::colors_enabled();
Style recalculation was measured against eight alternative @scope shapes on the heaviest commerce route. Every shape that preserves the isolation contract is equal or slower than the one already emitted, and the lower boundary is a saving rather than a cost: removing it, or making it implicit, costs 5.4% more recalculation because it prunes nested component subtrees out of the scope. Per-element marker stamping is 25-27% faster but fails a computed-style equivalence check, since the added class or attribute raises specificity and changes the cascade against entry CSS. Record both facts where the pessimization would otherwise look like a simplification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 165 out of 170 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
crates/webui-parser/src/component_registry.rs:326
- replace_css_content clones the entire pre-transform CSS into authored_component_css, temporarily doubling memory for every transformed stylesheet. Since replace_css_content is already overwriting component.css_content, you can move the existing String out with take() instead of cloning it.
/// Replace a component's processed CSS after its boundary-specific transform.
pub(crate) fn replace_css_content(
&mut self,
tag_name: &str,
css_content: String,
) -> Result<()> {
let component = self.components.get_mut(tag_name).ok_or_else(|| {
ParserError::NotFound(format!(
"component <{tag_name}> disappeared before CSS compilation"
))
})?;
if let Some(authored) = component.css_content.as_ref() {
self.authored_component_css
.entry(tag_name.to_string())
.or_insert_with(|| authored.clone());
}
component.css_content = Some(css_content);
The router treats only <link>/<style> nodes carrying data-webui-strategy as route-owned style markers, so an importmap emitted as a direct child of a route element would be cleared on navigation and mistaken for the mounted component. Emission already places it inside the component's own Light DOM; assert that position so the router's assumption stays true. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 165 out of 170 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/webui-handler/src/html_encode.rs:87
- write_style_text currently escapes any case-insensitive "</style" prefix, even when it would not terminate a raw-text <style> element (e.g. "</stylesheet" or "</stylex"). HTML only treats it as an end tag when the next byte is EOF, whitespace, '/', or '>', so the current logic can unnecessarily mutate authored CSS string/comment content.
while index + 7 <= bytes.len() {
if bytes[index] == b'<'
&& bytes[index + 1] == b'/'
&& bytes[index + 2].eq_ignore_ascii_case(&b's')
&& bytes[index + 3].eq_ignore_ascii_case(&b't')
replace_css_content overwrites css_content on the next line, so cloning the authored bytes allocated and copied a second full stylesheet for every transformed component. Take the existing String instead. Also record why isRouteStyleMarker can require data-webui-strategy: only the server writes markers as direct route children and it always emits the attribute, while the client installer appends into a Document head or ShadowRoot and importmaps go inside the component host. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An earlier note recorded that per-element marker stamping renders differently by raising selector specificity. Re-measuring with an identity-rebuild control shows that claim came from a harness bug: the harness gave each host its own marker, but inside @scope a bare selector is relative and never matches the scoping root. Correctly stamped descendants reproduce the current computed styles exactly. Record what the measurements actually support: the plain-descendant host prefix leaks into nested components and is strictly dominated, while stamping is exact and faster but cannot mark DOM created outside a compiled template, which @scope covers natively. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the native `@scope (tag[data-wl]) to (:scope [data-wl] > *)` enclosure with per-element scope markers stamped at build time. Every element a Light component's template declares receives a hashed `data-wl-<id>` attribute, and every top-level compound in that component's CSS is qualified with a zero-specificity `:where([data-wl-<id>])`. Measured on the commerce example, this removes the 27-35% style recalculation cost that Blink's `@scope` activation bookkeeping added, in exchange for roughly +200 bytes of compressed markup per document. Computed styles are identical: ~12,500 declarations per route match byte-for-byte against an identity-rebuild control on two routes. Split the transform into four layers so a future minifier or dead-selector pass has the primitives it needs: `css_scan` produces byte-level tokens, `css_selector` walks top-level compounds with roles, `css_boundary` applies the transform, and `light_scope` derives markers and stamps HTML. Stamping is what makes dead-selector analysis sound, since a rule is now statically bounded to exactly one template. Also fix an SSR bug the new fixture test surfaced: a valueless `data-wl-*` marker on a nested component host was silently dropped, because component elements skip `data-`-prefixed attributes and only re-emit the ones carrying a value. Reserved markers are compiler-owned and must reach SSR bytes verbatim, so they are now excluded from the skip set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Build-time stamping can only mark elements a compiled template declares. A
template with a raw binding (`{{{expr}}}`) interpolates author-supplied markup
at render time, so those elements carry no marker and every marker-qualified
selector silently stops matching them.
This was caught by a computed-style equivalence harness run against two live
builds: of 642,539 declarations compared across four commerce routes, 8
diverged, all on one element. `<div class="product-description">
{{{descriptionHtml}}}</div>` stamped the div but not the interpolated `<p>`,
so `.product-description p { margin: 0 0 1.5rem }` stopped applying and the
paragraph fell back to the UA default.
Make the boundary a per-component decision instead of a global one. `LightScope`
has two shapes and the compiler picks the strongest the component's DOM permits:
`Stamped` when the rendered DOM is fully build-time known, `Enclosed` (the
native `@scope` prelude, which resolves membership at match time) when it is
not. The two shapes differ in only three places -- whether compounds are
qualified, what `:host` lowers to, and whether the body is wrapped -- so the
selection routes through `Stamper` without a second transform.
Detection is core, not plugin-specific: `{{{` is `HandlebarsParser`'s raw-signal
syntax, and binding `innerHTML`/`outerHTML`/`srcdoc`/`content` is already
blocked, so it is the only sanctioned dynamic-HTML path. The test is a
conservative substring check, so a literal `{{{` in text costs the fast path but
never correctness.
The equivalence harness now reports 0 of 642,539 declarations different, and
the win survives: on the commerce example only `mp-page-product` takes the
enclosure. Style recalculation is 11-14% faster at load (88-90% paired win
rate) and 5-8% faster across a route change (76-88%), at +3.9-4.7% compressed
document bytes.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A nested at-rule inherits the enclosing style block's kind, so the scoping
pass treated `@media (min-width: 1px)` as a selector list and spliced the
component marker into it, emitting `@media:where([data-wl-x]) (min-width:
1px)`. Browsers drop the whole at-rule, silently losing every declaration
inside it. The same applied to nested `@supports`, `@container`, `@layer`,
`@starting-style`, and `@scope`.
CSS nesting with a nested at-rule is how responsive component CSS is
written, so this would have hit the first app that used it. It did not
surface earlier because no CSS in the repository nests an at-rule, which is
also why the computed-style equivalence run reported no divergence: the
corpus never reached the broken path.
Track the pending at-rule prelude alongside its block start and qualify a
prelude only when it is a real selector list. A nested `@scope` now also
routes to the prelude-aware path instead of being stamped as a compound.
Also exempt custom properties from the nested-rule heuristic: a custom
property value is an arbitrary token stream that may legally contain
braces, so `--x: { color: red }` was being rewritten as a selector.
Add a modern-CSS corpus pinning the exact output for both shapes, and two
output-side invariants: stamping may only insert qualifiers, and no
qualifier may land between an at-keyword and the brace it opens. Both
placement tests fail without this fix.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolve six conflicts against microsoft#430 (root bindings on the host element) and microsoft#432 (single pre-order SSR hydration pass), and fix one interaction the merge exposed. `buildSSRIndex` in microsoft#432 replaced the per-parent ordinal walk with a flat pre-order pairing but did not carry over `findByOrdinal`'s rule that a compiler-emitted `data-webui-resource` element is server-only. That rule was dormant on main because nothing rendered such an element ahead of a bound one. Making Light the default moves inline component CSS into the render root, so a Shadow component now serves `<style data-webui-resource>` as the first child of its shadow root. The walk counted it, shifting every binding onto the previous element's node: `host-interactive` wrote its label into the `<style>` and its counter into the indicator. Verified on a clean `origin/main` worktree that all six affected specs pass there, confirming this is a merge interaction rather than a defect on either side alone. Documentation conflicts kept this branch's Light-default framing while taking main's corrections about root bindings living on the host element. The `sa` row main re-added to the metadata table is left out: this branch removed adopted-stylesheet delivery, and no emitter, consumer, or type remains. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The stamper rewrites developer CSS, so the risk that matters is silent corruption of syntax it predates. The existing corpus pins exact output, which makes each new case expensive to add - and an expensive corpus stops being extended, which is how a gap hides. Add MODERN_CSS_INVARIANTS: inputs with no expected output. Three invariants derive their own oracle from each entry, so a new construct costs one line and still gets full coverage. Add the third invariant, closing the last uncovered regression class: a qualifier placed after a pseudo-element emits invalid CSS, and stripping the qualifier undoes the mistake, so the round-trip invariant cannot see it. Verified by mutation - forcing compound-end insertion fails the test with the offending selector named. Extend rejects_global_at_rules with @Property, @position-try, @scroll-timeline, and @font-feature-values to document that unrecognized at-rules fail closed rather than being rewritten. Test-only; no behavior change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Fifteen findings from a correctness, performance, and maintainability pass.
Correctness, parser
- `:host` nested inside a functional pseudo-class (`:not(:host)`, `:is(:host,
.a)`) cannot be represented by stamping: the host carries `data-wl` while
its descendants carry `data-wl-<id>`, so no single zero-specificity token
bounds both branches. Such components now take the `@scope` enclosure,
chosen by `stamping_is_representable` and memoized per tag so a component
first reached through a CSS-less path cannot change shape mid-build. The
rewriter fails loudly if the two ever disagree.
- CSS escape sequences are consumed whole. `\{`, `\(`, and `\:host` were read
as real structure, which desynced the depth counters for the rest of the
stylesheet.
- A `//` line comment no longer swallows a `}` on the same line. `//` is a
WebUI dialect extension, so the brace still closes its block; swallowing it
left the block open and silently un-scoped everything after it.
- Statement-form `@layer a, b;` passes through verbatim instead of being
rejected. It declares only cascade order - no selector list, no block.
- The equivalence corpus now derives a host-inclusive oracle, closing the gap
that hid the nested-`:host` bug.
Correctness, handler and protocol
- A component already covered by a shared CSS chunk is no longer re-shipped
inline. Coverage is computed from the chunk index rather than from
traversal order, so the result no longer depends on which route is rendered
first. `WebUIProtocol::style_chunk_index()` is now the single definition of
"already covered".
Correctness, framework
- `loadWebUIDataBlock` publishes parsed state and templates before registering
component styles. A rejected `componentStyles` payload discarded a
successful parse and left the block to be re-parsed on every subsequent
read.
Performance
- `directResourceMarkers` caches its scan per style target, keyed on
`childElementCount`, instead of walking the scope's children on every
install.
- Component-asset styles are validated and deep-copied once and memoized by
payload identity, instead of once in `validateAsset` and again in
`prepareComponentPayload`.
Maintainability
- A DEV-only warning fires when two style closures disagree on resource
order, which is otherwise silent and position-dependent.
- A missing-template error lists every missing tag with remediation, rather
than the first one found.
- `installComponentStyles` documents why an unknown root returns quietly but
an unknown resource throws.
- The framework's `dispatchTemplatesRegistered` drops its unused
`componentStyles` parameter. The router's separate dispatcher still
attaches styles when the framework bridge is absent; that path is live and
stays.
Two findings were resolved as non-defects and are recorded rather than
changed: `closure.component_tags` is read by five call sites and cannot be
cleared, and `css_boundary.rs` is left intact - moving the riskiest file in
the change right before merge hides more than it reveals.
DESIGN.md and docs/ai/SKILL.md record the two behavior changes: statement-form
at-rule passthrough, and CSS shape as a second reason a component takes the
enclosed path.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The example server could select a CSS strategy but not enable bundling, so there was no way to A/B `--css link` against `--css link --css-bundle` on a realistic 26-component app. Thread `css_bundle` through `ApiArgs`, `AppState::load`, and `FrontendRuntime::load` into `BuildOptions`. `serve_asset` already serves anything in `build_result.css_files` by relative path, so emitted chunk files need no extra wiring. Measured on the home route at 40 ms emulated RTT, 20 paired iterations, bundling won every iteration: FCP -23.1%, LCP -17.2%, DOM interactive -24.3%, CSS requests 13 -> 9, and CSS bytes -14.3% because fewer larger files compress better than many small ones. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bundling's value is a shared chunk that stays cached across routes and deploys, but the default asset filename template is [name].[ext], so a chunk keeps the same URL after its bytes change and cannot carry a long immutable Cache-Control. Point at --asset-file-name-template and record what bundling actually buys over HTTP/2, where request count is multiplexed rather than head-of-line blocked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Integrate the render-policy ladder from microsoft#425 with the Light DOM invariant and CSS bundling from microsoft#429. - Protocol: keep `component_render_css` at field 9 and renumber the branch fields to `style_closures = 10` and `style_chunks = 11`. Field 5 stays reserved for the removed `dom_strategy`. - Parser: a policy `<template>` wrapper is unwrapped for Light components and retained only when the author declared `shadowrootmode="open"`. Policy attributes are stripped on both paths by `strip_template_build_attrs`. - Handler: emit the render-policy `<style>` before the style closures so authored component CSS wins ties. Drop the per-component `<link>` loop, which the closures supersede. - Framework: combine `$installStyles` with the hydration barrier so a deferred element installs its styles on activation. - Docs: replace every `--dom=light` / `--dom=shadow` reference with the authored-wrapper rule and closure-based cross-boundary delivery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The merge with main brought PR microsoft#425's lazy-hydration fixtures onto a branch where Light DOM is the default, leaving four E2E regressions and one type error. Drop the dead `dom` passthrough in the shared fixture renderer and the matching config key. `BuildOptions.dom` no longer exists, so assigning it failed `typecheck:e2e` and aborted the whole webui-framework E2E job before Playwright ever started. Give `test-shadow-policy-parent` an authored `<template shadowrootmode="open">`. Its purpose is to prove render-policy CSS crosses a Shadow boundary, so it is exactly the case that must opt in now that wrappers are the only way to get a shadow root. Query the Light DOM in the three specs that reached through `shadowRoot` into `test-streamed-lazy-parent` and `test-lazy-item`. Neither component needs a shadow root, and the surrounding assertions already use Playwright locators that pierce either tree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Light DOM needs a complete CSS delivery path without a build-wide DOM policy switch. This change makes DOM ownership a component authoring invariant: every unwrapped component is Light DOM, while a sole authored
<template shadowrootmode="open">opts that component into Shadow DOM.What changed
DomStrategy,--dom, and the corresponding Rust, Node, WASM, CLI, and config surfaces. The compiler never generates Shadow wrappers and there are no legacy aliases or fallback readers.uses_shadow_domcomponent fact for SSR, streaming, hydration, client-created components, component assets, and CSS-tree cut points.@scopeenclosure for components that render opaque HTML — and deliver orderedcomponentStylesclosures consistently through full SSR, progressive streaming, partial navigation, routed outlets, and component assets.<outlet>, including delegated outlets inside nested Light or Shadow components.this.$emit()always bubbling and composed so a Light child inside an authored Shadow tree can reach root event handlers on the Shadow host.<slot>in unwrapped components and reject malformed, closed, dynamic, or incorrectly placed Shadow wrappers with actionable diagnostics.Performance hardening
Controlled A/B benchmarks
Final head
3bf63b4ewas compared with pre-hardening headfc11de9fusing the same fixtures. Runs were pinned to one CPU, used a 3-second warmup plus an 8-second measurement window, and combined two reversed-order runs into 100 raw samples per variant.Parser latency
Style mean throughput improves 8.66%. Link's mixed tail movement is within host noise and does not support a material performance claim.
Component assets
The overlapping-root fixture's mean throughput improves 12.45%. Its P99 gain is outlier-sensitive; P50-P95 are the defensible headline. Single-root output and latency remain effectively unchanged.
Final Contact Book percentiles
Values are the median of three fresh runs; brackets show the minimum-to-maximum run range.
Delivery-size result
For Contact Book in Style mode, an inventoried repeat partial drops from 26,828 B to 1,934 B, saving 24,894 B (92.77%). Resources go from 7 to 0, closures from 5 to 0, templates from 5 to 0, and CSS characters from 16,615 to 0.
The shared Windows host produced substantial scheduler noise on unpinned sub-0.1 ms work, so P99 outliers are not used as headline claims.
Style mode still includes first-delivery CSS in both SSR fallback markers and the bootstrap catalog. Exact repetition compresses well, and Link remains the default strategy; removing the decompressed duplication requires a different hydration/recovery contract and is intentionally outside this change.
Browser UI responsiveness
End-to-end browser A/B against
main's merge baseacb7c8f0(every component wrapped in a generated shadow root) versus this branch (Light DOM invariant). Both arms are the commerce example (26 components), each rebuilt from its own commit with its own@microsoft/webui-frameworkand@microsoft/webui-routeroutput, served in--css linkmode by a releasemarketplace-api.Both servers run concurrently, and a single Chromium process alternates arms every iteration, so thermal drift and background load hit both arms equally.
Δp50is the head-versus-base median change;win%is the paired rate at which head beat base in the same iteration, which separates real effects from host noise.Structural result (deterministic, 100% paired win rate)
<link rel=stylesheet>elements in///product/:slugEvery component previously re-linked its stylesheet into its own shadow root, so the same CSS produced 83 link elements and 71 separate style resolvers. Light DOM resolves each sheet once per CSS tree.
Core Web Vitals, home page (60 paired iterations)
Medians are flat to slightly better; the tail is consistently better, with FCP P95 improving 17.2%. No long tasks and no layout shift in either arm.
Core Web Vitals, heaviest page (product detail, 20 paired iterations)
The product page has the deepest component tree, where a scoped-matching regression would be most visible.
Head is better on the heavy page across the board, and the advantage grows with component depth rather than shrinking.
Interaction latency (SPA route change, click to first paint after commit)
User-visible interaction latency is unchanged, and the script and layout work behind it drops.
Light CSS scoping: two shapes, chosen per component
Light DOM has no native style boundary, so the compiler builds one. It picks the strongest shape each component's DOM permits:
data-wl-<id>attribute, and every top-level compound is qualified with a zero-specificity:where([data-wl-<id>]). The boundary compiles away into ordinary selector matching.@scope (<tag>[data-wl]) to (:scope [data-wl] > *), which resolves membership at match time.A component takes the enclosure when its template contains a raw HTML binding (
{{{expr}}}), because that interpolates author-supplied markup at render time — elements that exist in no compiled template and therefore cannot be stamped. On the commerce example that is exactly one of 26 components.This was not a design guess. An earlier revision stamped unconditionally, and a computed-style equivalence harness against two live builds caught it: of 642,539 declarations compared across four routes, 8 diverged, all on one element.
<div class="product-description">{{{descriptionHtml}}}</div>stamped thedivbut not the interpolated<p>, so.product-description p { margin: 0 0 1.5rem }stopped matching and the paragraph fell back to the UA default. With the dual strategy the same harness reports 0 of 642,539 different.Stamped vs. enclosed, measured on real builds
Two release
marketplace-apibinaries, each compiled from its own commit, served concurrently in--css linkmode while one Chromium process alternated arms every iteration. Base is the all-@scopebuild; head is this branch.Recalculation count is identical in every pass, so each recalculation is genuinely cheaper — Blink never computes scope activations for a stamped component. Node count, element count, stylesheet count, and CSS-tree count are byte-identical between arms; TBT and CLS are zero in both.
The cost is markup: +3.9% to +4.7% compressed document bytes (9,630 → 10,079 B on
/, 11,957 → 12,418 B on/product/:slug), a deterministic 0% win rate. Interaction latency, layout, script time, and heap are within noise.This reverses the one regression an earlier revision of this PR reported. That revision measured
@scopeat +0.22 ms style recalculation at load and +0.47–1.00 ms across a route change; stamping removes it and lands ahead of the pre-PR shadow-root baseline.Shapes that were measured and rejected
Eight alternative shapes were measured in-page on the heaviest route (13 scopes, 348 elements), rotating variant order across 40 iterations and reading
RecalcStyleDurationper full-document restyle.to (:scope [data-wl] > *)@scopebaselineto ([data-wl] > *)[data-wl]:where()-wrapped limit@scopeTwo findings shaped the result:
@scopelimit is a saving, not a cost. Removing it is 5.4% slower, because it prunes nested component subtrees out of the scope and shrinks the element set Blink computes activations for. The obvious "simplification" is a pessimization, so the prelude is pinned with a comment incss_boundary.rsand a note inDESIGN.md.@scopebut slower than stamping, and it is wrong: a computed-style fingerprint over every element caught it leaking parent rules into nested components on both/and/product/:slug(20–30 divergent declarations, e.g. a nestedmp-pricesized 62 px → 78 px). Rejected permanently.Route CSS delivery was also audited for waste: across
/,/search, and/product/:slug, every shipped scope matches at least one host on the page (0 unmatched scopes, 0 dead bytes), so there is nothing to prune.Authoring trade-off
Stamping marks only elements a compiled template declares. Two behaviors follow, both documented in
DESIGN.mdanddocs/ai/SKILL.md:el.innerHTML = '<div class="x">') carries no marker and is not styled by that component's CSS. Declaring markup in the template — including through<if>and<for>— is scoped automatically, and creating a component host from script is unaffected, since its content comes from the compiled template.The stamped shape is also the one a future minifier or dead-selector pass needs: a stamped rule is statically bounded to exactly one template, so whether it can ever match is decidable at build time. Enclosed components are not eligible for those passes.
SSR response latency (400 paired keep-alive requests per path)
//search/shirts/product/:slugServer render is unchanged: medians differ by 7-10 µs with near-coin-flip win rates, and P95/P99 are equal or better on head while emitting 4-7% fewer bytes.
Shared CSS bundle delivery
--css-bundleas an opt-in build and serve flag that composes with Link and Style delivery. Module builds reject it because they have no external stylesheet requests to merge.members, the browser records those member aliases as installed, and streaming tracks exact chunk IDs so hydration never adds redundant fallback requests._chunk-*IDs for multi-member chunks and escape case-insensitive</stylesequences in FAST inline CSS.A live Contact Book build loaded one shared chunk plus three singleton chunks on the dashboard. Every CSS response returned 200, hydration requested no retained component fallback, and a dashboard-to-contacts-to-dashboard route round trip produced no duplicate stylesheet request.
Breaking changes
DomStrategyenum is removed; the former root field number and name are reserved, and legacy JSON DOM fields are rejected.componentStyles; legacytemplateStyles, strategy inference, and WebUIsafallbacks are unsupported.Closes #410
Closes #433