diff --git a/packages/hooks/src/createRateLimitHooks/createRateLimitEffect.ts b/packages/hooks/src/createRateLimitHooks/createRateLimitEffect.ts new file mode 100644 index 0000000000..aea627540c --- /dev/null +++ b/packages/hooks/src/createRateLimitHooks/createRateLimitEffect.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +import type { DependencyList, EffectCallback } from 'react'; +import useUpdateEffect from '../useUpdateEffect'; + +type noop = (...args: any[]) => any; + +export function createRateLimitEffect( + useRateLimitFn: ( + fn: T, + options?: Options, + ) => { + run: T; + cancel: () => void; + flush: () => void; + }, +) { + return function useRateLimitEffect( + effect: EffectCallback, + deps?: DependencyList, + options?: Options, + ) { + const [flag, setFlag] = useState({}); + + // Note: The type assertion is safe here because we're creating a zero-argument + // callback that matches the noop signature. The callback will be called without + // arguments by the rate-limiting logic. + const { run } = useRateLimitFn( + (() => { + setFlag({}); + }) as T, + options, + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: deps is intentionally passed through from the user + useEffect(() => { + return run(); + }, deps); + + useUpdateEffect(effect, [flag]); + }; +} diff --git a/packages/hooks/src/createRateLimitHooks/createRateLimitFn.ts b/packages/hooks/src/createRateLimitHooks/createRateLimitFn.ts new file mode 100644 index 0000000000..ea56ff3d18 --- /dev/null +++ b/packages/hooks/src/createRateLimitHooks/createRateLimitFn.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import useLatest from '../useLatest'; +import useUnmount from '../useUnmount'; +import { isFunction } from '../utils'; +import isDev from '../utils/isDev'; + +type noop = (...args: any[]) => any; + +export interface RateLimitFunction { + (...args: Parameters): ReturnType; + cancel: () => void; + flush: () => void; +} + +// Base constraint for rate limit options to ensure they have a 'wait' property +interface BaseRateLimitOptions { + wait?: number; +} + +export function createRateLimitFn< + T extends noop, + Options extends BaseRateLimitOptions = BaseRateLimitOptions, +>( + rateLimitFn: ( + func: (...args: Parameters) => ReturnType, + wait: number, + options?: Options, + ) => RateLimitFunction, + hookName: string, +) { + return function useRateLimitFn(fn: T, options?: Options) { + if (isDev) { + if (!isFunction(fn)) { + console.error(`${hookName} expected parameter is a function, got ${typeof fn}`); + } + } + + const fnRef = useLatest(fn); + + const wait = options?.wait ?? 1000; + + // Note: We intentionally use an empty dependency array here. + // The rateLimitFn is created once and captures the latest fn via fnRef.current + // eslint-disable-next-line react-hooks/exhaustive-deps + const rateLimited = useMemo( + () => + rateLimitFn( + (...args: Parameters): ReturnType => { + return fnRef.current(...args); + }, + wait, + options, + ), + // biome-ignore lint/correctness/useExhaustiveDependencies: rateLimitFn is stable, fnRef updates are captured via .current + [], + ); + + useUnmount(() => { + rateLimited.cancel(); + }); + + return { + run: rateLimited, + cancel: rateLimited.cancel, + flush: rateLimited.flush, + }; + }; +} diff --git a/packages/hooks/src/createRateLimitHooks/createRateLimitValue.ts b/packages/hooks/src/createRateLimitHooks/createRateLimitValue.ts new file mode 100644 index 0000000000..6407ec4314 --- /dev/null +++ b/packages/hooks/src/createRateLimitHooks/createRateLimitValue.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +type noop = (...args: any[]) => any; + +export function createRateLimitValue( + useRateLimitFn: ( + fn: T, + options?: Options, + ) => { + run: T; + cancel: () => void; + flush: () => void; + }, +) { + return function useRateLimitValue(value: V, options?: Options) { + const [rateLimited, setRateLimited] = useState(value); + + // Note: The type assertion is safe here because we're creating a zero-argument + // callback that matches the noop signature. The callback will be called without + // arguments by the rate-limiting logic. + const { run } = useRateLimitFn( + (() => { + setRateLimited(value); + }) as T, + options, + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: run is stable, we only want to trigger on value changes + useEffect(() => { + run(); + }, [value]); + + return rateLimited; + }; +} diff --git a/packages/hooks/src/createRateLimitHooks/index.ts b/packages/hooks/src/createRateLimitHooks/index.ts new file mode 100644 index 0000000000..1cf080d452 --- /dev/null +++ b/packages/hooks/src/createRateLimitHooks/index.ts @@ -0,0 +1,4 @@ +export { createRateLimitFn } from './createRateLimitFn'; +export type { RateLimitFunction } from './createRateLimitFn'; +export { createRateLimitValue } from './createRateLimitValue'; +export { createRateLimitEffect } from './createRateLimitEffect'; diff --git a/packages/hooks/src/useDebounce/index.ts b/packages/hooks/src/useDebounce/index.ts index baff02e742..b83c2dca7d 100644 --- a/packages/hooks/src/useDebounce/index.ts +++ b/packages/hooks/src/useDebounce/index.ts @@ -1,19 +1,8 @@ -import { useEffect, useState } from 'react'; -import useDebounceFn from '../useDebounceFn'; import type { DebounceOptions } from './debounceOptions'; +import useDebounceFn from '../useDebounceFn'; +import { createRateLimitValue } from '../createRateLimitHooks'; -function useDebounce(value: T, options?: DebounceOptions) { - const [debounced, setDebounced] = useState(value); - - const { run } = useDebounceFn(() => { - setDebounced(value); - }, options); - - useEffect(() => { - run(); - }, [value]); - - return debounced; -} +const useDebounce = createRateLimitValue(useDebounceFn); export default useDebounce; +export type { DebounceOptions }; diff --git a/packages/hooks/src/useDebounceEffect/index.ts b/packages/hooks/src/useDebounceEffect/index.ts index 2866d91d98..a21976df84 100644 --- a/packages/hooks/src/useDebounceEffect/index.ts +++ b/packages/hooks/src/useDebounceEffect/index.ts @@ -1,25 +1,8 @@ -import { useEffect, useState } from 'react'; -import type { DependencyList, EffectCallback } from 'react'; import type { DebounceOptions } from '../useDebounce/debounceOptions'; import useDebounceFn from '../useDebounceFn'; -import useUpdateEffect from '../useUpdateEffect'; +import { createRateLimitEffect } from '../createRateLimitHooks'; -function useDebounceEffect( - effect: EffectCallback, - deps?: DependencyList, - options?: DebounceOptions, -) { - const [flag, setFlag] = useState({}); - - const { run } = useDebounceFn(() => { - setFlag({}); - }, options); - - useEffect(() => { - return run(); - }, deps); - - useUpdateEffect(effect, [flag]); -} +const useDebounceEffect = createRateLimitEffect(useDebounceFn); export default useDebounceEffect; +export type { DebounceOptions }; diff --git a/packages/hooks/src/useDebounceFn/index.ts b/packages/hooks/src/useDebounceFn/index.ts index 24910f4b37..9b3a02e219 100644 --- a/packages/hooks/src/useDebounceFn/index.ts +++ b/packages/hooks/src/useDebounceFn/index.ts @@ -1,45 +1,10 @@ import { debounce } from '../utils/lodash-polyfill'; -import { useMemo } from 'react'; import type { DebounceOptions } from '../useDebounce/debounceOptions'; -import useLatest from '../useLatest'; -import useUnmount from '../useUnmount'; -import { isFunction } from '../utils'; -import isDev from '../utils/isDev'; +import { createRateLimitFn } from '../createRateLimitHooks'; type noop = (...args: any[]) => any; -function useDebounceFn(fn: T, options?: DebounceOptions) { - if (isDev) { - if (!isFunction(fn)) { - console.error(`useDebounceFn expected parameter is a function, got ${typeof fn}`); - } - } - - const fnRef = useLatest(fn); - - const wait = options?.wait ?? 1000; - - const debounced = useMemo( - () => - debounce( - (...args: Parameters): ReturnType => { - return fnRef.current(...args); - }, - wait, - options, - ), - [], - ); - - useUnmount(() => { - debounced.cancel(); - }); - - return { - run: debounced, - cancel: debounced.cancel, - flush: debounced.flush, - }; -} +const useDebounceFn = createRateLimitFn(debounce, 'useDebounceFn'); export default useDebounceFn; +export type { DebounceOptions }; diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts index 717fe7b8cb..cbbb3903dc 100644 --- a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts +++ b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts @@ -41,10 +41,12 @@ describe('useInfiniteScroll', () => { beforeAll(() => { vi.useFakeTimers(); // Mock requestAnimationFrame to execute callbacks immediately - mockRaf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - cb(0); - return 0; - }) as ReturnType; + mockRaf = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as ReturnType; }); afterAll(() => { diff --git a/packages/hooks/src/useThrottle/index.ts b/packages/hooks/src/useThrottle/index.ts index 07d960d20d..2efdcb6931 100644 --- a/packages/hooks/src/useThrottle/index.ts +++ b/packages/hooks/src/useThrottle/index.ts @@ -1,19 +1,8 @@ -import { useEffect, useState } from 'react'; -import useThrottleFn from '../useThrottleFn'; import type { ThrottleOptions } from './throttleOptions'; +import useThrottleFn from '../useThrottleFn'; +import { createRateLimitValue } from '../createRateLimitHooks'; -function useThrottle(value: T, options?: ThrottleOptions) { - const [throttled, setThrottled] = useState(value); - - const { run } = useThrottleFn(() => { - setThrottled(value); - }, options); - - useEffect(() => { - run(); - }, [value]); - - return throttled; -} +const useThrottle = createRateLimitValue(useThrottleFn); export default useThrottle; +export type { ThrottleOptions }; diff --git a/packages/hooks/src/useThrottleEffect/index.ts b/packages/hooks/src/useThrottleEffect/index.ts index e0e52ba67d..3b59aea126 100644 --- a/packages/hooks/src/useThrottleEffect/index.ts +++ b/packages/hooks/src/useThrottleEffect/index.ts @@ -1,25 +1,8 @@ -import { useEffect, useState } from 'react'; -import type { DependencyList, EffectCallback } from 'react'; import type { ThrottleOptions } from '../useThrottle/throttleOptions'; import useThrottleFn from '../useThrottleFn'; -import useUpdateEffect from '../useUpdateEffect'; +import { createRateLimitEffect } from '../createRateLimitHooks'; -function useThrottleEffect( - effect: EffectCallback, - deps?: DependencyList, - options?: ThrottleOptions, -) { - const [flag, setFlag] = useState({}); - - const { run } = useThrottleFn(() => { - setFlag({}); - }, options); - - useEffect(() => { - return run(); - }, deps); - - useUpdateEffect(effect, [flag]); -} +const useThrottleEffect = createRateLimitEffect(useThrottleFn); export default useThrottleEffect; +export type { ThrottleOptions }; diff --git a/packages/hooks/src/useThrottleFn/index.ts b/packages/hooks/src/useThrottleFn/index.ts index 4575b32cf8..2156b92a64 100644 --- a/packages/hooks/src/useThrottleFn/index.ts +++ b/packages/hooks/src/useThrottleFn/index.ts @@ -1,45 +1,10 @@ import throttle from 'lodash/throttle'; -import { useMemo } from 'react'; -import useLatest from '../useLatest'; import type { ThrottleOptions } from '../useThrottle/throttleOptions'; -import useUnmount from '../useUnmount'; -import { isFunction } from '../utils'; -import isDev from '../utils/isDev'; +import { createRateLimitFn } from '../createRateLimitHooks'; type noop = (...args: any[]) => any; -function useThrottleFn(fn: T, options?: ThrottleOptions) { - if (isDev) { - if (!isFunction(fn)) { - console.error(`useThrottleFn expected parameter is a function, got ${typeof fn}`); - } - } - - const fnRef = useLatest(fn); - - const wait = options?.wait ?? 1000; - - const throttled = useMemo( - () => - throttle( - (...args: Parameters): ReturnType => { - return fnRef.current(...args); - }, - wait, - options, - ), - [], - ); - - useUnmount(() => { - throttled.cancel(); - }); - - return { - run: throttled, - cancel: throttled.cancel, - flush: throttled.flush, - }; -} +const useThrottleFn = createRateLimitFn(throttle, 'useThrottleFn'); export default useThrottleFn; +export type { ThrottleOptions };