Skip to content

feat: make Light DOM a global CSS opt-in - #429

Merged
Mohamed Mansour (mohamedmansour) merged 33 commits into
microsoft:mainfrom
mohamedmansour:mmansour-microsoft-investigate-light-dom
Aug 23, 2026
Merged

feat: make Light DOM a global CSS opt-in#429
Mohamed Mansour (mohamedmansour) merged 33 commits into
microsoft:mainfrom
mohamedmansour:mmansour-microsoft-investigate-light-dom

Conversation

@mohamedmansour

@mohamedmansour Mohamed Mansour (mohamedmansour) commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Current behavior

WebUI uses Shadow DOM for unwrapped components by default. This PR makes explicit --dom=light a first-class mode for authored/global Light CSS:

  • Unwrapped components under --dom=light render directly into the host and use ordinary CSS in their owning Document or ShadowRoot CSS tree.
  • A sole bare top-level <template> is an explicit Light-mode wrapper even when the fallback is --dom=shadow; WebUI removes the wrapper before rendering and hydration.
  • A sole <template shadowrootmode="open"> explicitly selects native Shadow DOM in either build mode.
  • Templates with attributes, w-render/w-hydrate policy wrappers, and nested templates do not select a mode; they retain their ordinary/policy meaning.
  • Light CSS is not selector-rewritten, marker-scoped, wrapped in compiler-generated @scope, or namespaced for keyframes/layers. Normal cascade and inheritance apply across Light components in the same CSS tree.
  • :host, :host(...), :host-context(...), and ::slotted(...) in effective Light CSS fail at build time with unsupported-light-css and actionable help to use a normal selector or opt into Shadow.
  • Ordered CSS closures remain the delivery contract across SSR, routes, streaming, partial navigation, hydration, and component assets. Link/Style/Module delivery, compatible bundling, active Document head hoisting, and ShadowRoot preloads are preserved.
  • FAST 2/3 continues to require effective Shadow components; the WebUI plugin supports global Light CSS.

Benchmark: representative 432-instance fixture

Eight browser cycles and eight release-server cycles were run on the current head with 48 component types, nested Light/Shadow variants, shared selectors, container queries, and bundled/unbundled Link delivery. Computed-style parity was identical across all 48 component types.

Metric Light Shadow Light delta
Release render, unbundled 0.09 ms 0.12 ms -29.5%
Release render, bundled 0.08 ms 0.12 ms -30.5%
SSR output 1.09 MiB 1.15 MiB -5.1%
Protocol, unbundled 140.91 KiB 144.06 KiB -2.2%
Bundled DOMContentLoaded 39.85 ms 546.60 ms -92.7%
Bundled stylesheet requests 1 48 -97.9%
Bundled style recalculation 4,170.52 ms 849.03 ms +391.2%
Bundled host class workload 317.05 ms 139.90 ms +126.6%
Bundled host attribute workload 354.85 ms 178.95 ms +98.3%

Global Light improves server rendering, output size, stylesheet consolidation, and document readiness. Native Shadow remains the stronger CSS-engine boundary for CSS-heavy or frequently restyled trees; this PR does not claim Light is universally faster.

Validation

  • cargo xtask check
  • 601 parser tests
  • 273 framework unit tests
  • 222 framework E2E tests
  • 28 router E2E tests
  • 58 Node package tests
  • 24 WASM tests

Closes #410
Closes #433

