fix: ESC key handler only works once in embed modal (#25615)

* fix: ESC key handler only works once in embed modal

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Fix bug found by cubic and add tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-12-22 10:06:09 -03:00
committed by GitHub
co-authored by Devin AI
parent 0b7f51410a
commit e23354b48b
4 changed files with 172 additions and 13 deletions
@@ -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<string, EventListenerOrEventListenerObject>();
const animationFrameCallbacks: Map<number, FrameRequestCallback> = new Map();
const colorSchemeListenerCallbacks: Map<string, EventListenerOrEventListenerObject> = 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);
});
});
});
});
@@ -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() {
@@ -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);
});
});
@@ -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<HTMLElement>(".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 = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml({