From 66794e054940cbcb90ed6fe2c28e766351b7b6ab Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 24 Jul 2026 18:39:15 +0200 Subject: [PATCH] refactor: replace debounce/throttle deps with first-party code (#38610) Adds `web_src/js/utils/func.ts` with `debounce` and `throttle`, dropping `throttle-debounce` and `perfect-debounce` dependencies. Both were needed before: the former has no promise-returning debounce, which `TextExpander` requires, and the latter no `throttle`. Signature follows lodash and es-toolkit: `(func, wait, {leading, trailing})` plus `cancel`, so argument order flips at the migrated call sites. --- package.json | 3 - pnpm-lock.yaml | 25 ------- web_src/js/components/RepoFileSearch.vue | 6 +- web_src/js/components/WorkflowGraph.vue | 4 +- web_src/js/features/comp/TextExpander.ts | 2 +- web_src/js/features/ref-issue.ts | 4 +- web_src/js/modules/search.ts | 6 +- web_src/js/types.ts | 1 + web_src/js/utils/dom.ts | 4 +- web_src/js/utils/func.test.ts | 74 ++++++++++++++++++++ web_src/js/utils/func.ts | 82 +++++++++++++++++++++++ web_src/js/webcomponents/overflow-menu.ts | 6 +- 12 files changed, 173 insertions(+), 44 deletions(-) create mode 100644 web_src/js/utils/func.test.ts create mode 100644 web_src/js/utils/func.ts diff --git a/package.json b/package.json index 7e13830f75c..22ec3c5c3d2 100644 --- a/package.json +++ b/package.json @@ -55,13 +55,11 @@ "mermaid": "11.16.0", "online-3d-viewer": "0.18.0", "pdfobject": "2.3.1", - "perfect-debounce": "2.1.0", "postcss": "8.5.19", "rolldown-license-plugin": "3.0.9", "sortablejs": "1.15.7", "swagger-ui-dist": "5.32.8", "tailwindcss": "3.4.19", - "throttle-debounce": "5.0.2", "tippy.js": "6.3.7", "toastify-js": "1.12.0", "tributejs": "5.1.3", @@ -84,7 +82,6 @@ "@types/pdfobject": "2.2.5", "@types/sortablejs": "1.15.9", "@types/swagger-ui-dist": "3.30.6", - "@types/throttle-debounce": "5.0.2", "@types/toastify-js": "1.12.4", "@typescript-eslint/parser": "8.64.0", "@vitejs/plugin-vue": "6.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbf8d6f6970..7f1dc12dafd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,9 +155,6 @@ importers: pdfobject: specifier: 2.3.1 version: 2.3.1 - perfect-debounce: - specifier: 2.1.0 - version: 2.1.0 postcss: specifier: 8.5.19 version: 8.5.19 @@ -173,9 +170,6 @@ importers: tailwindcss: specifier: 3.4.19 version: 3.4.19(yaml@2.9.0) - throttle-debounce: - specifier: 5.0.2 - version: 5.0.2 tippy.js: specifier: 6.3.7 version: 6.3.7 @@ -237,9 +231,6 @@ importers: '@types/swagger-ui-dist': specifier: 3.30.6 version: 3.30.6 - '@types/throttle-debounce': - specifier: 5.0.2 - version: 5.0.2 '@types/toastify-js': specifier: 1.12.4 version: 1.12.4 @@ -1335,9 +1326,6 @@ packages: '@types/tern@0.23.9': resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==} - '@types/throttle-debounce@5.0.2': - resolution: {integrity: sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==} - '@types/toastify-js@1.12.4': resolution: {integrity: sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==} @@ -3546,9 +3534,6 @@ packages: pdfobject@2.3.1: resolution: {integrity: sha512-vluuGiSDmMGpOvWFGiUY4trNB8aGKLDVxIXuuGHjX0kK3bMxCANUVtLivctE7uejLBScWCnbVarKatFVvdwXaQ==} - perfect-debounce@2.1.0: - resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4068,10 +4053,6 @@ packages: three@0.176.0: resolution: {integrity: sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==} - throttle-debounce@5.0.2: - resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} - engines: {node: '>=12.22'} - time-span@5.1.0: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} @@ -5579,8 +5560,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - '@types/throttle-debounce@5.0.2': {} - '@types/toastify-js@1.12.4': {} '@types/tough-cookie@4.0.5': {} @@ -8155,8 +8134,6 @@ snapshots: pdfobject@2.3.1: {} - perfect-debounce@2.1.0: {} - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -8774,8 +8751,6 @@ snapshots: three@0.176.0: {} - throttle-debounce@5.0.2: {} - time-span@5.1.0: dependencies: convert-hrtime: 5.0.0 diff --git a/web_src/js/components/RepoFileSearch.vue b/web_src/js/components/RepoFileSearch.vue index 4b006c9cde4..ecc325b5af3 100644 --- a/web_src/js/components/RepoFileSearch.vue +++ b/web_src/js/components/RepoFileSearch.vue @@ -5,7 +5,7 @@ import {GET} from '../modules/fetch.ts'; import {filterRepoFilesWeighted} from '../features/repo-findfile.ts'; import {pathEscapeSegments} from '../utils/url.ts'; import {SvgIcon} from '../svg.ts'; -import {throttle} from 'throttle-debounce'; +import {throttle} from '../utils/func.ts'; const props = defineProps({ repoLink: { type: String, required: true }, @@ -31,10 +31,10 @@ const filteredFiles = computed(() => { return filterRepoFilesWeighted(allFiles.value, searchQuery.value); }); -const applySearchQuery = throttle(300, () => { +const applySearchQuery = throttle(() => { searchQuery.value = refElemInput.value.value; selectedIndex.value = 0; -}); +}, 300); const handleSearchInput = () => { loadFileListForSearch(); diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index dd2147340a3..8da4351a50a 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -5,7 +5,7 @@ import ActionStatusIcon from './ActionStatusIcon.vue'; import {localUserSettings} from '../modules/user-settings.ts'; import {isPlainClick} from '../utils/dom.ts'; import {trN} from '../modules/i18n.ts'; -import {debounce} from 'throttle-debounce'; +import {debounce} from '../utils/func.ts'; import type {ActionsJob} from '../modules/gitea-actions.ts'; import type {ActionRunViewStore} from './ActionRunView.ts'; import { @@ -178,7 +178,7 @@ function handleWheel(event: WheelEvent) { onMounted(() => { loadSavedState(); - watch([translateX, translateY, scale], debounce(500, saveState)); + watch([translateX, translateY, scale], debounce(saveState, 500)); document.addEventListener('mousemove', handleMouseMoveOnDocument); document.addEventListener('mouseup', handleMouseUpOnDocument); }); diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts index 09fee8dc74d..a01598086eb 100644 --- a/web_src/js/features/comp/TextExpander.ts +++ b/web_src/js/features/comp/TextExpander.ts @@ -4,7 +4,7 @@ import {svg} from '../../svg.ts'; import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts'; import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts'; import {getIssueColorClass, getIssueIcon} from '../issue.ts'; -import {debounce} from 'perfect-debounce'; +import {debounce} from '../../utils/func.ts'; import type TextExpanderElement from '@github/text-expander-element'; import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element'; diff --git a/web_src/js/features/ref-issue.ts b/web_src/js/features/ref-issue.ts index b010011e1ea..9b155860e75 100644 --- a/web_src/js/features/ref-issue.ts +++ b/web_src/js/features/ref-issue.ts @@ -3,7 +3,7 @@ import {GET} from '../modules/fetch.ts'; import {createApp} from 'vue'; import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts'; import {addDelegatedEventListener} from '../utils/dom.ts'; -import type {Issue} from '../types.ts'; +import type {Issue, TimeoutId} from '../types.ts'; type IssueInfo = { convertedIssue: Issue, @@ -55,7 +55,7 @@ export function initRefIssueContextPopup() { link.setAttribute('data-ref-issue-popup', ''); // delay so a mouse passing over the link doesn't fire a fetch - let timer: ReturnType; + let timer: TimeoutId; const cancel = () => { clearTimeout(timer); link.removeAttribute('data-ref-issue-popup'); diff --git a/web_src/js/modules/search.ts b/web_src/js/modules/search.ts index 835053f646c..00ade5dc436 100644 --- a/web_src/js/modules/search.ts +++ b/web_src/js/modules/search.ts @@ -1,4 +1,4 @@ -import {debounce} from 'throttle-debounce'; +import {debounce} from '../utils/func.ts'; import {GET} from './fetch.ts'; import {errorName} from './errors.ts'; import {html, htmlRaw} from '../utils/html.ts'; @@ -69,7 +69,7 @@ export function attachSearchBox(container: HTMLElement, url: string hide(); }; - const search = debounce(200, async (query: string) => { + const search = debounce(async (query: string) => { fetchController?.abort(); if (query.length < minCharacters) return hide(); const ctrl = (fetchController = new AbortController()); @@ -82,7 +82,7 @@ export function attachSearchBox(container: HTMLElement, url: string } catch (err) { if (errorName(err) !== 'AbortError') hide(); } - }); + }, 200); // cancel + hide ensures a debounced fetch scheduled before any of these can't fire afterwards const dismiss = () => { search.cancel(); hide() }; diff --git a/web_src/js/types.ts b/web_src/js/types.ts index 1453495f790..1c4be742025 100644 --- a/web_src/js/types.ts +++ b/web_src/js/types.ts @@ -1,3 +1,4 @@ +export type TimeoutId = ReturnType; export type IntervalId = ReturnType; export type Intent = 'error' | 'warning' | 'info'; diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index aa17298c9b7..3fa865645db 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -1,4 +1,4 @@ -import {debounce} from 'throttle-debounce'; +import {debounce} from './func.ts'; import type {Promisable} from '../types.ts'; import type $ from 'jquery'; import {isInFrontendUnitTest} from './testhelper.ts'; @@ -242,7 +242,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = } export function onInputDebounce(fn: () => Promisable) { - return debounce(300, fn); + return debounce(fn, 300); } type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement; diff --git a/web_src/js/utils/func.test.ts b/web_src/js/utils/func.test.ts new file mode 100644 index 00000000000..ce70558dba1 --- /dev/null +++ b/web_src/js/utils/func.test.ts @@ -0,0 +1,74 @@ +import {debounce, throttle} from './func.ts'; + +test('debounce', {concurrent: false}, () => { + vi.useFakeTimers(); + const spy = vi.fn(); + const fn = debounce(spy, 10); + fn(); + fn(); + fn(); + expect(spy).toHaveBeenCalledTimes(0); + vi.advanceTimersByTime(30); + expect(spy).toHaveBeenCalledTimes(1); + vi.useRealTimers(); +}); + +test('debounce leading', {concurrent: false}, () => { + vi.useFakeTimers(); + const spy = vi.fn(); + const fn = debounce(spy, 10, {leading: true, trailing: false}); + fn(); + expect(spy).toHaveBeenCalledTimes(1); + fn(); + vi.advanceTimersByTime(30); + expect(spy).toHaveBeenCalledTimes(1); + vi.useRealTimers(); +}); + +test('debounce result', {concurrent: false}, async () => { + vi.useFakeTimers(); + const fn = debounce((value: number) => value * 2, 10); + const first = fn(1); + const second = fn(2); + vi.advanceTimersByTime(10); + expect(await first).toEqual(4); // both calls collapse into the last one + expect(await second).toEqual(4); + vi.useRealTimers(); +}); + +test('debounce cancel', {concurrent: false}, () => { + vi.useFakeTimers(); + const spy = vi.fn(); + const fn = debounce(spy, 10); + fn(); + fn.cancel(); + vi.advanceTimersByTime(30); + expect(spy).toHaveBeenCalledTimes(0); + vi.useRealTimers(); +}); + +test('throttle', {concurrent: false}, () => { + vi.useFakeTimers(); + const spy = vi.fn(); + const fn = throttle(spy, 10); + fn(); + fn(); + fn(); + expect(spy).toHaveBeenCalledTimes(1); // leading + vi.advanceTimersByTime(30); + expect(spy).toHaveBeenCalledTimes(2); // plus one trailing for the collapsed rest + vi.useRealTimers(); +}); + +test('throttle trailing only', {concurrent: false}, () => { + vi.useFakeTimers(); + const spy = vi.fn(); + const fn = throttle(spy, 10, {leading: false}); + fn(); + fn(); + fn(); + expect(spy).toHaveBeenCalledTimes(0); + vi.advanceTimersByTime(30); + expect(spy).toHaveBeenCalledTimes(1); + vi.useRealTimers(); +}); diff --git a/web_src/js/utils/func.ts b/web_src/js/utils/func.ts new file mode 100644 index 00000000000..df045379f9e --- /dev/null +++ b/web_src/js/utils/func.ts @@ -0,0 +1,82 @@ +import type {TimeoutId} from '../types.ts'; + +/** Options for `debounce` */ +export type DebounceOpts = { + /** Invoke on the leading edge of the wait period. Default: `false` */ + leading?: boolean, + /** Invoke on the trailing edge of the wait period. Default: `true` */ + trailing?: boolean, +}; + +/** Options for `throttle` */ +export type ThrottleOpts = { + /** Invoke on the leading edge of the interval. Default: `true` */ + leading?: boolean, + /** Invoke on the trailing edge of the interval. Default: `true` */ + trailing?: boolean, +}; + +/** A debounced or throttled function. Calls collapsed into one invocation settle with its result, dropped calls never settle. */ +export type TimedFunction) => any> = ((...args: Parameters) => Promise>>) & { + /** Drop the pending invocation */ + cancel: () => void, +}; + +function createTimed) => any>(func: T, wait: number, leading: boolean, trailing: boolean, isThrottle: boolean): TimedFunction { + let timer: TimeoutId | null = null; + let pendingArgs: Parameters | null = null; + let resolvers: Array<{resolve: (value: any) => void, reject: (reason: any) => void}> = []; + + const invoke = async (args: Parameters): Promise => { + const settling = resolvers; + resolvers = []; + try { + const value = await func(...args); + for (const {resolve} of settling) resolve(value); + } catch (err) { + for (const {reject} of settling) reject(err); + } + }; + + const onTimer = (): void => { + timer = null; + const args = pendingArgs; + pendingArgs = null; + if (trailing && args) { + invoke(args); + if (isThrottle) timer = setTimeout(onTimer, wait); // keep the window open so a burst stays rate-limited + } else { + resolvers = []; + } + }; + + const cancel = (): void => { + if (timer) clearTimeout(timer); + timer = null; + pendingArgs = null; + resolvers = []; + }; + + const wrapper = (...args: Parameters): Promise>> => { + const promise = new Promise>>((resolve, reject) => { + resolvers.push({resolve, reject}); + }); + const isLeadingCall = leading && timer === null; + if (!isThrottle && timer) { clearTimeout(timer); timer = null } // debounce restarts the wait on every call, throttle does not + if (isLeadingCall) invoke(args); else pendingArgs = args; + if (timer === null) timer = setTimeout(onTimer, wait); + return promise; + }; + + return Object.assign(wrapper, {cancel}); +} + +/** Debounce a function, delaying invocation until `wait` milliseconds have passed without another call */ +export function debounce) => any>(func: T, wait: number, {leading = false, trailing = true}: DebounceOpts = {}): TimedFunction { + return createTimed(func, wait, leading, trailing, false); +} + +/** Throttle a function to invoke at most once per `interval` milliseconds */ +export function throttle) => any>(func: T, interval: number, {leading = true, trailing = true}: ThrottleOpts = {}): TimedFunction { + return createTimed(func, interval, leading, trailing, true); +} diff --git a/web_src/js/webcomponents/overflow-menu.ts b/web_src/js/webcomponents/overflow-menu.ts index 51409a3324f..e0cd3a1ef02 100644 --- a/web_src/js/webcomponents/overflow-menu.ts +++ b/web_src/js/webcomponents/overflow-menu.ts @@ -1,4 +1,4 @@ -import {throttle} from 'throttle-debounce'; +import {throttle} from '../utils/func.ts'; import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; @@ -37,7 +37,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } }; - updateItems = throttle(100, () => { + updateItems = throttle(() => { if (!this.popup) { const div = document.createElement('div'); div.classList.add('overflow-menu-popup'); @@ -183,7 +183,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { this.append(this.button); this.append(this.popup); this.updateButtonActivationState(); - }); + }, 100); init() { // for horizontal menus where fomantic boldens active items, prevent this bold text from