diff --git a/packages/core/changelog.md b/packages/core/changelog.md index f11bcaa3..623ec82a 100644 --- a/packages/core/changelog.md +++ b/packages/core/changelog.md @@ -337,3 +337,7 @@ function Component() { ## 6.1.8(Dec 2025) - fix(useMap): fix type parameter support by moving generics into function signature, now `useMap()` works correctly + +## 6.1.9(Jan 2026) + +- fix(useRafState): fix bug where multiple consecutive functional updates would only apply the last one. Now correctly accumulates all updates within the same animation frame, matching React's useState behavior. For example, calling `setState(n => n + 1)` three times consecutively will now correctly increase the value by 3 instead of 1. diff --git a/packages/core/package.json b/packages/core/package.json index 43af27d6..f16827c8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@reactuses/core", - "version": "6.1.8", + "version": "6.1.9", "license": "Unlicense", "homepage": "https://www.reactuse.com/", "repository": { diff --git a/packages/core/src/useRafState/index.spec.ts b/packages/core/src/useRafState/index.spec.ts index e1479ba3..1514425c 100644 --- a/packages/core/src/useRafState/index.spec.ts +++ b/packages/core/src/useRafState/index.spec.ts @@ -23,4 +23,49 @@ describe('useRafState', () => { expect(result.current[0]).toBe(1) mockRaf.mockRestore() }) + + it('should handle multiple consecutive functional updates correctly', () => { + const mockRaf = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + cb(0) + return 0 + }) + const { result } = renderHook(() => useRafState(0)) + const setRafState = result.current[1] + expect(result.current[0]).toBe(0) + + // Multiple consecutive updates in the same tick + act(() => { + setRafState(count => count + 1) + setRafState(count => count + 1) + setRafState(count => count + 1) + }) + + // Should apply all three updates, resulting in 3 + expect(result.current[0]).toBe(3) + mockRaf.mockRestore() + }) + + it('should handle mixed value and functional updates', () => { + const mockRaf = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + cb(0) + return 0 + }) + const { result } = renderHook(() => useRafState(0)) + const setRafState = result.current[1] + expect(result.current[0]).toBe(0) + + act(() => { + setRafState(5) + setRafState(count => count + 1) + setRafState(count => count * 2) + }) + + // Should apply: set to 5, then +1 (=6), then *2 (=12) + expect(result.current[0]).toBe(12) + mockRaf.mockRestore() + }) }) diff --git a/packages/core/src/useRafState/index.ts b/packages/core/src/useRafState/index.ts index 16281206..d9616179 100644 --- a/packages/core/src/useRafState/index.ts +++ b/packages/core/src/useRafState/index.ts @@ -5,12 +5,21 @@ import { useUnmount } from '../useUnmount' export function useRafState(initialState: S | (() => S)): readonly [S, Dispatch>] { const frame = useRef(0) const [state, setState] = useState(initialState) + const pendingUpdates = useRef>>([]) const setRafState = useCallback((value: S | ((prevState: S) => S)) => { + pendingUpdates.current.push(value) cancelAnimationFrame(frame.current) frame.current = requestAnimationFrame(() => { - setState(value) + const updates = pendingUpdates.current.splice(0) + setState(prevState => { + let newState = prevState + for (const update of updates) { + newState = typeof update === 'function' ? (update as (prevState: S) => S)(newState) : update + } + return newState + }) }) }, []) diff --git a/packages/website-docusaurus/docs/changelog.md b/packages/website-docusaurus/docs/changelog.md index d95a6564..bcda45e1 100644 --- a/packages/website-docusaurus/docs/changelog.md +++ b/packages/website-docusaurus/docs/changelog.md @@ -344,3 +344,7 @@ function Component() { ## 6.1.8(Dec 2025) - fix(useMap): fix type parameter support by moving generics into function signature, now `useMap()` works correctly + +## 6.1.9(Jan 2026) + +- fix(useRafState): fix bug where multiple consecutive functional updates would only apply the last one. Now correctly accumulates all updates within the same animation frame, matching React's useState behavior. For example, calling `setState(n => n + 1)` three times consecutively will now correctly increase the value by 3 instead of 1.