refactor(editor): Move useDebounce and durations into @n8n/composables (no-changelog) (#34143)

Co-authored-by: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Alex Grozav
2026-07-15 12:34:40 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d6dcf3b749
commit 16b5ee2ff1
7 changed files with 135 additions and 111 deletions
@@ -29,6 +29,7 @@
},
"dependencies": {
"@vueuse/core": "catalog:frontend",
"lodash": "catalog:",
"vue": "catalog:frontend"
},
"devDependencies": {
@@ -39,6 +40,7 @@
"@testing-library/vue": "catalog:frontend",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"@types/lodash": "catalog:",
"@vitejs/plugin-vue": "catalog:frontend",
"@vue/tsconfig": "catalog:frontend",
"tsdown": "catalog:",
@@ -1,6 +1,7 @@
import { vi, describe, it, expect } from 'vitest';
import { useDebounce } from './useDebounce';
import { render, screen } from '@testing-library/vue';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { getDebounceTime, useDebounce } from './useDebounce';
describe('useDebounce()', () => {
const debounceTime = 500;
@@ -32,7 +33,7 @@ describe('useDebounce()', () => {
},
};
it('debounces a function call', async () => {
it('debounces a function call', () => {
const mockFn = vi.fn();
render(TestComponent, { props: { mockFn } });
const button = screen.getByText('Click me');
@@ -43,7 +44,7 @@ describe('useDebounce()', () => {
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('supports trailing option', async () => {
it('supports trailing option', () => {
const mockFn = vi.fn();
render(TestComponent, { props: { mockFn } });
const button = screen.getByText('Click me trailing');
@@ -58,7 +59,7 @@ describe('useDebounce()', () => {
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('works with async functions', async () => {
it('works with async functions', () => {
const mockAsyncFn = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
@@ -71,3 +72,41 @@ describe('useDebounce()', () => {
expect(mockAsyncFn).toHaveBeenCalledTimes(1);
});
});
describe('getDebounceTime()', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('returns original time when no multiplier is set', () => {
expect(getDebounceTime(100)).toBe(100);
expect(getDebounceTime(1500)).toBe(1500);
});
it('applies multiplier from sessionStorage', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0.5');
expect(getDebounceTime(100)).toBe(50);
expect(getDebounceTime(1500)).toBe(750);
});
it('returns 0 when multiplier is 0', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0');
expect(getDebounceTime(100)).toBe(0);
expect(getDebounceTime(1500)).toBe(0);
});
it('handles multiplier greater than 1', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '2');
expect(getDebounceTime(100)).toBe(200);
});
it('defaults to 1 for invalid multiplier values', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', 'invalid');
expect(getDebounceTime(100)).toBe(100);
});
it('rounds to nearest integer', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0.33');
expect(getDebounceTime(100)).toBe(33);
});
});
@@ -0,0 +1,68 @@
import lodashDebounce from 'lodash/debounce';
import { ref } from 'vue';
/**
* Get debounce time with optional multiplier from sessionStorage.
* Reads 'N8N_DEBOUNCE_MULTIPLIER' - defaults to 1 if not set.
*/
export function getDebounceTime(time: number): number {
const stored = sessionStorage.getItem('N8N_DEBOUNCE_MULTIPLIER');
const multiplier = stored !== null ? parseFloat(stored) : 1;
return Math.round(time * (Number.isNaN(multiplier) ? 1 : multiplier));
}
export interface DebounceOptions {
debounceTime: number;
trailing?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFunction = (...args: any) => any;
type DebouncedFunc<T extends AnyFunction> = ReturnType<typeof lodashDebounce<T>>;
export function useDebounce() {
// Create a ref for the WeakMap to store debounced functions.
const debounceCache = ref(new WeakMap<AnyFunction, DebouncedFunc<AnyFunction>>());
const debounce = <T extends AnyFunction>(fn: T, options: DebounceOptions): DebouncedFunc<T> => {
const { trailing, debounceTime } = options;
const effectiveDebounceTime = getDebounceTime(debounceTime);
// Check if a debounced version of the function is already stored in the WeakMap.
let debouncedFn = debounceCache.value.get(fn);
// If a debounced version is not found, create one and store it in the WeakMap.
if (debouncedFn === undefined) {
debouncedFn = lodashDebounce(
// eslint-disable-next-line @typescript-eslint/require-await -- kept async so the debounced wrapper always returns a promise
async (...args: Parameters<T>) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- AnyFunction return is intentionally `any`
return fn(...args);
},
effectiveDebounceTime,
trailing ? { trailing } : { leading: true },
);
debounceCache.value.set(fn, debouncedFn);
}
return debouncedFn;
};
const callDebounced = <T extends AnyFunction>(
fn: T,
options: DebounceOptions,
...inputParameters: Parameters<T>
): ReturnType<T> | undefined => {
const debouncedFn = debounce(fn, options);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- AnyFunction return is intentionally `any`
return debouncedFn(...inputParameters);
};
return {
debounce,
callDebounced,
};
}
@@ -1,57 +1,8 @@
import { ref } from 'vue';
import _debounce from 'lodash/debounce';
import { getDebounceTime } from '@/app/constants/durations';
export interface DebounceOptions {
debounceTime: number;
trailing?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFunction = (...args: any) => any;
type DebouncedFunc<T extends AnyFunction> = ReturnType<typeof _debounce<T>>;
export function useDebounce() {
// Create a ref for the WeakMap to store debounced functions.
const debounceCache = ref(new WeakMap<AnyFunction, DebouncedFunc<AnyFunction>>());
const debounce = <T extends AnyFunction>(fn: T, options: DebounceOptions): DebouncedFunc<T> => {
const { trailing, debounceTime } = options;
const effectiveDebounceTime = getDebounceTime(debounceTime);
// Check if a debounced version of the function is already stored in the WeakMap.
let debouncedFn = debounceCache.value.get(fn);
// If a debounced version is not found, create one and store it in the WeakMap.
if (debouncedFn === undefined) {
debouncedFn = _debounce(
async (...args: Parameters<T>) => {
return fn(...args);
},
effectiveDebounceTime,
trailing ? { trailing } : { leading: true },
);
debounceCache.value.set(fn, debouncedFn);
}
return debouncedFn;
};
const callDebounced = <T extends AnyFunction>(
fn: T,
options: DebounceOptions,
...inputParameters: Parameters<T>
): ReturnType<T> | undefined => {
const debouncedFn = debounce(fn, options);
return debouncedFn(...inputParameters);
};
return {
debounce,
callDebounced,
};
}
/**
* @deprecated Import from `@n8n/composables/useDebounce` instead.
*
* Temporary re-export shim for the frontend modularization effort (CAT-3686).
* `useDebounce` now lives in `@n8n/composables`; call sites are migrated off
* this path per-directory before it is removed.
*/
export * from '@n8n/composables/useDebounce';
@@ -1,39 +0,0 @@
import { getDebounceTime } from './durations';
describe('getDebounceTime', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('returns original time when no multiplier is set', () => {
expect(getDebounceTime(100)).toBe(100);
expect(getDebounceTime(1500)).toBe(1500);
});
it('applies multiplier from sessionStorage', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0.5');
expect(getDebounceTime(100)).toBe(50);
expect(getDebounceTime(1500)).toBe(750);
});
it('returns 0 when multiplier is 0', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0');
expect(getDebounceTime(100)).toBe(0);
expect(getDebounceTime(1500)).toBe(0);
});
it('handles multiplier greater than 1', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '2');
expect(getDebounceTime(100)).toBe(200);
});
it('defaults to 1 for invalid multiplier values', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', 'invalid');
expect(getDebounceTime(100)).toBe(100);
});
it('rounds to nearest integer', () => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0.33');
expect(getDebounceTime(100)).toBe(33);
});
});
@@ -26,16 +26,6 @@ export const SIX_MONTHS_IN_MILLIS = 6 * 30 * TIME.DAY;
export const LOADING_ANIMATION_MIN_DURATION = 1000;
/**
* Get debounce time with optional multiplier from sessionStorage.
* Reads 'N8N_DEBOUNCE_MULTIPLIER' - defaults to 1 if not set.
*/
export function getDebounceTime(time: number): number {
const stored = sessionStorage.getItem('N8N_DEBOUNCE_MULTIPLIER');
const multiplier = stored !== null ? parseFloat(stored) : 1;
return Math.round(time * (Number.isNaN(multiplier) ? 1 : multiplier));
}
/** Centralized debounce timing constants. Use with getDebounceTime(). */
export const DEBOUNCE_TIME = {
/** UI responsiveness - very fast feedback */
@@ -88,3 +78,10 @@ export const DEBOUNCE_TIME = {
WEBSOCKET_DISCONNECT: 500,
},
} as const;
/**
* `getDebounceTime` lives in `@n8n/composables`, colocated with `useDebounce`
* (its only consumer). Re-exported here so existing `@/app/constants/durations`
* and `@/app/constants` call sites keep working unchanged.
*/
export { getDebounceTime } from '@n8n/composables/useDebounce';
+6
View File
@@ -4789,6 +4789,9 @@ importers:
'@vueuse/core':
specifier: catalog:frontend
version: 10.11.0([email protected]([email protected]))
lodash:
specifier: 4.18.1
version: 4.18.1
vue:
specifier: catalog:frontend
version: 3.5.26([email protected])
@@ -4814,6 +4817,9 @@ importers:
'@testing-library/vue':
specifier: catalog:frontend
version: 8.1.0(@vue/[email protected])([email protected]([email protected]))
'@types/lodash':
specifier: 'catalog:'
version: 4.17.17
'@vitejs/plugin-vue':
specifier: catalog:frontend
version: 5.2.4([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))