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;