Add debouncing to setupOverlayOnField (#17860)

* Add debouncing to `setupOverlayOnField`

* add teardown of debounce to deleteCachedAutofillElement calls

---------

Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
Jeffrey Holland 2026-03-10 10:46:17 -05:00 committed by GitHub
parent cae8799d11
commit aefbbd4705
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 171 additions and 13 deletions

View File

@ -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<HTMLInputElement>;
const autofillField = mock<AutofillField>();
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<FormFieldElement>;
document.body.appendChild(formFieldElement);
const autofillField = mock<AutofillField>();
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<FormFieldElement>;
document.body.appendChild(formFieldElement);
const autofillField = mock<AutofillField>();
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<FormFieldElement>;
// Note: not appending to document.body
const autofillField = mock<AutofillField>();
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<FormFieldElement>;
document.body.appendChild(formFieldElement);
const autofillField = mock<AutofillField>();
// 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<FormFieldElement>;
const formFieldElement2 = document.createElement(
"input",
) as ElementWithOpId<FormFieldElement>;
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", () => {

View File

@ -52,6 +52,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
private mutationObserver: MutationObserver;
private mutationsQueue: MutationRecord[][] = [];
private updateAfterMutationIdleCallback: NodeJS.Timeout | number;
private pendingOverlaySetup: Map<Element, NodeJS.Timeout | number> = 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<FormFieldElement>,
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();
}