Before filing
Area
WebUI Framework package
Problem or need
We ship a moderately large WebUI app (32 components, 41 stylesheets) as a component asset loaded from a CDN into host pages we do not control. Some of those hosts render the component inside a shadow root they own, so we tried moving our components to --dom light and letting the host provide the single encapsulation boundary.
--dom light compiles and emits an asset, but the result is not usable. Two independent gaps mean a light-DOM component asset cannot render correctly today, so --dom light is effectively shadow-only in practice.
1. No CSS delivery path works in light mode.
Same project, same source, only --css varying (compiler + framework 0.0.19):
--dom light --css … |
Result |
link |
No <link> is emitted inside any template, and no aggregate stylesheet is emitted → nothing ever loads the CSS. |
module |
templateStyles is emitted (the data:text/css importmap, ~96 KB) and registerAssetStyles injects it into document.head, but sa is undefined on all 32 templates → injectModuleStyle is never called, so the registered specifiers are never imported. |
style |
CSS is delivered, but inlined into every template, so it is duplicated per instance rather than per component, and the stylesheet text becomes part of the element's textContent. |
The module case looks like the intended light-DOM path, since injectModuleStyle already has the light-DOM branch:
// element/styles.js
export function injectModuleStyle(specifier, shadowRoot) {
if (shadowRoot) { /* adoptedStyleSheets on the shadow root */ }
else if (!headInjected.has(specifier)) {
headInjected.add(specifier);
import(specifier, { with: { type: 'css' } }).then((mod) => {
document.adoptedStyleSheets.push(mod.default); // <-- light DOM path
}, () => { });
}
}
That branch dedupes by specifier and pushes to document.adoptedStyleSheets, which is exactly right for light DOM — it just never runs, because the compiler does not emit sa when --dom light.
Comparing the two builds directly:
shadow + module : sd=32 sa=32 keys=["h","c","sa","sd","tr","ta","b"]
light + module : sd=0 sa=0 keys=["h","c","tr","ta","b"]
2. <slot> is emitted verbatim but never projected.
The compiler passes <slot> through unchanged in light mode. Four of our primitives project children, and all four are affected:
button | shadow slots: 1 | light slots: 1
tab | shadow slots: 1 | light slots: 1
tablist | shadow slots: 1 | light slots: 1
divider | shadow slots: 1 | light slots: 1
Outside a shadow root <slot> does not project, and the framework has no projection emulation — the only slot-related code in template-element.js is template binding slots for <if>/repeat blocks, which is a different concept. So authored children are dropped.
Observed result. Building our real app with --dom light --css style (the only mode that delivers CSS at all) and loading it in a browser:
rootHasShadow : false <- light DOM active, as expected
innerBg : rgb(255, 255, 255) <- class-based rules DO apply
rootDisplay : "inline" <- :host { display: block } is dead
tabLabels : [] <- no tabs rendered at all
buttonLabels : [""] <- slot projection dead
headerText : ":host { display: block; } .header { box-sizing: bo…"
The app renders as loading skeletons only — no content, no tabs, no images — with zero page errors, so nothing signals that the mode is unsupported.
Related and lower priority: compiled CSS is byte-identical between --dom shadow and --dom light. :host rules are left as-is (dead selectors in light DOM), and class selectors are not scoped, so every component's classes land in one global namespace. In our app that collapses 38 class names shared across components — .panel and .panel-title are each defined by 15 different components with different rules.
Who would benefit?
Anyone shipping WebUI component assets into a host page they do not control. The host often already owns a shadow root, or needs to apply its own theming and layout to the embedded component, and does not want a second encapsulation boundary per component.
It also matters for hosts that cannot server-render — where there is no SSR endpoint available and the component must be fetched from a CDN and rendered entirely client-side. Light DOM is the natural fit there.
Desired outcome
--dom light emits sa per template just as --dom shadow does, so injectModuleStyle's existing document.adoptedStyleSheets branch runs and each component's CSS is registered exactly once per document.
<slot> is projected in light DOM — either the compiler lowers <slot> into an explicit insertion point the runtime fills from the element's authored children, or the framework emulates projection at hydration/wire time.
- Ideally,
:host is lowered to the component's tag selector when --dom light, so :host, :host([attr]), and :host([attr])::before keep working. Optional but very helpful: scope remaining selectors by tag to avoid the global-namespace collapse.
- Failing all of the above,
--dom light should fail the build (or warn loudly) when a component uses <slot> or :host, rather than emitting an asset that silently renders blank.
Concrete example
webui build ./src \
--entry index.html \
--out ./dist \
--plugin webui \
--css module \
--dom light \
--emit-component-assets my-card
Given a component that projects children and styles its host:
<!-- my-button.html -->
<button class="control" type="button"><slot></slot></button>
/* my-button.css */
:host { display: inline-flex; }
.control { padding: 4px 12px; }
Used as <my-button>Save</my-button>, we would expect in light DOM:
sa: "my-button" on the template, so my-button.css is imported once into document.adoptedStyleSheets
Save projected into .control
:host { display: inline-flex } lowered to my-button { display: inline-flex }
Today the label is dropped, the CSS is never imported, and the :host rule is inert.
Constraints
- Migration:
--dom shadow remains the default; this only needs to change behavior under --dom light, so existing shadow consumers are unaffected.
- Compatibility:
document.adoptedStyleSheets and CSS module imports (with { type: 'css' }) are already relied on by the shadow path, so light DOM adds no new platform requirement.
- Global CSS is inherent to light DOM, so tag-scoping (item 3) is what keeps it tractable at our scale (32 components, 41 stylesheets). Without it, adopting light DOM means hand-editing every stylesheet.
- Correctness over silence: the current failure mode is the worst kind — a clean build, zero runtime errors, and a blank component. Even just item 4 would have saved us the investigation.
Alternatives or workarounds
- Staying on
--dom shadow and letting the host mount the component inside its own shadow root. This is what we shipped. Nested shadow roots work fine, custom properties pierce the boundary, and we verified the full app renders and is interactive inside a host-owned shadow root. It is a good outcome, but it does not give the host the ability to style component internals.
- A mixed asset — light for our own components (none of which use
<slot>), shadow for the primitives that do. The runtime already supports this, since sd is a per-template flag and wantShadow = hasShadow || !!meta.sd, so merging a light build with the shadow build's entries for the slot-using primitives produces a valid asset. But the compiler only exposes --dom globally, so this requires post-processing the emitted asset, plus hand-rewriting :host and resolving the class collisions. A per-component --dom override, or a template-level opt-in, would make this a supported path.
- Wrapping templates in
<template shadowrootmode="open"> to opt individual components back into shadow under --dom light. This does not work: the compiler strips the wrapper and keeps only its contents (shadowrootmode occurrences in the emitted asset: 0).
Before filing
Area
WebUI Framework package
Problem or need
We ship a moderately large WebUI app (32 components, 41 stylesheets) as a component asset loaded from a CDN into host pages we do not control. Some of those hosts render the component inside a shadow root they own, so we tried moving our components to
--dom lightand letting the host provide the single encapsulation boundary.--dom lightcompiles and emits an asset, but the result is not usable. Two independent gaps mean a light-DOM component asset cannot render correctly today, so--dom lightis effectively shadow-only in practice.1. No CSS delivery path works in light mode.
Same project, same source, only
--cssvarying (compiler + framework0.0.19):--dom light --css …link<link>is emitted inside any template, and no aggregate stylesheet is emitted → nothing ever loads the CSS.moduletemplateStylesis emitted (thedata:text/cssimportmap, ~96 KB) andregisterAssetStylesinjects it intodocument.head, butsaisundefinedon all 32 templates →injectModuleStyleis never called, so the registered specifiers are never imported.styletextContent.The
modulecase looks like the intended light-DOM path, sinceinjectModuleStylealready has the light-DOM branch:That branch dedupes by specifier and pushes to
document.adoptedStyleSheets, which is exactly right for light DOM — it just never runs, because the compiler does not emitsawhen--dom light.Comparing the two builds directly:
2.
<slot>is emitted verbatim but never projected.The compiler passes
<slot>through unchanged in light mode. Four of our primitives project children, and all four are affected:Outside a shadow root
<slot>does not project, and the framework has no projection emulation — the only slot-related code intemplate-element.jsis template binding slots for<if>/repeat blocks, which is a different concept. So authored children are dropped.Observed result. Building our real app with
--dom light --css style(the only mode that delivers CSS at all) and loading it in a browser:The app renders as loading skeletons only — no content, no tabs, no images — with zero page errors, so nothing signals that the mode is unsupported.
Related and lower priority: compiled CSS is byte-identical between
--dom shadowand--dom light.:hostrules are left as-is (dead selectors in light DOM), and class selectors are not scoped, so every component's classes land in one global namespace. In our app that collapses 38 class names shared across components —.paneland.panel-titleare each defined by 15 different components with different rules.Who would benefit?
Anyone shipping WebUI component assets into a host page they do not control. The host often already owns a shadow root, or needs to apply its own theming and layout to the embedded component, and does not want a second encapsulation boundary per component.
It also matters for hosts that cannot server-render — where there is no SSR endpoint available and the component must be fetched from a CDN and rendered entirely client-side. Light DOM is the natural fit there.
Desired outcome
--dom lightemitssaper template just as--dom shadowdoes, soinjectModuleStyle's existingdocument.adoptedStyleSheetsbranch runs and each component's CSS is registered exactly once per document.<slot>is projected in light DOM — either the compiler lowers<slot>into an explicit insertion point the runtime fills from the element's authored children, or the framework emulates projection at hydration/wire time.:hostis lowered to the component's tag selector when--dom light, so:host,:host([attr]), and:host([attr])::beforekeep working. Optional but very helpful: scope remaining selectors by tag to avoid the global-namespace collapse.--dom lightshould fail the build (or warn loudly) when a component uses<slot>or:host, rather than emitting an asset that silently renders blank.Concrete example
Given a component that projects children and styles its host:
Used as
<my-button>Save</my-button>, we would expect in light DOM:sa: "my-button"on the template, somy-button.cssis imported once intodocument.adoptedStyleSheetsSaveprojected into.control:host { display: inline-flex }lowered tomy-button { display: inline-flex }Today the label is dropped, the CSS is never imported, and the
:hostrule is inert.Constraints
--dom shadowremains the default; this only needs to change behavior under--dom light, so existing shadow consumers are unaffected.document.adoptedStyleSheetsand CSS module imports (with { type: 'css' }) are already relied on by the shadow path, so light DOM adds no new platform requirement.Alternatives or workarounds
--dom shadowand letting the host mount the component inside its own shadow root. This is what we shipped. Nested shadow roots work fine, custom properties pierce the boundary, and we verified the full app renders and is interactive inside a host-owned shadow root. It is a good outcome, but it does not give the host the ability to style component internals.<slot>), shadow for the primitives that do. The runtime already supports this, sincesdis a per-template flag andwantShadow = hasShadow || !!meta.sd, so merging a light build with the shadow build's entries for the slot-using primitives produces a valid asset. But the compiler only exposes--domglobally, so this requires post-processing the emitted asset, plus hand-rewriting:hostand resolving the class collisions. A per-component--domoverride, or a template-level opt-in, would make this a supported path.<template shadowrootmode="open">to opt individual components back into shadow under--dom light. This does not work: the compiler strips the wrapper and keeps only its contents (shadowrootmodeoccurrences in the emitted asset: 0).