Skip to content

Commit d4bfbb5

Browse files
authored
feat: Dynamically update DOM event listeners (#112)
1 parent 2a67daa commit d4bfbb5

File tree

6 files changed

+400
-26
lines changed

6 files changed

+400
-26
lines changed

app/dom_event.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434
cwr('enable');
3535
}
3636

37+
function dynamicallyCreateButton() {
38+
const parentButton = document.getElementById(
39+
'dynamicallyCreateButton'
40+
);
41+
const newButton = document.createElement('button');
42+
newButton.innerHTML = 'Button Four';
43+
newButton.id = 'button4';
44+
newButton.setAttribute('label', 'label1');
45+
parentButton.insertAdjacentElement('afterend', newButton);
46+
}
47+
3748
function registerDomEvents() {
3849
cwr('registerDomEvents', [
3950
{ event: 'click', cssLocator: '[label="label2"]' }
@@ -68,6 +79,12 @@
6879
<button id="button2" label="label1">Button Two</button>
6980
<button id="button3" label="label1">Button Three</button>
7081
<hr />
82+
<button
83+
id="dynamicallyCreateButton"
84+
onclick="dynamicallyCreateButton()"
85+
>
86+
Add Button
87+
</button>
7188
<button id="registerDomEvents" onclick="registerDomEvents()">
7289
Update Plugin
7390
</button>

docs/cdn_installation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ telemetries: [
137137

138138
| Name | Type | Default | Description |
139139
| --- | --- | --- | --- |
140-
| events | Array | `[]` | An array of target DOM events to record. Each DOM event is defined by an *event* and a *selector*. The event must be a [DOM event](https://www.w3schools.com/jsref/dom_obj_event.asp). The selector must be one of (1) `cssLocator`, (2) `elementId` or (3) `element`.<br/><br/>When two or more selectors are provided for a target DOM event, only one selector will be used. The selectors will be honored with the following precedence: (1) `cssLocator`, (2) `elementId` or (3) `element`. For example, if both `cssLocator` and `elementId` are provided, only the `cssLocator` selector will be used.<br/><br/>**Examples:**<br/>Record all elements identified by CSS selector `[label="label1"]`:<br/> `[{ event: 'click', cssLocator: '[label="label1"]'`<br/><br/>Record a single element with ID `mybutton`:<br/>`[{ event: 'click', elementId: 'mybutton' }]`<br/><br/>Record a complete clickstream<br/>`[{ event: 'click', element: document }]`. |
140+
| events | Array | `[]` | An array of target DOM events to record. Each DOM event is defined by an *event* and a *selector*. The event must be a [DOM event](https://www.w3schools.com/jsref/dom_obj_event.asp). The selector must be one of (1) `cssLocator`, (2) `elementId` or (3) `element`.<br/><br/>When two or more selectors are provided for a target DOM event, only one selector will be used. The selectors will be honored with the following precedence: (1) `cssLocator`, (2) `elementId` or (3) `element`. For example, if both `cssLocator` and `elementId` are provided, only the `cssLocator` selector will be used.<br/><br/>**Examples:**<br/>Record all elements identified by CSS selector `[label="label1"]`:<br/> `[{ event: 'click', cssLocator: '[label="label1"]' }]`<br/><br/>Record a single element with ID `mybutton`:<br/>`[{ event: 'click', elementId: 'mybutton' }]`<br/><br/>Record a complete clickstream<br/>`[{ event: 'click', element: document }]`. |
141141

142142
## Performance
143143

src/loader/loader-dom-event.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', {
1818
event: 'click',
1919
elementId: 'button1',
2020
element: document
21+
},
22+
{
23+
event: 'click',
24+
elementId: 'button4'
2125
}
2226
]
2327
})

src/plugins/event-plugins/DomEventPlugin.ts

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,25 @@ const defaultConfig: DomEventPluginConfig = {
3838
events: []
3939
};
4040

41+
export type ElementEventListener = {
42+
element: HTMLElement;
43+
eventListener: EventListener;
44+
};
45+
4146
export class DomEventPlugin implements Plugin {
4247
private recordEvent: RecordEvent | undefined;
4348
private pluginId: string;
44-
private eventListenerMap: Map<TargetDomEvent, EventListener>;
49+
private eventListenerMap: Map<TargetDomEvent, ElementEventListener[]>;
4550
private enabled: boolean;
4651
private config: DomEventPluginConfig;
52+
private observer: MutationObserver;
4753

4854
constructor(config?: PartialDomEventPluginConfig) {
4955
this.pluginId = DOM_EVENT_PLUGIN_ID;
50-
this.eventListenerMap = new Map<TargetDomEvent, EventListener>();
56+
this.eventListenerMap = new Map<
57+
TargetDomEvent,
58+
ElementEventListener[]
59+
>();
5160
this.config = { ...defaultConfig, ...config };
5261
this.enabled = false;
5362
}
@@ -62,6 +71,7 @@ export class DomEventPlugin implements Plugin {
6271
return;
6372
}
6473
this.addListeners();
74+
this.observeDOMMutation();
6575
this.enabled = true;
6676
}
6777

@@ -70,6 +80,7 @@ export class DomEventPlugin implements Plugin {
7080
return;
7181
}
7282
this.removeListeners();
83+
this.observer.disconnect();
7384
this.enabled = false;
7485
}
7586

@@ -131,41 +142,59 @@ export class DomEventPlugin implements Plugin {
131142
private addEventHandler(domEvent: TargetDomEvent): void {
132143
const eventType = domEvent.event;
133144
const eventListener = this.getEventListener(domEvent.cssLocator);
134-
this.eventListenerMap.set(domEvent, eventListener);
145+
146+
const identifiedElementList = [];
147+
const elementEventListenerList: ElementEventListener[] = this.eventListenerMap.has(
148+
domEvent
149+
)
150+
? this.eventListenerMap.get(domEvent)
151+
: [];
135152

136153
// first add event listener to all elements identified by the CSS locator
137154
if (domEvent.cssLocator) {
138-
const elementList = document.querySelectorAll(domEvent.cssLocator);
139-
elementList.forEach((element: HTMLElement) => {
140-
element.addEventListener(eventType, eventListener);
155+
const cssLocatedElements = document.querySelectorAll(
156+
domEvent.cssLocator
157+
);
158+
cssLocatedElements.forEach((element) => {
159+
identifiedElementList.push(element);
141160
});
142161
} else if (domEvent.elementId) {
143-
document
144-
.getElementById(domEvent.elementId)
145-
?.addEventListener(eventType, eventListener);
162+
const identifiedElement = document.getElementById(
163+
domEvent.elementId
164+
);
165+
if (identifiedElement) {
166+
identifiedElementList.push(identifiedElement);
167+
}
146168
} else if (domEvent.element) {
147-
domEvent.element.addEventListener(eventType, eventListener);
169+
identifiedElementList.push(domEvent.element);
148170
}
171+
172+
identifiedElementList.forEach((element) => {
173+
element.addEventListener(eventType, eventListener);
174+
elementEventListenerList.push({ element, eventListener });
175+
});
176+
177+
this.eventListenerMap.set(domEvent, elementEventListenerList);
149178
}
150179

151180
private removeEventHandler(domEvent: TargetDomEvent): void {
152-
const eventListener:
153-
| EventListener
154-
| undefined = this.eventListenerMap.get(domEvent);
155-
156-
if (domEvent.cssLocator && eventListener) {
157-
const elementList = document.querySelectorAll(domEvent.cssLocator);
158-
elementList.forEach((element: HTMLElement) => {
181+
const elementEventListenerList = this.eventListenerMap.get(domEvent);
182+
if (elementEventListenerList) {
183+
elementEventListenerList.forEach((elementEventListener) => {
184+
const element = elementEventListener.element;
185+
const eventListener = elementEventListener.eventListener;
159186
element.removeEventListener(domEvent.event, eventListener);
160187
});
161-
} else if (domEvent.elementId && eventListener) {
162-
const element = document.getElementById(domEvent.elementId);
163-
if (element) {
164-
element.removeEventListener(domEvent.event, eventListener);
165-
}
166-
} else if (domEvent.element && eventListener) {
167-
domEvent.element.removeEventListener(domEvent.event, eventListener);
188+
this.eventListenerMap.delete(domEvent);
168189
}
169-
this.eventListenerMap.delete(domEvent);
190+
}
191+
192+
private observeDOMMutation() {
193+
this.observer = new MutationObserver(() => {
194+
this.removeListeners();
195+
this.addListeners();
196+
});
197+
// we track only changes to nodes/DOM elements, not attributes or characterData
198+
this.observer.observe(document, { childList: true, subtree: true });
170199
}
171200
}

src/plugins/event-plugins/__integ__/DomEventPlugin.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const button2: Selector = Selector(`#button2`);
1515
const button3: Selector = Selector(`#button3`);
1616

1717
const registerDomEvents: Selector = Selector(`#registerDomEvents`);
18+
const dynamicallyCreateButton: Selector = Selector(`#dynamicallyCreateButton`);
19+
const button4: Selector = Selector(`#button4`);
1820
const button5: Selector = Selector(`#button5`);
1921

2022
const dispatch: Selector = Selector(`#dispatch`);
@@ -297,3 +299,152 @@ test('when new DOM events are registered and then a button is clicked, the event
297299
});
298300
}
299301
});
302+
303+
test('when listening for a click on a dynamically added element given an element id, the event is recorded', async (t: TestController) => {
304+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
305+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
306+
await t
307+
.wait(300)
308+
.click(dynamicallyCreateButton)
309+
.wait(300)
310+
.click(button4)
311+
.click(dispatch)
312+
.expect(REQUEST_BODY.textContent)
313+
.contains('BatchId');
314+
315+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
316+
(e) =>
317+
e.type === DOM_EVENT_TYPE &&
318+
JSON.parse(e.details).elementId === 'button4'
319+
);
320+
321+
const eventType = events[0].type;
322+
const eventDetails = JSON.parse(events[0].details);
323+
324+
await t
325+
.expect(eventType)
326+
.eql(DOM_EVENT_TYPE)
327+
.expect(eventDetails)
328+
.contains({
329+
event: 'click',
330+
elementId: 'button4'
331+
});
332+
});
333+
334+
test('when listening for a click on a dynamically added element given a CSS locator, the event is recorded', async (t: TestController) => {
335+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
336+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
337+
await t
338+
.wait(300)
339+
.click(dynamicallyCreateButton)
340+
.wait(300)
341+
.click(button4)
342+
.click(dispatch)
343+
.expect(REQUEST_BODY.textContent)
344+
.contains('BatchId');
345+
346+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
347+
(e) =>
348+
e.type === DOM_EVENT_TYPE &&
349+
JSON.parse(e.details).cssLocator === '[label="label1"]'
350+
);
351+
352+
for (let i = 0; i < events.length; i++) {
353+
let eventType = events[i].type;
354+
let eventDetails = JSON.parse(events[i].details);
355+
356+
await t
357+
.expect(events.length)
358+
.eql(1)
359+
.expect(eventType)
360+
.eql(DOM_EVENT_TYPE)
361+
.expect(eventDetails)
362+
.contains({
363+
event: 'click',
364+
cssLocator: '[label="label1"]'
365+
});
366+
}
367+
});
368+
369+
test('when listening for a click given an element id on an existing element and a dynamically added element, both events are recorded', async (t: TestController) => {
370+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
371+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
372+
await t
373+
.wait(300)
374+
.click(dynamicallyCreateButton)
375+
.wait(300)
376+
.click(button4)
377+
.click(button2)
378+
.click(dispatch)
379+
.expect(REQUEST_BODY.textContent)
380+
.contains('BatchId');
381+
382+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
383+
(e) =>
384+
e.type === DOM_EVENT_TYPE &&
385+
JSON.parse(e.details).cssLocator === '[label="label1"]'
386+
);
387+
388+
for (let i = 0; i < events.length; i++) {
389+
let eventType = events[i].type;
390+
let eventDetails = JSON.parse(events[i].details);
391+
392+
await t
393+
.expect(events.length)
394+
.eql(2)
395+
.expect(eventType)
396+
.eql(DOM_EVENT_TYPE)
397+
.expect(eventDetails)
398+
.contains({
399+
event: 'click',
400+
cssLocator: '[label="label1"]'
401+
});
402+
}
403+
});
404+
405+
test('when client is disabled then click on dynamically added element is not recorded', async (t: TestController) => {
406+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
407+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
408+
await t
409+
.wait(300)
410+
.click(disable)
411+
.click(dynamicallyCreateButton)
412+
.wait(300)
413+
.click(button4)
414+
.click(enable)
415+
.click(dispatch)
416+
.expect(REQUEST_BODY.textContent)
417+
.contains('BatchId');
418+
419+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
420+
(e) =>
421+
e.type === DOM_EVENT_TYPE &&
422+
JSON.parse(e.details).elementId === 'button4'
423+
);
424+
425+
await t.expect(events.length).eql(0);
426+
});
427+
428+
test('when client is disabled then clicks on existing or dynamically added element are not recorded', async (t: TestController) => {
429+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
430+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
431+
await t
432+
.wait(300)
433+
.click(disable)
434+
.click(dynamicallyCreateButton)
435+
.wait(300)
436+
.click(button4)
437+
.click(button2)
438+
.click(enable)
439+
.click(dispatch)
440+
.expect(REQUEST_BODY.textContent)
441+
.contains('BatchId');
442+
443+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
444+
(e) =>
445+
e.type === DOM_EVENT_TYPE &&
446+
JSON.parse(e.details).elementId === 'button2'
447+
);
448+
449+
await t.expect(events.length).eql(0);
450+
});

0 commit comments

Comments
 (0)