Skip to content

Commit 3be864d

Browse files
committed
add tests
1 parent 7922928 commit 3be864d

File tree

2 files changed

+131
-12
lines changed

2 files changed

+131
-12
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/* eslint-disable jest/no-done-callback */
2+
import { sleep } from '@segment/analytics-core'
3+
import {
4+
MutationObservable,
5+
MutationObservableSettings,
6+
MutationObservableSubscriber,
7+
} from './mutation-observer'
8+
9+
describe('MutationObservable', () => {
10+
let mutationObservable: MutationObservable
11+
let testButton: HTMLButtonElement
12+
let testInput: HTMLInputElement
13+
const subscribeFn = jest.fn() as jest.Mock<MutationObservableSubscriber>
14+
beforeEach(() => {
15+
document.body.innerHTML =
16+
'<div id="test-element" role="button" aria-pressed="false"></div>' +
17+
'<input id="test-input" />'
18+
testButton = document.getElementById('test-element') as HTMLButtonElement
19+
testInput = document.getElementById('test-input') as HTMLInputElement
20+
})
21+
22+
afterEach(() => {
23+
mutationObservable.cleanup()
24+
})
25+
26+
it('should capture attribute changes', async () => {
27+
mutationObservable = new MutationObservable(
28+
new MutationObservableSettings({
29+
observedRoles: () => ['button'],
30+
observedAttributes: () => ['aria-pressed'],
31+
debounceMs: 500,
32+
})
33+
)
34+
35+
mutationObservable.subscribe(subscribeFn)
36+
testButton.setAttribute('aria-pressed', 'true')
37+
await sleep(0)
38+
39+
expect(subscribeFn).toHaveBeenCalledTimes(1)
40+
expect(subscribeFn).toHaveBeenCalledWith({
41+
element: testButton,
42+
attributes: [{ attributeName: 'aria-pressed', newValue: 'true' }],
43+
})
44+
})
45+
46+
it('should capture multiple attribute changes', async () => {
47+
mutationObservable = new MutationObservable(
48+
new MutationObservableSettings({
49+
observedRoles: () => ['button'],
50+
observedAttributes: () => ['aria-pressed'],
51+
debounceMs: 500,
52+
})
53+
)
54+
55+
mutationObservable.subscribe(subscribeFn)
56+
testButton.setAttribute('aria-pressed', 'true')
57+
await sleep(0)
58+
testButton.setAttribute('aria-pressed', 'false')
59+
await sleep(0)
60+
61+
expect(subscribeFn).toHaveBeenCalledTimes(2)
62+
expect(subscribeFn).toHaveBeenNthCalledWith(1, {
63+
element: testButton,
64+
attributes: [{ attributeName: 'aria-pressed', newValue: 'true' }],
65+
})
66+
expect(subscribeFn).toHaveBeenNthCalledWith(2, {
67+
element: testButton,
68+
attributes: [{ attributeName: 'aria-pressed', newValue: 'false' }],
69+
})
70+
})
71+
72+
it('should debounce attribute changes if they occur in text inputs', async () => {
73+
mutationObservable = new MutationObservable(
74+
new MutationObservableSettings({
75+
debounceMs: 100,
76+
})
77+
)
78+
mutationObservable.subscribe(subscribeFn)
79+
testInput.setAttribute('value', 'hello')
80+
await sleep(0)
81+
testInput.setAttribute('value', 'hello wor')
82+
await sleep(0)
83+
testInput.setAttribute('value', 'hello world')
84+
await sleep(200)
85+
86+
expect(subscribeFn).toHaveBeenCalledTimes(1)
87+
expect(subscribeFn).toHaveBeenCalledWith({
88+
element: testInput,
89+
attributes: [{ attributeName: 'value', newValue: 'hello world' }],
90+
})
91+
})
92+
93+
it('should not emit event for aria-selected=false', (done) => {
94+
mutationObservable = new MutationObservable(
95+
new MutationObservableSettings({
96+
observedRoles: () => ['button'],
97+
observedAttributes: () => ['aria-selected'],
98+
})
99+
)
100+
101+
mutationObservable.subscribe(() => {
102+
done.fail('Should not emit event for aria-selected=false')
103+
})
104+
105+
testButton.setAttribute('aria-selected', 'false')
106+
setTimeout(done, 1000) // Wait to ensure no event is emitted
107+
})
108+
})

