Skip to content

Shadow DOM: shadow trees, slots and scoped styles - #892

Open
LinuxBoy-96 wants to merge 3 commits into
DioxusLabs:mainfrom
LinuxBoy-96:Shadow-DOM
Open

LinuxBoy-96 wants to merge 3 commits into
DioxusLabs:mainfrom
LinuxBoy-96:Shadow-DOM

Conversation

@LinuxBoy-96

@LinuxBoy-96 LinuxBoy-96 commented Sep 14, 2026

Copy link
Copy Markdown

Implements shadow DOM on the model agreed in #<numéro de l'issue shadow-dom>:
Node::children is never rewritten, the composed tree is a view.

Model

  • Node::children stays the light DOM, so index-based mutations
    (dioxus-native), childNodes, querySelector and text content keep DOM
    semantics with no special cases.
  • The composed (flat) tree is a view: Node::composed_children() yields a
    host's shadow-root children, a <slot>'s assigned nodes (its own children
    when nothing is assigned), otherwise children. Style traversal
    (Traverser), box construction (every container-iteration site in
    construct.rs, including the helpers push_hoisted_children_and_pseudos,
    find_inline_layout_embedded_boxes and iter_children_and_pseudos_mut,
    which now go through iter_composed_children_mut) and, through the
    resulting layout tree, painting and hit testing walk the composed view.
    Light-tree walks stay on children; the accessor's doc comment states the
    rule.
  • A shadow root is a node of element kind named shadow-root, flagged
    IS_SHADOW_ROOT, whose parent is its host but which is not in the host's
    children. UA style shadow-root { display: contents }; display_style()
    reports contents for it since Stylo never styles it.
  • Slot assignment is tree-side state (assigned_nodes on the slot,
    assigned_slot on the light child), refreshed by assign_slots before
    style resolution (slot attribute vs name, unnamed slot = default, text
    nodes to the default slot, first matching slot wins). Slotted nodes inherit
    from their slot (traversal_parent).
  • Damage: a shadow root has no style data, so damage from mutations under it
    goes to the host (damage_box_owner), and damage propagation walks the
    shadow root's children so changes inside a shadow tree rebuild the host's
    boxes. Stylesheets owned by a subtree are re-scoped when the subtree is
    inserted (a script-built <style> is registered before it is appended).

Style scoping

  • TShadowRoot, TNode::as_shadow_root, TElement::{shadow_root, containing_shadow, containing_shadow_host, parent_node_is_shadow_root, is_html_slot_element, traversal_parent, traversal_children} are wired to
    Stylo.
  • Each shadow root owns an AuthorStyles<DocumentStyleSheet>; <style> /
    <link> inside a shadow tree register there instead of the document
    stylist and are flushed in resolve(). Document author sheets apply
    outside shadow trees only, shadow sheets inside theirs: :host works, page
    CSS no longer leaks into components.

Script bindings

blitz-vibey-script: Element.prototype.attachShadow({ mode }) and
element.shadowRoot (open roots only).

Not in this PR

  • ::slotted() (needs slotted_nodes / assigned_slot slices; follow-up).
  • mode: closed is recorded, not enforced.
  • Declarative shadow DOM (<template shadowrootmode>).

Tests

  • cargo test -p blitz-dom shadow (light DOM untouched, composition through
    the shadow root, named slots, removal).
  • Manual pages: scoped <style> + :host border, default and named slots,
    no leak of page CSS into the component, shadowRoot identity (in the PR
    thread).
  • WPT shadow-dom/ and css/css-scoping/ once the runner has those
    directories.

WPT results

Subtests: 122 newly passing, 1 newly failing (net +121). Crashes: +1.

Full diff (80 changed tests)
+ FAIL => FAIL     [1/2]   +1  css/css-anchor-position/anchor-name-in-shadow.html
+ FAIL => PASS     [1/1]   +1  css/css-cascade/revert-layer-004.html
+ FAIL => PASS     [1/1]   +1  css/css-cascade/revert-layer-013.html
+ FAIL => FAIL    [5/12]   +5  css/css-cascade/revert-rule-shadow.html
! FAIL => CRASH    [0/1]   +0  css/css-cascade/scope-shadow-sharing.html
+ FAIL => PASS     [1/1]   +1  css/css-contain/content-visibility/content-visibility-044.html
+ FAIL => PASS     [1/1]   +1  css/css-contain/content-visibility/content-visibility-053.html
+ FAIL => PASS     [1/1]   +1  css/css-display/display-contents-shadow-host-whitespace.html
+ FAIL => PASS     [1/1]   +1  css/css-lists/counter-slot-order-scoping.html
+ FAIL => PASS     [1/1]   +1  css/css-lists/counter-slot-order.html
+ FAIL => PASS     [1/1]   +1  css/css-nesting/host-nesting-001.html
+ FAIL => PASS     [1/1]   +1  css/css-nesting/host-nesting-002.html
+ FAIL => PASS     [1/1]   +1  css/css-nesting/host-nesting-004.html
+ FAIL => PASS     [1/1]   +1  css/css-nesting/host-nesting-005.html
! FAIL => FAIL     [0/2]   +0  css/css-pseudo/highlight-cascade/highlight-cascade-shadow-boundary.html
! FAIL => FAIL     [0/2]   +0  css/css-scroll-snap/snap-events/snap-events-with-shadow-dom.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-assigned-node-with-before-after.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-assigned-node-with-rules.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-dynamic-remove-style-detached.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-host-functional-rule.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-host-rule.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-host-with-before-after.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-invisible-slot.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-root-hides-children.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-slot-display-override.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-slot-fallback.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-slot-style.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-slot.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-with-outside-rules.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-with-rules-no-style-leak.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/css-scoping-shadow-with-rules.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-descendant-002.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-descendant-003.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-dom-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-is-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-is-002.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-is-004.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-is-005.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-multiple-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-multiple-002.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-multiple-003.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-multiple-005.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-not-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-specificity-002.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-specificity-003.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-specificity.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/host-with-default-namespace-001.html
! FAIL => SKIP     [0/1]   +0  css/css-shadow/part/invalidation-part-pseudo.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/scoped-reference-animation-002.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-at-import.html
+ FAIL => FAIL   [44/64]  +44  css/css-shadow/shadow-cascade-order-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-disabled-sheet-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-fallback-dynamic-003.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-host-removal-invalidation.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-link-rel-stylesheet-no-style-leak.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-link-rel-stylesheet.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-multiple-links.html
+ FAIL => FAIL     [1/2]   +1  css/css-shadow/shadow-reassign-dynamic-003.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-root-insert-into-document.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/shadow-shared-style-cache-001.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/slotted-matches.html
! FAIL => FAIL     [0/2]   +0  css/css-shadow/slotted-nested.html
- PASS => FAIL     [0/1]   -1  css/css-shadow/slotted-placeholder.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/slotted-slot.html
+ FAIL => PASS     [1/1]   +1  css/css-shadow/svg-id-ref-001.html
+ FAIL => PASS     [1/1]   +1  css/css-transitions/shadow-root-insertion.html
! FAIL => FAIL     [0/3]   +0  css/css-values/tree-counting/calc-sibling-function-in-shadow-dom.html
! FAIL => FAIL    [0/15]   +0  css/cssom-view/checkVisibility.html
+ FAIL => FAIL     [1/3]   +1  css/cssom-view/elementsFromPoint-shadowroot.html
+ FAIL => PASS     [1/1]   +1  css/cssom-view/scrollIntoView-shadow.html
! FAIL => FAIL    [0/13]   +0  css/cssom/CSSStyleSheet-constructable.html
+ FAIL => PASS     [2/2]   +1  css/cssom/getComputedStyle-display-none-001.html
+ FAIL => FAIL     [1/2]   +1  css/cssom/getComputedStyle-display-none-shadow-invalidation.html
+ FAIL => FAIL     [1/2]   +1  css/cssom/selectorText-modification-restyle-002.html
+ FAIL => PASS     [1/1]   +1  css/printing/animations-shadow-print.html
! FAIL => FAIL     [0/2]   +0  css/selectors/focus-visible-020.html
+ FAIL => FAIL     [4/6]   +4  css/selectors/invalidation/host-context-pseudo-class-in-has.html
+ FAIL => FAIL     [2/4]   +2  css/selectors/invalidation/host-pseudo-class-in-has.html
+ FAIL => FAIL     [1/3]   +1  css/selectors/is-where-shadow.html
+ FAIL => FAIL     [2/3]   +2  css/selectors/scope-selector.html

