WC capabilities: shadow modes, slots, per-host state
What
dmWc / data-m-wc is narrow today. Three small additions would make it
first-class and closes the gap with Datastar Rocket:
| Construct |
Form |
Effect |
| Shadow modes |
mods on data-m-wc |
^dom.open or ^dom.closed attach a shadow root |
| Slots |
implicit |
if a <slot> appears in the template, project host children into it — no opt-in mod |
| Per-host state |
special signal path :_wc |
per-instance state lives on host._wc instead of dm |
Why
dmax's principle: mods alter behavior; signal paths define where data lives. These
three additions follow the same split:
- Behavior knobs (open/closed/light, shadow on/off) → mods on
data-m-wc.
- Where the data lives (per-host vs page store) → a special signal path
:_wc.
dmax already uses the _ prefix for special triggers (@_window.resize, @_viewed,
@_init); :_wc extends the same family to signal roots.
A data-m-scope attribute or <template data-m-wc-shadow> would mix the two axes.
:_wc lives where any other path lives: in data-m-ex:, data-m-si:, dmGet, dmSet.
The duplication bug, in one line
A WC's internal state (chart data, view options, hover, selection) is per-instance by
definition. With today's API, it has to live in either a module-level Map<host, state>
or a page-level signal — both wrong. The Map isn't tied to host lifetime; the page signal
pollutes the page store and doesn't survive delete+recreate of the same host id. :_wc
makes the host be the state container.
The duplication bug, in three symptoms
A user-visible symptom that motivated this: "when I duplicate a WC, the second one stops
being interactive." Tracing it in the PR's <mx-uplot> example revealed three stacked
causes. The v2 design addresses all three with one primitive.
Symptom 1: cloned data goes stale on the source
<div data-m-si='{"uplot":{"n":1},"uplot2":null}'></div>
<mx-uplot id="u1" data-m-ex:.cfg@uplot="dm.uplot"></mx-uplot>
<mx-uplot id="u2" data-m-ex:.cfg@uplot2="dm.uplot2"></mx-uplot>
<button data-m-ex:uplot2@.click="JSON.parse(JSON.stringify(dm.uplot))">dup</button>
<button data-m-ex:uplot@.click="({...dm.uplot, n: dm.uplot.n+1})">bump</button>
| Step |
dm.uplot.n |
dm.uplot2 |
u1.cfg |
u2.cfg |
Rendered on u1.box |
Rendered on u2.box |
| initial |
1 |
null |
{n:1} |
null |
n:1 |
(empty) |
| bump |
2 |
null |
{n:2} |
null |
n:2 |
(empty) |
| dup |
2 |
clone of n=2 |
{n:2} |
{n:2} |
(re-fires) |
n:2 |
| bump |
3 |
still n=2 |
{n:3} |
{n:2} |
n:3 |
n:2 (stale) |
dm.uplot2 is a snapshot of dm.uplot taken at dup-time. u2 is bound to dm.uplot2;
edits to dm.uplot don't propagate. Correct behavior given the bindings, wrong shape for
"duplicate". Fix: bind u2 to its own :_wc.cfg — live state from creation.
Symptom 2: per-instance state has nowhere to live
The standard workaround is Map<host, state>. Works, but: state is not tied to host
lifetime, host id must be plumbed through every binding, and delete+recreate of
<mx-plot id="p1"> silently reuses the deleted instance's state. Fix: host._wc is
owned by the host; GC follows the element; re-bindings go through setSiAndNotifySubs.
Symptom 3: page-level state pollutes the page signal store
The escape from the Map is one page signal per instance: dm.plot1, dm.plot2, …
dm.stylePanel.open is purely a WC concern but lives at the top of dm. Fix:
:_wc.open is the WC's concern and stays on the host.
All three are the same problem at three altitudes: WC state has nowhere to live except
the page signal store or a parallel Map. :_wc gives it a home.
Design
1. Shadow modes: default = light DOM
The W3C custom elements spec default is light DOM (you call attachShadow() to opt in).
dmax should match the platform: light DOM is the default. Shadow is opt-in.
| Mod |
Effect |
| (none) |
light DOM, as today |
^dom.open |
attach an open shadow root |
^dom.closed |
attach a closed shadow root |
The naming matches the W3C attachShadow({mode}) parameter names exactly. Only
^dom.open and ^dom.closed exist — no alias.
Branch once in defWc.connectedCallback:
const target = mods.has('dom.closed') ? this.attachShadow({ mode: 'closed' })
: mods.has('dom.open') ? this.attachShadow({ mode: 'open' })
: this
if (!target.firstElementChild && tpl.content)
target.appendChild(tpl.content.cloneNode(true)),
wireItClone(target)
for (const p of props) { /* re-dispatch the prop-change event into `target` */ }
getQueryRoot and wireItClone need an audit pass for shadow-root correctness. The
prop-change CustomEvent is already bubbles: true, composed: true, so the cross-shadow
boundary is fine.
2. Slots — implicit
No opt-in mod. If a <slot> element appears in the template, dmax projects host
children into the slot position: default slot matches host > :not([slot]), named slot
matches host > [slot="<name>"], fallback content is the slot's existing children.
Recursive: a <slot> inside a nested WC inside the outer WC is also projected.
In shadow modes the browser handles this for free; in light mode dmax does the
equivalent projection (move children, replace slot element, skip-already-wired).
3. :_wc special signal path root
The center of the issue. The implementation is one helper, plugged into the existing
path-resolution step. No new mod, no new MV code, no new source in the read pipeline:
const resolveSignalStore = (root, host) =>
root === '_wc' ? (host._wc ||= Object.create(null)) : _dm
That is it. The mod parser, write pipeline, read pipeline, two-way binding, typed
mods, write modes — all work unchanged because the new behavior is in the path, not
the mod.
Composition (free, by construction):
<!-- per-host state -->
<my-counter data-m-si:_wc.count="0" data-m-ex:_wc.count^inc@.click></my-counter>
<!-- two-way: open state on the host, not in dm -->
<details data-m-ex@.^rw@:_wc.open>…</details>
<!-- reads and writes flow naturally -->
<p data-m-ex:.@:_wc.label></p>
<button data-m-ex:_wc.todos^merge@.input="[{...val, done: !val.done}]">toggle</button>
:_wc is reusable across WCs, list items, forms, dialogs, sections — anywhere a signal
path is allowed. ^inc, ^rw, ^num, ^merge, ^attrs, ^sel, ^sel-all all
compose.
External helpers (for imperative code):
globalThis.dmGetHost = (host, path) =>
host._wc ? getPrValAndDepth(host._wc, path)[0] : undefined
globalThis.dmSetHost = (host, path, val) => {
if (!host._wc) host._wc = Object.create(null)
setPrValByPath(host._wc, path, val)
notifyHostScope(host, path)
return val
}
notifyHostScope reuses setSiAndNotifySubs machinery keyed off the host element.
Cleanup is automatic. host._wc is GC'd with the host. dmax's existing
MutationObserver (_cleanupBoundSubs) tears down every binding on removal, including
those pointing at :_wc.*.
Proof of value: dm-style.js <dm-style-panel>
The PR's dmStyle.panel(name, defs, opts) shows the cost of not having these
features:
// CURRENT panelCss — 1 line, ~1500 chars, 18 ${name} interpolations to scope CSS:
const panelCss = (name) =>
`${name}{position:fixed;…}${name} details{…}${name} summary{…}…`
// CURRENT panel — 1 line, ~1000 chars, 5 ${…} interpolations in bindings:
return dmWc(name,
`<style>${panelCss(name)}</style>` +
`<details data-m-ex@.^rw@${open}>…` +
`<button data-m-ex:${signal}@.click="…">…`)
The page that uses the panel has to declare stylePanel: { open: false } — a
page-level signal that only the WC uses.
With v2:
^dom.open on the panel → panelCss becomes plain CSS with zero ${name}
interpolations. ~600 chars saved, CSS becomes readable.
:_wc on the open-state binding → stylePanel.open page signal disappears. The
panel() body loses its ${open} interpolation and the open parameter.
dm-style.js total: ~700 chars saved, one fewer page-signal entry, conceptual
cleanup (the WC owns its own state).
Good to have (ship together if scope allows)
These follow the same "host-scoped" pattern. They are independent of each other and of
the three primary features, but if the implementation already has the wiring (per-host
notification, slotted projection, signal-root resolution), they fall out cheaply:
^remount mod — wipe + re-clone the WC on prop change. dmax reacts in place today;
a WC whose imperative connectedCallback work needs to re-run can opt in with
^remount. Same ^dom.* mod-parser plumbing.
- WC-internal list primitive — a
data-for analog that appends into the WC's subtree
instead of a sibling. Thin wrapper around dmIt.
- Codec-style prop typing registry — fluent codecs (
number, string, bool,
date, oneOf(...), array(codec), object(shape)) as an alternative to inline
^num / ^str / ^bool mods. Larger surface; the inline typed mods cover the
common cases for now.
Test plan
End-to-end tests in tests/dmWc.e2e.js (or tests/dmHostScope.e2e.js):
^dom.open — <my-card^dom.open> has host.shadowRoot (open); cloned template is
in the shadow root; a child binding's data-m-ex:foo@.input fires when the input
inside the shadow root dispatches input.
^dom.closed — host.shadowRoot is null from outside; bindings still work.
- Default is light DOM —
<my-card> (no mod) has no shadow root; cloned template is
in host directly; same as today.
- Slots (implicit) —
<my-card><span slot="title">x</span></my-card> with a template
containing <slot name="title">fallback</slot> projects the <span> into the slot
position; fallback is preserved when no matching child. Recursive: <slot> inside a
WC inside another WC.
:_wc on a WC — two <my-card> instances each with
data-m-si:_wc='{"count":0}'; data-m-ex:_wc.count^inc@.click on one does not affect
the other. Verify via host._wc.count on each.
:_wc on a regular element — <div data-m-si:_wc='{"x":1}'> followed by
<div data-m-ex:_wc.x^inc@.click> reads and writes the same _wc on the first
div; independent of dm.
:_wc on a list item — <ul data-m-it@items> containing
<template><li data-m-ex:_wc.hovered@.mouseover="true">…</li></template>; hovering
one <li> sets only that <li>'s _wc.hovered.
- Composition with write modes —
data-m-ex:_wc.todos^merge@.input="[…]" merges
against host._wc.todos, not dm.todos.
- Composition with
^rw — data-m-ex:_wc.draft^rw@.input two-way binds to
host._wc.draft.
- Cleanup —
host.remove() → _wc is GC-eligible; pending
setSiAndNotifySubs callbacks torn down by the existing MutationObserver pass.
- Public helpers —
dmGetHost(host, 'x') / dmSetHost(host, 'x', 1) work and
trigger re-render of data-m-ex:_wc.x@.input bindings.
WC capabilities: shadow modes, slots, per-host state
What
dmWc/data-m-wcis narrow today. Three small additions would make itfirst-class and closes the gap with Datastar Rocket:
data-m-wc^dom.openor^dom.closedattach a shadow root<slot>appears in the template, project host children into it — no opt-in mod:_wchost._wcinstead ofdmWhy
dmax's principle: mods alter behavior; signal paths define where data lives. These
three additions follow the same split:
data-m-wc.:_wc.dmax already uses the
_prefix for special triggers (@_window.resize,@_viewed,@_init);:_wcextends the same family to signal roots.A
data-m-scopeattribute or<template data-m-wc-shadow>would mix the two axes.:_wclives where any other path lives: indata-m-ex:,data-m-si:,dmGet,dmSet.The duplication bug, in one line
A WC's internal state (chart data, view options, hover, selection) is per-instance by
definition. With today's API, it has to live in either a module-level
Map<host, state>or a page-level signal — both wrong. The Map isn't tied to host lifetime; the page signal
pollutes the page store and doesn't survive delete+recreate of the same host id.
:_wcmakes the host be the state container.
The duplication bug, in three symptoms
A user-visible symptom that motivated this: "when I duplicate a WC, the second one stops
being interactive." Tracing it in the PR's
<mx-uplot>example revealed three stackedcauses. The v2 design addresses all three with one primitive.
Symptom 1: cloned data goes stale on the source
dm.uplot.ndm.uplot2u1.cfgu2.cfgu1.boxu2.boxnull{n:1}nulln:1null{n:2}nulln:2{n:2}{n:2}n:2{n:3}{n:2}n:3n:2(stale)dm.uplot2is a snapshot ofdm.uplottaken at dup-time.u2is bound todm.uplot2;edits to
dm.uplotdon't propagate. Correct behavior given the bindings, wrong shape for"duplicate". Fix: bind
u2to its own:_wc.cfg— live state from creation.Symptom 2: per-instance state has nowhere to live
The standard workaround is
Map<host, state>. Works, but: state is not tied to hostlifetime, host id must be plumbed through every binding, and delete+recreate of
<mx-plot id="p1">silently reuses the deleted instance's state. Fix:host._wcisowned by the host; GC follows the element; re-bindings go through
setSiAndNotifySubs.Symptom 3: page-level state pollutes the page signal store
The escape from the Map is one page signal per instance:
dm.plot1,dm.plot2, …dm.stylePanel.openis purely a WC concern but lives at the top ofdm. Fix::_wc.openis the WC's concern and stays on the host.All three are the same problem at three altitudes: WC state has nowhere to live except
the page signal store or a parallel Map.
:_wcgives it a home.Design
1. Shadow modes: default = light DOM
The W3C custom elements spec default is light DOM (you call
attachShadow()to opt in).dmax should match the platform: light DOM is the default. Shadow is opt-in.
^dom.open^dom.closedThe naming matches the W3C
attachShadow({mode})parameter names exactly. Only^dom.openand^dom.closedexist — no alias.Branch once in
defWc.connectedCallback:getQueryRootandwireItCloneneed an audit pass for shadow-root correctness. Theprop-change
CustomEventis alreadybubbles: true, composed: true, so the cross-shadowboundary is fine.
2. Slots — implicit
No opt-in mod. If a
<slot>element appears in the template, dmax projects hostchildren into the slot position: default slot matches
host > :not([slot]), named slotmatches
host > [slot="<name>"], fallback content is the slot's existing children.Recursive: a
<slot>inside a nested WC inside the outer WC is also projected.In shadow modes the browser handles this for free; in light mode dmax does the
equivalent projection (move children, replace slot element, skip-already-wired).
3.
:_wcspecial signal path rootThe center of the issue. The implementation is one helper, plugged into the existing
path-resolution step. No new mod, no new MV code, no new source in the read pipeline:
That is it. The mod parser, write pipeline, read pipeline, two-way binding, typed
mods, write modes — all work unchanged because the new behavior is in the path, not
the mod.
Composition (free, by construction):
:_wcis reusable across WCs, list items, forms, dialogs, sections — anywhere a signalpath is allowed.
^inc,^rw,^num,^merge,^attrs,^sel,^sel-allallcompose.
External helpers (for imperative code):
notifyHostScopereusessetSiAndNotifySubsmachinery keyed off the host element.Cleanup is automatic.
host._wcis GC'd with the host. dmax's existingMutationObserver(_cleanupBoundSubs) tears down every binding on removal, includingthose pointing at
:_wc.*.Proof of value:
dm-style.js<dm-style-panel>The PR's
dmStyle.panel(name, defs, opts)shows the cost of not having thesefeatures:
The page that uses the panel has to declare
stylePanel: { open: false }— apage-level signal that only the WC uses.
With v2:
^dom.openon the panel →panelCssbecomes plain CSS with zero${name}interpolations. ~600 chars saved, CSS becomes readable.
:_wcon the open-state binding →stylePanel.openpage signal disappears. Thepanel()body loses its${open}interpolation and theopenparameter.dm-style.jstotal: ~700 chars saved, one fewer page-signal entry, conceptualcleanup (the WC owns its own state).
Good to have (ship together if scope allows)
These follow the same "host-scoped" pattern. They are independent of each other and of
the three primary features, but if the implementation already has the wiring (per-host
notification, slotted projection, signal-root resolution), they fall out cheaply:
^remountmod — wipe + re-clone the WC on prop change. dmax reacts in place today;a WC whose imperative
connectedCallbackwork needs to re-run can opt in with^remount. Same^dom.*mod-parser plumbing.data-foranalog that appends into the WC's subtreeinstead of a sibling. Thin wrapper around
dmIt.number,string,bool,date,oneOf(...),array(codec),object(shape)) as an alternative to inline^num/^str/^boolmods. Larger surface; the inline typed mods cover thecommon cases for now.
Test plan
End-to-end tests in
tests/dmWc.e2e.js(ortests/dmHostScope.e2e.js):^dom.open—<my-card^dom.open>hashost.shadowRoot(open); cloned template isin the shadow root; a child binding's
data-m-ex:foo@.inputfires when the inputinside the shadow root dispatches input.
^dom.closed—host.shadowRootisnullfrom outside; bindings still work.<my-card>(no mod) has no shadow root; cloned template isin
hostdirectly; same as today.<my-card><span slot="title">x</span></my-card>with a templatecontaining
<slot name="title">fallback</slot>projects the<span>into the slotposition; fallback is preserved when no matching child. Recursive:
<slot>inside aWC inside another WC.
:_wcon a WC — two<my-card>instances each withdata-m-si:_wc='{"count":0}';data-m-ex:_wc.count^inc@.clickon one does not affectthe other. Verify via
host._wc.counton each.:_wcon a regular element —<div data-m-si:_wc='{"x":1}'>followed by<div data-m-ex:_wc.x^inc@.click>reads and writes the same_wcon the firstdiv; independent of
dm.:_wcon a list item —<ul data-m-it@items>containing<template><li data-m-ex:_wc.hovered@.mouseover="true">…</li></template>; hoveringone
<li>sets only that<li>'s_wc.hovered.data-m-ex:_wc.todos^merge@.input="[…]"mergesagainst
host._wc.todos, notdm.todos.^rw—data-m-ex:_wc.draft^rw@.inputtwo-way binds tohost._wc.draft.host.remove()→_wcis GC-eligible; pendingsetSiAndNotifySubscallbacks torn down by the existingMutationObserverpass.dmGetHost(host, 'x')/dmSetHost(host, 'x', 1)work andtrigger re-render of
data-m-ex:_wc.x@.inputbindings.