What the boundary actually costs
Focus, form participation and event retargeting all change behaviour at the shadow edge — a component that never joins the host’s form and never fires an event the host can read is an island, not a graft.
The gate renders correctly in the host. Then three things happen that nobody wrote a test for. The host’s Save button submits and your confidence value is not in the payload. Their integration code listens for agent-review-approved and hears nothing, ever. And a keyboard user tabs into your component, at which point the host’s own focus logic starts reporting a different element than the one the user is actually on.
None of these is a bug in your code. Each one is the shadow boundary behaving exactly as specified, and each has a specific, small fix that you have to know exists. This is the lesson that turns an island into a graft.
Cost one: your events do not leave
This is the most common failure and the cheapest fix, and it comes down to two independent flags that both default to the wrong value for a graft.
bubbles decides whether an event travels up. It has nothing to do with the shadow boundary. composed decides whether the event crosses the boundary at all. MDN defines composed as whether the event “will propagate across the shadow DOM boundary into the standard DOM” and notes that all user-agent-dispatched UI events are composed. Most others are not. On the Event() constructor, both flags default to false.
The two really are independent, and the platform proves it: W3C UI Events specifies focus and blur as composed while not bubbling. So “my event bubbles, therefore the host will hear it” is a false inference in both directions. Set both, explicitly:
this.dispatchEvent(
new CustomEvent('agent-review-approved', {
bubbles: true, // travels up the tree
composed: true, // crosses the shadow boundary
detail: { fieldId, confidence, approvedAt }
})
)Once it crosses, the host does not see the element that fired it. Retargeting rewrites event.target to the host element, so their handler receives <agent-review-gate>, not your internal button. That is what you want: the host binds to your tag, your internals stay private, and you can restructure the shadow tree without breaking their listener. composedPath() is the escape hatch, and MDN documents that what it returns depends on the mode: the path through a closed root does not expose the nodes inside it. If your own debugging depends on reading the path, that is one more argument for an open root.
Cost two: you are not in their form
A host form does not know your element exists. It will not submit a value for it, will not reset it, and will not disable it when the fieldset is disabled. The fix is form-associated custom elements, and the WHATWG HTML Standard sets the terms: only an autonomous custom element can be form-associated, and once it is, it becomes “listed, labelable, submittable, and resettable.”
class ReviewGate extends HTMLElement {
static formAssociated = true
constructor() {
super()
this.attachShadow({ mode: 'open' })
this._internals = this.attachInternals()
}
formResetCallback() {
this._internals.setFormValue(null)
}
}Four lifecycle callbacks come with it: formAssociatedCallback, formResetCallback, formDisabledCallback and formStateRestoreCallback, the last of which receives either "autocomplete" or "restore" so you can tell the two situations apart.
Three details decide whether this works in a real host rather than in your demo.
attachInternals()throws, on three conditions. MDN lists them: the element is not a custom element,internalswas disabled for the definition viadisabledFeatures, or it has already been called once on this element. The middle one is the graft-specific hazard, because it is a decision made at definition time by whoever defines the element.- Value and state are two different things.
setFormValue()takes both: the value is what gets submitted, and the state is what gets handed back toformStateRestoreCallbackwhen the browser restores the page. A gate that submits an approval decision but restores nothing after a back-navigation is a half-finished implementation, not a working one. - Support is per-member, not per-feature. MDN records
ElementInternalsas Baseline widely available since March 2023 — Chrome 77, Firefox 93, Safari 16.4 — but Firefox did not shipsetFormValue()until 98, andCustomStateSetdid not reach Safari until 17.4 (checked 2026-09-03). A feature-detect onattachInternalsalone will pass on an engine where the method you need is missing.
Where people get burned
Setting static formAssociated = true does not make the gate work in a form; it makes it eligible. The value/state split and the reset and restore callbacks are separate work, and on an older engine the element can be form-associated and still submit nothing at all — silently, with no error in the console. If the host has a browser floor below the versions above, that is a fact for the delivery decision, not a detail for the changelog.
Cost three: focus stops being where you think
Click your component and, by default, focus lands wherever the internal markup says. The host element itself is not focusable unless you make it so, and the host’s focus logic now has a tree it cannot see into.
delegatesFocus — an option on attachShadow(), Baseline widely available since November 2021 (checked 2026-09-03) — makes focusing the host focus the first focusable element inside it, which is what you want for a component the host will call .focus() on. It also introduces the split MDN documents: document.activeElement returns the host while shadowRoot.activeElement returns the element actually focused. You need both readings, and nested shadow roots mean recursing through them.
The practical consequence is this course’s own reasoning rather than a documented rule, but it follows from the same tree scoping: host code that enumerates focusable elements from the document — a modal focus trap, a “skip to first error” helper, a keyboard shortcut manager — will not find yours, because a query on the document does not descend into shadow trees. Your component can be skipped by their focus trap or hold focus in a way their trap cannot release. Test it inside their dialog, not on a bare page.
One thing this course will not tell you: how :focus-visible behaves on a shadow host. MDN’s page for it does not mention shadow DOM, and the Selectors Level 4 editor’s draft truncated on fetch before the relevant text, so the honest status is unverified. Plain :focus with delegatesFocus is documented; the focus ring heuristic across a boundary is not. Verify it in the engines your host actually supports rather than taking a rule from anywhere, including here.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
The host team asks for one sentence on what they have to do to receive an approval decision from your gate. What do you give them, and what have you had to build for that sentence to be true?
Check your answer
The sentence is: listen for agent-review-approved on the <agent-review-gate> element or on any ancestor, and read event.detail. Nothing about shadow DOM appears in it, which is the point.
For it to be true you had to dispatch with both bubbles and composed set, because either alone leaves the event unreachable. You had to accept that event.target is your host element rather than the button, and design the detail payload to carry everything the host would otherwise have had to reach inside for. And if the decision also has to travel with a form submission, you had to make the element form-associated and call setFormValue() with both a submitted value and a restorable state, then check the browser floor against Firefox 98 and Safari 16.4 rather than against the March 2023 Baseline date for the interface as a whole.
Hands on
Make the gate submit and speak
Done when: ARTIFACT.md records the gate participating in a real host form — a captured submission payload containing your field — and one event the host received from outside the shadow root, with the listener code the host would write pasted in verbatim. Both checkpoints in the Module 2 list are ticked with evidence, not assertion.
- Add
static formAssociated = trueandattachInternals()to the gate, then callsetFormValue()whenever the approval state changes. Pass a state as well as a value, and say in a comment which is which. - Put the gate inside a real
<form>with a native submit button, submit it, and capture the payload — the network request body, ornew FormData(form)logged onsubmit. Paste the payload intolearning/grafting-ui/ARTIFACT.md. If your field is not in it, nothing below matters yet. - Implement
formResetCallbackand press the form’s reset button. Then implementformDisabledCallbackand wrap the gate in a disabled<fieldset>. Both of these are behaviours the host gets for free from every native control and will assume from yours. - Dispatch your approval event with both
bubblesandcomposedset. Write the host-side listener as the host would write it — bound to the tag, not to anything inside it — and confirmevent.targetis the host element. - Now break it deliberately. Remove
composed, reload, and watch the listener go quiet with no error anywhere. Do this once so you recognise the symptom in a host at four in the afternoon. - Attach the shadow root with
delegatesFocus: true, tab into the gate, and log bothdocument.activeElementandshadowRoot.activeElement. Record the two values side by side. Then check your host’s browser floor against the two per-member dates that decide form participation — Firefox 98 forsetFormValue(), Safari 17.4 forCustomStateSet— and write down whether you are above or below, with the date you checked. - Bring the payload and the listener into the chat. I will ask which engine in the host’s support matrix this would fail on, and “it works in Chrome” is not an answer to that question.
What this does not cover
Everything above has a fix. The next one does not. Assistive technology reaches your component through ARIA, ARIA works on ID references, and ID references are scoped to a single tree — so the label-and-describe patterns that every host already uses stop working at your boundary in the exact direction a component author needs. The lesson on accessibility across a shadow root covers what the element-reflection work fixed, what it did not, and what you write down instead of pretending it is handled.
Wrapping an existing React tree in the element you have just made form-aware and event-emitting, without shipping React a second time into a host that already has it, is the lesson that closes this module. The singleton failure that lurks underneath it gets its full treatment in the Module Federation lesson, in the module on micro-frontends and their seams.
Read this next — primary source
Custom elementsWHATWG HTML Standard — free, living standard
This is the normative text for everything in the form half of this lesson: that only an autonomous custom element can be form-associated, that doing so makes it listed, labelable, submittable and resettable, and what each of the four form lifecycle callbacks receives. Read it rather than a tutorial, because the tutorials tend to show the happy path and the specification is where the conditions live — including the definition-time disabledFeatures list that can take attachInternals() away from you in a host you do not control. It is long; the custom element reactions and form-associated sections are the two that pay for themselves immediately.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.