Skip to content

[Feature]: --dom light is unusable - no CSS delivery path and no <slot> projection #410

Description

Before filing

  • I searched existing issues and did not find a duplicate.

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

  1. --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.
  2. <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.
  3. 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.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions