From d5d81bac24a9cdb6b1a7ae43b5eb87ff94cd9fbc Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Thu, 15 Oct 2026 22:37:54 +0900 Subject: [PATCH 1/2] refactor: separate internal debounce and throttle hooks Update callback, state, and value hooks to use internal implementations. Exclude internal hooks from public API JSDoc and export checks. --- .github/workflows/integration.yml | 13 + .../hooks/_internal/useDebounce/debounce.ts | 93 +++++++ .../_internal/useDebounce/useDebounce.spec.ts | 99 +++++++ .../_internal/useDebounce/useDebounce.ts | 60 +++++ .../_internal/useThrottle/throttle.spec.ts | 248 ++++++++++++++++++ .../hooks/_internal/useThrottle/throttle.ts | 80 ++++++ .../_internal/useThrottle/useThrottle.spec.ts | 155 +++++++++++ .../_internal/useThrottle/useThrottle.ts | 55 ++++ .../useDebouncedCallback.ts | 2 +- .../useDebouncedState/useDebouncedState.ts | 2 +- .../useDebouncedValue/useDebouncedValue.ts | 2 +- .../useThrottledCallback.ts | 2 +- .../useThrottledState/useThrottledState.ts | 2 +- .../useThrottledValue/useThrottledValue.ts | 2 +- 14 files changed, 809 insertions(+), 6 deletions(-) create mode 100644 packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts create mode 100644 packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts create mode 100644 packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts create mode 100644 packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts create mode 100644 packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts create mode 100644 packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts create mode 100644 packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 4fa60234..7caf5197 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,6 +10,9 @@ on: - main - v1-base +env: + INTERNAL_HOOKS_PATH: 'packages/*/src/hooks/_internal/*' + jobs: quality: runs-on: ubuntu-latest @@ -227,6 +230,9 @@ jobs: - name: Check for JSDoc comments run: | for file in ${{ needs.check-changes.outputs.changed_files }}; do + case "$file" in + $INTERNAL_HOOKS_PATH) continue ;; + esac dir_name=$(basename $(dirname "$file")) base_name=$(basename $dir_name) implementation="$(dirname "$file")/$base_name.ts" @@ -279,6 +285,9 @@ jobs: - name: Check JSDoc structure run: | for file in ${{ needs.check-changes.outputs.changed_files }}; do + case "$file" in + $INTERNAL_HOOKS_PATH) continue ;; + esac directory=$(basename "$(dirname "$file")") filename=$(basename $file) filename="${filename%%.*}" @@ -357,6 +366,10 @@ jobs: continue fi + case "$impl_dir" in + $INTERNAL_HOOKS_PATH) continue ;; + esac + impl_name=$(basename "$impl_dir") impl_index="$impl_dir/index.ts" impl_file="" diff --git a/packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts b/packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts new file mode 100644 index 00000000..a956065b --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts @@ -0,0 +1,93 @@ +// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic function constraint requires `any[]` due to parameter contravariance +export type DebouncedFunction void> = { + (...args: Parameters): void; + cancel: () => void; +}; + +type DebounceOptions = { + /** + * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * If `edges` includes "leading", the function will be invoked at the start of the delay period. + * If `edges` includes "trailing", the function will be invoked at the end of the delay period. + * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period. + * @default ["leading", "trailing"] + */ + edges?: Array<'leading' | 'trailing'>; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic function constraint requires `any[]` due to parameter contravariance +export function debounce void>( + func: F, + debounceMs: number, + { edges = ['leading', 'trailing'] }: DebounceOptions = {} +): DebouncedFunction & { schedule: () => void } { + let pendingThis: ThisParameterType | undefined = undefined; + let pendingArgs: Parameters | null = null; + + const leading = edges != null && edges.includes('leading'); + const trailing = edges == null || edges.includes('trailing'); + + const invoke = () => { + if (pendingArgs !== null) { + func.apply(pendingThis, pendingArgs); + pendingThis = undefined; + pendingArgs = null; + } + }; + + const onTimerEnd = () => { + if (trailing) { + invoke(); + } + + cancel(); + }; + + let timeoutId: ReturnType | null = null; + + const schedule = () => { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + timeoutId = null; + + onTimerEnd(); + }, debounceMs); + }; + + const cancelTimer = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const cancel = () => { + cancelTimer(); + pendingThis = undefined; + pendingArgs = null; + }; + + const debounced = function (this: ThisParameterType, ...args: Parameters) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + pendingThis = this; + pendingArgs = args; + + const isFirstCall = timeoutId == null; + + schedule(); + + if (leading && isFirstCall) { + invoke(); + } + }; + + debounced.schedule = schedule; + debounced.cancel = cancel; + + return debounced; +} diff --git a/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts new file mode 100644 index 00000000..7e33c365 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../../_internal/test-utils/renderHookSSR.tsx'; + +import { DebouncedFunction } from './debounce.ts'; +import { useDebounce } from './useDebounce.ts'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('is safe on server side rendering', () => { + const callback = vi.fn(); + renderHookSSR.serverOnly(() => useDebounce(callback, 100)); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should debounce the callback with default options', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100)); + + result.current(); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(50); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(50); + expect(callback).toBeCalledTimes(1); + }); + + it('should handle leading edge', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100, { leading: true })); + + result.current(); + expect(callback).toBeCalledTimes(1); + + result.current(); + vi.advanceTimersByTime(50); + expect(callback).toBeCalledTimes(1); + + vi.advanceTimersByTime(40); + expect(callback).toBeCalledTimes(1); + + vi.advanceTimersByTime(10); + expect(callback).toBeCalledTimes(2); + }); + + it('should handle trailing edge', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100, { trailing: true })); + + result.current(); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(50); + result.current(); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(100); + expect(callback).toBeCalledTimes(1); + }); + + it('should handle both leading and trailing edges', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100, { leading: true, trailing: true })); + + result.current(); + expect(callback).toBeCalledTimes(1); + + result.current(); + vi.advanceTimersByTime(150); + expect(callback).toBeCalledTimes(2); + }); + + it('should inference the callback type', async () => { + const callback = (value: string) => { + console.log('test::', value); + }; + + const { result } = renderHookSSR(() => useDebounce(callback, 100)); + + expectTypeOf(result.current).toEqualTypeOf>(); + }); + + it('should cleanup on unmount', async () => { + const callback = vi.fn(); + const { result, unmount } = await renderHookSSR(() => useDebounce(callback, 100)); + + result.current(); + unmount(); + vi.advanceTimersByTime(100); + + expect(callback).not.toBeCalled(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts new file mode 100644 index 00000000..7015270c --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect } from 'react'; +import { useMemo } from 'react'; + +import { usePreservedCallback } from '../../usePreservedCallback/usePreservedCallback.ts'; + +import { debounce, DebouncedFunction } from './debounce.ts'; + +type DebounceOptions = { + leading?: boolean; + trailing?: boolean; +}; + +/** + * An internal React hook shared by debounce-related hooks. + * It debounces the provided callback and manages execution timing and cancellation of pending calls. + * + * With the default options, the last call runs after `debounceMs` milliseconds without another call. + * Pending calls are cancelled when the component unmounts or when `debounceMs`, `leading`, + * or `trailing` changes. + * + * `.cancel()` only cancels a pending callback; it does not stop work that has already started. + */ +export function useDebounce unknown>( + callback: F, + debounceMs: number, + options: DebounceOptions = {} +): DebouncedFunction { + const preservedCallback = usePreservedCallback(callback) as F; + + const { leading = false, trailing = true } = options; + + const edges = useMemo(() => { + const _edges: Array<'leading' | 'trailing'> = []; + if (leading) { + _edges.push('leading'); + } + + if (trailing) { + _edges.push('trailing'); + } + + return _edges; + }, [leading, trailing]); + + const debounced = useMemo(() => { + return debounce(preservedCallback, debounceMs, { edges }); + }, [preservedCallback, debounceMs, edges]); + + useEffect( + function cancelDebouncedOnUnmount() { + return () => { + debounced.cancel(); + }; + }, + [debounced] + ); + + return debounced; +} diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts new file mode 100644 index 00000000..d844d563 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { throttle } from './throttle.ts'; + +describe('throttle', () => { + it('should throttle function calls', async () => { + const func = vi.fn(); + const throttledFunc = throttle(func, 100); + + throttledFunc(); + throttledFunc(); + throttledFunc(); + + expect(func).toHaveBeenCalledTimes(1); + }); + + it('should trigger a trailing call as soon as possible', async () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttleMs = 50; + + const throttled = throttle(func, throttleMs); + + throttled(); + throttled(); + expect(func).toBeCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + expect(func).toBeCalledTimes(2); + }); + + it('should execute the function immediately if not called within the wait time', async () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttleMs = 500; + const throttledFunc = throttle(func, throttleMs); + + throttledFunc(); // should be executed + expect(func).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs / 2); + expect(func).toHaveBeenCalledTimes(1); + + throttledFunc(); // should be ignored + expect(func).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs / 2 + 1); + expect(func).toHaveBeenCalledTimes(1); + + throttledFunc(); // should be executed + expect(func).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(throttleMs / 2 - 1); + expect(func).toHaveBeenCalledTimes(2); + + throttledFunc(); // should be ignored + expect(func).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(throttleMs / 2 + 1); + expect(func).toHaveBeenCalledTimes(2); + + throttledFunc(); // should be executed + expect(func).toHaveBeenCalledTimes(3); + }); + + it('should call the function with correct arguments', async () => { + const func = vi.fn(); + const throttleMs = 50; + const throttledFunc = throttle(func, throttleMs); + + throttledFunc('test', 123); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenCalledWith('test', 123); + }); + + it('should not trigger a trailing call when invoked once', async () => { + const func = vi.fn(); + const throttleMs = 50; + + const throttled = throttle(func, throttleMs); + + throttled(); + expect(func).toBeCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + expect(func).toBeCalledTimes(1); + }); + + it('should invoke once per window while calls keep coming with trailing edge only', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + for (let i = 1; i <= 10; i++) { + throttled(i); + vi.advanceTimersByTime(30); + } + expect(func.mock.calls.map(call => call[0])).toEqual([5, 9]); + + vi.advanceTimersByTime(100); + expect(func.mock.calls.map(call => call[0])).toEqual([5, 9, 10]); + + vi.useRealTimers(); + }); + + it('should defer a call after an idle period with trailing edge only', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + expect(func).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(400); + throttled('b'); + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(2); + expect(func).toHaveBeenLastCalledWith('b'); + + vi.useRealTimers(); + }); + + it('should invoke once per window while calls keep coming with leading edge only', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['leading'] }); + + for (let i = 1; i <= 10; i++) { + throttled(i); + vi.advanceTimersByTime(30); + } + vi.advanceTimersByTime(200); + + expect(func.mock.calls.map(call => call[0])).toEqual([1, 5, 9]); + + vi.useRealTimers(); + }); + + it('should forget the pending call on cancel', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + throttled.cancel(); + vi.advanceTimersByTime(500); + + throttled('b'); + expect(func).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenLastCalledWith('b'); + + vi.useRealTimers(); + }); + + it('should never invoke when edges is empty', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: [] }); + + throttled('a'); + vi.advanceTimersByTime(100); + throttled('b'); + vi.advanceTimersByTime(300); + + expect(func).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should keep deferring calls after an idle period until the reopened window elapses', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenLastCalledWith('a'); + + vi.advanceTimersByTime(200); + throttled('b'); + vi.advanceTimersByTime(10); + throttled('c'); + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(2); + expect(func).toHaveBeenLastCalledWith('c'); + + vi.useRealTimers(); + }); + + it('should use the actual invocation time, not the call time, as the start of the next window', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + vi.advanceTimersByTime(100); // the debounce timer fires here, invoking with 'a' + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(50); // 50ms after the invocation above + throttled('b'); + + vi.advanceTimersByTime(60); // 110ms after the invocation, so the window has elapsed + throttled('c'); + expect(func).toHaveBeenCalledTimes(2); + expect(func).toHaveBeenLastCalledWith('c'); + + vi.useRealTimers(); + }); + + it('should treat a call exactly throttleMs after the window start as elapsed', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + vi.advanceTimersByTime(50); + throttled('b'); + vi.advanceTimersByTime(50); + throttled('c'); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenLastCalledWith('c'); + + vi.useRealTimers(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts new file mode 100644 index 00000000..40315b16 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts + +import { debounce } from '../useDebounce/debounce.ts'; + +type ThrottleOptions = { + /** + * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * If `edges` includes "leading", the function will be invoked at the start of the delay period. + * If `edges` includes "trailing", the function will be invoked at the end of the delay period. + * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period. + * @default ["leading", "trailing"] + */ + edges?: Array<'leading' | 'trailing'>; +}; + +type ThrottledFunction void> = { + (...args: Parameters): void; + cancel: () => void; +}; + +export function throttle void>( + func: F, + throttleMs: number, + { edges = ['leading', 'trailing'] }: ThrottleOptions = {} +): ThrottledFunction { + const leading = edges.includes('leading'); + const trailing = edges.includes('trailing'); + + let pendingAt: number | null = null; + // `true` while a call is waiting on the debounce timer. A trailing-only throttle uses it to tell a + // continuous stream of calls (invoke at the window boundary) from a call after an idle period + // (open a fresh window and invoke at its end). es-toolkit invokes at the boundary in both cases, + // which makes a trailing-only throttle fire on the leading edge after it has been idle. + let isPending = false; + + const debounced = debounce( + function (this: ThisParameterType, ...args: Parameters) { + pendingAt = Date.now(); + isPending = false; + func.apply(this, args); + }, + throttleMs, + { edges } + ); + + const throttled = function (this: ThisParameterType, ...args: Parameters) { + if (pendingAt === null) { + pendingAt = Date.now(); + } + + const windowElapsed = Date.now() - pendingAt >= throttleMs; + + if (windowElapsed && (leading || (trailing && isPending))) { + pendingAt = Date.now(); + isPending = false; + func.apply(this, args); + + // Re-arm the timer without arguments so the next call in this window does not count as a + // leading call for the debounce. + debounced.cancel(); + debounced.schedule(); + return; + } + + if (windowElapsed) { + pendingAt = Date.now(); + } + + isPending = true; + debounced.apply(this, args); + }; + + throttled.cancel = () => { + isPending = false; + debounced.cancel(); + }; + + return throttled; +} diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts new file mode 100644 index 00000000..71f3a146 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../../_internal/test-utils/renderHookSSR.tsx'; + +import { useThrottle } from './useThrottle.ts'; + +describe('useThrottle', () => { + it('is safe on server side rendering', () => { + const callback = vi.fn(); + renderHookSSR.serverOnly(() => useThrottle(callback, 50)); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should call throttle cancel when unmount', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const { result, unmount } = await renderHookSSR(() => useThrottle(callback, 50)); + + const cancel = vi.spyOn(result.current, 'cancel'); + + unmount(); + + expect(cancel).toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('should throttle function calls', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useThrottle(callback, 100)); + + result.current(); + result.current(); + result.current(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger a trailing call after throttleMs time', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs)); + + result.current(); + result.current(); + expect(callback).toBeCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + expect(callback).toBeCalledTimes(2); + + vi.useRealTimers(); + }); + + it('should call the function with correct arguments', async () => { + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs)); + + result.current('test', 123); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('test', 123); + }); + + it('should execute immediately if not called within throttleMs time', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 500; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs)); + + result.current(); // should execute + expect(callback).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + + result.current(); // should execute again + expect(callback).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it('should execute on leading and trailing when called multiple times with leading and trailing', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { leading: true, trailing: true })); + + result.current(); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + + expect(callback).toHaveBeenCalledTimes(1); + + result.current(); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(2); + + result.current(); + result.current(); + + expect(callback).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(4); + + vi.useRealTimers(); + }); + + it('should skip the leading edge when leading is false', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { leading: false })); + + result.current(); + + expect(callback).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('should skip the trailing edge when trailing is false', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { trailing: false })); + + result.current(); + + expect(callback).toHaveBeenCalledTimes(1); + + result.current(); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts new file mode 100644 index 00000000..11cbb8b1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useMemo } from 'react'; + +import { usePreservedCallback } from '../../usePreservedCallback/usePreservedCallback.ts'; + +import { throttle } from './throttle.ts'; + +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + +/** + * An internal React hook shared by throttle-related hooks. + * It throttles the provided callback and manages execution timing and cancellation of pending calls. + * + * It limits how frequently consecutive calls execute based on the `throttleMs` interval. + * Pending calls are cancelled when the component unmounts or when `throttleMs`, `leading`, + * or `trailing` changes. + * + * `.cancel()` only cancels a pending callback; it does not stop work that has already started. + */ +export function useThrottle any>( + callback: F, + throttleMs: number, + { leading = true, trailing = true }: ThrottleOptions = {} +) { + const preservedCallback = usePreservedCallback(callback); + + const edges = useMemo(() => { + const _edges: Array<'leading' | 'trailing'> = []; + if (leading) { + _edges.push('leading'); + } + + if (trailing) { + _edges.push('trailing'); + } + + return _edges; + }, [leading, trailing]); + + const throttledCallback = useMemo( + () => throttle(preservedCallback, throttleMs, { edges }), + [preservedCallback, throttleMs, edges] + ); + + useEffect(() => { + return () => { + throttledCallback.cancel(); + }; + }, [throttledCallback]); + + return throttledCallback; +} diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts index 6122591f..017a8c4b 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts @@ -1,6 +1,6 @@ import { useRef } from 'react'; -import { useDebounce } from '../useDebounce/index.ts'; +import { useDebounce } from '../_internal/useDebounce/useDebounce.ts'; type DebounceOptions = { leading?: boolean; diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts index e397bd4f..2ad9c3d1 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react'; -import { useDebounce } from '../useDebounce/index.ts'; +import { useDebounce } from '../_internal/useDebounce/useDebounce.ts'; type DebounceOptions = { leading?: boolean; diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts b/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts index bc40e0fc..8d7c4fcd 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { useDebounce } from '../useDebounce/index.ts'; +import { useDebounce } from '../_internal/useDebounce/useDebounce.ts'; type DebounceOptions = { leading?: boolean; diff --git a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts index 9b8f8d40..fdbe8893 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts @@ -1,6 +1,6 @@ import { useRef } from 'react'; -import { useThrottle } from '../useThrottle/index.ts'; +import { useThrottle } from '../_internal/useThrottle/useThrottle.ts'; type ThrottleOptions = { leading?: boolean; diff --git a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts index 87d92f86..006e3457 100644 --- a/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react'; -import { useThrottle } from '../useThrottle/index.ts'; +import { useThrottle } from '../_internal/useThrottle/useThrottle.ts'; type ThrottleOptions = { leading?: boolean; diff --git a/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts b/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts index 3dded32e..621df53b 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts +++ b/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { useThrottle } from '../useThrottle/index.ts'; +import { useThrottle } from '../_internal/useThrottle/useThrottle.ts'; type ThrottleOptions = { leading?: boolean; From 65d5645de6e1bef292f85ae2fb4ff1b0d445899b Mon Sep 17 00:00:00 2001 From: Antoliny0919 Date: Thu, 15 Oct 2026 23:52:21 +0900 Subject: [PATCH 2/2] fix: avoid docs ci internal hooks --- .scripts/verifyDocsI18n.ts | 2 +- .vitepress/libs/legacyRedirects.mts | 10 ++++++---- .vitepress/shared.mts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.scripts/verifyDocsI18n.ts b/.scripts/verifyDocsI18n.ts index 7d5aeae1..f86a91f6 100644 --- a/.scripts/verifyDocsI18n.ts +++ b/.scripts/verifyDocsI18n.ts @@ -137,7 +137,7 @@ try { async directory => ( await fs.readdir(path.join(root, 'packages/react-simplikit/src', directory), { withFileTypes: true }) - ).filter(entry => entry.isDirectory()).length + ).filter(entry => entry.isDirectory() && entry.name !== '_internal').length ) ) ).reduce((total, count) => total + count, 0); diff --git a/.vitepress/libs/legacyRedirects.mts b/.vitepress/libs/legacyRedirects.mts index 436e042d..f3b2a4bc 100644 --- a/.vitepress/libs/legacyRedirects.mts +++ b/.vitepress/libs/legacyRedirects.mts @@ -61,10 +61,12 @@ export function collectLegacyRedirects(): RedirectPair[] { function collectReferenceRedirects(): RedirectPair[] { return ['hooks', 'components', 'utils'].flatMap(category => - listDirectories(path.join(packageSourceRoot, category)).map(name => ({ - from: `${RETIRED_MOBILE_PAGES.has(name) ? 'mobile' : 'core'}/${category}/${name}.html`, - to: `${category}/${name}.html`, - })) + listDirectories(path.join(packageSourceRoot, category)) + .filter(name => name !== '_internal') + .map(name => ({ + from: `${RETIRED_MOBILE_PAGES.has(name) ? 'mobile' : 'core'}/${category}/${name}.html`, + to: `${category}/${name}.html`, + })) ); } diff --git a/.vitepress/shared.mts b/.vitepress/shared.mts index e369dac6..12c4e6c5 100644 --- a/.vitepress/shared.mts +++ b/.vitepress/shared.mts @@ -10,6 +10,6 @@ export const docsRoot = path.join(projectRoot, 'docs'); export function listDirectories(directory: string): string[] { return fs .readdirSync(directory, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() && entry.name !== '_internal') .map(entry => entry.name); }