diff --git a/packages/embeds/embed-core/src/EmbedElement.test.ts b/packages/embeds/embed-core/src/EmbedElement.test.ts index d41f15eacc..d68e27f78b 100644 --- a/packages/embeds/embed-core/src/EmbedElement.test.ts +++ b/packages/embeds/embed-core/src/EmbedElement.test.ts @@ -9,6 +9,12 @@ import inlineHTML from "./Inline/inlineHtml"; import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants"; import { getColorSchemeDarkQuery } from "./ui-utils"; +type EmbedElementWithPrivateMethodsAccess = { + boundResizeHandler: () => void; + boundPrefersDarkThemeChangedHandler: (e: MediaQueryListEvent) => void; + boundEnsureContainerTakesSkeletonHeightWhenVisible: () => void; +} + (function defineEmbedTestElement() { class TestEmbedElement extends EmbedElement { constructor({ @@ -31,6 +37,75 @@ import { getColorSchemeDarkQuery } from "./ui-utils"; customElements.define("test-embed", TestEmbedElement); })(); +function mockWindowEventListeners() { + const eventListenerCallbacks = new Map(); + const animationFrameCallbacks: Map = new Map(); + const colorSchemeListenerCallbacks: Map = new Map(); + const colorSchemeQuery = getColorSchemeDarkQuery(); + let nextAnimationFrameId = 1; + + vi + .spyOn(window, "addEventListener") + .mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => { + eventListenerCallbacks.set(event, callback); + }); + + vi + .spyOn(window, "removeEventListener") + .mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => { + const registeredCallback = eventListenerCallbacks.get(event); + expect(registeredCallback).toBe(callback); + eventListenerCallbacks.delete(event); + }); + + vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + const id = nextAnimationFrameId++; + animationFrameCallbacks.set(id, callback); + return id; + }); + + vi + .spyOn(window, "cancelAnimationFrame") + .mockImplementation((id: number) => { + animationFrameCallbacks.delete(id); + }); + + vi + .spyOn(colorSchemeQuery, "addEventListener") + .mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => { + colorSchemeListenerCallbacks.set(event, callback); + }); + + vi + .spyOn(colorSchemeQuery, "removeEventListener") + .mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => { + colorSchemeListenerCallbacks.delete(event); + }); + + return { + expectListenerToBeRegistered: (event: string, callback: EventListenerOrEventListenerObject) => { + expect(eventListenerCallbacks.get(event)).toBe(callback); + }, + expectListenerToBeUnregistered: (event: string, callback: EventListenerOrEventListenerObject) => { + expect(eventListenerCallbacks.get(event)).not.toBe(callback); + }, + expectAnimationFrameListenerToBeRegistered: (rafId: number, callback: FrameRequestCallback) => { + expect(animationFrameCallbacks.get(rafId)).toBe(callback); + }, + expectAnimationFrameListenerToBeUnregistered: (rafId: number, callback: FrameRequestCallback) => { + expect(animationFrameCallbacks.get(rafId)).not.toBe(callback); + }, + expectColorSchemeListenerToBeRegistered: (callback: (e: MediaQueryListEvent) => void) => { + expect(colorSchemeListenerCallbacks.get("change")).toBe(callback); + }, + expectColorSchemeListenerToBeUnregistered: (callback: (e: MediaQueryListEvent) => void) => { + expect(colorSchemeListenerCallbacks.get("change")).not.toBe(callback); + }, + }; +} + function buildMediaQueryListEvent({ type, matches }: { type: string; matches: boolean }) { return { type, @@ -143,7 +218,9 @@ describe("EmbedElement", () => { if (!element) { throw new Error("`element` not defined"); } - document.body.removeChild(element); + if (element.parentNode) { + document.body.removeChild(element); + } vi.restoreAllMocks(); }); @@ -358,5 +435,33 @@ describe("EmbedElement", () => { expectLayoutToBe("month_view", element); }); }); + + describe("Cleanup Behavior", () => { + it("should clean up all resources when element is disconnected", () => { + const { expectListenerToBeRegistered, expectListenerToBeUnregistered, expectAnimationFrameListenerToBeRegistered, expectAnimationFrameListenerToBeUnregistered, expectColorSchemeListenerToBeRegistered, expectColorSchemeListenerToBeUnregistered } = mockWindowEventListeners(); + + element = createTestEmbedElement({ + dataset: { pageType: "user.event.booking.slots" }, + }); + + + + const internalEmbed = element as unknown as EmbedElementWithPrivateMethodsAccess; + + const boundResizeHandler = internalEmbed.boundResizeHandler; + const boundPrefersDarkThemeChangedHandler = internalEmbed.boundPrefersDarkThemeChangedHandler; + const boundEnsureContainerTakesSkeletonHeightWhenVisible = internalEmbed.boundEnsureContainerTakesSkeletonHeightWhenVisible; + + expectListenerToBeRegistered("resize", boundResizeHandler); + expectColorSchemeListenerToBeRegistered(boundPrefersDarkThemeChangedHandler); + expectAnimationFrameListenerToBeRegistered(element.skeletonContainerHeightTimer!, boundEnsureContainerTakesSkeletonHeightWhenVisible); + + document.body.removeChild(element); + + expectListenerToBeUnregistered("resize", boundResizeHandler); + expectColorSchemeListenerToBeUnregistered(boundPrefersDarkThemeChangedHandler); + expectAnimationFrameListenerToBeUnregistered(element.skeletonContainerHeightTimer!, boundEnsureContainerTakesSkeletonHeightWhenVisible); + }); + }); }); }); diff --git a/packages/embeds/embed-core/src/EmbedElement.ts b/packages/embeds/embed-core/src/EmbedElement.ts index e84f625038..c05ca1eda7 100644 --- a/packages/embeds/embed-core/src/EmbedElement.ts +++ b/packages/embeds/embed-core/src/EmbedElement.ts @@ -29,6 +29,8 @@ export class EmbedElement extends HTMLElement { private boundResizeHandler: () => void; private boundPrefersDarkThemeChangedHandler: (e: MediaQueryListEvent) => void; + private boundEnsureContainerTakesSkeletonHeightWhenVisible: () => void; + private isSkeletonSupportedPageType() { const pageType = this.getPageType(); // Any pageType being set is considered as skeleton supported. There is always a fallback skeleton loader if no direct match for a skeleton loader is found based on pageType @@ -99,7 +101,7 @@ export class EmbedElement extends HTMLElement { return; } } - const rafId = requestAnimationFrame(this.ensureContainerTakesSkeletonHeightWhenVisible.bind(this)); + const rafId = requestAnimationFrame(this.boundEnsureContainerTakesSkeletonHeightWhenVisible); this.skeletonContainerHeightTimer = rafId; return rafId; } @@ -158,6 +160,7 @@ export class EmbedElement extends HTMLElement { this.getSkeletonData = data.getSkeletonData; this.boundResizeHandler = this.resizeHandler.bind(this); this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this); + this.boundEnsureContainerTakesSkeletonHeightWhenVisible = this.ensureContainerTakesSkeletonHeightWhenVisible.bind(this); } public isSkeletonLoaderVisible() { diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.test.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.test.ts new file mode 100644 index 0000000000..14ef689208 --- /dev/null +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.test.ts @@ -0,0 +1,50 @@ +import "../../test/__mocks__/windowMatchMedia"; + +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; + +import { EmbedElement } from "../EmbedElement"; +import { ModalBox } from "./ModalBox"; + +function setupWindowCal() { + // Mock window.Cal.__css which is required by ModalBox constructor + if (!window.Cal) { + // @ts-expect-error - Test setup + window.Cal = {}; + } + window.Cal.__css = "/* mock css */"; +} + +// Register ModalBox as a custom element for testing +if (!customElements.get("cal-modal-box")) { + customElements.define("cal-modal-box", ModalBox); +} + +describe("ModalBox", () => { + let modalBox: ModalBox; + + beforeEach(() => { + setupWindowCal(); + }); + + afterEach(() => { + if (modalBox && modalBox.parentNode) { + document.body.removeChild(modalBox); + } + vi.restoreAllMocks(); + }); + + it("should verify that connectedCallback and disconnectedCallback of ModalBox call the EmbedElement's methods", () => { + const embedElementConnectedCallbackSpy = vi.spyOn(EmbedElement.prototype, "connectedCallback"); + const embedElementDisconnectedCallbackSpy = vi.spyOn(EmbedElement.prototype, "disconnectedCallback"); + + modalBox = new ModalBox(); + + document.body.appendChild(modalBox); + + expect(embedElementConnectedCallbackSpy).toHaveBeenCalledTimes(1); + + document.body.removeChild(modalBox); + + expect(embedElementDisconnectedCallbackSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index abb607c390..5a41e8b1f2 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -6,6 +6,12 @@ import modalBoxHtml, { getSkeletonData } from "./ModalBoxHtml"; export class ModalBox extends EmbedElement { static htmlOverflow: string; + private escHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.close(); + } + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore static get observedAttributes() { @@ -220,17 +226,7 @@ export class ModalBox extends EmbedElement { super.connectedCallback(); this.assertHasShadowRoot(); const closeEl = this.shadowRoot.querySelector(".close"); - document.addEventListener( - "keydown", - (e) => { - if (e.key === "Escape") { - this.close(); - } - }, - { - once: true, - } - ); + document.addEventListener("keydown", this.escHandler); // The backdrop is inside the host element, and a click on host element is only possible if the user clicks outside the iframe. // So, it is backdrop click handler @@ -245,6 +241,11 @@ export class ModalBox extends EmbedElement { } } + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("keydown", this.escHandler); + } + constructor() { super({ isModal: true, getSkeletonData }); const modalHtml = `${modalBoxHtml({