diff --git a/packages/js/src/lib/automaticActions.ts b/packages/js/src/lib/automaticActions.ts index 2d60dbcb92..cbfb9709df 100644 --- a/packages/js/src/lib/automaticActions.ts +++ b/packages/js/src/lib/automaticActions.ts @@ -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; } }; diff --git a/packages/js/src/lib/eventListeners.ts b/packages/js/src/lib/eventListeners.ts new file mode 100644 index 0000000000..017a4acbb3 --- /dev/null +++ b/packages/js/src/lib/eventListeners.ts @@ -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; +}; diff --git a/packages/js/src/lib/init.ts b/packages/js/src/lib/init.ts index 12b185e550..c32438f4f6 100644 --- a/packages/js/src/lib/init.ts +++ b/packages/js/src/lib/init.ts @@ -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> => { - 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 diff --git a/packages/js/src/lib/noCodeEvents.ts b/packages/js/src/lib/noCodeEvents.ts index a6db3bd657..29c5e097eb 100644 --- a/packages/js/src/lib/noCodeEvents.ts +++ b/packages/js/src/lib/noCodeEvents.ts @@ -46,14 +46,31 @@ export const checkPageUrl = async (): Promise { - 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; }; diff --git a/packages/js/src/lib/sync.ts b/packages/js/src/lib/sync.ts index 85d4310e05..6d733f646a 100644 --- a/packages/js/src/lib/sync.ts +++ b/packages/js/src/lib/sync.ts @@ -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> => { const url = `${config.get().apiHost}/api/v1/js/sync`; @@ -39,21 +41,49 @@ const syncWithBackend = async (): Promise> => { }; export const sync = async (): Promise => { - 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; } };