fix: make sure event listeners are only added once in formbricks-js (#825)

* add singleton pattern for js widget

* make sure event listeners are only initiated once
This commit is contained in:
Matti Nannt
2023-09-17 22:51:40 +09:00
committed by GitHub
parent 9a6a75056c
commit ac11537e07
5 changed files with 185 additions and 94 deletions

View File

@@ -1,40 +1,61 @@
import { trackAction } from "./actions";
import { err } from "./errors";
let exitIntentListenerAdded = false;
let exitIntentListenerWrapper = async function (e: MouseEvent) {
if (e.clientY <= 0) {
const trackResult = await trackAction("Exit Intent (Desktop)");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined") {
const exitIntentListener = async function (e: MouseEvent) {
if (e.clientY <= 0) {
const trackResult = await trackAction("Exit Intent (Desktop)");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
document.addEventListener("mouseleave", exitIntentListener);
if (typeof document !== "undefined" && !exitIntentListenerAdded) {
document.addEventListener("mouseleave", exitIntentListenerWrapper);
exitIntentListenerAdded = true;
}
};
export const removeExitIntentListener = (): void => {
if (exitIntentListenerAdded) {
document.removeEventListener("mouseleave", exitIntentListenerWrapper);
exitIntentListenerAdded = false;
}
};
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
let scrollDepthListenerWrapper = async () => {
const scrollPosition = window.scrollY;
const windowSize = window.innerHeight;
const bodyHeight = document.documentElement.scrollHeight;
if (scrollPosition === 0) {
scrollDepthTriggered = false;
}
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
scrollDepthTriggered = true;
const trackResult = await trackAction("50% Scroll");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined") {
let scrollDepthTriggered = false;
// 'load' event is used to setup listener after full page load
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
window.addEventListener("load", () => {
window.addEventListener("scroll", async () => {
const scrollPosition = window.pageYOffset;
const windowSize = window.innerHeight;
const bodyHeight = document.documentElement.scrollHeight;
if (scrollPosition === 0) {
scrollDepthTriggered = false;
}
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
scrollDepthTriggered = true;
const trackResult = await trackAction("50% Scroll");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
});
window.addEventListener("scroll", scrollDepthListenerWrapper);
});
scrollDepthListenerAdded = true;
}
};
export const removeScrollDepthListener = (): void => {
if (scrollDepthListenerAdded) {
window.removeEventListener("scroll", scrollDepthListenerWrapper);
scrollDepthListenerAdded = false;
}
};

View File

@@ -0,0 +1,35 @@
import {
addExitIntentListener,
addScrollDepthListener,
removeExitIntentListener,
removeScrollDepthListener,
} from "./automaticActions";
import {
addClickEventListener,
addPageUrlEventListeners,
removeClickEventListener,
removePageUrlEventListeners,
} from "./noCodeEvents";
import { addSyncEventListener, removeSyncEventListener } from "./sync";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (): void => {
addSyncEventListener();
addPageUrlEventListeners();
addClickEventListener();
addExitIntentListener();
addScrollDepthListener();
};
export const addCleanupEventListeners = (): void => {
if (areRemoveEventListenersAdded) return;
window.addEventListener("beforeunload", () => {
removeSyncEventListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
});
areRemoveEventListenersAdded = true;
};

View File

@@ -1,5 +1,4 @@
import type { InitConfig } from "../../../types/js";
import { addExitIntentListener, addScrollDepthListener } from "./automaticActions";
import { Config } from "./config";
import {
ErrorHandler,
@@ -11,8 +10,9 @@ import {
err,
okVoid,
} from "./errors";
import { addCleanupEventListeners, addEventListeners } from "./eventListeners";
import { Logger } from "./logger";
import { addClickEventListener, addPageUrlEventListeners, checkPageUrl } from "./noCodeEvents";
import { checkPageUrl } from "./noCodeEvents";
import { resetPerson } from "./person";
import { isExpired } from "./session";
import { sync } from "./sync";
@@ -21,40 +21,25 @@ import { addWidgetContainer } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
let isInitialized = false;
const addSyncEventListener = (debug?: boolean): void => {
const updateInverval = debug ? 1000 * 30 : 1000 * 60 * 2; // 2 minutes in production, 30 seconds in debug mode
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined") {
// clear any existing interval
if (syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
}
syncIntervalId = window.setInterval(async () => {
if (!config.isSyncAllowed) {
return;
}
logger.debug("Syncing.");
await sync();
}, updateInverval);
// clear interval on page unload
window.addEventListener("beforeunload", () => {
if (syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
}
});
const setDebugLevel = (c: InitConfig): void => {
if (c.debug) {
logger.debug(`Setting log level to debug`);
logger.configure({ logLevel: "debug" });
}
};
export const initialize = async (
c: InitConfig
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (c.debug) {
logger.debug(`Setting log level to debug`);
logger.configure({ logLevel: "debug" });
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
setDebugLevel(c);
ErrorHandler.getInstance().printStatus();
logger.debug("Start initialize");
@@ -111,21 +96,11 @@ export const initialize = async (
await sync();
}
logger.debug("Add session event listeners");
addSyncEventListener(c.debug);
logger.debug("Add page url event listeners");
addPageUrlEventListeners();
logger.debug("Add click event listeners");
addClickEventListener();
logger.debug("Add exit intent (Desktop) listener");
addExitIntentListener();
logger.debug("Add scroll depth 50% listener");
addScrollDepthListener();
logger.debug("Adding event listeners");
addEventListeners();
addCleanupEventListeners();
isInitialized = true;
logger.debug("Initialized");
// check page url if initialized after page load

View File

@@ -46,14 +46,31 @@ export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError
return okVoid();
};
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined") return;
let arePageUrlEventListenersAdded = false;
const checkPageUrlWrapper = () => checkPageUrl();
window.addEventListener("hashchange", checkPageUrl);
window.addEventListener("popstate", checkPageUrl);
window.addEventListener("pushstate", checkPageUrl);
window.addEventListener("replacestate", checkPageUrl);
window.addEventListener("load", checkPageUrl);
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
window.addEventListener("hashchange", checkPageUrlWrapper);
window.addEventListener("popstate", checkPageUrlWrapper);
window.addEventListener("pushstate", checkPageUrlWrapper);
window.addEventListener("replacestate", checkPageUrlWrapper);
window.addEventListener("load", checkPageUrlWrapper);
arePageUrlEventListenersAdded = true;
};
export const removePageUrlEventListeners = (): void => {
if (typeof window === "undefined" || !arePageUrlEventListenersAdded) return;
window.removeEventListener("hashchange", checkPageUrlWrapper);
window.removeEventListener("popstate", checkPageUrlWrapper);
window.removeEventListener("pushstate", checkPageUrlWrapper);
window.removeEventListener("replacestate", checkPageUrlWrapper);
window.removeEventListener("load", checkPageUrlWrapper);
arePageUrlEventListenersAdded = false;
};
export function checkUrlMatch(
@@ -144,8 +161,21 @@ export const checkClickMatch = (event: MouseEvent) => {
});
};
export const addClickEventListener = (): void => {
if (typeof window === "undefined") return;
let isClickEventListenerAdded = false;
const checkClickMatchWrapper = (e: MouseEvent) => checkClickMatch(e);
document.addEventListener("click", checkClickMatch);
export const addClickEventListener = (): void => {
if (typeof window === "undefined" || isClickEventListenerAdded) return;
document.addEventListener("click", checkClickMatchWrapper);
isClickEventListenerAdded = true;
};
export const removeClickEventListener = (): void => {
if (!isClickEventListenerAdded) return;
document.removeEventListener("click", checkClickMatchWrapper);
isClickEventListenerAdded = false;
};

View File

@@ -8,6 +8,8 @@ import packageJson from "../../package.json";
const config = Config.getInstance();
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
const syncWithBackend = async (): Promise<Result<TJsState, NetworkError>> => {
const url = `${config.get().apiHost}/api/v1/js/sync`;
@@ -39,21 +41,49 @@ const syncWithBackend = async (): Promise<Result<TJsState, NetworkError>> => {
};
export const sync = async (): Promise<void> => {
const syncResult = await syncWithBackend();
if (syncResult.ok !== true) {
throw syncResult.error;
}
const state = syncResult.value;
const oldState = config.get().state;
config.update({ state });
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames);
// if session is new, track action
if (!oldState?.session || oldState.session.id !== state.session.id) {
const trackActionResult = await trackAction("New Session");
if (trackActionResult.ok !== true) {
throw trackActionResult.error;
try {
const syncResult = await syncWithBackend();
if (syncResult.ok !== true) {
logger.error(`Sync failed: ${syncResult.error}`);
return;
}
const state = syncResult.value;
const oldState = config.get().state;
config.update({ state });
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
// if session is new, track action
if (!oldState?.session || oldState.session.id !== state.session.id) {
const trackActionResult = await trackAction("New Session");
if (trackActionResult.ok !== true) {
logger.error(`Action tracking failed: ${trackActionResult.error}`);
}
}
} catch (error) {
logger.error(`Error during sync: ${error}`);
}
};
export const addSyncEventListener = (debug: boolean = false): void => {
const updateInterval = debug ? 1000 * 60 : 1000 * 60 * 5; // 5 minutes in production, 1 minute in debug mode
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(async () => {
if (!config.isSyncAllowed) {
return;
}
logger.debug("Syncing.");
await sync();
}, updateInterval);
}
};
export const removeSyncEventListener = (): void => {
if (typeof window !== "undefined" && syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
syncIntervalId = null;
}
};