1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-14 23:45:37 +00:00

[PM-31281] Add teardown of listeners/observers (#18593)

* add teardown of listeners/observers

* add tests
This commit is contained in:
Jonathan Prusik
2026-02-04 11:12:25 -05:00
committed by GitHub
parent b0cfe37e02
commit b044427f41
8 changed files with 541 additions and 16 deletions

View File

@@ -347,6 +347,18 @@ describe("AutofillInit", () => {
);
});
it("removes the LOAD event listener", () => {
jest.spyOn(window, "removeEventListener");
autofillInit.init();
autofillInit.destroy();
expect(window.removeEventListener).toHaveBeenCalledWith(
"load",
autofillInit["sendCollectDetailsMessage"],
);
});
it("removes the extension message listeners", () => {
autofillInit.destroy();

View File

@@ -72,21 +72,24 @@ class AutofillInit implements AutofillInitInterface {
* to act on the page.
*/
private collectPageDetailsOnLoad() {
const sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
750,
);
};
if (globalThis.document.readyState === "complete") {
sendCollectDetailsMessage();
this.sendCollectDetailsMessage();
}
globalThis.addEventListener(EVENTS.LOAD, sendCollectDetailsMessage);
globalThis.addEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage);
}
/**
* Sends a message to collect page details after a short delay.
*/
private sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
750,
);
};
/**
* Collects the page details and sends them to the
* extension background script. If the `sendDetailsInResponse`
@@ -218,6 +221,7 @@ class AutofillInit implements AutofillInitInterface {
*/
destroy() {
this.clearCollectPageDetailsOnLoadTimeout();
globalThis.removeEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage);
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();

View File

@@ -32,4 +32,5 @@ export type BackgroundPortMessageHandlers = {
export interface AutofillInlineMenuIframeService {
initMenuIframe(): void;
destroy(): void;
}

View File

@@ -645,6 +645,292 @@ describe("AutofillInlineMenuContentService", () => {
expect(disconnectSpy).toHaveBeenCalled();
});
it("unobserves custom elements", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService.destroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it("unobserves the container element", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService.destroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it("clears the mutation observer iterations reset timeout", () => {
jest.useFakeTimers();
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"] = setTimeout(
jest.fn(),
1000,
);
autofillInlineMenuContentService.destroy();
expect(clearTimeoutSpy).toHaveBeenCalled();
expect(autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"]).toBeNull();
});
it("destroys the button iframe", () => {
const mockButtonIframe = { destroy: jest.fn() };
autofillInlineMenuContentService["buttonIframe"] = mockButtonIframe as any;
autofillInlineMenuContentService.destroy();
expect(mockButtonIframe.destroy).toHaveBeenCalled();
});
it("destroys the list iframe", () => {
const mockListIframe = { destroy: jest.fn() };
autofillInlineMenuContentService["listIframe"] = mockListIframe as any;
autofillInlineMenuContentService.destroy();
expect(mockListIframe.destroy).toHaveBeenCalled();
});
});
describe("observeCustomElements", () => {
it("observes the button element for attribute mutations", () => {
const buttonElement = document.createElement("div");
autofillInlineMenuContentService["buttonElement"] = buttonElement;
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeCustomElements"]();
expect(observeSpy).toHaveBeenCalledWith(buttonElement, { attributes: true });
});
it("observes the list element for attribute mutations", () => {
const listElement = document.createElement("div");
autofillInlineMenuContentService["listElement"] = listElement;
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeCustomElements"]();
expect(observeSpy).toHaveBeenCalledWith(listElement, { attributes: true });
});
it("does not observe when no elements exist", () => {
autofillInlineMenuContentService["buttonElement"] = undefined;
autofillInlineMenuContentService["listElement"] = undefined;
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeCustomElements"]();
expect(observeSpy).not.toHaveBeenCalled();
});
});
describe("observeContainerElement", () => {
it("observes the container element for child list mutations", () => {
const containerElement = document.createElement("div");
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeContainerElement"](containerElement);
expect(observeSpy).toHaveBeenCalledWith(containerElement, { childList: true });
});
});
describe("unobserveContainerElement", () => {
it("disconnects the container element mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobserveContainerElement"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("handles the case when the mutation observer is undefined", () => {
autofillInlineMenuContentService["containerElementMutationObserver"] = undefined as any;
expect(() => autofillInlineMenuContentService["unobserveContainerElement"]()).not.toThrow();
});
});
describe("observePageAttributes", () => {
it("observes the document element for attribute mutations", () => {
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["htmlMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observePageAttributes"]();
expect(observeSpy).toHaveBeenCalledWith(document.documentElement, { attributes: true });
});
it("observes the body element for attribute mutations", () => {
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["bodyMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observePageAttributes"]();
expect(observeSpy).toHaveBeenCalledWith(document.body, { attributes: true });
});
});
describe("unobservePageAttributes", () => {
it("disconnects the html mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["htmlMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobservePageAttributes"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("disconnects the body mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["bodyMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobservePageAttributes"]();
expect(disconnectSpy).toHaveBeenCalled();
});
});
describe("checkPageRisks", () => {
it("returns true and closes inline menu when page is not opaque", async () => {
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(false);
const closeInlineMenuSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"closeInlineMenu",
);
const result = await autofillInlineMenuContentService["checkPageRisks"]();
expect(result).toBe(true);
expect(closeInlineMenuSpy).toHaveBeenCalled();
});
it("returns true and closes inline menu when inline menu is disabled", async () => {
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true);
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
const closeInlineMenuSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"closeInlineMenu",
);
const result = await autofillInlineMenuContentService["checkPageRisks"]();
expect(result).toBe(true);
expect(closeInlineMenuSpy).toHaveBeenCalled();
});
it("returns false when page is opaque and inline menu is enabled", async () => {
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true);
autofillInlineMenuContentService["inlineMenuEnabled"] = true;
const closeInlineMenuSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"closeInlineMenu",
);
const result = await autofillInlineMenuContentService["checkPageRisks"]();
expect(result).toBe(false);
expect(closeInlineMenuSpy).not.toHaveBeenCalled();
});
});
describe("handlePageMutations", () => {
it("checks page risks when mutations include attribute changes", async () => {
const checkPageRisksSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"checkPageRisks",
);
const mutations = [{ type: "attributes" } as MutationRecord];
await autofillInlineMenuContentService["handlePageMutations"](mutations);
expect(checkPageRisksSpy).toHaveBeenCalled();
});
it("does not check page risks when mutations do not include attribute changes", async () => {
const checkPageRisksSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"checkPageRisks",
);
const mutations = [{ type: "childList" } as MutationRecord];
await autofillInlineMenuContentService["handlePageMutations"](mutations);
expect(checkPageRisksSpy).not.toHaveBeenCalled();
});
});
describe("clearPersistentLastChildOverrideTimeout", () => {
it("clears the timeout when it exists", () => {
jest.useFakeTimers();
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout(
jest.fn(),
1000,
);
autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"]();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
it("does nothing when the timeout is null", () => {
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = null;
autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"]();
expect(clearTimeoutSpy).not.toHaveBeenCalled();
});
});
describe("elementAtCenterOfInlineMenuPosition", () => {
it("returns the element at the center of the given position", () => {
const mockElement = document.createElement("div");
jest.spyOn(globalThis.document, "elementFromPoint").mockReturnValue(mockElement);
const result = autofillInlineMenuContentService["elementAtCenterOfInlineMenuPosition"]({
top: 100,
left: 200,
width: 50,
height: 30,
});
expect(globalThis.document.elementFromPoint).toHaveBeenCalledWith(225, 115);
expect(result).toBe(mockElement);
});
});
describe("getOwnedTagNames", () => {
@@ -975,6 +1261,25 @@ describe("AutofillInlineMenuContentService", () => {
});
});
describe("unobserveCustomElements", () => {
it("disconnects the inline menu elements mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobserveCustomElements"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("handles the case when the mutation observer is undefined", () => {
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"] = undefined as any;
expect(() => autofillInlineMenuContentService["unobserveCustomElements"]()).not.toThrow();
});
});
describe("getPageIsOpaque", () => {
it("returns false when no page elements exist", () => {
jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any);

View File

@@ -41,7 +41,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 ||
globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1;
private buttonElement?: HTMLElement;
private buttonIframe?: AutofillInlineMenuButtonIframe;
private listElement?: HTMLElement;
private listIframe?: AutofillInlineMenuListIframe;
private htmlMutationObserver: MutationObserver;
private bodyMutationObserver: MutationObserver;
private inlineMenuElementsMutationObserver: MutationObserver;
@@ -264,18 +266,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (this.isFirefoxBrowser) {
this.buttonElement = globalThis.document.createElement("div");
this.buttonElement.setAttribute("popover", "manual");
new AutofillInlineMenuButtonIframe(this.buttonElement);
this.buttonIframe = new AutofillInlineMenuButtonIframe(this.buttonElement);
return this.buttonElement;
}
const customElementName = this.generateRandomCustomElementName();
const self = this;
globalThis.customElements?.define(
customElementName,
class extends HTMLElement {
constructor() {
super();
new AutofillInlineMenuButtonIframe(this);
self.buttonIframe = new AutofillInlineMenuButtonIframe(this);
}
},
);
@@ -293,18 +296,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (this.isFirefoxBrowser) {
this.listElement = globalThis.document.createElement("div");
this.listElement.setAttribute("popover", "manual");
new AutofillInlineMenuListIframe(this.listElement);
this.listIframe = new AutofillInlineMenuListIframe(this.listElement);
return this.listElement;
}
const customElementName = this.generateRandomCustomElementName();
const self = this;
globalThis.customElements?.define(
customElementName,
class extends HTMLElement {
constructor() {
super();
new AutofillInlineMenuListIframe(this);
self.listIframe = new AutofillInlineMenuListIframe(this);
}
},
);
@@ -778,5 +782,13 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
this.closeInlineMenu();
this.clearPersistentLastChildOverrideTimeout();
this.unobservePageAttributes();
this.unobserveCustomElements();
this.unobserveContainerElement();
if (this.mutationObserverIterationsResetTimeout) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
this.mutationObserverIterationsResetTimeout = null;
}
this.buttonIframe?.destroy();
this.listIframe?.destroy();
}
}

View File

@@ -1,6 +1,8 @@
import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service";
export class AutofillInlineMenuIframeElement {
private autofillInlineMenuIframeService: AutofillInlineMenuIframeService;
constructor(
element: HTMLElement,
portName: string,
@@ -12,14 +14,14 @@ export class AutofillInlineMenuIframeElement {
const shadow: ShadowRoot = element.attachShadow({ mode: "closed" });
shadow.prepend(style);
const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
this.autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
shadow,
portName,
initStyles,
iframeTitle,
ariaAlert,
);
autofillInlineMenuIframeService.initMenuIframe();
this.autofillInlineMenuIframeService.initMenuIframe();
}
/**
@@ -67,4 +69,11 @@ export class AutofillInlineMenuIframeElement {
return style;
}
/**
* Cleans up the iframe service to prevent memory leaks.
*/
destroy() {
this.autofillInlineMenuIframeService?.destroy();
}
}

View File

@@ -752,4 +752,164 @@ describe("AutofillInlineMenuIframeService", () => {
expect(autofillInlineMenuIframeService["iframe"].title).toBe("title");
});
});
describe("destroy", () => {
beforeEach(() => {
autofillInlineMenuIframeService.initMenuIframe();
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillInlineMenuIframeService["port"];
});
it("removes the LOAD event listener from the iframe", () => {
const removeEventListenerSpy = jest.spyOn(
autofillInlineMenuIframeService["iframe"],
"removeEventListener",
);
autofillInlineMenuIframeService.destroy();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
EVENTS.LOAD,
autofillInlineMenuIframeService["setupPortMessageListener"],
);
});
it("clears the aria alert timeout", () => {
jest.spyOn(autofillInlineMenuIframeService, "clearAriaAlert");
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.destroy();
expect(autofillInlineMenuIframeService.clearAriaAlert).toHaveBeenCalled();
});
it("clears the fade in timeout", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["fadeInTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.destroy();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["fadeInTimeout"]).toBeNull();
});
it("clears the delayed close timeout", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["delayedCloseTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.destroy();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["delayedCloseTimeout"]).toBeNull();
});
it("clears the mutation observer iterations reset timeout", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"] = setTimeout(
jest.fn(),
1000,
);
autofillInlineMenuIframeService.destroy();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"]).toBeNull();
});
it("unobserves the iframe mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuIframeService["iframeMutationObserver"],
"disconnect",
);
autofillInlineMenuIframeService.destroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it("removes the port message listeners and disconnects the port", () => {
autofillInlineMenuIframeService.destroy();
expect(portSpy.onMessage.removeListener).toHaveBeenCalledWith(handlePortMessageSpy);
expect(portSpy.onDisconnect.removeListener).toHaveBeenCalledWith(handlePortDisconnectSpy);
expect(portSpy.disconnect).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["port"]).toBeNull();
});
it("handles the case when the port is null", () => {
autofillInlineMenuIframeService["port"] = null;
expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow();
});
it("handles the case when the iframe is undefined", () => {
autofillInlineMenuIframeService["iframe"] = undefined as any;
expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow();
});
});
describe("clearAriaAlert", () => {
it("clears the aria alert timeout when it exists", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.clearAriaAlert();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["ariaAlertTimeout"]).toBeNull();
});
it("does nothing when the aria alert timeout is null", () => {
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["ariaAlertTimeout"] = null;
autofillInlineMenuIframeService.clearAriaAlert();
expect(globalThis.clearTimeout).not.toHaveBeenCalled();
});
});
describe("unobserveIframe", () => {
it("disconnects the iframe mutation observer", () => {
autofillInlineMenuIframeService.initMenuIframe();
const disconnectSpy = jest.spyOn(
autofillInlineMenuIframeService["iframeMutationObserver"],
"disconnect",
);
autofillInlineMenuIframeService["unobserveIframe"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("handles the case when the mutation observer is undefined", () => {
autofillInlineMenuIframeService["iframeMutationObserver"] = undefined as any;
expect(() => autofillInlineMenuIframeService["unobserveIframe"]()).not.toThrow();
});
});
describe("observeIframe", () => {
beforeEach(() => {
autofillInlineMenuIframeService.initMenuIframe();
});
it("observes the iframe for attribute mutations", () => {
const observeSpy = jest.spyOn(
autofillInlineMenuIframeService["iframeMutationObserver"],
"observe",
);
autofillInlineMenuIframeService["observeIframe"]();
expect(observeSpy).toHaveBeenCalledWith(autofillInlineMenuIframeService["iframe"], {
attributes: true,
});
});
});
});

View File

@@ -555,4 +555,26 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
return false;
}
/**
* Cleans up all event listeners, timeouts, and observers to prevent memory leaks.
*/
destroy() {
this.iframe?.removeEventListener(EVENTS.LOAD, this.setupPortMessageListener);
this.clearAriaAlert();
this.clearFadeInTimeout();
if (this.delayedCloseTimeout) {
clearTimeout(this.delayedCloseTimeout);
this.delayedCloseTimeout = null;
}
if (this.mutationObserverIterationsResetTimeout) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
this.mutationObserverIterationsResetTimeout = null;
}
this.unobserveIframe();
this.port?.onMessage.removeListener(this.handlePortMessage);
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
this.port = null;
}
}