Copilot AI lite review requested due to automatic review settings August 6, 2026 07:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, versioned componentStyles (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.

Comment thread packages/webui-router/src/templates.ts
Comment thread packages/webui-framework/src/component-asset/loader.ts
Copilot AI review requested due to automatic review settings August 6, 2026 08:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 6, 2026 19:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 6, 2026 21:15
@mohamedmansour Mohamed Mansour (mohamedmansour) changed the title feat: make Light DOM the default rendering mode feat: make Light DOM the component invariant Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 6, 2026 22:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.json supports a dom key, but the fixture renderer only reads css and script (see packages/webui-test-support/src/fixture-render.ts:62-83 and :113-120). Keeping dom here is misleading and suggests a build option that no longer exists.

Copilot AI review requested due to automatic review settings August 6, 2026 23:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 164 out of 164 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-strategy attribute, but the client runtime’s style installer (packages/webui-framework/src/element/styles.ts, appendResource) only sets data-webui-resource on 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.

Copilot AI review requested due to automatic review settings August 7, 2026 05:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

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>
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>
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>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reintroduce the build-time DOM fallback across Rust, CLI, Node, and WASM while preserving per-component mixed roots and hardening scoped CSS, style delivery, streaming, deferred assets, and router lifecycle behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Share the style-closure walk, the CSS strategy wire name, and the FAST
template builder instead of keeping per-call-site copies, and drop the
exports and helpers the pivot left unreferenced.

- Add `WebUIProtocol::style_closure_unit` and `style_closure_unit_count`
  so all three delivery walks resolve closure members identically. The two
  handler walks now consult `style_chunk_index`, which its own docs already
  named the single definition of "covered by a chunk"; chunk-root closures
  keep byte-identical output.
- Carry `style_chunk_index` on `WebUIProcessContext` so it is built once
  per render. It cannot live on `RouteHandlerProtocol`, which owns the
  protocol it would borrow from.
- Add `CssStrategy::wire_name`; prost's `as_str_name` yields the uppercase
  proto identifier, which the client runtime does not accept.
- Delegate `render_style_resource` to `component_style_resource` and drop
  the now-redundant strategy parameters.
- Move `build_f_template`, `f_template_style_injection`, and
  `minify_inter_tag_whitespace` into `fast_v3`, extending the existing
  `fast_v2` -> `fast_v3` import, and delete the duplicate tests.
- Add `sameComponentStyleClosure` and a `queueModule` helper, fix the
  router's `sameComponentStyleResource` to compare members like the
  framework's, and de-export `readNonce`, `isRouteStyleMarker`, and
  `hasRegisteredComponentStyleClosure`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Make explicit --dom=light use ordinary authored CSS in the owning Document or
ShadowRoot instead of compiler-generated selector and template scoping.

- Remove the Light boundary compiler, scope marker stamping, and marker runtime
  writes; delete the now-unused selector/scope modules.
- Reject :host, :host-context, and ::slotted in effective Light CSS with the
  existing unsupported-light-css diagnostic instead of rewriting them.
- Keep authored Shadow CSS native and preserve CSS-tree closures, ordered
  bundling, route head hoisting, ShadowRoot preloads, streaming, navigation,
  and component assets.
- Generate lazy-render policy CSS as a normal tag selector for Light trees and
  retain the Shadow host form for Shadow trees.
- Update mixed-mode tests, benchmark fixtures, DESIGN.md, and user docs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Review the authored Light CSS refactor for dead code, false positives, and stale
contract documentation.

- Parse CSS into selector preludes before checking Shadow-only pseudos, so
  declaration values such as `--state:host` and `url(:host)` remain valid.
- Keep the removed marker/keyframe diagnostic codes as deprecated public
  constants for downstream compatibility; they are no longer emitted.
- Avoid policy metadata lookups for direct parser helper calls without a
  registered component while preserving explicit missing-component errors in
  the registry.
- Update the remaining AI, framework, interactivity, rationale, and how-it-works
  documentation that still described compiler-scoped Light CSS.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Treat a sole top-level bare template as an explicit Light-DOM wrapper even when
--dom=shadow is the build fallback. Unwrap it before SSR and client hydration,
while attributed templates and compiler policy wrappers keep their existing
meaning and shadowrootmode="open" remains the explicit Shadow form.

- Extend effective DOM analysis with an authored Light-root content range.
- Preserve Shadow fallback behavior for attributed and policy templates.
- Add parser coverage for mode selection, slot rejection, exact unwrapping,
  and the attributed-template fallback.
- Add a real default-Shadow Playwright fixture proving a bare template renders
  direct Light DOM with global CSS.
- Synchronize DESIGN.md, framework, CLI, AI, and authoring documentation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Correct deferred component asset URLs, refresh the generated Python fixture, and reuse SSR stylesheet preloads. Align contact-book CSS assertions and snapshots with the documented per-CSS-tree Shadow DOM behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update documentation search tests for nested ShadowRoots and restore Ubuntu visual baselines generated from the corrected component behavior.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Harden CSS bundling and hydration for independent component delivery.

- Reject top-level @import rules when creating shared CSS chunks because
  concatenation cannot preserve import ordering; report an actionable build
  error instead of emitting invalid CSS.
- Reuse compatibility scratch storage across bundle-root checks.
- Resolve bundled chunks for non-root Light closures only when the complete
  ordered chunk membership is present; otherwise keep the component fallback,
  preventing unrelated global CSS from leaking into an independently loaded
  tree.
- Exclude compiler-generated CSS import-map scripts from SSR element ordinals
  while keeping them distinct from retained Link/Style markers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mohamedmansour
Mohamed Mansour (mohamedmansour) merged commit 2efb60e into microsoft:main Aug 23, 2026
24 checks passed
@mohamedmansour
Mohamed Mansour (mohamedmansour) deleted the mmansour-microsoft-investigate-light-dom branch August 23, 2026 18:02
Mohamed Mansour (mohamedmansour) added a commit that referenced this pull request Aug 25, 2026
## Summary

- allow inert `#webui-data` startup blocks to omit `componentStyles`
- preserve strict validation for malformed `componentStyles` values when
the field is present
- cover the templates/state-only bootstrap regression introduced by #429

## Validation

- regression-red proof produced `[WebUI] componentStyles is required.`
with the pre-fix loader
- `pnpm --dir packages/webui-framework test:unit`
- `pnpm --dir packages/webui-framework typecheck:e2e`
- `pnpm --dir packages/webui-framework exec playwright test
lazy-hydration.spec.ts light-shadow-policy.spec.ts` (37 passed)
- `WEBUI_LAZY_HYDRATION_RUNS=1 WEBUI_LAZY_HYDRATION_TRACE_RUNS=0 pnpm
--dir examples/integration/streaming-browser-bench test:lazy-hydration`
(1 passed)
- `cargo xtask check`

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: febce809-72a9-4daf-bb94-6ee494bed686
Mohamed Mansour (mohamedmansour) added a commit that referenced this pull request Aug 25, 2026
## The failure on `main`

`cargo xtask bench all` mapped every Criterion target onto a single
`cargo bench --workspace`. Cargo forwards everything after `--` to
*every* benchable target in the workspace, including the libtest
unit-test harnesses of libraries and binaries. Those harnesses don't
understand Criterion's baseline flags, so the run aborted on the first
one it reached.

Reproduced on today's tip (`e971c13a`):

```console
$ cargo xtask bench all --save-baseline a9-red-final
    Finished `bench` profile [optimized] target(s) in 1m 09s
     Running unittests src\main.rs (target\release\deps\demo_shell-ffd2d20b0bea4e63.exe)
error: Unrecognized option: 'save-baseline'
error: bench failed, to rerun pass `-p demo-shell --bin demo-shell`
bench failed: exit code 101
error: process didn't exit successfully: `xtask.exe bench all --save-baseline a9-red-final` (exit code: 1)
```

**Baselines recorded: 0.** The before/after workflow documented in
`BENCHMARKS.md` could not work at all.

## The fix

`bench all` now walks an explicit `CRITERION_BENCHES` table and invokes
each target on its own, so baseline flags only ever reach a real
Criterion binary:

```
cargo bench -p <package> --bench <target> -- [extra args] --save-baseline NAME
```

Declared targets (11):

| package | bench target |
|---|---|
| microsoft-webui-parser | parser_bench |
| microsoft-webui-handler | handler_bench |
| microsoft-webui-handler | bootstrap_state_bench |
| microsoft-webui-handler | streaming_hydration_bench |
| microsoft-webui-protocol | protocol_bench |
| microsoft-webui-expressions | expressions_bench |
| microsoft-webui-state | state_bench |
| microsoft-webui-ffi | protocol_bench |
| microsoft-webui | contact_book_bench |
| microsoft-webui | streaming_bench |
| microsoft-webui | component_assets_bench |

Each target is announced and run separately:

```console
$ cargo xtask bench all --save-baseline a9-green-final
▸ microsoft-webui-parser / parser_bench
     Running benches\parser_bench.rs (...)
▸ microsoft-webui-handler / handler_bench
     Running benches\handler_bench.rs (...)
▸ microsoft-webui-handler / bootstrap_state_bench
     Running benches\bootstrap_state_bench.rs (...)
▸ microsoft-webui-handler / streaming_hydration_bench
```

**Baseline directories recorded under `target/criterion/`: 0 on `main` →
77 with this change.** That is the claim this PR makes.

The run stays **fail-fast**: a declared benchmark that is broken stops
the run rather than being silently skipped.

## New: `cargo xtask bench lazy-hydration`

Exposes the existing Playwright/CDP offscreen hydration matrix in
`examples/integration/streaming-browser-bench` as a first-class bench
target, matching how the other integration benches are surfaced.
Baseline flags map onto `WEBUI_LAZY_HYDRATION_SAVE` /
`WEBUI_LAZY_HYDRATION_COMPARE`, which is exactly what
`tests/lazy_hydration_matrix.spec.ts` already reads and what the bench
README already documents.

The shared driver/fixtures also now assert the Toggle/Delete bindings
are wired to the *right* handlers via `__benchToggleCount` /
`__benchRemoveCount`, instead of only counting interactions in
aggregate, and `TODO_TEMPLATE` is typed as `TemplateMeta` so fixture
drift is caught at typecheck time.

## Two pre-existing `main` failures this surfaces — neither introduced
here

This PR touches no file under `crates/` or `packages/`. Both failures
below reproduce on pristine `main` with every change in this PR stashed.

**1. `microsoft-webui-handler / streaming_hydration_bench`**

```
thread 'main' panicked at crates\webui-handler\benches\streaming_hydration_bench.rs:175:9:
legacy render failed: Rendering invariant error: Shadow style hook `bench-island` does not match the active component root
```

The old `--workspace` path died at the `demo-shell` harness before ever
reaching this bench, so fixing the routing *un-masks* it. Left declared
and fail-fast deliberately; skipping it would hide a real failure.

The other 7 declared targets were each run individually to confirm they
route correctly and accept their baseline arguments — all exit 0.

**2. `cargo xtask bench lazy-hydration` → `[WebUI] componentStyles is
required.`**

Verified identical on pristine `main` via `pnpm test:lazy-hydration`
(same assertion, `lazy_hydration_matrix.spec.ts:312`). Root cause:
`packages/webui-framework/src/template.ts:274` calls
`registerComponentStyles(parsed.componentStyles)` unconditionally in
`loadWebUIDataBlock()`, and `requireComponentStyles` throws when the
value is `undefined`. The bench fixture's `#webui-data` block
legitimately carries only `{templates, state}` — it has no component
CSS. `git log -L` blames that line to #429.

Not patched here: the fix belongs in the runtime, and emitting dummy
`componentStyles` from the fixture would paper over the regression. Both
failures are being routed to separate correctness PRs.

The new command itself is verified correct — it resolves the bench
directory, spawns `playwright test -c
playwright.lazy-hydration.config.ts`, and faithfully propagates the
child's exit code; `cargo xtask bench lazy-hydration` and a direct `pnpm
test:lazy-hydration` produce identical output.

## Validation

`cargo xtask check`:

```
✔ license-headers   ✔ fmt   ✔ clippy   ✔ proto (drift check)
✔ deny              ✔ test  ✔ build    ✔ build (wasm)
✔ build (examples)  ✔ bench (validate)
✘ docs
```

The `docs` failure is `PROJ-C013: Adapter module graph is incomplete or
inconsistent`, an esbuild projection error. Confirmed
**pristine-`main`-equivalent** by stashing this PR and running `pnpm
build` in `docs/` — byte-identical failure. Documented, not fixed; out
of scope here.

Includes 7 new `xtask` unit tests covering per-target routing,
`--`-separator placement, extra args ordering ahead of baseline flags,
and `CRITERION_BENCHES` integrity (non-empty, no duplicate pairs, every
declared harness exists on disk).

## Scope

Four files: `xtask/src/main.rs`, `BENCHMARKS.md`, and the two
lazy-hydration bench lib files.

Deliberately excluded: `benchmark_report` / `bench report`,
`ssr-frameworks` and `examples/integration/ssr-framework-bench`, docs
snapshot generation and published benchmark JSON,
`xtask/src/build_examples.rs`, workspace member changes, and any
lockfile, dependency, or product runtime change.

This is a tooling-correctness change. It makes **no product performance
claim** and reports no framework speedup.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2605df0b-c217-4047-9413-49be787b1786
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants