Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/lucky-pugs-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading