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 }}