Skip to content

feat(zoning): add support for shadow dom - #1922

Merged
cely404 merged 4 commits into
mainfrom
SR-4788-zoning-shadow-dom-support
Aug 26, 2026
Merged

feat(zoning): add support for shadow dom#1922
cely404 merged 4 commits into
mainfrom
SR-4788-zoning-shadow-dom-support

Conversation

@cely404

@cely404 cely404 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?:

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 shadowDomEnabled and maxShadowDomDepth (defaults off, depth clamped 1–10).

@amplitude/element-selector generates composite paths joined by >>> when piercing is on, exports resolveSelector (required instead of document.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-browser wires a page-scoped ShadowGate that latches shadow mode on first enabling remote config; updates hierarchy, closest-ancestor, text masking, composedPath target resolution, and mutation/exposure observers that fan out into discovered open shadow roots. Remote-config handling now accepts explicit shadowDomEnabled (not only enabled), 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.

@linear-code

linear-code Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

SR-4788

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 63.56 KB (+3.22% 🔺)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 134.97 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 217.66 KB (+0.95% 🔺)
@amplitude/element-selector (gzipped esm) 3.4 KB (+27.56% 🔺)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/plugin-autocapture-browser/src/helpers.ts Outdated
Comment thread packages/plugin-autocapture-browser/src/data-extractor.ts Outdated
Comment thread test-server/shadow-dom-perf.html
Comment thread packages/plugin-autocapture-browser/src/autocapture-plugin.ts Outdated
Comment thread packages/element-selector/src/types.ts Outdated
@cely404
cely404 requested a balanced review from Copilot August 10, 2026 17:11
@cely404 cely404 changed the title Sr 4788 zoning shadow dom support feat(zoning): add support for shadow dom Aug 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 maxDepth from every added node instead of accounting for the shadow depth of the mutation target. For example, with maxDepth: 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

  • testIgnore is still applied when Playwright receives an explicit file filter, so the documented npx playwright test .../shadow-dom-perf.spec.ts command 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.

Comment thread packages/plugin-autocapture-browser/src/observables.ts Outdated
Comment thread packages/element-selector/src/helpers/shadow.ts
@cely404
cely404 force-pushed the SR-4788-zoning-shadow-dom-support branch from 0365733 to a331327 Compare August 10, 2026 17:58
@cely404
cely404 marked this pull request as ready for review August 10, 2026 18:22
@cely404
cely404 requested a review from a team as a code owner August 10, 2026 18:22
@daniel-graham-amplitude

Copy link
Copy Markdown
Collaborator

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

Comment thread packages/plugin-autocapture-browser/src/helpers.ts
Comment thread packages/plugin-autocapture-browser/src/observables.ts
Comment thread packages/element-selector/src/helpers/shadow.ts

@Mercy811 Mercy811 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@cely404

cely404 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

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.

Comment thread packages/plugin-autocapture-browser/SHADOW-DOM.md
@jxiwang

jxiwang commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

@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.

@cely404
cely404 force-pushed the SR-4788-zoning-shadow-dom-support branch from 60b9654 to 5201912 Compare August 26, 2026 16:35

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

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.

Create PR

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.

Comment thread packages/plugin-autocapture-browser/src/observables.ts
Comment thread packages/plugin-autocapture-browser/src/helpers.ts
@cely404
cely404 merged commit 6c2d5c2 into main Aug 26, 2026
17 checks passed
@cely404
cely404 deleted the SR-4788-zoning-shadow-dom-support branch August 26, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants