Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/hooks/src/createRateLimitHooks/createRateLimitEffect.ts
Original file line number Diff line number Diff line change
@@ -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<T extends noop, Options = any>(
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]);
};
}
68 changes: 68 additions & 0 deletions packages/hooks/src/createRateLimitHooks/createRateLimitFn.ts
Original file line number Diff line number Diff line change
@@ -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<T extends noop> {
(...args: Parameters<T>): ReturnType<T>;
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<T>) => ReturnType<T>,
wait: number,
options?: Options,
) => RateLimitFunction<T>,
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<T>): ReturnType<T> => {
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,
};
};
}
35 changes: 35 additions & 0 deletions packages/hooks/src/createRateLimitHooks/createRateLimitValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';

type noop = (...args: any[]) => any;

export function createRateLimitValue<T extends noop, Options = any>(
useRateLimitFn: (
fn: T,
options?: Options,
) => {
run: T;
cancel: () => void;
flush: () => void;
},
) {
return function useRateLimitValue<V>(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;
};
}
4 changes: 4 additions & 0 deletions packages/hooks/src/createRateLimitHooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { createRateLimitFn } from './createRateLimitFn';
export type { RateLimitFunction } from './createRateLimitFn';
export { createRateLimitValue } from './createRateLimitValue';
export { createRateLimitEffect } from './createRateLimitEffect';
19 changes: 4 additions & 15 deletions packages/hooks/src/useDebounce/index.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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 };
23 changes: 3 additions & 20 deletions packages/hooks/src/useDebounceEffect/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
41 changes: 3 additions & 38 deletions packages/hooks/src/useDebounceFn/index.ts
Original file line number Diff line number Diff line change
@@ -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<T extends noop>(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<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);

useUnmount(() => {
debounced.cancel();
});

return {
run: debounced,
cancel: debounced.cancel,
flush: debounced.flush,
};
}
const useDebounceFn = createRateLimitFn<noop, DebounceOptions>(debounce, 'useDebounceFn');

export default useDebounceFn;
export type { DebounceOptions };
10 changes: 6 additions & 4 deletions packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>;
mockRaf = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as ReturnType<typeof vi.spyOn>;
});

afterAll(() => {
Expand Down
19 changes: 4 additions & 15 deletions packages/hooks/src/useThrottle/index.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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 };
23 changes: 3 additions & 20 deletions packages/hooks/src/useThrottleEffect/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
41 changes: 3 additions & 38 deletions packages/hooks/src/useThrottleFn/index.ts
Original file line number Diff line number Diff line change
@@ -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<T extends noop>(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<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);

useUnmount(() => {
throttled.cancel();
});

return {
run: throttled,
cancel: throttled.cancel,
flush: throttled.flush,
};
}
const useThrottleFn = createRateLimitFn<noop, ThrottleOptions>(throttle, 'useThrottleFn');

export default useThrottleFn;
export type { ThrottleOptions };
Loading