Skip to content

Commit

Permalink
✨ [RUM-3684] Capture scroll record on shadow dom elements (#2708)
Browse files Browse the repository at this point in the history
  • Loading branch information
N-Boutaib authored Apr 22, 2024
1 parent 716545c commit 75e8f93
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 22 deletions.
25 changes: 24 additions & 1 deletion packages/rum/src/domain/record/record.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-co
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
import type { Clock } from '@datadog/browser-core/test'
import { createNewEvent, collectAsyncCalls } from '@datadog/browser-core/test'
import { findFullSnapshot, findNode, recordsPerFullSnapshot } from '../../../test'
import { findElement, findFullSnapshot, findNode, recordsPerFullSnapshot } from '../../../test'
import type {
BrowserIncrementalSnapshotRecord,
BrowserMutationData,
BrowserRecord,
DocumentFragmentNode,
ElementNode,
FocusRecord,
ScrollData,
} from '../../types'
import { NodeType, RecordType, IncrementalSource } from '../../types'
import { appendElement } from '../../../../rum-core/test'
Expand Down Expand Up @@ -334,6 +335,28 @@ describe('record', () => {
expect(inputRecords.length).toBe(1)
})

it('should record the scroll event inside a shadow root', () => {
const div = appendElement('<div unique-selector="enabled"></div>', createShadow()) as HTMLDivElement
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())

div.dispatchEvent(createNewEvent('scroll', { target: div, composed: false }))

recordApi.flushMutations()

const scrollRecords = getEmittedRecords().filter(
(record) => record.type === RecordType.IncrementalSnapshot && record.data.source === IncrementalSource.Scroll
)
expect(scrollRecords.length).toBe(1)

const scrollData = getLastIncrementalSnapshotData<ScrollData>(getEmittedRecords(), IncrementalSource.Scroll)

const fs = findFullSnapshot({ records: getEmittedRecords() })!
const scrollableNode = findElement(fs.data.node, (node) => node.attributes['unique-selector'] === 'enabled')!

expect(scrollData.id).toBe(scrollableNode.id)
})

