mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
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:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
35
packages/js/src/lib/eventListeners.ts
Normal file
35
packages/js/src/lib/eventListeners.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user