Generated by the WPT workflow.

@LinuxBoy-96

Copy link
Copy Markdown
Author

This is the .html file I used to test this PR

shadow-test.html

@yinnho yinnho 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.

Reviewing against the model we converged on in #889 — we shipped the same shape in our own renderer (children never rewritten, composed view built at box construction), so most of this is confirmation from the other side of that decision, plus one bug we think is real.

Inheritance direction — your traversal_parent for slotted nodes is right (Chrome-verified). This was the subtlest call in the PR, so here's ground truth from local Chrome (headless, 152):

<div style="color:blue"><my-e>hello <span>light text</span></my-e></div>
<script>
  const sr = document.querySelector('my-e').attachShadow({mode:'open'});
  sr.innerHTML = '<slot style="color:red"></slot>';
</script>
  • computed color of the slotted <span>: rgb(255, 0, 0) — inherited from the slot, not from its light parent
  • with color:red removed: slot, slotted span, and a shadow <div> all compute rgb(0, 0, 255) — blue walks host → shadow content and host → slot → slotted

So flat-tree inheritance through the slot is what Chrome does; traversal_parent returning self.assigned_slot matches, and inherited properties set on shadow-tree elements correctly reach slotted light content. (What must NOT reach slotted content is ordinary selector matching — which ::slotted() being unimplemented already guarantees.)

Bug: in-document bookkeeping never crosses into shadow trees. attach_shadow snapshots in_document at attach time, and process_added_subtree/process_removed_subtree walk iter_subtree_mut, which only iterates children — the shadow root is deliberately not in the host's children, so:

const el = document.createElement('my-e');      // detached
const sr = el.attachShadow({mode: 'open'});     // root created without IS_IN_DOCUMENT
sr.innerHTML = '<link rel="stylesheet" href="a.css"><slot></slot>';
document.body.appendChild(el);                  // process_added_subtree walks light children only

The shadow subtree never becomes in-document, and since LoadStylesheet / LoadImage / style_nodes registration all happen inside that walk, a <link rel=stylesheet> inside this shadow tree never loads. Later appends into the tree read new_parent_is_in_document off the unflagged root, so nothing eager-loads afterwards either. The remove direction has the mirrored staleness: detaching a host leaves the shadow subtree flagged and its id-map entries live. The fix shape that worked for us: make just the added/removed bookkeeping walks visit shadow_root alongside children — not iter_subtree_mut itself, since light-semantics callers rely on it staying light.

One determinism question. add_shadow_stylesheet appends in registration order, which for <link>s is fetch-completion order — two same-specificity conflicting <link>s in one shadow tree whose loads land out of order will cascade nondeterministically. The document side has the same append-at-completion shape today, so this is a pre-existing family rather than a regression, but shadow trees concentrate <link> usage, so it may be worth sorting by owner node order per root at flush time. This bit us once: we collect shadow <style>s in document order precisely because iterating them from a HashMap reshuffled equal-specificity rules per run.

JS-face nit. attachShadow({ mode: "bogus" }) throws TypeError in Chrome (invalid ShadowRootMode); the binding here silently coerces to open. Looks like a one-line validation in attach_shadow.

Everything else held up against the scars on our implementation: switching Traverser to composed_children() keeps flat-tree order correct for sibling combinators, running assign_slots at resolve() entry closes the mutation→resolve staleness window for assigned_nodes/assigned_slot, and the rescope_stylesheets dance (script-built <style> registers document-wide first, moves to its root on append) is the same corner we had to handle separately.

@LinuxBoy-96

Copy link
Copy Markdown
Author

Thanks — the in-document bug was real. Fixed in the shape you describe: a
separate iter_subtree_with_shadow_mut used only by the added/removed
bookkeeping walks (visits shadow_root alongside children), so a <link>
in a shadow tree of a detached element loads once the host is inserted, and
detaching a host unflags its shadow subtree; iter_subtree_mut stays light.
Shadow stylesheets are now inserted in their owners' tree order rather than
fetch-completion order, and attachShadow throws TypeError for an invalid
mode. Good to have the slot-inheritance direction confirmed against Chrome.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants