diff --git a/shared/constants/utils.tsx b/shared/constants/utils.tsx index c9bc306e0b04..645dd8577ed7 100644 --- a/shared/constants/utils.tsx +++ b/shared/constants/utils.tsx @@ -41,7 +41,7 @@ export const useNav = () => { export {wrapErrors} from '@/util/debug' export {default as shallowEqual} from '@/util/shallow-equal' -export {useDebouncedCallback, useThrottledCallback, type DebouncedState} from 'use-debounce' +export {useDebouncedCallback, useThrottledCallback, type DebouncedState} from '@/util/use-debounce' export {useShallow, useDeep} from '@/util/zustand' export {default as useRPC} from '@/util/use-rpc' export {produce} from 'immer' diff --git a/shared/jest.config.js b/shared/jest.config.js index ea29b1fc9d84..2aab5e645614 100644 --- a/shared/jest.config.js +++ b/shared/jest.config.js @@ -22,9 +22,18 @@ module.exports = { setupFiles: ['/jest.setup.js'], testEnvironment: 'node', testMatch: [ - '/stores/**/*.test.ts', - '/common-adapters/**/*.test.ts', - '/common-adapters/**/*.test.tsx', + '/**/*.test.ts', + '/**/*.test.tsx', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/desktop/dist/', + '/desktop/release/', + '/ios/', + '/android/', + '/images/', + '/perf/', + '/.tsOuts/', ], transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', diff --git a/shared/package.json b/shared/package.json index 32e48cba3040..a98547f52e17 100644 --- a/shared/package.json +++ b/shared/package.json @@ -131,7 +131,6 @@ "react-native-webview": "13.16.1", "react-native-worklets": "0.8.1", "react-native-zoom-toolkit": "5.0.1", - "use-debounce": "10.1.1", "zustand": "5.0.12" }, "devDependencies": { diff --git a/shared/util/use-debounce.test.tsx b/shared/util/use-debounce.test.tsx new file mode 100644 index 000000000000..1b9d85c349d6 --- /dev/null +++ b/shared/util/use-debounce.test.tsx @@ -0,0 +1,289 @@ +/** @jest-environment jsdom */ +/// + +import {afterEach, beforeEach, expect, jest, test} from '@jest/globals' +import {act, cleanup, renderHook} from '@testing-library/react' +import {useDebouncedCallback, useThrottledCallback} from './use-debounce' + +const advance = (ms: number) => { + act(() => { + jest.advanceTimersByTime(ms) + }) +} + +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + cleanup() + jest.restoreAllMocks() + jest.useRealTimers() +}) + +test('useDebouncedCallback delays calls until the trailing edge by default', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => useDebouncedCallback(callback, 100)) + + let returnValue: string | undefined + act(() => { + returnValue = result.current('alpha') + }) + + expect(returnValue).toBeUndefined() + expect(callback).not.toHaveBeenCalled() + + advance(99) + expect(callback).not.toHaveBeenCalled() + + advance(1) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('alpha') +}) + +test('useDebouncedCallback with leading true does not fire an extra trailing call for a single invocation', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => useDebouncedCallback(callback, 100, {leading: true})) + + let returnValue: string | undefined + act(() => { + returnValue = result.current('alpha') + }) + + expect(returnValue).toBe('done:alpha') + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('alpha') + + advance(100) + expect(callback).toHaveBeenCalledTimes(1) + expect(result.current.isPending()).toBe(false) +}) + +test('useDebouncedCallback supports combined leading and trailing behavior', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => + useDebouncedCallback(callback, 100, {leading: true, trailing: true}) + ) + + act(() => { + result.current('alpha') + }) + advance(50) + act(() => { + result.current('beta') + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenNthCalledWith(1, 'alpha') + + advance(99) + expect(callback).toHaveBeenCalledTimes(1) + + advance(1) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 'beta') +}) + +test('useDebouncedCallback respects trailing false', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => + useDebouncedCallback(callback, 100, {leading: true, trailing: false}) + ) + + act(() => { + result.current('alpha') + }) + advance(50) + act(() => { + result.current('beta') + }) + + advance(100) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('alpha') + + act(() => { + result.current('gamma') + }) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 'gamma') +}) + +test('useDebouncedCallback cancel clears pending work', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => useDebouncedCallback(callback, 100)) + + act(() => { + result.current('alpha') + }) + expect(result.current.isPending()).toBe(true) + + act(() => { + result.current.cancel() + }) + + expect(result.current.isPending()).toBe(false) + advance(100) + expect(callback).not.toHaveBeenCalled() +}) + +test('useDebouncedCallback flush runs pending work immediately', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => useDebouncedCallback(callback, 100)) + + act(() => { + result.current('alpha') + }) + + let flushed: string | undefined + act(() => { + flushed = result.current.flush() + }) + + expect(flushed).toBe('done:alpha') + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('alpha') + expect(result.current.isPending()).toBe(false) +}) + +test('useDebouncedCallback uses the latest callback after rerender', () => { + const first = jest.fn((value: string) => `first:${value}`) + const second = jest.fn((value: string) => `second:${value}`) + const {result, rerender} = renderHook( + ({callback}: {callback: (value: string) => string}) => useDebouncedCallback(callback, 100), + {initialProps: {callback: first}} + ) + + act(() => { + result.current('alpha') + }) + rerender({callback: second}) + + advance(100) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledWith('alpha') +}) + +test('useDebouncedCallback returns the last invocation result on later calls', () => { + const callback = jest.fn(() => 42) + const {result} = renderHook(() => useDebouncedCallback(callback, 100)) + + let firstReturn: number | undefined + act(() => { + firstReturn = result.current() + }) + expect(firstReturn).toBeUndefined() + + advance(100) + expect(callback).toHaveBeenCalledTimes(1) + + let secondReturn: number | undefined + act(() => { + secondReturn = result.current() + }) + expect(secondReturn).toBe(42) +}) + +test('useThrottledCallback defaults to leading and trailing calls without dropping cooldown after a trailing invoke', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => useThrottledCallback(callback, 100)) + + let firstReturn: string | undefined + act(() => { + firstReturn = result.current('alpha') + }) + expect(firstReturn).toBe('done:alpha') + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenNthCalledWith(1, 'alpha') + + advance(50) + let secondReturn: string | undefined + act(() => { + secondReturn = result.current('beta') + }) + expect(secondReturn).toBe('done:alpha') + + advance(50) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 'beta') + + advance(50) + act(() => { + result.current('gamma') + }) + expect(callback).toHaveBeenCalledTimes(2) + + advance(50) + expect(callback).toHaveBeenCalledTimes(3) + expect(callback).toHaveBeenNthCalledWith(3, 'gamma') +}) + +test('useThrottledCallback supports trailing false', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => + useThrottledCallback(callback, 100, {trailing: false}) + ) + + act(() => { + result.current('alpha') + }) + advance(50) + act(() => { + result.current('beta') + }) + + advance(50) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenNthCalledWith(1, 'alpha') + + advance(50) + act(() => { + result.current('gamma') + }) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 'gamma') +}) + +test('useThrottledCallback supports leading false', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => + useThrottledCallback(callback, 100, {leading: false, trailing: true}) + ) + + act(() => { + result.current('alpha') + }) + expect(callback).not.toHaveBeenCalled() + + advance(50) + act(() => { + result.current('beta') + }) + expect(callback).not.toHaveBeenCalled() + + advance(50) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenNthCalledWith(1, 'beta') +}) + +test('useThrottledCallback collapses repeated calls within the wait window to the latest args', () => { + const callback = jest.fn((value: string) => `done:${value}`) + const {result} = renderHook(() => useThrottledCallback(callback, 100)) + + act(() => { + result.current('alpha') + }) + advance(25) + act(() => { + result.current('beta') + }) + advance(25) + act(() => { + result.current('gamma') + }) + + advance(50) + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenNthCalledWith(2, 'gamma') +}) diff --git a/shared/util/use-debounce.ts b/shared/util/use-debounce.ts new file mode 100644 index 000000000000..ac12a7f71d01 --- /dev/null +++ b/shared/util/use-debounce.ts @@ -0,0 +1,217 @@ +import * as React from 'react' + +type AnyFunction = (...args: Array) => any +type TimerID = ReturnType + +export type DebouncedState = ((...args: Parameters) => ReturnType | undefined) & { + cancel: () => void + flush: () => ReturnType | undefined + isPending: () => boolean +} + +type DebounceOptions = { + leading?: boolean + trailing?: boolean +} + +const normalizeWait = (wait?: number) => Math.max(0, wait ?? 0) + +export const useDebouncedCallback = ( + func: T, + wait?: number, + options?: DebounceOptions +): DebouncedState => { + const funcRef = React.useRef(func) + funcRef.current = func + + const waitMs = normalizeWait(wait) + const leading = options?.leading ?? false + const trailing = options?.trailing ?? true + + const debounced = React.useMemo(() => { + let lastArgs: Parameters | undefined + let lastCallTime: number | undefined + let lastResult: ReturnType | undefined + let timerID: TimerID | undefined + + const clearTimer = () => { + if (timerID !== undefined) { + clearTimeout(timerID) + timerID = undefined + } + } + + const invoke = () => { + const args = lastArgs + lastArgs = undefined + if (!args) { + return lastResult + } + const result = funcRef.current(...args) + lastResult = result + return result + } + + const remainingWait = (time: number) => { + const sinceLastCall = time - (lastCallTime ?? 0) + return waitMs - sinceLastCall + } + + const shouldInvoke = (time: number) => { + if (lastCallTime === undefined) { + return true + } + const sinceLastCall = time - lastCallTime + return sinceLastCall >= waitMs || sinceLastCall < 0 + } + + const trailingEdge = () => { + clearTimer() + if (trailing && lastArgs) { + return invoke() + } + lastArgs = undefined + return lastResult + } + + const timerExpired = () => { + const time = Date.now() + if (shouldInvoke(time)) { + trailingEdge() + return + } + timerID = setTimeout(timerExpired, remainingWait(time)) + } + + const leadingEdge = () => { + timerID = setTimeout(timerExpired, waitMs) + return leading ? invoke() : lastResult + } + + const next = ((...args: Parameters) => { + const time = Date.now() + const invokeNow = shouldInvoke(time) + + lastArgs = args + lastCallTime = time + + if (invokeNow && timerID === undefined) { + return leadingEdge() + } + + clearTimer() + timerID = setTimeout(timerExpired, waitMs) + return lastResult + }) as DebouncedState + + next.cancel = () => { + clearTimer() + lastArgs = undefined + lastCallTime = undefined + } + + next.flush = () => { + if (timerID === undefined) { + return lastResult + } + return trailingEdge() + } + + next.isPending = () => timerID !== undefined + + return next + }, [leading, trailing, waitMs]) + + React.useEffect(() => () => debounced.cancel(), [debounced]) + + return debounced +} + +export const useThrottledCallback = ( + func: T, + wait: number, + options?: DebounceOptions +): DebouncedState => { + const funcRef = React.useRef(func) + funcRef.current = func + + const waitMs = normalizeWait(wait) + const leading = options?.leading ?? true + const trailing = options?.trailing ?? true + + const throttled = React.useMemo(() => { + let lastArgs: Parameters | undefined + let lastInvokeTime: number | undefined + let lastResult: ReturnType | undefined + let timerID: TimerID | undefined + + const clearTimer = () => { + if (timerID !== undefined) { + clearTimeout(timerID) + timerID = undefined + } + } + + const invoke = (time: number, args: Parameters) => { + lastArgs = undefined + lastInvokeTime = time + const result = funcRef.current(...args) + lastResult = result + return result + } + + const schedule = (time: number) => { + const delay = + lastInvokeTime === undefined ? waitMs : Math.max(0, waitMs - (time - lastInvokeTime)) + clearTimer() + timerID = setTimeout(() => { + timerID = undefined + if (trailing && lastArgs) { + invoke(Date.now(), lastArgs) + } else { + lastArgs = undefined + } + }, delay) + } + + const next = ((...args: Parameters) => { + const time = Date.now() + lastArgs = args + + if (leading && (lastInvokeTime === undefined || time - lastInvokeTime >= waitMs)) { + const result = invoke(time, args) + schedule(time) + return result + } + + schedule(time) + return lastResult + }) as DebouncedState + + next.cancel = () => { + clearTimer() + lastArgs = undefined + lastInvokeTime = undefined + } + + next.flush = () => { + if (timerID === undefined) { + return lastResult + } + clearTimer() + if (trailing && lastArgs) { + return invoke(Date.now(), lastArgs) + } + lastArgs = undefined + return lastResult + } + + next.isPending = () => timerID !== undefined + + return next + }, [leading, trailing, waitMs]) + + React.useEffect(() => () => throttled.cancel(), [throttled]) + + return throttled +} diff --git a/shared/yarn.lock b/shared/yarn.lock index 9d3316ad1c33..ad2081d18e7e 100644 --- a/shared/yarn.lock +++ b/shared/yarn.lock @@ -10522,13 +10522,6 @@ react-dom@19.2.0: dependencies: scheduler "^0.27.0" -react-error-boundary@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-5.0.0.tgz#6b6c7e075c922afb0283147e5b084efa44e68570" - integrity sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ== - dependencies: - "@babel/runtime" "^7.12.5" - react-freeze@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.4.tgz#cbbea2762b0368b05cbe407ddc9d518c57c6f3ad" @@ -12183,11 +12176,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -use-debounce@10.1.1: - version "10.1.1" - resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.1.1.tgz#b08b596b60a55fd4c18b44b37fdc02f058baf30a" - integrity sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ== - use-latest-callback@^0.2.4: version "0.2.6" resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.6.tgz#e5ea752808c86219acc179ace0ae3c1203255e77"