Skip to content

Improve m-wc #142

Description

@dadhi

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):

  1. ^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.
  2. ^dom.closedhost.shadowRoot is null from outside; bindings still work.
  3. Default is light DOM<my-card> (no mod) has no shadow root; cloned template is
    in host directly; same as today.
  4. 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.
  5. :_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.
  6. :_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.
  7. :_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.
  8. Composition with write modesdata-m-ex:_wc.todos^merge@.input="[…]" merges
    against host._wc.todos, not dm.todos.
  9. Composition with ^rwdata-m-ex:_wc.draft^rw@.input two-way binds to
    host._wc.draft.
  10. Cleanuphost.remove()_wc is GC-eligible; pending
    setSiAndNotifySubs callbacks torn down by the existing MutationObserver pass.
  11. Public helpersdmGetHost(host, 'x') / dmSetHost(host, 'x', 1) work and
    trigger re-render of data-m-ex:_wc.x@.input bindings.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions