Shadow DOM

The Shadow DOM gives an element a second, private subtree that composes with its ordinary children at render time. This page explains the tree model turbohtml builds for it and the choices behind it. For recipes see Use the Shadow DOM; for the API see Nodes.

Two trees, one host

An element that calls Element.attach_shadow() gains a shadow tree rooted at a ShadowRoot. The host’s own children stay where they are – the DOM calls them the light tree – and the shadow tree lives beside them, not inside them. That separation is the whole point: the shadow tree is encapsulated, so a document-wide Node.select() or Node.find() never descends into it, and serializing the host emits only the light tree. The shadow root is reachable just two ways: the reference attach_shadow returns, and – for an open root only – Element.shadow_root. A closed root reads None there, matching the browser rule that closed shadows hide their internals from the page.

turbohtml holds the shadow root as a document-fragment-like node kept off the light tree entirely: it is never a child of the host, so every existing walk (queries, traversal, serialization) skips it for free, and a per-tree table records the host-to-root link in both directions. The shadow root and its content share the host’s tree and arena, so building, querying, and editing a shadow tree take the same C code paths – and the same critical section – as the light tree.

Slots and assignment

A shadow tree exposes <slot> elements as the seams the host’s children flow into. Assignment is by name: a light-DOM child is assigned to the first slot, in tree order, whose name attribute equals the child’s slot attribute (both default to the empty string, so the unnamed default slot collects everything unlabeled). turbohtml computes this on demand rather than caching an assignment on each edit – Element.assigned_nodes(), Element.assigned_elements(), and Node.assigned_slot each run the spec’s find a slot / find slotables walk when you ask – so an assignment is always current with the tree, with no observer bookkeeping to keep in sync.

The flattened tree is what a browser would actually render: the composed view in which every slot is replaced by the nodes assigned to it (or, when a slot got nothing, by its own fallback children). Node.flattened_children produces one level of that view – a shadow host yields its shadow tree with its slots filled, a slot yields its assigned (or fallback) nodes, and an ordinary node yields its own children with any child slot expanded. Walk it recursively to compose a whole subtree. Nested shadow slots forward correctly: a slot whose fallback is another shadow slot expands through both.

Declarative shadow roots

Shadow trees also arrive in the markup. A <template shadowrootmode> is declarative shadow DOM: the server writes the shadow tree inline, and the parser – not a script – attaches it, so the encapsulation is in place the moment the document is parsed. turbohtml implements the WHATWG tree-construction steps directly in the C tree builder. When a <template> start tag carries a shadowrootmode of open or closed and its parent is a valid shadow host, the builder attaches a shadow root to that parent (reusing the same off-tree shadow node and host table as Element.attach_shadow()) and redirects the template’s content into it. The template element is created only on the stack of open elements, never inserted into the light tree, so it vanishes once its content is parsed – leaving just the populated shadow root. shadowrootdelegatesfocus and shadowrootclonable set the corresponding flags, readable as ShadowRoot.delegates_focus and ShadowRoot.clonable.

The spec gates this on a per-document allow declarative shadow roots flag, and turbohtml follows it: whole-document parse() sets it on, matching a browser navigating to the page, while parse_fragment() leaves it off, matching an innerHTML assignment. The allow_declarative_shadow_roots argument on each flips that default – turning it off for a document treats a stray shadowrootmode as an ordinary template, and turning it on for a fragment matches the browser’s setHTMLUnsafe. Two guards keep the transform faithful: the host must be a valid shadow host (a flow-content element or a hyphenated custom-element name, the DOM’s valid shadow host name), and a parent that already hosts a shadow root keeps its first one, so a second declarative template stays a plain template rather than replacing it.

What it is not

This is the tree model, not a rendering or scripting engine. There are no lifecycle callbacks, no slotchange events, and no style scoping – turbohtml has no layout or CSS cascade across the boundary to scope. What you get is the structural core: attach open and closed roots (imperatively or declaratively from markup), assign named and default slots, read the assignment both ways, and flatten the composed tree – enough to build, inspect, and transform shadow-DOM markup with the same typed, C-backed API as the rest of the tree.

The imperative attach and slot-assignment surface is validated against jsdom in tests/conformance/test_dom_jsdom_differential.py, which builds the same host and shadow trees through both libraries and compares mode reachability, assignedNodes/assignedElements, and each child’s assignedSlot. Declarative shadow roots are the one place jsdom cannot follow: version 29 has no <template shadowrootmode> parsing, so those cases are checked against the WHATWG attach-a-shadow-root algorithm directly.