Why clicks inside modern web components go missing today — and how the proof of concept fixes each gap, step by step.
Status: POC working end-to-end — clicks, form changes, element addresses, and page-structure data all captured inside open Shadow DOM. Off by default; zero change in behavior until switched on.
Teams ship reusable widgets — date pickers, checkout forms, whole design systems. Shadow DOM is the browser feature that puts each widget in its own sealed box, so nothing else on the page can accidentally break it.
Outsiders can look inside — if they know the special way in. This is what most component frameworks use, and what this POC supports.
The browser makes it impossible for any page script to look inside. Out of scope — for us and for everyone else.
A major customer’s product suite builds its UI almost entirely out of these sealed components.
A reporter standing at the page’s front door. Every click “bubbles up” to it.
Clicks naturally travel from the element up to the top of the page — AutoCapture hears every one.
Is it a button, link, form field — something on the tracking list?
What it was, its text, its surroundings (family tree), and its address on the page — so analysts can chart it and label it later.
Works perfectly … as long as everything is in the open. ✅
A click inside a component still reaches AutoCapture — but the report is ruined on the way out.
Outsiders are only told “something in the box” — never the actual button. The click is dropped, or blamed on a nameless box.
We can’t see the element’s surroundings, so we can’t tell which “Pay” button it was.
Page addresses (CSS selectors) can’t point inside a box — so visual labeling can never find the element again.
Form changes inside the box never leave it at all, and our page watchers can’t see in.
Next: each problem, and how the POC solves it. →
The swap is deliberate — a browser privacy screen called retargeting. It protects components from outsiders… including us.
A nameless box isn’t on the tracking list. The click vanishes — or worse, is logged with no text and no meaning.
The browser also keeps the click’s full travel log — composedPath(). For glass (open) boxes, stop 1 is the real button. Session Replay uses the same trick.
Every event includes the element’s surroundings — that’s how analysts tell the checkout “Pay” from the settings “Pay”.
Walking up from the button, the “parent” trail simply ends at the box edge. Everything above — the component, the section, the page — is invisible.
Every glass box knows its owner. At the wall, we step through to the owner and keep climbing — all the way to the top.
The recorded family tree — now complete, with the doorway marked:
The 🚪 marker tells our own tools (visual labeling, queries) exactly where the boundary was.
Events carry an “address” (a CSS selector) so tools like visual labeling can find the exact element again later. Page addresses can’t point inside a box.
Read as: “find the building on the page, step through its door, then find the room inside.”
We also ship the resolver that follows these addresses — the piece visual labeling will use.
Clicks escape the box (renamed). Form changes — “typed an email”, “picked an option” — never leave the box at all.
Unlike clicks, “changed” signals stop at the box wall — no listener outside can ever hear them. Zero Element Changed events from components.
We can’t hear through the wall — so we listen inside. The moment a user first touches a component (a click or focus travels through the box and reveals it), we place a tiny listener inside. The “changed” that follows is heard.
The page watchers that power zoning & “what’s on screen” get a twin inside every open box — including boxes that appear later.
| Scenario | POC |
|---|---|
| Clicks in open components | ✓ captured |
| Form changes in open components | ✓ captured |
| Boxes inside boxes (nested) | ✓ captured |
| Components added after page load | ✓ captured |
| Visual-labeling addresses | ✓ emitted + resolvable |
| Closed (vault) components | safely ignored — no errors |
Verified live in a browser on the interactive playground, plus 550+ automated tests across the two packages.
The standard mode used by component frameworks — including the customer driving this work. Clicks, changes, addresses, family trees, zoning: covered.
The browser makes the inside unreachable for any page script — not an Amplitude limitation. We keep today’s behavior: the box itself may be logged, never its contents, never an error.
Browser autofill into a never-touched component field · components that seal an already-visible element (affects zoning watchers only). Both rare, both no worse than today.
pnpm dev → localhost:5173/autocapture/shadow-dom.html
Click the light vs. shadow buttons and watch the events — add ?shadow=off to see today’s behavior.
composedPath()event.target at every boundaryRetargeting is spec'd behavior, not a quirk: as an event propagates out of a shadow tree, the
browser rewrites target to the nearest ancestor that lives in the same tree as
the listener. Our listener is on document, so by the time we see the event its
target has become the host element. Every listener sees a target appropriate to its
own tree — which is exactly the encapsulation guarantee components are sold on.
Note the event reaches document at all only because click is
composed: true. Events that are not composed never leave the box — that is
problem 4, and it needs a different fix.
Every place that previously read event.target now calls this
instead: click enrichment, change enrichment, the cursor-pointer check, and action-click
resolution in track-action-click.ts. The optional-chaining and the
?? event.target tail mean an exotic event with no composedPath degrades to
today's behavior rather than throwing.
No — the browser enforces it for us. For a closed root, composedPath()
omits the internals entirely and the first entry is the host. So
resolveEventTarget returns the same host we get today: no new information, no
error, no special-casing in our code.
Yes — rrweb resolves every interaction through a getEventTarget helper built on
composedPath()[0]. It lives inside the bundled rrweb dist and isn't exported, so we
reimplemented the five lines rather than importing a vendored internal. Same idea, independently
gated.
parentElement returning nullThe element at the top of a shadow tree does have a parent — but that parent is the
ShadowRoot itself, and a ShadowRoot is a DocumentFragment,
not an Element. Since parentElement only returns parents that are
Elements, it returns null there. Any loop written as “walk parentElement
until null” quietly ends at the boundary.
wrapperTwo APIs combine into the step-through. getRootNode() hands back the shadow root
the element lives in; that root's .host is the element that called
attachShadow().
composedParent · packages/element-selector/src/helpers/shadow.tsSo the walk becomes:
button → wrapper → (wall: parentElement is null → getRootNode().host) → my-widget → section → body.
getClosestElement in helpers.ts does the identical hop for allowlist
matching, with the same crossings < maxShadowDepth budget.
root.host actually works for closed shadow roots too — if you already hold a
reference to an element inside. But from outside we never obtain one:
composedPath() refuses to expose closed internals, so the walk never starts
inside a closed tree in the first place. The gate is at entry, not at the hop.
With crossShadow = false (the default) the hop never happens, so the off path is
byte-identical to main. maxShadowDepth caps how many boundaries one event may
cross (default 1, hard cap 10), keeping traversal cost predictable on deeply nested
component trees.
The 🚪 shadow: true marker in the recorded family tree is placed on exactly the
node where parentElement was null (wrapper) — it tells downstream
consumers “the next entry up is a host; descend through .shadowRoot, not
.children, when re-resolving this path.”
document.querySelector searches the document tree only. A shadow tree is a
separate node tree, so no selector string evaluated against the document can ever match an
element inside one. CSS briefly had piercing combinators — /deep/ and a native
>>> — and both were removed from the spec and from browsers precisely because
they defeated encapsulation. There is no replacement coming.
“Two queries” is the whole insight. If one string can't work, the address becomes a sequence of per-tree strings, joined by a delimiter we own.
Each segment is then handed to the existing selector engine, scoped to that segment's own
root, and the results are joined with >>> . The per-tree algorithm is
unchanged — we only changed how many times we run it and what scope we run it in.
ShadowRoot is not an elementInside the document, a positional chain is implicitly anchored by the unique <html>
root. Inside a shadow root there is no root element — so a chain like
div:nth-of-type(2) > button would match anywhere in that tree, and
:scope matches nothing because :scope needs an element. Root-anchored
segments therefore carry a :scope > marker and are resolved by strict direct-child
descent instead of querySelector.
>>> confusing, given CSS once used it?
The full string is never handed to querySelector — it's split first, and each
segment on its own is ordinary, valid CSS. The delimiter is our encoding between our SDK and our
tools, chosen to be visually obvious and impossible to confuse with the > child
combinator we emit within a segment.
found.shadowRoot is null, scope becomes null, and the next
iteration returns null cleanly. A closed-root address can't round-trip — a documented
limitation, not a crash. In practice we never generate one, because capture never gets inside a
closed tree to begin with.
Each DOM event carries a composed boolean. true means it crosses shadow
boundaries and reaches outside listeners (retargeted — problem 1). false means it stops
at the shadow root and no outside listener will ever see it, no matter where it's attached.
| Event | composed | Reaches our document listener? |
|---|---|---|
| click | true | yes — retargeted to the host |
| focusin | true | yes — retargeted to the host |
| input | true | yes — retargeted to the host |
| change | false | never |
So Element Changed can only be captured by a listener placed
inside the shadow root itself. Which raises the real problem: there is no API that enumerates
a page's shadow roots. document.querySelectorAll can't see them, and nothing fires when
one is created. We cannot know the roots up front.
A user cannot change a control without first interacting with it — and those interactions
(focusin, click) are composed, so their
composedPath() hands us every open root on the way to the control. We attach a
change listener to each root the first time we see it.
The same pass prunes roots whose host has left the DOM (!root.host.isConnected), so a
component-heavy SPA doesn't accumulate listeners for unmounted trees.
With listeners in several places, an event could be emitted twice. Ownership is decided by the
event's true origin — composedPath()[0]'s root — never by target,
which is retargeted per listener and would look local to every one of them.
That second condition matters more than it looks. An earlier version deferred for any shadow
origin — which silently dropped composed change events re-dispatched by web components
from roots we'd never discovered. That made the feature-on path capture less than
feature-off. Caught in adversarial review; the has(originRoot) check is the fix.
A MutationObserver with subtree: true also stops at each boundary. One
observer instance is attached to document.body and to every open root found by an
initial scan, with each batch's addedNodes re-scanned so late-mounting components get
covered too. Exposure seeding unions querySelectorAll across the same roots.
click too, if focusin precedes a change?
Because it doesn't, on WebKit. Safari (macOS and iOS) deliberately does not focus
checkbox / radio controls on click — change fires with no
focus event at all. Discovering on click as well closes what would otherwise be a
browser-shaped hole in the data.
attachShadow like rrweb does?
rrweb replaces Element.prototype.attachShadow globally, which catches every root at
creation. That's a page-wide side effect on a method the customer's own framework calls; we chose
scan-based discovery instead. The cost is one documented gap — a root attached to an element
already in the DOM emits no mutation record, so zoning watchers can miss it (clicks and
changes are unaffected, since those paths discover lazily).