From 8f04f149224567b10c33255b0df343533dffd3c4 Mon Sep 17 00:00:00 2001 From: bbjbc Date: Thu, 13 Aug 2026 23:25:38 +0900 Subject: [PATCH] fix(useThrottledCallback): invoke the callback when the first value is false The hook compares an incoming value against the last one it forwarded to onChange so it can skip redundant invocations. That comparison was seeded with `false`, but the hook never receives an initial value from the caller, so a first call of `false` looked redundant and was dropped. Consumers whose state starts as `true` therefore lost the transition back to `false` entirely, with no way to tell the hook otherwise. Seed the comparison with a sentinel instead so the first call is always forwarded. --- .changeset/lucky-pugs-repeat.md | 7 ++++++ .../useThrottledCallback.spec.ts | 23 +++++++++++++++++++ .../useThrottledCallback.ts | 14 ++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-pugs-repeat.md diff --git a/.changeset/lucky-pugs-repeat.md b/.changeset/lucky-pugs-repeat.md new file mode 100644 index 00000000..2b55e62b --- /dev/null +++ b/.changeset/lucky-pugs-repeat.md @@ -0,0 +1,7 @@ +--- +'react-simplikit': patch +--- + +fix(useThrottledCallback): invoke the callback when the first value is `false` + +The hook skips redundant invocations by comparing the incoming value against the last one it forwarded, but that comparison was seeded with `false`. Because the hook takes no initial value from the caller, a first call of `false` looked redundant and was dropped — so consumers whose state starts as `true` lost the transition back to `false`. The comparison now starts from a sentinel, so the first call is always forwarded regardless of its value. diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts index 010d8f29..d757453f 100644 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts @@ -97,6 +97,29 @@ describe('useThrottledCallback', () => { expect(onChange).toBeCalledTimes(1); }); + it('should invoke the callback when the first value is false', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + result.current(false); + + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith(false); + }); + + it('should still skip a repeated false after the first one', () => { + const onChange = vi.fn(); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + + result.current(false); + vi.advanceTimersByTime(100); + expect(onChange).toBeCalledTimes(1); + + result.current(false); + vi.advanceTimersByTime(100); + expect(onChange).toBeCalledTimes(1); + }); + it('should handle value toggling', () => { const onChange = vi.fn(); const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); diff --git a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts index c24293b8..830eea78 100644 --- a/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts +++ b/packages/core/src/hooks/useThrottledCallback/useThrottledCallback.ts @@ -8,6 +8,15 @@ type ThrottleOptions = { edges?: Array<'leading' | 'trailing'>; }; +/** + * Marks that no value has been forwarded to `onChange` yet. + * + * The hook skips redundant invocations by comparing against the last forwarded value, but it + * receives no initial value from the caller. Seeding that comparison with `false` would treat the + * first `false` as redundant and swallow it, so an unreachable sentinel is used instead. + */ +const NOT_INVOKED = Symbol('NOT_INVOKED'); + /** * @description * `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. @@ -38,7 +47,10 @@ export function useThrottledCallback({ timeThreshold: number; }) { const handleChange = usePreservedCallback(onChange); - const ref = useRef({ value: false, clearPreviousThrottle: () => {} }); + const ref = useRef<{ value: boolean | typeof NOT_INVOKED; clearPreviousThrottle: () => void }>({ + value: NOT_INVOKED, + clearPreviousThrottle: () => {}, + }); useEffect(function cleanupThrottleOnUnmount() { const current = ref.current;