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.
| * @param selector A selector produced by the engine (may contain boundary delimiters). | ||
| */ | ||
| export function resolveSelector(root: ParentNode, selector: string): Element | null { | ||
| const segments = selector.split(SHADOW_BOUNDARY_DELIMITER); |
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.
| ? querySelectorAllDeepInShadow(root, selectorString, shadow.maxDepth) | ||
| : querySelectorAllDeepLight(root, selectorString); | ||
|
|
||
| const querySelectorAllDeepLight = (root: Element | Document, selectorString: string): Element[] => { |
There was a problem hiding this comment.
Would it work if we made a return type like:
type ElementList = Element[] || NodeListOf<Element>;
And then just have this function return the result of querySelectorAll without having to coerce it into a JS Array?
| const shadow = readGate(shadowGate); | ||
| if (!shadow.enabled) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
I noticed that you still observe for mutations even if !shadow.enabled. Is this because it's possible for shadow capture to be enabled later and we want the observer to be running when that happens?
There was a problem hiding this comment.
Zoning added an exposure event as part of the initial launch months ago so we need to observe for mutations with or without shadow dom piercing. Mutations are still emitted we just skip attaching/discovering open shadow roots when the gate is off.
Rn we have a single observer lifecycle.
We could swap to separate observables when remote config lands, but that would mean tearing down and reconnecting observers. During the switch we may drop mutations.
Wdyt?
| * `maxShadowDepth` crossings and by {@link MAX_SHADOW_DOM_TRAVERSAL_NODES} | ||
| * total nodes. Closed roots are invisible (`el.shadowRoot` is null) and skipped. | ||
| */ | ||
| export function collectOpenShadowRoots(root: Element, maxShadowDepth: number): OpenShadowRootEntry[] { |
There was a problem hiding this comment.
This function is the one addition to the code that gives me concerns about performance, being a depth first search traversal, it could cause some lengthy main thread blocking and add jank to our customers website.
Do you think it would be a good idea, not for the scope of this PR, if we added something like a shadowNodeCssSelector that, when provided by the customer, it identifies which nodes are shadow nodes.
There was a problem hiding this comment.
Yea that sounds like a good idea.
My cursor agent also recommends a registry where we do a DFS scan once when remote config lands and then register new roots incrementally when addedNodes exposes them.
There was a problem hiding this comment.
Okay, yeah let's leave it as a follow-up.
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. |
| - Cheaper `addedNodes` checks (e.g. `node.shadowRoot`) before DFS | ||
| - Batched discovery and more targeted exposure indexing | ||
|
|
||
| **Do not patch `attachShadow` by default.** Session replay (`@amplitude/rrweb-record`) and other libraries already wrap the same API; a global monkey patch is a last resort and needs dual-plugin validation (autocapture + session replay) before consideration. |
There was a problem hiding this comment.
https://github.com/amplitude/rrweb/blob/master/packages/rrweb/src/record/shadow-dom-manager.ts#L118
Probably okay to do if we verify things work as expected
|
@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. |
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 core autocapture DOM traversal and selector generation on customer pages; mitigated by default-off flag, latch semantics, traversal caps, and differential tests proving the off path is unchanged.
Overview
Adds remotely gated Shadow DOM support (
shadowDomEnabled,maxShadowDomDepth) that is separate from the v1 selector engine kill switch. With piercing on,@amplitude/element-selectoremits composite selectors joined by>>>and consumers must re-resolve viaresolveSelector; with it off, output stays byte-identical to pre-shadow behavior.The autocapture plugin introduces a per-page
ShadowGatethat latches on the first config delivery that enables shadow support (disabling mid-session has no effect until reload). Capture usescomposedPath/ composed ancestor walks, deepquerySelectorAll, and oneMutationObserverper discovered open shadow root for mutations and exposure—plus a one-time scan when the gate arms.Docs and tests:
SHADOW-DOM.md, schema/README updates, broad unit coverage (kill-switch differential, round-trips), Playwright e2e for clicks, and a manual-only perf spec excluded from CI.Reviewed by Cursor Bugbot for commit 60b9654. Bugbot is set up for automated code reviews on this repo. Configure here.