feat(zoning): add support for shadow dom - #1922
Conversation
size-limit report 📦
|
There was a problem hiding this comment.
Pull request overview
Adds remotely gated Shadow DOM traversal and autocapture support across element selection, event enrichment, and observers.
Changes:
- Adds depth-bounded Shadow DOM selector generation and resolution.
- Extends autocapture, mutation, and exposure handling into open shadow roots.
- Adds documentation, unit/E2E coverage, and manual performance guards.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
test-server/shadow-dom-test.html |
Adds Shadow DOM E2E fixture. |
test-server/shadow-dom-perf.html |
Adds performance workload fixture. |
playwright.config.ts |
Excludes manual performance tests from CI. |
packages/plugin-autocapture-browser/test/shadow-gate.test.ts |
Tests gate and late-config behavior. |
packages/plugin-autocapture-browser/test/observables.test.ts |
Tests shadow-aware observers. |
packages/plugin-autocapture-browser/test/observables-coverage.test.ts |
Covers missing-body handling. |
packages/plugin-autocapture-browser/test/hierarchy.test.ts |
Tests composed hierarchies. |
packages/plugin-autocapture-browser/test/helpers.test.ts |
Tests shadow traversal helpers. |
packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts |
Tests setup failure handling. |
packages/plugin-autocapture-browser/test/data-extractor.test.ts |
Tests masking across shadow boundaries. |
packages/plugin-autocapture-browser/src/shadow-mode.ts |
Implements the shared shadow gate. |
packages/plugin-autocapture-browser/src/observables.ts |
Observes open shadow roots and exposures. |
packages/plugin-autocapture-browser/src/hierarchy.ts |
Adds composed ancestor traversal. |
packages/plugin-autocapture-browser/src/helpers.ts |
Adds deep queries and target resolution. |
packages/plugin-autocapture-browser/src/frustration-plugin.ts |
Enables shadow mutation observation. |
packages/plugin-autocapture-browser/src/data-extractor.ts |
Integrates shadow configuration and enrichment. |
packages/plugin-autocapture-browser/src/autocapture-plugin.ts |
Wires the gate into plugin setup. |
packages/plugin-autocapture-browser/SHADOW-DOM.md |
Documents architecture and limitations. |
packages/plugin-autocapture-browser/README.md |
Links Shadow DOM documentation. |
packages/plugin-autocapture-browser/e2e/shadow-dom.spec.ts |
Tests browser autocapture behavior. |
packages/plugin-autocapture-browser/e2e/shadow-dom-perf.spec.ts |
Adds manual performance comparisons. |
packages/element-selector/test/strategies/stable-id.test.ts |
Updates resolved-config fixture. |
packages/element-selector/test/strategies/explicit-tracking-attribute.test.ts |
Updates resolved-config fixture. |
packages/element-selector/test/shadow.test.ts |
Tests shadow selector primitives. |
packages/element-selector/test/schema/element-selector-remote-config.schema.test.ts |
Tests new schema fields. |
packages/element-selector/test/scenarios/off-path-differential.test.ts |
Verifies disabled-path compatibility. |
packages/element-selector/test/orchestrator.test.ts |
Updates resolved-config fixture. |
packages/element-selector/test/index.test.ts |
Tests public shadow exports. |
packages/element-selector/test/helpers/get-stable-id.test.ts |
Updates resolved-config fixture. |
packages/element-selector/test/fallback-css-path.test.ts |
Updates resolved-config fixture. |
packages/element-selector/test/config/resolve-config.test.ts |
Tests shadow configuration resolution. |
packages/element-selector/src/types.ts |
Defines shadow configuration fields. |
packages/element-selector/src/index.ts |
Exports shadow traversal APIs. |
packages/element-selector/src/helpers/shadow.ts |
Implements traversal and resolution. |
packages/element-selector/src/fallback-css-path.ts |
Adds shadow-root path anchoring. |
packages/element-selector/src/engine.ts |
Generates composite shadow selectors. |
packages/element-selector/src/config/resolve-config.ts |
Resolves and clamps shadow settings. |
packages/element-selector/schema/element-selector-remote-config.schema.json |
Defines remote-config schema fields. |
packages/element-selector/README.md |
Documents selector Shadow DOM support. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 40 out of 40 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
packages/plugin-autocapture-browser/src/observables.ts:327
- This descendant scan restarts
maxDepthfrom every added node instead of accounting for the shadow depth of the mutation target. For example, withmaxDepth: 1, a node added inside a depth-1 root can contain another shadow root, and this call observes matching depth-2 elements even though mutation discovery correctly refuses that root. Those elements can then produce exposure paths truncated to an ancestor, bypassing the configured budget. Carry the mutation's absolute/root depth into exposure discovery and scan only the remaining budget.
observeMatchesInShadow(node);
playwright.config.ts:60
testIgnoreis still applied when Playwright receives an explicit file filter, so the documentednpx playwright test .../shadow-dom-perf.spec.tscommand will report no tests instead of running this manual guard. Make this ignore conditional on an opt-in environment variable (and use it in the documented command), or provide a separate Playwright config that clears the ignore.
0365733 to
a331327
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 60b9654. Configure here.
Mercy811
left a comment
There was a problem hiding this comment.
LGTM! I thought some code changes are for zoning only and thought we could split this into a smaller PR for autocapture but looks like this is already the smallest PR.
One question: is shadowDomEnabled remote config public or it's on dynconf only?
Would you prefer I use stacked prs for this? Happy to do so if that is the preference. And as for rollout @jxiwang can you advise? I was planning to roll this out in the same way that the selector engine changes were rolled out. |
|
@cely404 I'd lean towards remote config settings for more flexibility in the future. Would this be something we enable by default in the future? I'm assuming not since there are some performance concerns. |
60b9654 to
5201912
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Shadow roots pinned after unmount
- Each discovered shadow root now gets its own MutationObserver that is disconnected when the host unmounts, so detached trees are no longer pinned until plugin teardown.
- ✅ Fixed: Non-element targets break enrichment
- resolveEventTarget now skips non-Element composedPath entries, and enrichment bails out unless the resolved target is an Element, so getComputedStyle is never called on text nodes.
Or push these changes by commenting:
@cursor push 67ca3c5ed9
Preview (67ca3c5ed9)
diff --git a/packages/plugin-autocapture-browser/src/data-extractor.ts b/packages/plugin-autocapture-browser/src/data-extractor.ts
--- a/packages/plugin-autocapture-browser/src/data-extractor.ts
+++ b/packages/plugin-autocapture-browser/src/data-extractor.ts
@@ -350,9 +350,13 @@
// Read once and pass into each call below, so the mode governing this
// event is visible in one place.
const shadow = this.getShadowMode();
- const eventTarget = resolveEventTarget(baseEvent.event, shadow);
+ const resolved = resolveEventTarget(baseEvent.event, shadow);
+ const eventTarget = resolved instanceof Element ? resolved : null;
+ if (!eventTarget) {
+ return baseEvent;
+ }
if (isCapturingCursorPointer) {
- const isCursorPointer = isElementPointerCursor(eventTarget as Element, baseEvent.type);
+ const isCursorPointer = isElementPointerCursor(eventTarget, baseEvent.type);
if (isCursorPointer) {
baseEvent.closestTrackedAncestor = eventTarget as HTMLElement;
baseEvent.targetElementProperties = this.getEventProperties(
diff --git a/packages/plugin-autocapture-browser/src/helpers.ts b/packages/plugin-autocapture-browser/src/helpers.ts
--- a/packages/plugin-autocapture-browser/src/helpers.ts
+++ b/packages/plugin-autocapture-browser/src/helpers.ts
@@ -291,9 +291,11 @@
if (!path || path.length === 0) {
return fallback;
}
- const innermost = path[0];
- if (!(innermost instanceof Element)) {
- return innermost ?? fallback;
+ // composedPath()[0] can be a Text/Comment node (or other non-Element).
+ // Enrichment needs an Element (`getComputedStyle`, `matches`, selector walks).
+ const innermost = path.find((node): node is Element => node instanceof Element);
+ if (!innermost) {
+ return fallback;
}
const { segments, truncated } = segmentWalk(innermost, shadow.maxDepth);
diff --git a/packages/plugin-autocapture-browser/src/observables.ts b/packages/plugin-autocapture-browser/src/observables.ts
--- a/packages/plugin-autocapture-browser/src/observables.ts
+++ b/packages/plugin-autocapture-browser/src/observables.ts
@@ -59,11 +59,20 @@
return new Observable<MutationRecord[]>((observer) => {
// Track observed roots to avoid double-observing, plus the shadow-boundary
- // depth of each observed shadow root (the main document is depth 0). Both are
- // weak so shadow roots that unmount (common on component-heavy SPAs) are
- // reclaimable rather than pinned for the subscription's lifetime.
+ // depth of each observed shadow root (the main document is depth 0). The
+ // collections are weak so they do not themselves pin detached trees.
+ //
+ // Each root gets its own MutationObserver: `observe()` holds a strong
+ // reference to the target for the observer's lifetime, so a single shared
+ // observer would pin every shadow root ever discovered until unsubscribe.
+ // Per-root observers can be disconnected when a host unmounts (common on
+ // component-heavy SPAs), which drops that native reference and lets the
+ // detached tree be reclaimed. `liveObservers` only retains currently
+ // attached observers so teardown can disconnect what is still live.
const observed = new WeakSet<Node>();
const rootDepth = new WeakMap<Node, number>();
+ const observerForRoot = new WeakMap<Node, MutationObserver>();
+ const liveObservers = new Set<MutationObserver>();
const observeRoot = (root: Node, depth: number): void => {
if (observed.has(root)) {
@@ -73,9 +82,24 @@
if (isShadowRoot(root)) {
rootDepth.set(root, depth);
}
+ const mutationObserver = new MutationObserver(onMutations);
mutationObserver.observe(root, MUTATION_OBSERVER_INIT);
+ observerForRoot.set(root, mutationObserver);
+ liveObservers.add(mutationObserver);
};
+ const unobserveRoot = (root: Node): void => {
+ const mutationObserver = observerForRoot.get(root);
+ if (!mutationObserver) {
+ return;
+ }
+ mutationObserver.disconnect();
+ liveObservers.delete(mutationObserver);
+ observerForRoot.delete(root);
+ observed.delete(root);
+ rootDepth.delete(root);
+ };
+
// Attach to every open shadow root within `host`'s subtree, offset by the
// crossing depth of the tree `host` already lives in.
const observeShadowRootsWithin = (host: Element, baseDepth: number, maxDepth: number): void => {
@@ -86,6 +110,10 @@
collectOpenShadowRoots(host, remaining).forEach(({ root, depth }) => observeRoot(root, baseDepth + depth));
};
+ const unobserveShadowRootsWithin = (host: Element, baseDepth: number, maxDepth: number): void => {
+ collectOpenShadowRoots(host, maxDepth - baseDepth).forEach(({ root }) => unobserveRoot(root));
+ };
+
// Crossing depth of the tree a mutation occurred in: 0 for the main
// document, or the recorded depth of the enclosing shadow root.
const depthOfTree = (node: Node): number => {
@@ -98,7 +126,7 @@
return recorded ?? 0;
};
- const mutationObserver = new MutationObserver((mutations) => {
+ const onMutations = (mutations: MutationRecord[]): void => {
observer.next(mutations);
// Mutations are emitted first, then shadow discovery runs. The whole
@@ -109,10 +137,18 @@
if (!shadow.enabled) {
return;
}
- // Attach to shadow roots that appeared since the last batch, recursing
- // into nested roots within the depth budget.
+ // Drop observers for shadow roots whose hosts left the tree, then
+ // attach to roots that appeared since the last batch. Removals run
+ // first so a moved host is disconnected and then re-observed.
for (const mutation of mutations) {
const baseDepth = depthOfTree(mutation.target);
+ mutation.removedNodes.forEach((node) => {
+ // Skip connected nodes: the DOM is already in its post-batch
+ // state, so a still-connected host was moved, not unmounted.
+ if (node instanceof Element && !node.isConnected) {
+ unobserveShadowRootsWithin(node, baseDepth, shadow.maxDepth);
+ }
+ });
mutation.addedNodes.forEach((node) => {
if (node instanceof Element) {
observeShadowRootsWithin(node, baseDepth, shadow.maxDepth);
@@ -122,7 +158,7 @@
} catch {
// Discovery failed for this batch. The mutations were already emitted.
}
- });
+ };
// Undefined when no gate was supplied, in which case no scan is registered.
let cancelArmListener: (() => void) | undefined;
@@ -138,10 +174,10 @@
});
}
- // One disconnect detaches the observer from every root it was attached to.
return () => {
cancelArmListener?.();
- mutationObserver.disconnect();
+ liveObservers.forEach((mutationObserver) => mutationObserver.disconnect());
+ liveObservers.clear();
};
});
};
diff --git a/packages/plugin-autocapture-browser/test/data-extractor.test.ts b/packages/plugin-autocapture-browser/test/data-extractor.test.ts
--- a/packages/plugin-autocapture-browser/test/data-extractor.test.ts
+++ b/packages/plugin-autocapture-browser/test/data-extractor.test.ts
@@ -714,6 +714,57 @@
// Verify the result
expect(result.closestTrackedAncestor).toBeDefined();
});
+
+ test('should enrich pointer-cursor clicks whose composed path starts with a text node', () => {
+ resetSharedShadowGateForTesting();
+ dataExtractor.updateSelectorConfig({ shadowDomEnabled: true, maxShadowDomDepth: 1 });
+
+ const span = document.createElement('span');
+ span.style.cursor = 'pointer';
+ const text = document.createTextNode('Click me');
+ span.appendChild(text);
+ document.body.appendChild(span);
+
+ const clickEvent = {
+ bubbles: true,
+ cancelable: true,
+ view: window,
+ target: span,
+ composedPath: () => [text, span, document.body, document.documentElement, document],
+ };
+
+ const result = dataExtractor.addAdditionalEventProperties(
+ clickEvent,
+ 'click',
+ ['.button'],
+ 'data-amp-',
+ true,
+ ) as ElementBasedTimestampedEvent<MouseEvent>;
+
+ expect(result.closestTrackedAncestor).toBe(span);
+ resetSharedShadowGateForTesting();
+ dataExtractor.updateSelectorConfig(undefined);
+ });
+
+ test('should not throw when isCapturingCursorPointer is true and the target is a non-element', () => {
+ const text = document.createTextNode('Click me');
+ const clickEvent = {
+ bubbles: true,
+ cancelable: true,
+ view: window,
+ target: text,
+ };
+
+ const result = dataExtractor.addAdditionalEventProperties(
+ clickEvent,
+ 'click',
+ ['.button'],
+ 'data-amp-',
+ true,
+ ) as ElementBasedTimestampedEvent<MouseEvent>;
+
+ expect(result.closestTrackedAncestor).toBeUndefined();
+ });
});
describe('getEventProperties with maskion', () => {
diff --git a/packages/plugin-autocapture-browser/test/helpers.test.ts b/packages/plugin-autocapture-browser/test/helpers.test.ts
--- a/packages/plugin-autocapture-browser/test/helpers.test.ts
+++ b/packages/plugin-autocapture-browser/test/helpers.test.ts
@@ -472,13 +472,20 @@
expect(resolveEventTarget(event, on(1))).toBe(host);
});
- test('should return a non-element composed path node when shadow support is on', () => {
+ test('should skip non-element composed path nodes and resolve to the nearest element', () => {
const text = document.createTextNode('x');
const host = document.createElement('my-host');
const event = { target: host, composedPath: () => [text, host] } as unknown as Event;
- expect(resolveEventTarget(event, on(1))).toBe(text);
+ expect(resolveEventTarget(event, on(1))).toBe(host);
});
+ test('should fall back to event.target when composed path has no elements', () => {
+ const host = document.createElement('my-host');
+ const text = document.createTextNode('x');
+ const event = { target: host, composedPath: () => [text] } as unknown as Event;
+ expect(resolveEventTarget(event, on(1))).toBe(host);
+ });
+
test('should return null when the event has no target and composed path is empty', () => {
const event = { target: null, composedPath: () => [] } as unknown as Event;
expect(resolveEventTarget(event, on(1))).toBeNull();
diff --git a/packages/plugin-autocapture-browser/test/observables.test.ts b/packages/plugin-autocapture-browser/test/observables.test.ts
--- a/packages/plugin-autocapture-browser/test/observables.test.ts
+++ b/packages/plugin-autocapture-browser/test/observables.test.ts
@@ -161,6 +161,68 @@
expect(spanInsertions).toHaveLength(1);
sub.unsubscribe();
});
+
+ it('stops observing a shadow root after its host unmounts', async () => {
+ document.body.innerHTML = `<my-host></my-host>`;
+ const host = document.querySelector('my-host') as Element;
+ const root = attachOpen(host, `<div id="sr"></div>`);
+
+ const batches: MutationRecord[][] = [];
+ const sub = createMutationObservable(armedGate(1)).subscribe((m) => batches.push(m));
+
+ host.remove();
+ await tick();
+ batches.length = 0;
+
+ root.getElementById('sr')?.appendChild(document.createElement('span'));
+ await tick();
+
+ const sawShadowMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === root));
+ expect(sawShadowMutation).toBe(false);
+ sub.unsubscribe();
+ });
+
+ it('re-observes a shadow root when its host is remounted', async () => {
+ document.body.innerHTML = `<my-host></my-host>`;
+ const host = document.querySelector('my-host') as Element;
+ const root = attachOpen(host, `<div id="sr"></div>`);
+
+ const batches: MutationRecord[][] = [];
+ const sub = createMutationObservable(armedGate(1)).subscribe((m) => batches.push(m));
+
+ host.remove();
+ await tick();
+ document.body.appendChild(host);
+ await tick();
+ batches.length = 0;
+
+ root.getElementById('sr')?.appendChild(document.createElement('span'));
+ await tick();
+
+ const sawShadowMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === root));
+ expect(sawShadowMutation).toBe(true);
+ sub.unsubscribe();
+ });
+
+ it('does not throw when a never-observed shadow host is removed', async () => {
+ document.body.innerHTML = `<div id="light"></div>`;
+ const batches: MutationRecord[][] = [];
+ const sub = createMutationObservable(armedGate(1)).subscribe((m) => batches.push(m));
+
+ // attachShadow on an already-connected host is not discovered (scan-based
+ // gap), so removing it must still be a no-op rather than throw.
+ const host = document.createElement('my-host');
+ document.body.appendChild(host);
+ await tick();
+ attachOpen(host, `<div id="sr"></div>`);
+ host.remove();
+ await tick();
+
+ document.getElementById('light')?.appendChild(document.createElement('span'));
+ await tick();
+ expect(batches.length).toBeGreaterThan(0);
+ sub.unsubscribe();
+ });
});
describe('createExposureObservable — shadow mutation path', () => {You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 5201912. Configure here.


Summary
Adds remotely gated Shadow DOM traversal and autocapture support across element selection, event enrichment, and observers.
Changes:
Adds depth-bounded Shadow DOM selector generation and resolution.
Extends autocapture, mutation, and exposure handling into open shadow roots.
Adds documentation, unit/E2E coverage, and manual performance guards.
Checklist
Note
Medium Risk
Touches hot paths (every click/mutation) and arbitrary customer DOM with new observer fan-out when shadow is enabled; defaults and kill-switch tests limit blast radius, but mis-targeted selectors or perf on shadow-heavy SPAs are plausible rollout risks.
Overview
Adds opt-in open shadow DOM support for element selectors and browser autocapture, controlled independently from the v1 selector engine via remote config
shadowDomEnabledandmaxShadowDomDepth(defaults off, depth clamped 1–10).@amplitude/element-selectorgenerates composite paths joined by>>>when piercing is on, exportsresolveSelector(required instead ofdocument.querySelector), and keeps byte-identical output when shadow is off. The engine splits generation into per-tree segments with traversal caps and shadow-root anchoring for positional chains.@amplitude/plugin-autocapture-browserwires a page-scopedShadowGatethat latches shadow mode on first enabling remote config; updates hierarchy, closest-ancestor, text masking,composedPathtarget resolution, and mutation/exposure observers that fan out into discovered open shadow roots. Remote-config handling now accepts explicitshadowDomEnabled(not onlyenabled), and event enrichment is wrapped in a try/catch so DOM failures do not crash the page.Docs (
SHADOW-DOM.md, element-selector README/schema), unit/scenario tests, Playwright e2e, and a manual perf spec are included. The analytics-browser gzip size limit moves 65kb → 66kb for the added bundle cost.Reviewed by Cursor Bugbot for commit 5201912. Bugbot is set up for automated code reviews on this repo. Configure here.