Skip to content

Commit

Permalink
fix: add inject support to experiment-tag (#118)
Browse files Browse the repository at this point in the history
  • Loading branch information
bgiori authored Aug 20, 2024
1 parent 0312724 commit 23b8757
Show file tree
Hide file tree
Showing 2 changed files with 106 additions and 1 deletion.
58 changes: 57 additions & 1 deletion packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@amplitude/experiment-js-client';
import mutate, { MutationController } from 'dom-mutator';

import { getInjectUtils } from './inject-utils';
import { WindowMessenger } from './messenger';
import {
getGlobalScope,
Expand All @@ -17,7 +18,7 @@ import {
UUID,
concatenateQueryParamsOf,
} from './util';

const appliedInjections: Set<string> = new Set();
const appliedMutations: MutationController[] = [];
let previousUrl: string | undefined = undefined;

Expand Down Expand Up @@ -114,6 +115,8 @@ const applyVariants = (variants) => {
handleRedirect(action, key, variant);
} else if (action.action === 'mutate') {
handleMutate(action, key, variant);
} else if (action.action === 'inject') {
handleInject(action, key, variant);
}
}
}
Expand Down Expand Up @@ -186,6 +189,59 @@ const revertMutations = () => {
}
};

const inject = (js: string, html, utils, id) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
eval(js, html, utils, id);
};

const handleInject = (action, key: string, variant: Variant) => {
const globalScope = getGlobalScope();
if (!globalScope) {
return;
}
const urlExactMatch = variant?.metadata?.['urlMatch'] as string[];
const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href);
if (matchesUrl(urlExactMatch, currentUrl)) {
// Check for repeat invocations
const id = action.data.id;
if (appliedInjections.has(id)) {
return;
}
// Create CSS
const rawCss = action.data.css;
let style: HTMLStyleElement | undefined;
if (rawCss) {
style = globalScope.document.createElement('style');
style.innerHTML = rawCss;
style.id = `css-${id}`;
globalScope.document.head.appendChild(style);
}
// Create HTML
const rawHtml = action.data.html;
let html: Element | undefined;
if (rawHtml) {
html =
new DOMParser().parseFromString(rawHtml, 'text/html').body
.firstElementChild ?? undefined;
}
// Inject
const utils = getInjectUtils();
const js = action.data.js;
appliedInjections.add(id);
inject(js, html, utils, id);
// Push mutation to remove CSS and any custom state cleanup set in utils.
appliedMutations.push({
revert: () => {
if (utils.remove) utils.remove();
style?.remove();
appliedInjections.delete(id);
},
});
globalScope.experiment.exposure(key);
}
};

export const setUrlChangeListener = () => {
const globalScope = getGlobalScope();
if (globalScope) {
Expand Down
49 changes: 49 additions & 0 deletions packages/experiment-tag/src/inject-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export interface InjectUtils {
/**
* Returns a promise that is resolved when an element matching the selector
* is found in DOM.
*
* @param selector The element selector to query for.
*/
waitForElement(selector: string): Promise<Element>;

/**
* Function which can be set inside injected javascript code. This function is
* called on page change, when experiments are re-evaluated.
*
* Useful for cleaning up changes to the page that have been made in single
* page apps, where page the page is not fully reloaded. For example, if you
* inject an HTML element on a specific page, you can set this function to
* remove the injected element on page change.
*/
remove: (() => void) | undefined;
}

export const getInjectUtils = (): InjectUtils =>
({
async waitForElement(selector: string): Promise<Element> {
// If selector found in DOM, then return directly.
const elem = document.querySelector(selector);
if (elem) {
return elem;
}

return new Promise<Element>((resolve) => {
// An observer that is listening for all DOM mutation events.
const observer = new MutationObserver(() => {
const elem = document.querySelector(selector);
if (elem) {
observer.disconnect();
resolve(elem);
}
});

// Observe on all document changes.
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
});
});
},
} as InjectUtils);

0 comments on commit 23b8757

Please sign in to comment.