diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 4f2f2364ba6..7a1099e199b 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -2496,6 +2496,22 @@ describe("CollectAutofillContentService", () => { expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); }); + + it("clears pending overlay setup timeout when removing a field element", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField = mock(); + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + const timeoutId = setTimeout(jest.fn, 100); + collectAutofillContentService["pendingOverlaySetup"].set(fieldElement, timeoutId); + const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout"); + + collectAutofillContentService["deleteCachedAutofillElement"](fieldElement); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId); + expect(collectAutofillContentService["pendingOverlaySetup"].has(fieldElement)).toBe(false); + }); }); describe("handleWindowLocationMutation", () => { @@ -2797,6 +2813,7 @@ describe("CollectAutofillContentService", () => { it("sets up the inline menu listeners on a viewable field", async () => { const formFieldElement = document.createElement("input") as ElementWithOpId; + document.body.appendChild(formFieldElement); const autofillField = mock(); const entries = [ { target: formFieldElement, isIntersecting: true }, @@ -2816,6 +2833,66 @@ describe("CollectAutofillContentService", () => { }); }); + describe("setupOverlayOnField", () => { + it("executes immediately on first call then debounces subsequent rapid calls", () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + document.body.appendChild(formFieldElement); + const autofillField = mock(); + collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField); + const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( + collectAutofillContentService["autofillOverlayContentService"], + "setupOverlayListeners", + ); + jest.useFakeTimers(); + + // First call executes immediately + collectAutofillContentService["setupOverlayOnField"](formFieldElement, autofillField); + expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledTimes(1); + + // Subsequent rapid calls are debounced + collectAutofillContentService["setupOverlayOnField"](formFieldElement, autofillField); + collectAutofillContentService["setupOverlayOnField"](formFieldElement, autofillField); + expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledTimes(1); + + // After debounce delay, the next call executes immediately again + jest.advanceTimersByTime(150); + collectAutofillContentService["setupOverlayOnField"](formFieldElement, autofillField); + expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledTimes(2); + + jest.useRealTimers(); + }); + + it("does not call setupOverlayListeners if the element is not in DOM", () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + // Note: not appending to document.body + const autofillField = mock(); + collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField); + const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( + collectAutofillContentService["autofillOverlayContentService"], + "setupOverlayListeners", + ); + + collectAutofillContentService["setupOverlayOnField"](formFieldElement, autofillField); + + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("does not call setupOverlayListeners if the element is not in cache", () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + document.body.appendChild(formFieldElement); + const autofillField = mock(); + // Note: not adding to autofillFieldElements cache + const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( + collectAutofillContentService["autofillOverlayContentService"], + "setupOverlayListeners", + ); + + collectAutofillContentService["setupOverlayOnField"](formFieldElement, autofillField); + + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + }); + describe("destroy", () => { it("clears the updateAfterMutationIdleCallback", () => { jest.spyOn(window, "clearTimeout"); @@ -2827,6 +2904,29 @@ describe("CollectAutofillContentService", () => { collectAutofillContentService["updateAfterMutationIdleCallback"], ); }); + + it("clears all pending overlay setup timeouts", () => { + const formFieldElement1 = document.createElement( + "input", + ) as ElementWithOpId; + const formFieldElement2 = document.createElement( + "input", + ) as ElementWithOpId; + const clearTimeoutSpy = jest.spyOn(window, "clearTimeout"); + collectAutofillContentService["pendingOverlaySetup"].set( + formFieldElement1, + setTimeout(jest.fn, 100), + ); + collectAutofillContentService["pendingOverlaySetup"].set( + formFieldElement2, + setTimeout(jest.fn, 100), + ); + + collectAutofillContentService.destroy(); + + expect(clearTimeoutSpy).toHaveBeenCalledTimes(2); + expect(collectAutofillContentService["pendingOverlaySetup"].size).toBe(0); + }); }); describe("processMutations", () => { diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index aa99c510101..edf76d3f456 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -52,6 +52,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private mutationObserver: MutationObserver; private mutationsQueue: MutationRecord[][] = []; private updateAfterMutationIdleCallback: NodeJS.Timeout | number; + private pendingOverlaySetup: Map = new Map(); + private readonly overlaySetupDelayMs = 100; private shadowDomCheckTimeout: NodeJS.Timeout | number | null = null; private pendingShadowDomCheck = false; private ownedExperienceTagNames: string[] = []; @@ -1334,6 +1336,13 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.autofillFieldsByOpid.delete(autofillFieldData.opid); } } + + // Clear pending overlay setup timeout to prevent memory leak + const pendingTimeout = this.pendingOverlaySetup.get(element); + if (pendingTimeout) { + globalThis.clearTimeout(pendingTimeout); + this.pendingOverlaySetup.delete(element); + } } /** @@ -1603,6 +1612,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ /** * Sets up the inline menu listener on the passed field element. + * Debounced per-element to prevent excessive setup/teardown during rapid DOM changes. * * @param formFieldElement - The form field element to set up the inline menu listener on * @param autofillField - The metadata for the form field @@ -1613,20 +1623,66 @@ export class CollectAutofillContentService implements CollectAutofillContentServ autofillField: AutofillField, pageDetails?: AutofillPageDetails, ) { - if (this.autofillOverlayContentService) { - const autofillPageDetails = - pageDetails || - this.getFormattedPageDetails( - this.getFormattedAutofillFormsData(), - this.getFormattedAutofillFieldsData(), - ); - - void this.autofillOverlayContentService.setupOverlayListeners( - formFieldElement, - autofillField, - autofillPageDetails, - ); + if (!this.autofillOverlayContentService) { + return; } + + // Check if there's already a pending debounce for this element + const existingTimeout = this.pendingOverlaySetup.get(formFieldElement); + const shouldExecuteImmediately = !existingTimeout; + + // Cancel any pending setup for this element + if (existingTimeout) { + globalThis.clearTimeout(existingTimeout); + } + + // Execute immediately on first call (leading edge), then debounce subsequent calls + if (shouldExecuteImmediately) { + this.executeOverlaySetup(formFieldElement, autofillField, pageDetails); + } + + // Set up debounce timeout that clears the tracking after the delay + // This allows the next call after the delay to execute immediately again + const timeoutId = globalThis.setTimeout(() => { + this.pendingOverlaySetup.delete(formFieldElement); + }, this.overlaySetupDelayMs); + + this.pendingOverlaySetup.set(formFieldElement, timeoutId); + } + + /** + * Executes the overlay setup for a form field element. + * + * @param formFieldElement - The form field element to set up the inline menu listener on + * @param autofillField - The metadata for the form field + * @param pageDetails - The page details to use for the inline menu listeners + */ + private executeOverlaySetup( + formFieldElement: ElementWithOpId, + autofillField: AutofillField, + pageDetails?: AutofillPageDetails, + ) { + // Verify the field is still in the DOM and cached before setup + if ( + !formFieldElement.isConnected || + !this.autofillFieldElements.has(formFieldElement) || + !this.autofillOverlayContentService + ) { + return; + } + + const autofillPageDetails = + pageDetails || + this.getFormattedPageDetails( + this.getFormattedAutofillFormsData(), + this.getFormattedAutofillFieldsData(), + ); + + void this.autofillOverlayContentService.setupOverlayListeners( + formFieldElement, + autofillField, + autofillPageDetails, + ); } /** @@ -1653,6 +1709,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ if (this.shadowDomCheckTimeout) { clearTimeout(this.shadowDomCheckTimeout); } + this.pendingOverlaySetup.forEach((timeout) => globalThis.clearTimeout(timeout)); + this.pendingOverlaySetup.clear(); this.mutationObserver?.disconnect(); this.intersectionObserver?.disconnect(); }