From ff5c02dbdd21640d2e5727339e6eb95ea7f39307 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Thu, 20 Jun 2024 12:16:52 -0500 Subject: [PATCH] [PM-5189] Working through jest tests for OverlayBackground and refining repositioning delays --- .../background/overlay.background.spec.ts | 500 ++++++++---------- .../autofill/background/overlay.background.ts | 41 +- .../autofill-overlay-content.service.ts | 4 +- apps/browser/src/autofill/utils/index.ts | 31 ++ 4 files changed, 284 insertions(+), 292 deletions(-) diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index f71b3871026..a2cfbb8cd5f 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -338,19 +338,49 @@ describe("OverlayBackground", () => { const tabId = 1; const topFrameId = 0; const middleFrameId = 10; + const middleAdjacentFrameId = 11; const bottomFrameId = 20; let tab: chrome.tabs.Tab; + let sender: MockProxy; + + async function flushOverlayRepositionPromises() { + await flushPromises(); + jest.advanceTimersByTime(1000); + await flushPromises(); + } beforeEach(() => { + jest.useFakeTimers(); tab = createChromeTabMock({ id: tabId }); + sender = mock({ tab, frameId: middleFrameId }); overlayBackground["focusedFieldData"] = mock({ tabId, frameId: bottomFrameId, }); subFrameOffsetsSpy[tabId] = new Map([ - [topFrameId, { left: 1, top: 1, url: "https://top-frame.com" }], - [middleFrameId, { left: 2, top: 2, url: "https://middle-frame.com" }], - [bottomFrameId, { left: 3, top: 3, url: "https://bottom-frame.com" }], + [topFrameId, { left: 1, top: 1, url: "https://top-frame.com", parentFrameIds: [] }], + [ + middleFrameId, + { left: 2, top: 2, url: "https://middle-frame.com", parentFrameIds: [topFrameId] }, + ], + [ + middleAdjacentFrameId, + { + left: 3, + top: 3, + url: "https://middle-adjacent-frame.com", + parentFrameIds: [topFrameId], + }, + ], + [ + bottomFrameId, + { + left: 4, + top: 4, + url: "https://bottom-frame.com", + parentFrameIds: [topFrameId, middleFrameId], + }, + ], ]); tabsSendMessageSpy.mockResolvedValue( mock({ @@ -361,168 +391,214 @@ describe("OverlayBackground", () => { ); }); - describe("repositionInlineMenuForSubFrame", () => { - it("skips rebuilding sub frame offsets if the sender contains the currently focused field", () => { - const sender = mock({ tab, frameId: bottomFrameId }); + describe("triggerAutofillOverlayReposition", () => { + describe("checkShouldRepositionInlineMenu", () => { + let focusedFieldData: FocusedFieldData; + let repositionInlineMenuSpy: jest.SpyInstance; - sendMockExtensionMessage({ command: "repositionInlineMenuForSubFrame" }, sender); - - expect(getFrameDetailsSpy).not.toHaveBeenCalled(); - }); - - it("skips rebuilding sub frame offsets if the tab does not contain sub frames", () => { - const sender = mock({ - tab: createChromeTabMock({ id: 15 }), - frameId: 0, + beforeEach(() => { + focusedFieldData = createFocusedFieldDataMock({ tabId }); + repositionInlineMenuSpy = jest.spyOn(overlayBackground as any, "repositionInlineMenu"); }); - sendMockExtensionMessage({ command: "repositionInlineMenuForSubFrame" }, sender); + describe("blocking a reposition of the overlay", () => { + it("blocks repositioning when the focused field data is not set", async () => { + overlayBackground["focusedFieldData"] = undefined; - expect(getFrameDetailsSpy).not.toHaveBeenCalled(); + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender is from a different tab than the focused field", async () => { + const otherSender = mock({ frameId: 1, tab: { id: 2 } }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherSender, + ); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender frame is for the focused field, but the inline menu is not visible", async () => { + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + sender, + ); + tabsSendMessageSpy.mockImplementationOnce((_tab, message) => { + if (message.command === "checkIsAutofillInlineMenuButtonVisible") { + return Promise.resolve(false); + } + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + + it("blocks repositioning when the sender frame is not a parent frame of the focused field", async () => { + focusedFieldData = createFocusedFieldDataMock({ tabId }); + const otherFrameSender = mock({ + tab, + frameId: middleAdjacentFrameId, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + otherFrameSender, + ); + sender.frameId = bottomFrameId; + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).not.toHaveBeenCalled(); + }); + }); + + describe("allowing a reposition of the overlay", () => { + it("allows repositioning when the sender frame is for the focused field and the inline menu is visible, ", async () => { + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + sender, + ); + tabsSendMessageSpy.mockImplementation((_tab, message) => { + if (message.command === "checkIsAutofillInlineMenuButtonVisible") { + return Promise.resolve(true); + } + }); + + sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + await flushOverlayRepositionPromises(); + + expect(repositionInlineMenuSpy).toHaveBeenCalled(); + }); + }); }); - it("rebuilds the sub frame offsets for a given tab", async () => { - const sender = mock({ tab, frameId: middleFrameId }); - - sendMockExtensionMessage({ command: "repositionAutofillInlineMenuForSubFrame" }, sender); - await flushPromises(); - - expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: topFrameId }); - expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: bottomFrameId }); - expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: middleFrameId }); - }); - - // it("triggers an update of the inline menu position after rebuilding sub frames", async () => { - // jest.useFakeTimers(); - // overlayBackground["delayedUpdateInlineMenuPositionTimeout"] = setTimeout(jest.fn, 650); - // const sender = mock({ tab, frameId: middleFrameId }); - // jest.spyOn(overlayBackground as any, "updateInlineMenuPositionAfterRepositionEvent"); + // it("rebuilds the sub frame offsets for a given tab", async () => { + // sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); + // await flushOverlayRepositionPromises(); // - // sendMockExtensionMessage( - // { - // command: "repositionAutofillInlineMenuForSubFrame", - // triggerInlineMenuPositionUpdate: true, - // }, - // sender, - // ); - // await flushPromises(); - // jest.advanceTimersByTime(650); - // - // expect( - // overlayBackground["updateInlineMenuPositionAfterRepositionEvent"], - // ).toHaveBeenCalled(); + // expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: topFrameId }); + // expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: bottomFrameId }); + // expect(getFrameDetailsSpy).toHaveBeenCalledWith({ tabId, frameId: middleFrameId }); // }); }); - describe("updateInlineMenuPositionAfterRepositionEvent", () => { - let sender: chrome.runtime.MessageSender; - - async function flushInlineMenuUpdatePromises() { - await flushPromises(); - jest.advanceTimersByTime(650); - await flushPromises(); - } - - beforeEach(() => { - sender = mock({ tab, frameId: middleFrameId }); - jest.useFakeTimers(); - sendMockExtensionMessage({ - command: "updateIsFieldCurrentlyFocused", - isFieldCurrentlyFocused: true, - }); - }); - - it("skips updating the position of either inline menu element if a field is not currently focused", async () => { - sendMockExtensionMessage({ - command: "updateIsFieldCurrentlyFocused", - isFieldCurrentlyFocused: false, - }); - - sendMockExtensionMessage({ command: "repositionInlineMenuForSubFrame" }, sender); - await flushInlineMenuUpdatePromises(); - - expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( - sender.tab, - { - command: "appendAutofillInlineMenuToDom", - overlayElement: AutofillOverlayElement.Button, - }, - { frameId: 0 }, - ); - expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( - sender.tab, - { - command: "appendAutofillInlineMenuToDom", - overlayElement: AutofillOverlayElement.List, - }, - { frameId: 0 }, - ); - }); - - it("updates the position of the inline menu elements", async () => { - sendMockExtensionMessage( - { - command: "repositionAutofillInlineMenuForSubFrame", - triggerInlineMenuPositionUpdate: true, - }, - sender, - ); - await flushInlineMenuUpdatePromises(); - - expect(tabsSendMessageSpy).toHaveBeenCalledWith( - sender.tab, - { - command: "appendAutofillInlineMenuToDom", - overlayElement: AutofillOverlayElement.Button, - }, - { frameId: 0 }, - ); - expect(tabsSendMessageSpy).toHaveBeenCalledWith( - sender.tab, - { - command: "appendAutofillInlineMenuToDom", - overlayElement: AutofillOverlayElement.List, - }, - { frameId: 0 }, - ); - }); - - it("skips updating the inline menu list if the focused field has a value and the user status is not unlocked", async () => { - activeAccountStatusMock$.next(AuthenticationStatus.Locked); - tabsSendMessageSpy.mockImplementation((_tab, message, _options) => { - if (message.command === "checkMostRecentlyFocusedFieldHasValue") { - return Promise.resolve(true); - } - return Promise.resolve(); - }); - - sendMockExtensionMessage( - { - command: "repositionAutofillInlineMenuForSubFrame", - triggerInlineMenuPositionUpdate: true, - }, - sender, - ); - await flushInlineMenuUpdatePromises(); - - expect(tabsSendMessageSpy).toHaveBeenCalledWith( - sender.tab, - { - command: "appendAutofillInlineMenuToDom", - overlayElement: AutofillOverlayElement.Button, - }, - { frameId: 0 }, - ); - expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( - sender.tab, - { - command: "appendAutofillInlineMenuToDom", - overlayElement: AutofillOverlayElement.List, - }, - { frameId: 0 }, - ); - }); - }); + // describe("updateInlineMenuPositionAfterRepositionEvent", () => { + // let sender: chrome.runtime.MessageSender; + // + // async function flushInlineMenuUpdatePromises() { + // await flushPromises(); + // jest.advanceTimersByTime(650); + // await flushPromises(); + // } + // + // beforeEach(() => { + // sender = mock({ tab, frameId: middleFrameId }); + // jest.useFakeTimers(); + // sendMockExtensionMessage({ + // command: "updateIsFieldCurrentlyFocused", + // isFieldCurrentlyFocused: true, + // }); + // }); + // + // it("skips updating the position of either inline menu element if a field is not currently focused", async () => { + // sendMockExtensionMessage({ + // command: "updateIsFieldCurrentlyFocused", + // isFieldCurrentlyFocused: false, + // }); + // + // sendMockExtensionMessage({ command: "repositionInlineMenuForSubFrame" }, sender); + // await flushInlineMenuUpdatePromises(); + // + // expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + // sender.tab, + // { + // command: "appendAutofillInlineMenuToDom", + // overlayElement: AutofillOverlayElement.Button, + // }, + // { frameId: 0 }, + // ); + // expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + // sender.tab, + // { + // command: "appendAutofillInlineMenuToDom", + // overlayElement: AutofillOverlayElement.List, + // }, + // { frameId: 0 }, + // ); + // }); + // + // it("updates the position of the inline menu elements", async () => { + // sendMockExtensionMessage( + // { + // command: "triggerAutofillOverlayReposition", + // triggerInlineMenuPositionUpdate: true, + // }, + // sender, + // ); + // await flushInlineMenuUpdatePromises(); + // + // expect(tabsSendMessageSpy).toHaveBeenCalledWith( + // sender.tab, + // { + // command: "appendAutofillInlineMenuToDom", + // overlayElement: AutofillOverlayElement.Button, + // }, + // { frameId: 0 }, + // ); + // expect(tabsSendMessageSpy).toHaveBeenCalledWith( + // sender.tab, + // { + // command: "appendAutofillInlineMenuToDom", + // overlayElement: AutofillOverlayElement.List, + // }, + // { frameId: 0 }, + // ); + // }); + // + // it("skips updating the inline menu list if the focused field has a value and the user status is not unlocked", async () => { + // activeAccountStatusMock$.next(AuthenticationStatus.Locked); + // tabsSendMessageSpy.mockImplementation((_tab, message, _options) => { + // if (message.command === "checkMostRecentlyFocusedFieldHasValue") { + // return Promise.resolve(true); + // } + // return Promise.resolve(); + // }); + // + // sendMockExtensionMessage( + // { + // command: "triggerAutofillOverlayReposition", + // triggerInlineMenuPositionUpdate: true, + // }, + // sender, + // ); + // await flushInlineMenuUpdatePromises(); + // + // expect(tabsSendMessageSpy).toHaveBeenCalledWith( + // sender.tab, + // { + // command: "appendAutofillInlineMenuToDom", + // overlayElement: AutofillOverlayElement.Button, + // }, + // { frameId: 0 }, + // ); + // expect(tabsSendMessageSpy).not.toHaveBeenCalledWith( + // sender.tab, + // { + // command: "appendAutofillInlineMenuToDom", + // overlayElement: AutofillOverlayElement.List, + // }, + // { frameId: 0 }, + // ); + // }); + // }); }); describe("updating the overlay ciphers", () => { @@ -1246,95 +1322,6 @@ describe("OverlayBackground", () => { }); }); - describe("checkShouldRepositionInlineMenu message handler", () => { - const tabId = 1; - const frameId = 1; - const sender = mock({ - tab: createChromeTabMock({ id: tabId }), - frameId, - }); - const otherSender = mock({ - tab: createChromeTabMock({ id: tabId }), - frameId: 2, - }); - - it("returns false if the focused field data is not set", async () => { - sendMockExtensionMessage( - { command: "checkShouldRepositionInlineMenu" }, - sender, - sendResponse, - ); - await flushPromises(); - - expect(sendResponse).toHaveBeenCalledWith(false); - }); - - it("returns false if the sender is from a different tab than the focused field", async () => { - const focusedFieldData = createFocusedFieldDataMock(); - const otherSender = mock({ frameId: 1, tab: { id: 2 } }); - sendMockExtensionMessage( - { command: "updateFocusedFieldData", focusedFieldData }, - otherSender, - ); - - sendMockExtensionMessage( - { command: "checkShouldRepositionInlineMenu" }, - sender, - sendResponse, - ); - await flushPromises(); - - expect(sendResponse).toHaveBeenCalledWith(false); - }); - - it("returns true if the focused field's frame id is equal to the sender's frame id", async () => { - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); - - sendMockExtensionMessage( - { command: "checkShouldRepositionInlineMenu" }, - sender, - sendResponse, - ); - await flushPromises(); - - expect(sendResponse).toHaveBeenCalledWith(true); - }); - - describe("when the focused field is in a different frame than the sender", () => { - it("returns false if the tab does not contain and sub frame offset data", async () => { - const focusedFieldData = createFocusedFieldDataMock({ frameId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); - - sendMockExtensionMessage( - { command: "checkShouldRepositionInlineMenu" }, - otherSender, - sendResponse, - ); - await flushPromises(); - - expect(sendResponse).toHaveBeenCalledWith(false); - }); - - it("returns true if the sender's frameId is present in any of the parentFrameIds of the tab's sub frames", async () => { - const focusedFieldData = createFocusedFieldDataMock(); - subFrameOffsetsSpy[tabId] = new Map([ - [frameId, { left: 1, top: 1, url: "https://top-frame.com", parentFrameIds: [2, 0] }], - ]); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); - - sendMockExtensionMessage( - { command: "checkShouldRepositionInlineMenu" }, - otherSender, - sendResponse, - ); - await flushPromises(); - - expect(sendResponse).toHaveBeenCalledWith(true); - }); - }); - }); - describe("getCurrentTabFrameId message handler", () => { it("returns the sender's frame id", async () => { const sender = mock({ frameId: 1 }); @@ -1346,31 +1333,6 @@ describe("OverlayBackground", () => { }); }); - describe("rebuildSubFrameOffsets", () => { - it("triggers a rebuild of the sub frame offsets of the sender", async () => { - const buildSubFrameOffsetsSpy = jest.spyOn( - overlayBackground as any, - "buildSubFrameOffsets", - ); - const tab = mock({ id: 1 }); - const frameId = 10; - subFrameOffsetsSpy[tab.id] = new Map([ - [frameId, { left: 1, top: 1, url: "https://top-frame.com" }], - ]); - const sender = mock({ tab, frameId }); - - sendMockExtensionMessage({ command: "rebuildSubFrameOffsets" }, sender); - await flushPromises(); - - expect(buildSubFrameOffsetsSpy).toHaveBeenCalledWith( - sender.tab, - frameId, - sender.url, - sender, - ); - }); - }); - describe("destroyAutofillInlineMenuListeners", () => { it("sends a message to the passed frameId that triggers a destruction of the inline menu listeners on that frame", () => { const sender = mock({ tab: { id: 1 }, frameId: 0 }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 1a7b21ed38e..98d7ea98220 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -159,13 +159,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { private initOverlayObservables() { this.repositionInlineMenuSubject .pipe( - debounceTime(500), + debounceTime(1000), switchMap((sender) => this.repositionInlineMenu(sender)), ) .subscribe(); this.rebuildSubFrameOffsetsSubject .pipe( - throttleTime(650), + throttleTime(100), switchMap((sender) => this.rebuildSubFrameOffsets(sender)), ) .subscribe(); @@ -433,6 +433,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the message */ private async rebuildSubFrameOffsets(sender: chrome.runtime.MessageSender) { + this.cancelUpdateInlineMenuPositionSubject.next(); this.clearDelayedInlineMenuClosure(); const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; @@ -460,7 +461,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { } if (!(await this.checkIsInlineMenuButtonVisible(sender))) { - await this.toggleInlineMenuHidden( + void this.toggleInlineMenuHidden( { isInlineMenuHidden: false, setTransparentInlineMenu: true }, sender, ); @@ -1119,30 +1120,25 @@ export class OverlayBackground implements OverlayBackgroundInterface { * * @param sender - The sender of the message */ - private async checkShouldRepositionInlineMenu( - sender: chrome.runtime.MessageSender, - ): Promise { + private checkShouldRepositionInlineMenu(sender: chrome.runtime.MessageSender): boolean { if (!this.focusedFieldData || sender.tab.id !== this.focusedFieldData.tabId) { return false; } if (this.focusedFieldData.frameId === sender.frameId) { - return await this.checkIsInlineMenuButtonVisible(sender); + return true; } const subFrameOffsetsForTab = this.subFrameOffsetsForTab[sender.tab.id]; - if (!subFrameOffsetsForTab) { - return false; + if (subFrameOffsetsForTab) { + for (const value of subFrameOffsetsForTab.values()) { + if (value?.parentFrameIds.includes(sender.frameId)) { + return true; + } + } } - const parentFrameIds = new Set(); - subFrameOffsetsForTab.forEach((subFrameOffsetData) => - subFrameOffsetData?.parentFrameIds.forEach((parentFrameId) => - parentFrameIds.add(parentFrameId), - ), - ); - - return parentFrameIds.has(sender.frameId); + return false; } /** @@ -1348,18 +1344,20 @@ export class OverlayBackground implements OverlayBackgroundInterface { }; private async triggerOverlayReposition(sender: chrome.runtime.MessageSender) { - if (await this.checkShouldRepositionInlineMenu(sender)) { - await this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender); + if (this.checkShouldRepositionInlineMenu(sender)) { + this.cancelUpdateInlineMenuPositionSubject.next(); + void this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender); this.repositionInlineMenuSubject.next(sender); } } private async triggerSubFrameFocusInRebuild(sender: chrome.runtime.MessageSender) { - await this.rebuildSubFrameOffsets(sender); + this.rebuildSubFrameOffsetsSubject.next(sender); this.repositionInlineMenuSubject.next(sender); } private repositionInlineMenu = async (sender: chrome.runtime.MessageSender) => { + this.cancelUpdateInlineMenuPositionSubject.next(); if (!this.isFieldCurrentlyFocused) { await this.closeInlineMenuAfterReposition(sender); return; @@ -1375,10 +1373,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - if (this.focusedFieldData.frameId > 0 && sender.frameId !== this.focusedFieldData.frameId) { + if (this.focusedFieldData.frameId > 0) { this.rebuildSubFrameOffsetsSubject.next(sender); } + this.cancelUpdateInlineMenuPositionSubject.next(); this.startUpdateInlineMenuPositionSubject.next(sender); }; diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 742f2de5763..c265ca53b5e 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1000,7 +1000,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ globalThis.addEventListener( EVENTS.SCROLL, this.useEventHandlersMemo( - throttle(this.handleOverlayRepositionEvent, 200), + throttle(this.handleOverlayRepositionEvent, 150), AUTOFILL_OVERLAY_ON_SCROLL, ), { @@ -1010,7 +1010,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ globalThis.addEventListener( EVENTS.RESIZE, this.useEventHandlersMemo( - throttle(this.handleOverlayRepositionEvent, 200), + throttle(this.handleOverlayRepositionEvent, 150), AUTOFILL_OVERLAY_ON_RESIZE, ), ); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 829cb8a4ee1..ef30460c71c 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -331,6 +331,12 @@ export function getPropertyOrAttribute(element: HTMLElement, attributeName: stri return element.getAttribute(attributeName); } +/** + * Throttles a callback function to run at most once every `limit` milliseconds. + * + * @param callback - The callback function to throttle. + * @param limit - The time in milliseconds to throttle the callback. + */ export function throttle(callback: () => void, limit: number) { let waitingDelay = false; return function (...args: unknown[]) { @@ -341,3 +347,28 @@ export function throttle(callback: () => void, limit: number) { } }; } + +/** + * Debounces a callback function to run after a certain amount of time has passed. + * + * @param callback - The callback function to debounce. + * @param wait - The time in milliseconds to wait before running the callback. + * @param immediate - Determines whether the callback should run immediately. + */ +export function debounce(callback: () => void, wait: number, immediate?: boolean) { + let timeoutId: NodeJS.Timeout | number | null = null; + + return (...args: unknown[]) => { + if (immediate && !timeoutId) { + callback.apply(this, args); + } + + if (timeoutId) { + globalThis.clearTimeout(timeoutId); + } + timeoutId = globalThis.setTimeout(() => { + callback.apply(this, args); + timeoutId = null; + }, wait); + }; +}