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.
This commit is contained in:
silverwind
2026-07-24 18:39:15 +02:00
committed by GitHub
parent 1e42317b66
commit 66794e0549
12 changed files with 173 additions and 44 deletions
+3 -3
View File
@@ -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();
+2 -2
View File
@@ -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);
});
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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<typeof setTimeout>;
let timer: TimeoutId;
const cancel = () => {
clearTimeout(timer);
link.removeAttribute('data-ref-issue-popup');
+3 -3
View File
@@ -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<T = unknown>(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<T = unknown>(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() };
+1
View File
@@ -1,3 +1,4 @@
export type TimeoutId = ReturnType<typeof setTimeout>;
export type IntervalId = ReturnType<typeof setInterval>;
export type Intent = 'error' | 'warning' | 'info';
+2 -2
View File
@@ -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<any>) {
return debounce(300, fn);
return debounce(fn, 300);
}
type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement;
+74
View File
@@ -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();
});
+82
View File
@@ -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<T extends (...args: Array<any>) => any> = ((...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>) & {
/** Drop the pending invocation */
cancel: () => void,
};
function createTimed<T extends (...args: Array<any>) => any>(func: T, wait: number, leading: boolean, trailing: boolean, isThrottle: boolean): TimedFunction<T> {
let timer: TimeoutId | null = null;
let pendingArgs: Parameters<T> | null = null;
let resolvers: Array<{resolve: (value: any) => void, reject: (reason: any) => void}> = [];
const invoke = async (args: Parameters<T>): Promise<void> => {
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<T>): Promise<Awaited<ReturnType<T>>> => {
const promise = new Promise<Awaited<ReturnType<T>>>((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<T extends (...args: Array<any>) => any>(func: T, wait: number, {leading = false, trailing = true}: DebounceOpts = {}): TimedFunction<T> {
return createTimed(func, wait, leading, trailing, false);
}
/** Throttle a function to invoke at most once per `interval` milliseconds */
export function throttle<T extends (...args: Array<any>) => any>(func: T, interval: number, {leading = true, trailing = true}: ThrottleOpts = {}): TimedFunction<T> {
return createTimed(func, interval, leading, trailing, true);
}
+3 -3
View File
@@ -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