Skip to content

Commit 00b9a3d

Browse files
authored
feat: add new insertElement() function for SPA widgets (#240)
1 parent 698f5af commit 00b9a3d

File tree

2 files changed

+97
-6
lines changed

2 files changed

+97
-6
lines changed

packages/experiment-tag/src/experiment.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
706706
.firstElementChild ?? undefined;
707707
}
708708
// Inject
709-
const utils = getInjectUtils();
709+
const state = { cancelled: false }; // individual state per injection
710+
const utils = getInjectUtils(state);
710711
this.appliedInjections.add(id);
711712
try {
712713
const fn = this.globalScope[id];
@@ -728,6 +729,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
728729
// Push the mutation
729730
this.appliedMutations[flagKey][variantKey][INJECT_ACTION][id] = {
730731
revert: () => {
732+
state.cancelled = true;
731733
utils.remove?.();
732734
style?.remove();
733735
script?.remove();

packages/experiment-tag/src/util/inject-utils.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ export interface InjectUtils {
77
*/
88
waitForElement(selector: string): Promise<Element>;
99

10+
/**
11+
* Inserts an element into the DOM at the specified selectors.
12+
* Re-inserts element whenever it gets removed
13+
* Returns a promise that is resolved the first time the element is inserted.
14+
*
15+
* @param element The element to insert.
16+
* @param options The insertion options.
17+
* @param options.parentSelector The parent selector to insert the element into.
18+
* @param options.insertBeforeSelector The sibling selector to insert the element before.
19+
* @param callback Optional callback to be called after every insertion.
20+
*/
21+
insertElement(
22+
element: Element,
23+
options: {
24+
parentSelector: string;
25+
insertBeforeSelector: string | null;
26+
},
27+
callback?: () => void,
28+
): Promise<void>;
29+
1030
/**
1131
* Function which can be set inside injected javascript code. This function is
1232
* called on page change, when experiments are re-evaluated.
@@ -19,21 +39,32 @@ export interface InjectUtils {
1939
remove: (() => void) | undefined;
2040
}
2141

22-
export const getInjectUtils = (): InjectUtils =>
42+
export const getInjectUtils = (state: { cancelled: boolean }): InjectUtils =>
2343
({
2444
async waitForElement(selector: string): Promise<Element> {
45+
let observer: MutationObserver | undefined = undefined;
46+
47+
const findElement = () => {
48+
if (state.cancelled) {
49+
observer?.disconnect();
50+
return;
51+
}
52+
53+
return document.querySelector(selector);
54+
};
55+
2556
// If selector found in DOM, then return directly.
26-
const elem = document.querySelector(selector);
57+
const elem = findElement();
2758
if (elem) {
2859
return elem;
2960
}
3061

3162
return new Promise<Element>((resolve) => {
3263
// An observer that is listening for all DOM mutation events.
33-
const observer = new MutationObserver(() => {
34-
const elem = document.querySelector(selector);
64+
observer = new MutationObserver(() => {
65+
const elem = findElement();
3566
if (elem) {
36-
observer.disconnect();
67+
observer?.disconnect();
3768
resolve(elem);
3869
}
3970
});
@@ -46,4 +77,62 @@ export const getInjectUtils = (): InjectUtils =>
4677
});
4778
});
4879
},
80+
81+
insertElement(
82+
element: Element,
83+
options: {
84+
parentSelector: string;
85+
insertBeforeSelector: string | null;
86+
},
87+
callback?: () => void,
88+
): Promise<void> {
89+
return new Promise((resolve) => {
90+
let rateLimit = 0;
91+
let observer: MutationObserver | undefined = undefined;
92+
93+
const checkElementInserted = () => {
94+
if (state.cancelled) {
95+
observer?.disconnect();
96+
return;
97+
}
98+
99+
if (element.isConnected && element.ownerDocument === document) {
100+
return; // element was already inserted
101+
}
102+
103+
if (rateLimit >= 10) {
104+
return;
105+
}
106+
rateLimit++;
107+
setTimeout(() => rateLimit--, 1000);
108+
109+
const parent = document.querySelector(options.parentSelector);
110+
if (!parent) {
111+
return;
112+
}
113+
const sibling = options.insertBeforeSelector
114+
? parent.querySelector(options.insertBeforeSelector)
115+
: null;
116+
117+
if (options.insertBeforeSelector && !sibling) {
118+
// wait until matching sibling is found before inserting
119+
return;
120+
}
121+
122+
parent.insertBefore(element, sibling);
123+
callback?.();
124+
resolve();
125+
};
126+
127+
checkElementInserted();
128+
observer = new MutationObserver(checkElementInserted);
129+
130+
// Observe on all document changes.
131+
observer.observe(document.documentElement, {
132+
childList: true,
133+
subtree: true,
134+
attributes: true,
135+
});
136+
});
137+
},
49138
} as InjectUtils);

0 commit comments

Comments
 (0)