mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-07 21:10:55 +08:00
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:
parent
cae8799d11
commit
aefbbd4705
@ -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", () => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user