Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚗ [RUM-7213] DOM mutation ignoring #3276

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum ExperimentalFeature {
ACTION_NAME_MASKING = 'action_name_masking',
CONSISTENT_TRACE_SAMPLING = 'consistent_trace_sampling',
DELAY_VIEWPORT_COLLECTION = 'delay_viewport_collection',
DOM_MUTATION_IGNORING = 'dom_mutation_ignoring',
}

const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Expand Down
171 changes: 168 additions & 3 deletions packages/rum-core/src/browser/domMutationObservable.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { ExperimentalFeature } from '@datadog/browser-core'
import type { MockZoneJs } from '@datadog/browser-core/test'
import { registerCleanupTask, mockZoneJs } from '@datadog/browser-core/test'
import { createDOMMutationObservable, getMutationObserverConstructor } from './domMutationObservable'
import { registerCleanupTask, mockZoneJs, mockExperimentalFeatures } from '@datadog/browser-core/test'
import {
createDOMMutationObservable,
getMutationObserverConstructor,
IGNORE_MUTATIONS_ATTRIBUTE,
} from './domMutationObservable'

// The MutationObserver invokes its callback in an event loop microtask, making this asynchronous.
// We want to wait for a few event loop executions to potentially collect multiple mutation events.
const DOM_MUTATION_OBSERVABLE_DURATION = 16

describe('domMutationObservable', () => {
function domMutationSpec(mutate: (root: HTMLElement) => void, { expectedMutations }: { expectedMutations: number }) {
function domMutationSpec(
mutate: (root: HTMLElement) => void,
{ expectedMutations }: { expectedMutations: number },
isRootMutationsIgnored?: boolean
Copy link
Author

Choose a reason for hiding this comment

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

This parameter allows us to test the case where the experimental feature is enabled and the IGNORE_MUTATIONS_ATTRIBUTE is not present.

If you think it would be useful, I am also open to adding tests for the case where the experimental feature is not enabled and the IGNORE_MUTATIONS_ATTRIBUTE is present

) {
return (done: DoneFn) => {
const root = document.createElement('div')
root.setAttribute('data-test', 'foo')
if (isRootMutationsIgnored) {
root.setAttribute(IGNORE_MUTATIONS_ATTRIBUTE, '')
}
root.appendChild(document.createElement('span'))
root.appendChild(document.createTextNode('foo'))
document.body.appendChild(root)
Expand Down Expand Up @@ -101,6 +113,159 @@ describe('domMutationObservable', () => {
)
)

describe('with DOM_MUTATION_IGNORING enabled', () => {
beforeEach(() => {
mockExperimentalFeatures([ExperimentalFeature.DOM_MUTATION_IGNORING])
})

it(
'collects DOM mutation when an element is added',
domMutationSpec(
(root) => {
root.appendChild(document.createElement('button'))
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation when an element is added and the parent is ignored',
domMutationSpec(
(root) => {
root.appendChild(document.createElement('button'))
},
{ expectedMutations: 0 },
true
)
)

it(
'collects DOM mutation when a text node is added',
domMutationSpec(
(root) => {
root.appendChild(document.createTextNode('foo'))
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation when a text node is added and the parent is ignored',
domMutationSpec(
(root) => {
root.appendChild(document.createTextNode('foo'))
},
{ expectedMutations: 0 },
true
)
)

it(
'collects DOM mutation on attribute creation',
domMutationSpec(
(root) => {
root.setAttribute('data-test2', 'bar')
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation on attribute creation of ignored element',
domMutationSpec(
(root) => {
root.setAttribute('data-test2', 'bar')
},
{ expectedMutations: 0 },
true
)
)

it(
'collects DOM mutation on attribute change',
domMutationSpec(
(root) => {
root.setAttribute('data-test', 'bar')
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation on attribute change of ignored element',
domMutationSpec(
(root) => {
root.setAttribute('data-test', 'bar')
},
{ expectedMutations: 0 },
true
)
)

it(
'collects DOM mutation when an element is removed',
domMutationSpec(
(root) => {
root.removeChild(root.childNodes[0])
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation when an element is removed and parent is ignored',
domMutationSpec(
(root) => {
root.removeChild(root.childNodes[0])
},
{ expectedMutations: 0 },
true
)
)

it(
'collects DOM mutation when an element is moved',
domMutationSpec(
(root) => {
root.insertBefore(root.childNodes[0], null)
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation when an element is moved and parent element is ignored',
domMutationSpec(
(root) => {
root.insertBefore(root.childNodes[0], null)
},
{ expectedMutations: 0 },
true
)
)

it(
'collects DOM mutation when text node content changes',
domMutationSpec(
(root) => {
;(root.childNodes[1] as Text).data = 'bar'
},
{ expectedMutations: 1 }
)
)

it(
'does not collect DOM mutation when text node content changes and parent element is ignored',
domMutationSpec(
(root) => {
;(root.childNodes[1] as Text).data = 'bar'
},
{ expectedMutations: 0 },
true
)
)
})

describe('Zone.js support', () => {
let zoneJs: MockZoneJs
const OriginalMutationObserverConstructor = window.MutationObserver
Expand Down
36 changes: 34 additions & 2 deletions packages/rum-core/src/browser/domMutationObservable.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { monitor, noop, Observable, getZoneJsOriginalValue } from '@datadog/browser-core'
import {
monitor,
noop,
Observable,
getZoneJsOriginalValue,
ExperimentalFeature,
isExperimentalFeatureEnabled,
} from '@datadog/browser-core'

export const IGNORE_MUTATIONS_ATTRIBUTE = 'dd-ignore-mutations'

type MutationNotifier = (mutations: MutationRecord[]) => void

export function createDOMMutationObservable() {
const MutationObserver = getMutationObserverConstructor()
Expand All @@ -7,7 +18,18 @@ export function createDOMMutationObservable() {
if (!MutationObserver) {
return
}
const observer = new MutationObserver(monitor(() => observable.notify()))

let mutationNotifier: MutationNotifier = () => observable.notify()
if (isExperimentalFeatureEnabled(ExperimentalFeature.DOM_MUTATION_IGNORING)) {
mutationNotifier = (mutations: MutationRecord[]) => {
if (mutations.every((mutation) => isIgnored(mutation))) {
return
}
return observable.notify()
}
}

const observer = new MutationObserver(monitor(mutationNotifier))
observer.observe(document, {
attributes: true,
characterData: true,
Expand All @@ -18,6 +40,16 @@ export function createDOMMutationObservable() {
})
}

function isIgnored(mutation: MutationRecord): boolean {
switch (mutation.type) {
case 'attributes':
case 'childList':
return (mutation.target as Element).hasAttribute(IGNORE_MUTATIONS_ATTRIBUTE) === true
case 'characterData':
return mutation.target.parentElement?.hasAttribute(IGNORE_MUTATIONS_ATTRIBUTE) === true
}
}

type MutationObserverConstructor = new (callback: MutationCallback) => MutationObserver

export interface BrowserWindow extends Window {
Expand Down
Loading