it('should clean the state once the shadow dom is removed to avoid memory leak', () => {
const shadowRoot = createShadow()
appendElement('<div class="toto"></div>', shadowRoot)
Expand Down
7 changes: 2 additions & 5 deletions packages/rum/src/domain/record/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,7 @@ export function record(options: RecordOptions): RecordAPI {

const elementsScrollPositions = createElementsScrollPositions()

const shadowRootsController = initShadowRootsController(configuration, {
mutationCb: emitAndComputeStats,
inputCb: emitAndComputeStats,
})
const shadowRootsController = initShadowRootsController(configuration, emitAndComputeStats, elementsScrollPositions)

const { stop: stopFullSnapshots } = startFullSnapshots(
elementsScrollPositions,
Expand All @@ -76,7 +73,7 @@ export function record(options: RecordOptions): RecordAPI {
mutationTracker,
trackMove(configuration, emitAndComputeStats),
trackMouseInteraction(configuration, emitAndComputeStats, recordIds),
trackScroll(configuration, emitAndComputeStats, elementsScrollPositions),
trackScroll(configuration, emitAndComputeStats, elementsScrollPositions, document),
trackViewportResize(configuration, emitAndComputeStats),
trackInput(configuration, emitAndComputeStats),
trackMediaInteraction(configuration, emitAndComputeStats),
Expand Down
23 changes: 11 additions & 12 deletions packages/rum/src/domain/record/shadowRootsController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RumConfiguration } from '@datadog/browser-rum-core'
import type { InputCallback, MutationCallBack } from './trackers'
import { trackInput, trackMutation } from './trackers'
import type { BrowserIncrementalSnapshotRecord } from '../../types'
import { trackInput, trackMutation, trackScroll } from './trackers'
import type { ElementsScrollPositions } from './elementsScrollPositions'

interface ShadowRootController {
stop: () => void
Expand All @@ -18,13 +19,8 @@ export interface ShadowRootsController {

export const initShadowRootsController = (
configuration: RumConfiguration,
{
mutationCb,
inputCb,
}: {
mutationCb: MutationCallBack
inputCb: InputCallback
}
callback: (record: BrowserIncrementalSnapshotRecord) => void,
elementsScrollPositions: ElementsScrollPositions
): ShadowRootsController => {
const controllerByShadowRoot = new Map<ShadowRoot, ShadowRootController>()

Expand All @@ -33,14 +29,17 @@ export const initShadowRootsController = (
if (controllerByShadowRoot.has(shadowRoot)) {
return
}
const mutationTracker = trackMutation(mutationCb, configuration, shadowRootsController, shadowRoot)
// the change event no do bubble up across the shadow root, we have to listen on the shadow root
const inputTracker = trackInput(configuration, inputCb, shadowRoot)
const mutationTracker = trackMutation(callback, configuration, shadowRootsController, shadowRoot)
// The change event does not bubble up across the shadow root, we have to listen on the shadow root
const inputTracker = trackInput(configuration, callback, shadowRoot)
// The scroll event does not bubble up across the shadow root, we have to listen on the shadow root
const scrollTracker = trackScroll(configuration, callback, elementsScrollPositions, shadowRoot)
controllerByShadowRoot.set(shadowRoot, {
flush: () => mutationTracker.flush(),
stop: () => {
mutationTracker.stop()
inputTracker.stop()
scrollTracker.stop()
},
})
},
Expand Down
1 change: 1 addition & 0 deletions packages/rum/src/domain/record/trackers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export { trackFocus } from './trackFocus'
export { trackFrustration } from './trackFrustration'
export { trackViewEnd } from './trackViewEnd'
export { InputCallback, trackInput } from './trackInput'
export { ScrollCallback } from './trackScroll'
export { trackMutation, MutationCallBack, RumMutationRecord } from './trackMutation'
export { Tracker } from './types'
5 changes: 3 additions & 2 deletions packages/rum/src/domain/record/trackers/trackScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export type ScrollCallback = (incrementalSnapshotRecord: BrowserIncrementalSnaps
export function trackScroll(
configuration: RumConfiguration,
scrollCb: ScrollCallback,
elementsScrollPositions: ElementsScrollPositions
elementsScrollPositions: ElementsScrollPositions,
target: Document | ShadowRoot = document
): Tracker {
const { throttled: updatePosition, cancel: cancelThrottle } = throttle((event: Event) => {
const target = getEventTarget(event) as HTMLElement | Document
Expand Down Expand Up @@ -50,7 +51,7 @@ export function trackScroll(
)
}, SCROLL_OBSERVER_THRESHOLD)

const { stop: removeListener } = addEventListener(configuration, document, DOM_EVENT.SCROLL, updatePosition, {
const { stop: removeListener } = addEventListener(configuration, target, DOM_EVENT.SCROLL, updatePosition, {
capture: true,
passive: true,
})
Expand Down
78 changes: 76 additions & 2 deletions test/e2e/scenario/recorder/shadowDom.scenario.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { DocumentFragmentNode, MouseInteractionData, SerializedNodeWithId } from '@datadog/browser-rum/src/types'
import { MouseInteractionType, NodeType } from '@datadog/browser-rum/src/types'
import type {
DocumentFragmentNode,
MouseInteractionData,
ScrollData,
SerializedNodeWithId,
} from '@datadog/browser-rum/src/types'
import { IncrementalSource, MouseInteractionType, NodeType } from '@datadog/browser-rum/src/types'

import {
createMutationPayloadValidatorFromSegment,
findElementWithIdAttribute,
findElementWithTagName,
findFullSnapshot,
findIncrementalSnapshot,
findMouseInteractionRecords,
findNode,
findTextContent,
Expand Down Expand Up @@ -80,6 +86,49 @@ const divShadowDom = `<script>
</script>
`

/** Will generate the following HTML
* ```html
* <my-div id="titi">
* #shadow-root
* <div scrollable-div style="height:100px; overflow: scroll;">
* <div style="height:500px;"></div>
* </div>
* <button>scroll to 250</button>
*</my-div>
*```
when called like `<my-div />`
*/
const scrollableDivShadowDom = `<script>
class CustomScrollableDiv extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const div = document.createElement("div");
div.id = 'scrollable-div';
div.style.height = '100px';
div.style.overflow = 'scroll';
const innerDiv = document.createElement("div");
innerDiv.style.height = '500px';
const button = document.createElement("button");
button.textContent = 'scroll to 250';
button.onclick = () => {
div.scrollTo({ top: 250 });
}
div.appendChild(innerDiv);
this.shadowRoot.appendChild(button);
this.shadowRoot.appendChild(div);
}
}
window.customElements.define("my-scrollable-div", CustomScrollableDiv);
</script>
`

/** Will generate the following HTML
* ```html
* <div-with-style>
Expand Down Expand Up @@ -244,6 +293,31 @@ describe('recorder with shadow DOM', () => {
],
})
})

createTest('can record scroll from inside the shadow root')
.withRum({})
.withSetup(bundleSetup)
.withBody(html`
${scrollableDivShadowDom}
<my-scrollable-div id="host" />
`)
.run(async ({ intakeRegistry }) => {
const button = await getNodeInsideShadowDom('my-scrollable-div', 'button')

// Triggering scrollTo from the test itself is not allowed
// Thus, a callback to scroll the div was added to the button 'click' event
await button.click()

await flushEvents()
expect(intakeRegistry.replaySegments.length).toBe(1)
const scrollRecord = findIncrementalSnapshot(intakeRegistry.replaySegments[0], IncrementalSource.Scroll)
const fullSnapshot = findFullSnapshot(intakeRegistry.replaySegments[0])!
const divNode = findElementWithIdAttribute(fullSnapshot.data.node, 'scrollable-div')!

expect(scrollRecord).toBeTruthy()
expect((scrollRecord?.data as ScrollData).id).toBe(divNode.id)
expect((scrollRecord?.data as ScrollData).y).toBe(250)
})
})

function findElementsInShadowDom(node: SerializedNodeWithId, id: string) {
Expand Down

0 comments on commit 75e8f93

Please sign in to comment.