packages/signals/signals/src/core/signal-generators/dom-gen/mutation-observer.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Emitter } from '@segment/analytics-generic-utils'
22
import { exists } from '../../../lib/exists'
33
import { debounceWithKey } from '../../../lib/debounce'
4+
import { logger } from '../../../lib/logger'
45

56
const DEFAULT_OBSERVED_ATTRIBUTES = [
67
'aria-pressed',
@@ -133,6 +134,9 @@ const shouldDebounce = (el: HTMLElement): boolean => {
133134
return false
134135
}
135136

137+
export type MutationObservableSubscriber = (
138+
event: AttributeChangedEvent
139+
) => void
136140
/**
137141
* This class is responsible for observing changes to elements in the DOM
138142
* This is preferred over monitoring document 'change' events, as it captures changes to custom elements
@@ -143,9 +147,9 @@ export class MutationObservable {
143147
// WeakSet is used here to allow garbage collection of elements that are no longer in the DOM
144148
private observedElements = new WeakSet()
145149
private emitter = new ElementChangedEmitter()
146-
private listeners = new Set<(event: AttributeChangedEvent) => void>()
150+
private listeners = new Set<MutationObservableSubscriber>()
147151

148-
subscribe(fn: (event: AttributeChangedEvent) => void) {
152+
subscribe(fn: MutationObservableSubscriber) {
149153
this.listeners.add(fn)
150154
this.emitter.on('attributeChanged', fn)
151155
}
@@ -158,13 +162,8 @@ export class MutationObservable {
158162

159163
private pollTimeout: ReturnType<typeof setTimeout>
160164

161-
constructor(
162-
settings: MutationObservableSettingsConfig | MutationObservableSettings = {}
163-
) {
164-
this.settings =
165-
settings instanceof MutationObservableSettings
166-
? settings
167-
: new MutationObservableSettings(settings)
165+
constructor(settings?: MutationObservableSettings) {
166+
this.settings = settings ?? new MutationObservableSettings()
168167

169168
this.checkForNewElements(this.emitter)
170169

@@ -205,7 +204,9 @@ export class MutationObservable {
205204
? addOnBlurListener
206205
: _emitAttributeMutationEvent
207206

208-
const _emitAttributeMutationEventDebounced = shouldDebounce(element)
207+
const shouldDebounceElement = shouldDebounce(element)
208+
209+
const _emitAttributeMutationEventDebounced = shouldDebounceElement
209210
? debounceWithKey(
210211
emit,
211212
// debounce based on the attribute names, so that we can debounce all changes to a single attribute. e.g if attribute "value" changes, that gets debounced, but if another attribute changes, that gets debounced separately
@@ -214,17 +215,26 @@ export class MutationObservable {
214215
)
215216
: _emitAttributeMutationEvent
216217

218+
// any call to setAttribute triggers a mutation event
217219
const cb: MutationCallback = (mutationsList) => {
218220
const attributeMutations = mutationsList
219221
.filter((m) => m.type === 'attributes')
220222
.map((m) => {
221223
const attributeName = m.attributeName
222-
if (!attributeName) return
223-
const newValue = element.getAttribute(attributeName)
224+
const target = m.target
225+
if (!attributeName || !target || !(target instanceof HTMLElement))
226+
return
227+
228+
const newValue = target.getAttribute(attributeName)
224229
const v: AttributeMutation = {
225230
attributeName,
226231
newValue: newValue,
227232
}
233+
logger.debug('Attribute mutation', {
234+
newValue,
235+
oldValue: m.oldValue,
236+
target: m.target,
237+
})
228238
return v
229239
})
230240
.filter(exists)
@@ -258,6 +268,7 @@ export class MutationObservable {
258268
if (this.observedElements.has(element)) {
259269
return
260270
}
271+
logger.debug('Observing element', element)
261272
this.observeElementAttributes(
262273
element as HTMLElement,
263274
this.settings.observedAttributes,

0 commit comments

Comments
 (0)