diff --git a/packages/rum-core/src/browser/htmlDomUtils.ts b/packages/rum-core/src/browser/htmlDomUtils.ts
index d7404c19cd..6995e7328c 100644
--- a/packages/rum-core/src/browser/htmlDomUtils.ts
+++ b/packages/rum-core/src/browser/htmlDomUtils.ts
@@ -43,3 +43,19 @@ export function forEachChildNodes(node: Node, callback: (child: Node) => void) {
export function getParentNode(node: Node): Node | null {
return isNodeShadowRoot(node) ? node.host : node.parentNode
}
+
+/**
+ * Return the parent element, crossing shadow DOM boundaries.
+ * If the element is a direct child of a shadow root, returns the shadow host.
+ */
+export function getParentElement(element: Element): Element | null {
+ if (element.parentElement) {
+ return element.parentElement
+ }
+ // If parentElement is null, check if we're in a shadow root
+ const parentNode = element.parentNode
+ if (parentNode && isNodeShadowRoot(parentNode)) {
+ return parentNode.host
+ }
+ return null
+}
diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
index 8a3c03b403..cc28ba081a 100644
--- a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
+++ b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
@@ -930,4 +930,123 @@ describe('getActionNameFromElement', () => {
})
})
})
+
+ describe('shadow DOM support', () => {
+ it('extracts text content from element inside shadow DOM', () => {
+ const host = appendElement('
')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const button = document.createElement('button')
+ button.textContent = 'Shadow Button'
+ shadowRoot.appendChild(button)
+
+ const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
+ expect(name).toBe('Shadow Button')
+ expect(nameSource).toBe('text_content')
+ })
+
+ it('extracts aria-label from element inside shadow DOM', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const button = document.createElement('button')
+ button.setAttribute('aria-label', 'Accessible Label')
+ shadowRoot.appendChild(button)
+
+ const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
+ expect(name).toBe('Accessible Label')
+ expect(nameSource).toBe('standard_attribute')
+ })
+
+ it('traverses parent elements across shadow boundary to find action name', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const wrapper = document.createElement('div')
+ wrapper.setAttribute('title', 'Parent Title')
+ const target = document.createElement('span')
+ wrapper.appendChild(target)
+ shadowRoot.appendChild(wrapper)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('Parent Title')
+ expect(nameSource).toBe('standard_attribute')
+ })
+
+ it('finds data-dd-action-name on shadow host when element is inside shadow DOM', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const target = document.createElement('button')
+ shadowRoot.appendChild(target)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('Custom Action')
+ expect(nameSource).toBe('custom_attribute')
+ })
+
+ it('finds data-dd-action-name on ancestor outside shadow DOM', () => {
+ const ancestor = appendElement('')
+ const host = ancestor.querySelector('#host')!
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const target = document.createElement('button')
+ shadowRoot.appendChild(target)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('Ancestor Action')
+ expect(nameSource).toBe('custom_attribute')
+ })
+
+ it('prefers data-dd-action-name closer to the element inside shadow DOM', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const wrapper = document.createElement('div')
+ wrapper.setAttribute('data-dd-action-name', 'Inner Action')
+ const target = document.createElement('button')
+ wrapper.appendChild(target)
+ shadowRoot.appendChild(wrapper)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('Inner Action')
+ expect(nameSource).toBe('custom_attribute')
+ })
+
+ it('handles nested shadow DOMs', () => {
+ const outerHost = appendElement('')
+ const outerShadowRoot = outerHost.attachShadow({ mode: 'open' })
+
+ const innerHost = document.createElement('div')
+ outerShadowRoot.appendChild(innerHost)
+ const innerShadowRoot = innerHost.attachShadow({ mode: 'open' })
+
+ const target = document.createElement('button')
+ innerShadowRoot.appendChild(target)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('Outer Action')
+ expect(nameSource).toBe('custom_attribute')
+ })
+
+ it('gets parent textual content from shadow host when element has no name', () => {
+ const wrapper = appendElement('')
+ const host = wrapper.querySelector('#host-container')!
+ host.setAttribute('title', 'Host Title')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const target = document.createElement('span')
+ shadowRoot.appendChild(target)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('Host Title')
+ expect(nameSource).toBe('standard_attribute')
+ })
+
+ it('respects FORM boundary inside shadow DOM', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const form = document.createElement('form')
+ const target = document.createElement('span')
+ form.appendChild(target)
+ shadowRoot.appendChild(form)
+
+ const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
+ expect(name).toBe('')
+ expect(nameSource).toBe('blank')
+ })
+ })
})
diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.ts
index 0c937cca65..3431cf2e21 100644
--- a/packages/rum-core/src/domain/action/getActionNameFromElement.ts
+++ b/packages/rum-core/src/domain/action/getActionNameFromElement.ts
@@ -3,7 +3,7 @@ import { getPrivacySelector, NodePrivacyLevel } from '../privacyConstants'
import { getNodePrivacyLevel, maskDisallowedTextContent, shouldMaskNode, shouldMaskAttribute } from '../privacy'
import type { NodePrivacyLevelCache } from '../privacy'
import type { RumConfiguration } from '../configuration'
-import { isElementNode } from '../../browser/htmlDomUtils'
+import { isElementNode, getParentElement } from '../../browser/htmlDomUtils'
import {
ActionNameSource,
DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE,
@@ -49,7 +49,8 @@ export function getActionNameFromElement(
function getActionNameFromElementProgrammatically(targetElement: Element, programmaticAttribute: string) {
// We don't use getActionNameFromElementForStrategies here, because we want to consider all parents,
// without limit. It is up to the user to declare a relevant naming strategy.
- const elementWithAttribute = targetElement.closest(`[${programmaticAttribute}]`)
+ // Use shadow-aware closest to cross shadow DOM boundaries
+ const elementWithAttribute = closestShadowAware(targetElement, `[${programmaticAttribute}]`)
if (!elementWithAttribute) {
return
@@ -58,6 +59,17 @@ function getActionNameFromElementProgrammatically(targetElement: Element, progra
return truncate(normalizeWhitespace(name.trim()))
}
+function closestShadowAware(element: Element, selector: string): Element | null {
+ let current: Element | null = element
+ while (current) {
+ if (current.matches(selector)) {
+ return current
+ }
+ current = getParentElement(current)
+ }
+ return null
+}
+
type NameStrategy = (
element: Element | HTMLElement | HTMLInputElement | HTMLSelectElement,
rumConfiguration: RumConfiguration,
@@ -128,6 +140,7 @@ const fallbackStrategies: NameStrategy[] = [
/**
* Iterates over the target element and its parent, using the strategies list to get an action name.
* Each strategies are applied on each element, stopping as soon as a non-empty value is returned.
+ * Uses shadow-aware parent traversal to cross shadow DOM boundaries.
*/
const MAX_PARENTS_TO_CONSIDER = 10
function getActionNameFromElementForStrategies(
@@ -160,7 +173,7 @@ function getActionNameFromElementForStrategies(
if (element.nodeName === 'FORM') {
break
}
- element = element.parentElement
+ element = getParentElement(element)
recursionCounter += 1
}
}
diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts
index bf4a11af29..57b5845cf4 100644
--- a/packages/rum-core/src/domain/action/trackClickActions.ts
+++ b/packages/rum-core/src/domain/action/trackClickActions.ts
@@ -10,6 +10,7 @@ import {
createValueHistory,
PageExitReason,
} from '@datadog/browser-core'
+import { isNodeShadowHost } from '../../browser/htmlDomUtils'
import type { FrustrationType } from '../../rawRumEvent.types'
import { ActionType } from '../../rawRumEvent.types'
import type { LifeCycle } from '../lifeCycle'
@@ -143,10 +144,12 @@ function processPointerDown(
pointerDownEvent: MouseEventOnElement,
windowOpenObservable: Observable
) {
+ const target = configuration.trackActionsInShadowDom ? getEventTarget(pointerDownEvent) : pointerDownEvent.target
+
let nodePrivacyLevel: NodePrivacyLevel
if (configuration.enablePrivacyForActionName) {
- nodePrivacyLevel = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel)
+ nodePrivacyLevel = getNodePrivacyLevel(target, configuration.defaultPrivacyLevel)
} else {
nodePrivacyLevel = NodePrivacyLevel.ALLOW
}
@@ -244,13 +247,18 @@ function computeClickActionBase(
nodePrivacyLevel: NodePrivacyLevel,
configuration: RumConfiguration
): ClickActionBase {
- const rect = event.target.getBoundingClientRect()
- const selector = getSelectorFromElement(event.target, configuration.actionNameAttribute)
+ // Get the actual target element, handling shadow DOM if enabled
+ // For composed events from shadow DOM, event.target might be the shadow host,
+ // but we want the actual element that was clicked inside the shadow root
+ const target = configuration.trackActionsInShadowDom ? getEventTarget(event) : event.target
+
+ const rect = target.getBoundingClientRect()
+ const selector = getSelectorFromElement(target, configuration.actionNameAttribute)
if (selector) {
updateInteractionSelector(event.timeStamp, selector)
}
- const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel)
+ const { name, nameSource } = getActionNameFromElement(target, configuration, nodePrivacyLevel)
return {
type: ActionType.CLICK,
@@ -268,6 +276,15 @@ function computeClickActionBase(
nameSource,
}
}
+function getEventTarget(event: MouseEventOnElement): Element {
+ if (event.composed && isNodeShadowHost(event.target) && typeof event.composedPath === 'function') {
+ const composedPath = event.composedPath()
+ if (composedPath.length > 0 && composedPath[0] instanceof Element) {
+ return composedPath[0]
+ }
+ }
+ return event.target
+}
const enum ClickStatus {
// Initial state, the click is still ongoing.
diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts
index 06e5fdb973..747ea325a7 100644
--- a/packages/rum-core/src/domain/configuration/configuration.spec.ts
+++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts
@@ -612,6 +612,7 @@ describe('serializeRumConfiguration', () => {
trackFeatureFlagsForEvents: ['vital'],
profilingSampleRate: 42,
propagateTraceBaggage: true,
+ trackActionsInShadowDom: true,
}
type MapRumInitConfigurationKey = Key extends keyof InitConfiguration
@@ -626,7 +627,8 @@ describe('serializeRumConfiguration', () => {
: Key extends 'trackLongTasks'
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
: // The following options are not reported as telemetry. Please avoid adding more of them.
- Key extends 'applicationId' | 'subdomain'
+ // TODO: Remove trackActionsInShadowDom from this list once rum-events-format is updated
+ Key extends 'applicationId' | 'subdomain' | 'trackActionsInShadowDom'
? never
: CamelToSnakeCase
// By specifying the type here, we can ensure that serializeConfiguration is returning an
diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts
index f41e76b0ca..a078214bd5 100644
--- a/packages/rum-core/src/domain/configuration/configuration.ts
+++ b/packages/rum-core/src/domain/configuration/configuration.ts
@@ -203,6 +203,15 @@ export interface RumInitConfiguration extends InitConfiguration {
*/
actionNameAttribute?: string | undefined
+ /**
+ * Enables tracking of user interactions within Shadow DOM elements.
+ * When enabled, click actions inside Shadow DOM will have accurate names and selectors.
+ *
+ * @category Data Collection
+ * @defaultValue false
+ */
+ trackActionsInShadowDom?: boolean | undefined
+
// view options
/**
* Allows you to control RUM views creation. See [Override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#override-default-rum-view-names) for further information.
@@ -286,6 +295,7 @@ export interface GraphQlUrlOption {
export interface RumConfiguration extends Configuration {
// Built from init configuration
actionNameAttribute: string | undefined
+ trackActionsInShadowDom: boolean
traceSampleRate: number
rulePsr: number | undefined
allowedTracingUrls: TracingOption[]
@@ -358,6 +368,7 @@ export function validateAndBuildRumConfiguration(
return {
applicationId: initConfiguration.applicationId,
actionNameAttribute: initConfiguration.actionNameAttribute,
+ trackActionsInShadowDom: !!initConfiguration.trackActionsInShadowDom,
sessionReplaySampleRate,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
@@ -533,6 +544,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
remote_configuration_id: configuration.remoteConfigurationId,
profiling_sample_rate: configuration.profilingSampleRate,
use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy,
+ // TODO: Add track_actions_in_shadow_dom once rum-events-format is updated
...baseSerializedConfiguration,
} satisfies RawTelemetryConfiguration
}
diff --git a/packages/rum-core/src/domain/getSelectorFromElement.spec.ts b/packages/rum-core/src/domain/getSelectorFromElement.spec.ts
index 5a1b40bee1..727698e647 100644
--- a/packages/rum-core/src/domain/getSelectorFromElement.spec.ts
+++ b/packages/rum-core/src/domain/getSelectorFromElement.spec.ts
@@ -1,5 +1,5 @@
import { appendElement } from '../../test'
-import { getSelectorFromElement, isSelectorUniqueAmongSiblings } from './getSelectorFromElement'
+import { getSelectorFromElement, isSelectorUniqueAmongSiblings, SHADOW_DOM_MARKER } from './getSelectorFromElement'
describe('getSelectorFromElement', () => {
afterEach(() => {
@@ -220,3 +220,114 @@ describe('isSelectorUniqueAmongSiblings', () => {
expect(isSelectorUniqueAmongSiblings(element, 'DIV', 'HR')).toBeTrue()
})
})
+
+describe('getSelectorFromElement with shadow DOM', () => {
+ it('should generate selector with shadow marker for element inside shadow DOM', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const button = document.createElement('button')
+ button.classList.add('shadow-button')
+ shadowRoot.appendChild(button)
+
+ const selector = getSelectorFromElement(button, undefined)
+ expect(selector).toBeDefined()
+ expect(selector).toContain(SHADOW_DOM_MARKER)
+ expect(selector).toContain('BUTTON')
+ })
+
+ it('should use stable attribute for element inside shadow DOM with shadow marker', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const button = document.createElement('button')
+ button.setAttribute('data-testid', 'shadow-test')
+ shadowRoot.appendChild(button)
+
+ const selector = getSelectorFromElement(button, undefined)
+ expect(selector).toContain(SHADOW_DOM_MARKER)
+ expect(selector).toContain('data-testid="shadow-test"')
+ })
+
+ it('should insert shadow marker when traversing shadow boundary', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+ const button = document.createElement('button')
+ shadowRoot.appendChild(button)
+
+ const selector = getSelectorFromElement(button, undefined)
+
+ expect(selector).toContain(SHADOW_DOM_MARKER)
+ expect(selector).toContain('BUTTON')
+ })
+
+ it('should handle nested shadow DOMs with multiple markers', () => {
+ const outerHost = appendElement('')
+ const outerShadowRoot = outerHost.attachShadow({ mode: 'open' })
+
+ const innerHost = document.createElement('div')
+ innerHost.setAttribute('data-testid', 'inner-host')
+ outerShadowRoot.appendChild(innerHost)
+ const innerShadowRoot = innerHost.attachShadow({ mode: 'open' })
+
+ const button = document.createElement('button')
+ button.setAttribute('data-testid', 'deep-button')
+ innerShadowRoot.appendChild(button)
+
+ const selector = getSelectorFromElement(button, undefined)
+ expect(selector).toBeDefined()
+
+ const markerCount = (
+ selector!.match(new RegExp(SHADOW_DOM_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []
+ ).length
+ expect(markerCount).toBe(2)
+ expect(selector).toContain('data-testid="deep-button"')
+ })
+
+ it('should use position selector inside shadow DOM with shadow marker', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+
+ const div1 = document.createElement('div')
+ const div2 = document.createElement('div')
+ const target = document.createElement('span')
+ div2.appendChild(target)
+ shadowRoot.appendChild(div1)
+ shadowRoot.appendChild(div2)
+
+ const selector = getSelectorFromElement(target, undefined)
+ expect(selector).toBeDefined()
+ expect(selector).toContain(SHADOW_DOM_MARKER)
+ expect(selector).toContain('SPAN')
+ })
+
+ it('should generate unique selector when siblings exist inside shadow DOM', () => {
+ const host = appendElement('')
+ const shadowRoot = host.attachShadow({ mode: 'open' })
+
+ const button1 = document.createElement('button')
+ button1.classList.add('first')
+ const button2 = document.createElement('button')
+ button2.classList.add('second')
+
+ shadowRoot.appendChild(button1)
+ shadowRoot.appendChild(button2)
+
+ const selector1 = getSelectorFromElement(button1, undefined)
+ const selector2 = getSelectorFromElement(button2, undefined)
+
+ expect(selector1).toBeDefined()
+ expect(selector2).toBeDefined()
+ expect(selector1).not.toBe(selector2)
+ expect(selector1).toContain(SHADOW_DOM_MARKER)
+ expect(selector2).toContain(SHADOW_DOM_MARKER)
+ })
+
+ it('should NOT add shadow marker for elements in light DOM', () => {
+ const element = appendElement('')
+ const button = element.querySelector('button')!
+
+ const selector = getSelectorFromElement(button, undefined)
+ expect(selector).toBeDefined()
+ expect(selector).not.toContain(SHADOW_DOM_MARKER)
+ expect(selector).toBe('BODY>DIV>BUTTON.light-btn')
+ })
+})
diff --git a/packages/rum-core/src/domain/getSelectorFromElement.ts b/packages/rum-core/src/domain/getSelectorFromElement.ts
index 287f27aaf0..aa5cf046d0 100644
--- a/packages/rum-core/src/domain/getSelectorFromElement.ts
+++ b/packages/rum-core/src/domain/getSelectorFromElement.ts
@@ -1,5 +1,8 @@
+import { getParentElement, isNodeShadowRoot } from '../browser/htmlDomUtils'
import { DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from './action/actionNameConstants'
+export const SHADOW_DOM_MARKER = ' /shadow/ '
+
/**
* Stable attributes are attributes that are commonly used to identify parts of a UI (ex:
* component). Those attribute values should not be generated randomly (hardcoded most of the time)
@@ -46,8 +49,11 @@ export function getSelectorFromElement(
// parents, and we cannot determine if it's unique in the document.
return
}
- let targetElementSelector: string | undefined
+
+ let cssSelector: string | undefined
+ let outputSelector: string | undefined
let currentElement: Element | null = targetElement
+ let nextIsShadowBoundary = false
while (currentElement && currentElement.nodeName !== 'HTML') {
const globallyUniqueSelector = findSelector(
@@ -55,10 +61,10 @@ export function getSelectorFromElement(
GLOBALLY_UNIQUE_SELECTOR_GETTERS,
isSelectorUniqueGlobally,
actionNameAttribute,
- targetElementSelector
+ cssSelector
)
if (globallyUniqueSelector) {
- return globallyUniqueSelector
+ return combineSelector(globallyUniqueSelector, outputSelector, nextIsShadowBoundary)
}
const uniqueSelectorAmongChildren = findSelector(
@@ -66,15 +72,29 @@ export function getSelectorFromElement(
UNIQUE_AMONG_CHILDREN_SELECTOR_GETTERS,
isSelectorUniqueAmongSiblings,
actionNameAttribute,
- targetElementSelector
+ cssSelector
)
- targetElementSelector =
- uniqueSelectorAmongChildren || combineSelector(getPositionSelector(currentElement), targetElementSelector)
- currentElement = currentElement.parentElement
+ const currentSelector = uniqueSelectorAmongChildren || getPositionSelector(currentElement)
+
+ cssSelector = combineSelector(currentSelector, cssSelector)
+ outputSelector = combineSelector(currentSelector, outputSelector, nextIsShadowBoundary)
+
+ nextIsShadowBoundary = isCrossingShadowBoundary(currentElement)
+
+ currentElement = getParentElement(currentElement)
}
- return targetElementSelector
+ return outputSelector
+}
+
+/**
+ * Check if traversing to the parent element will cross a shadow DOM boundary.
+ * This happens when the element is a direct child of a shadow root.
+ */
+function isCrossingShadowBoundary(element: Element): boolean {
+ const parentNode = element.parentNode
+ return parentNode !== null && isNodeShadowRoot(parentNode)
}
function isGeneratedValue(value: string) {
@@ -136,7 +156,12 @@ function getStableAttributeSelector(element: Element, actionNameAttribute: strin
}
function getPositionSelector(element: Element): string {
- let sibling = element.parentElement!.firstElementChild
+ const parent = getElementParentNode(element)
+ if (!parent) {
+ return CSS.escape(element.tagName)
+ }
+
+ let sibling = parent.firstElementChild
let elementIndex = 1
while (sibling && sibling !== element) {
@@ -149,6 +174,22 @@ function getPositionSelector(element: Element): string {
return `${CSS.escape(element.tagName)}:nth-of-type(${elementIndex})`
}
+/**
+ * Get the parent node that contains the element's siblings.
+ * For elements in shadow DOM, this is the shadow root.
+ * For regular elements, this is the parent element.
+ */
+function getElementParentNode(element: Element): Element | ShadowRoot | null {
+ if (element.parentElement) {
+ return element.parentElement
+ }
+ const parentNode = element.parentNode
+ if (parentNode && isNodeShadowRoot(parentNode)) {
+ return parentNode
+ }
+ return null
+}
+
function findSelector(
element: Element,
selectorGetters: SelectorGetter[],
@@ -162,7 +203,7 @@ function findSelector(
continue
}
if (predicate(element, elementSelector, childSelector)) {
- return combineSelector(elementSelector, childSelector)
+ return elementSelector
}
}
}
@@ -249,7 +290,12 @@ export function isSelectorUniqueAmongSiblings(
isSiblingMatching = (sibling) => sibling.querySelector(scopedSelector) !== null
}
- const parent = currentElement.parentElement!
+ const parent = getElementParentNode(currentElement)
+ if (!parent) {
+ // If there's no parent (edge case), consider it unique
+ return true
+ }
+
let sibling = parent.firstElementChild
while (sibling) {
if (sibling !== currentElement && isSiblingMatching(sibling)) {
@@ -261,6 +307,10 @@ export function isSelectorUniqueAmongSiblings(
return true
}
-function combineSelector(parent: string, child: string | undefined): string {
- return child ? `${parent}>${child}` : parent
+function combineSelector(parent: string, child: string | undefined, useShadowSeparator = false): string {
+ if (!child) {
+ return parent
+ }
+ const separator = useShadowSeparator ? SHADOW_DOM_MARKER : '>'
+ return `${parent}${separator}${child}`
}
diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts
index 593c9d372b..12bf7be911 100644
--- a/test/e2e/scenario/rum/actions.scenario.ts
+++ b/test/e2e/scenario/rum/actions.scenario.ts
@@ -455,3 +455,116 @@ test.describe('action collection', () => {
})
})
})
+
+test.describe('action collection with shadow DOM', () => {
+ createTest('without trackShadowDom, click inside shadow DOM uses shadow host as target')
+ .withRum({ trackUserInteractions: true })
+ .withBody(html`
+
+
+ `)
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ const button = page.locator('my-button').first().locator('button')
+ await button.click()
+ await flushEvents()
+
+ const actionEvents = intakeRegistry.rumActionEvents
+ expect(actionEvents).toHaveLength(1)
+ expect(actionEvents[0].action?.target?.name).toBe('')
+ expect(actionEvents[0]._dd.action?.target?.selector).toBe('#shadow-host')
+ })
+
+ createTest('with trackShadowDom, get action name from element inside shadow DOM')
+ .withRum({ trackUserInteractions: true, trackActionsInShadowDom: true })
+ .withBody(html`
+
+
+ `)
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ const button = page.locator('my-button').first().locator('button')
+ await button.click()
+ await flushEvents()
+
+ const actionEvents = intakeRegistry.rumActionEvents
+ expect(actionEvents).toHaveLength(1)
+ expect(actionEvents[0].action?.target?.name).toBe('Shadow Button')
+ expect(actionEvents[0]._dd.action?.target?.selector).toContain('BUTTON')
+ })
+
+ createTest('with trackShadowDom, traverse shadow boundary for data-dd-action-name')
+ .withRum({ trackUserInteractions: true, trackActionsInShadowDom: true })
+ .withBody(html`
+
+
+ `)
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ const button = page.locator('my-button').first().locator('button')
+ await button.click()
+ await flushEvents()
+
+ const actionEvents = intakeRegistry.rumActionEvents
+ expect(actionEvents).toHaveLength(1)
+ expect(actionEvents[0].action?.target?.name).toBe('Custom Shadow Action')
+ })
+
+ createTest('with trackShadowDom, selector includes stable attributes from inside shadow DOM')
+ .withRum({ trackUserInteractions: true, trackActionsInShadowDom: true })
+ .withBody(html`
+
+
+ `)
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ const button = page.locator('my-button').first().locator('button')
+ await button.click()
+ await flushEvents()
+
+ const actionEvents = intakeRegistry.rumActionEvents
+ expect(actionEvents).toHaveLength(1)
+ expect(actionEvents[0]._dd.action?.target?.selector).toContain('data-testid')
+ })
+})