diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index d612e63f82c..eef7fe32dd0 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -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(); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index b6fc6c3392e..80cfe5de49f 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -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(); diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts index f55faec887a..ab8b0e2553e 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts @@ -32,4 +32,5 @@ export type BackgroundPortMessageHandlers = { export interface AutofillInlineMenuIframeService { initMenuIframe(): void; + destroy(): void; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index b7bd24c537b..00c214c32e7 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -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); diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index c2f872d7ba5..24e6f34df4b 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -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(); } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts index 3e2b364b17b..e26b6ba9ccc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts @@ -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(); + } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts index f1ed6875f90..5e9d7c1da48 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts @@ -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, + }); + }); + }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index ad1241e98d2..40db2eef9fd 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -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; + } } diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 8164a1f4a67..02c9873c295 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -229,12 +229,20 @@ export class ItemFooterComponent implements OnInit, OnChanges { } protected async archive() { - await this.archiveCipherUtilitiesService.archiveCipher(this.cipher); + /** + * When the Archive Button is used in the footer we can skip the reprompt since + * the user will have already passed the reprompt when they opened the item. + */ + await this.archiveCipherUtilitiesService.archiveCipher(this.cipher, true); this.onArchiveToggle.emit(); } protected async unarchive() { - await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher); + /** + * When the Unarchive Button is used in the footer we can skip the reprompt since + * the user will have already passed the reprompt when they opened the item. + */ + await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher, true); this.onArchiveToggle.emit(); } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index 276c0c2e6a3..08931c68900 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -363,7 +363,7 @@ describe("VaultItemDialogComponent", () => { }); it("refocuses the dialog header", async () => { - const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader"); + const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "handleAutofocus"); await component["changeMode"]("view"); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index ef861b7cab3..df73aacfdde 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -692,7 +692,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.dialogContent().nativeElement.parentElement.scrollTop = 0; // Refocus on title element, the built-in focus management of the dialog only works for the initial open. - this.dialogComponent().focusOnHeader(); + this.dialogComponent().handleAutofocus(); // Update the URL query params to reflect the new mode. await this.router.navigate([], { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html index 88719b93643..62b23fc580d 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html @@ -46,17 +46,21 @@ bitMenuItem (click)="unlinkSso(organization)" > + {{ "unlinkSso" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index 7c4f6d04a6b..9af071a1268 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -3,12 +3,10 @@ } @else { @let drawerDetails = dataService.drawerDetails$ | async;
-

{{ "allApplications" | i18n }}

-
@@ -20,7 +18,8 @@ (ngModelChange)="setFilterApplicationsByStatus($event)" fullWidth="false" class="tw-min-w-48" - > + > +