Skip to content

Commit 1511129

Browse files
authored
A number of StrictMode fixes and updates (#99)
* fix(useTimeout): make StrictMode safe * fix(useAnimationFrame)!: Make StrictMode safe BREAKING CHANGE: no longer supports `cancelPrevious` this is always true * fix:(useDebouncedCallback): Clean up timeout logic in strict mode * chore: deprecate useWillUnmount This hook is not possible in StrictMode, and can cause bugs * fix(useForceUpdate): ensure that chained calls produce an update * Update useCustomEffect.ts * address feedback
1 parent f249c92 commit 1511129

9 files changed

+136
-89
lines changed

src/useAnimationFrame.ts

+28-33
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { useRef } from 'react'
1+
import { useEffect, useState } from 'react'
22
import useMounted from './useMounted'
3-
import useStableMemo from './useStableMemo'
4-
import useWillUnmount from './useWillUnmount'
53

64
export interface UseAnimationFrameReturn {
75
cancel(): void
@@ -11,15 +9,12 @@ export interface UseAnimationFrameReturn {
119
* Previously registered callbacks will be cancelled
1210
*/
1311
request(callback: FrameRequestCallback): void
14-
15-
/**
16-
* Request for the provided callback to be called on the next animation frame.
17-
* Previously registered callbacks can be cancelled by providing `cancelPrevious`
18-
*/
19-
request(cancelPrevious: boolean, callback: FrameRequestCallback): void
12+
}
13+
type AnimationFrameState = {
14+
fn: FrameRequestCallback
2015
}
2116
/**
22-
* Returns a controller object for requesting and cancelling an animation freame that is properly cleaned up
17+
* Returns a controller object for requesting and cancelling an animation frame that is properly cleaned up
2318
* once the component unmounts. New requests cancel and replace existing ones.
2419
*
2520
* ```ts
@@ -45,32 +40,32 @@ export interface UseAnimationFrameReturn {
4540
*/
4641
export default function useAnimationFrame(): UseAnimationFrameReturn {
4742
const isMounted = useMounted()
48-
const handle = useRef<number | undefined>()
4943

50-
const cancel = () => {
51-
if (handle.current != null) {
52-
cancelAnimationFrame(handle.current)
53-
}
54-
}
44+
const [animationFrame, setAnimationFrameState] =
45+
useState<AnimationFrameState | null>(null)
5546

56-
useWillUnmount(cancel)
47+
useEffect(() => {
48+
if (!animationFrame) {
49+
return
50+
}
5751

58-
return useStableMemo(
59-
() => ({
60-
request(
61-
cancelPrevious: boolean | FrameRequestCallback,
62-
fn?: FrameRequestCallback,
63-
) {
64-
if (!isMounted()) return
52+
const { fn } = animationFrame
53+
const handle = requestAnimationFrame(fn)
54+
return () => {
55+
cancelAnimationFrame(handle)
56+
}
57+
}, [animationFrame])
6558

66-
if (cancelPrevious) cancel()
59+
const [returnValue] = useState(() => ({
60+
request(callback: FrameRequestCallback) {
61+
if (!isMounted()) return
62+
setAnimationFrameState({ fn: callback })
63+
},
64+
cancel: () => {
65+
if (!isMounted()) return
66+
setAnimationFrameState(null)
67+
},
68+
}))
6769

68-
handle.current = requestAnimationFrame(
69-
fn || (cancelPrevious as FrameRequestCallback),
70-
)
71-
},
72-
cancel,
73-
}),
74-
[],
75-
)
70+
return returnValue
7671
}

src/useCustomEffect.ts

-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
useEffect,
66
useDebugValue,
77
} from 'react'
8-
import useWillUnmount from './useWillUnmount'
98
import useMounted from './useMounted'
109

1110
export type EffectHook = (effect: EffectCallback, deps?: DependencyList) => void

src/useDebouncedCallback.ts

+2-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useMemo, useRef } from 'react'
22
import useTimeout from './useTimeout'
33
import useEventCallback from './useEventCallback'
4-
import useWillUnmount from './useWillUnmount'
54

65
export interface UseDebouncedCallbackOptions {
76
wait: number
@@ -55,8 +54,6 @@ function useDebouncedCallback<TCallback extends (...args: any[]) => any>(
5554

5655
const isTimerSetRef = useRef(false)
5756
const lastArgsRef = useRef<unknown[] | null>(null)
58-
// Use any to bypass type issue with setTimeout.
59-
const timerRef = useRef<any>(0)
6057

6158
const handleCallback = useEventCallback(fn)
6259

@@ -71,11 +68,6 @@ function useDebouncedCallback<TCallback extends (...args: any[]) => any>(
7168

7269
const timeout = useTimeout()
7370

74-
useWillUnmount(() => {
75-
clearTimeout(timerRef.current)
76-
isTimerSetRef.current = false
77-
})
78-
7971
return useMemo(() => {
8072
const hasMaxWait = !!maxWait
8173

@@ -168,14 +160,14 @@ function useDebouncedCallback<TCallback extends (...args: any[]) => any>(
168160
if (hasMaxWait) {
169161
// Handle invocations in a tight loop.
170162
isTimerSetRef.current = true
171-
timerRef.current = setTimeout(timerExpired, wait)
163+
timeout.set(timerExpired, wait)
172164
return invokeFunc(lastCallTimeRef.current)
173165
}
174166
}
175167

176168
if (!isTimerSetRef.current) {
177169
isTimerSetRef.current = true
178-
timerRef.current = setTimeout(timerExpired, wait)
170+
timeout.set(timerExpired, wait)
179171
}
180172

181173
return returnValueRef.current

src/useForceUpdate.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ import { useReducer } from 'react'
1919
export default function useForceUpdate(): () => void {
2020
// The toggling state value is designed to defeat React optimizations for skipping
2121
// updates when they are strictly equal to the last state value
22-
const [, dispatch] = useReducer((state: boolean) => !state, false)
22+
const [, dispatch] = useReducer((revision) => revision + 1, 0)
2323
return dispatch as () => void
2424
}

src/useTimeout.ts

+47-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { MutableRefObject, useMemo, useRef } from 'react'
1+
import { MutableRefObject, useEffect, useRef, useState } from 'react'
22
import useMounted from './useMounted'
3-
import useWillUnmount from './useWillUnmount'
43

54
/*
65
* Browsers including Internet Explorer, Chrome, Safari, and Firefox store the
@@ -28,12 +27,14 @@ function setChainedTimeout(
2827
)
2928
}
3029

30+
type TimeoutState = {
31+
fn: () => void
32+
delayMs: number
33+
}
3134
/**
3235
* Returns a controller object for setting a timeout that is properly cleaned up
3336
* once the component unmounts. New timeouts cancel and replace existing ones.
3437
*
35-
*
36-
*
3738
* ```tsx
3839
* const { set, clear } = useTimeout();
3940
* const [hello, showHello] = useState(false);
@@ -47,33 +48,60 @@ function setChainedTimeout(
4748
* ```
4849
*/
4950
export default function useTimeout() {
51+
const [timeout, setTimeoutState] = useState<TimeoutState | null>(null)
5052
const isMounted = useMounted()
5153

5254
// types are confused between node and web here IDK
53-
const handleRef = useRef<any>()
55+
const handleRef = useRef<any>(null)
5456

55-
useWillUnmount(() => clearTimeout(handleRef.current))
57+
useEffect(() => {
58+
if (!timeout) {
59+
return
60+
}
5661

57-
return useMemo(() => {
58-
const clear = () => clearTimeout(handleRef.current)
62+
const { fn, delayMs } = timeout
5963

60-
function set(fn: () => void, delayMs = 0): void {
61-
if (!isMounted()) return
64+
function task() {
65+
if (isMounted()) {
66+
setTimeoutState(null)
67+
}
68+
fn()
69+
}
6270

63-
clear()
71+
if (delayMs <= MAX_DELAY_MS) {
72+
// For simplicity, if the timeout is short, just set a normal timeout.
73+
handleRef.current = setTimeout(task, delayMs)
74+
} else {
75+
setChainedTimeout(handleRef, task, Date.now() + delayMs)
76+
}
77+
const handle = handleRef.current
6478

65-
if (delayMs <= MAX_DELAY_MS) {
66-
// For simplicity, if the timeout is short, just set a normal timeout.
67-
handleRef.current = setTimeout(fn, delayMs)
68-
} else {
69-
setChainedTimeout(handleRef, fn, Date.now() + delayMs)
79+
return () => {
80+
// this should be a no-op since they are either the same or `handle`
81+
// already expired but no harm in calling twice
82+
if (handleRef.current !== handle) {
83+
clearTimeout(handle)
7084
}
85+
86+
clearTimeout(handleRef.current)
87+
handleRef.current === null
7188
}
89+
}, [timeout])
7290

91+
const [returnValue] = useState(() => {
7392
return {
74-
set,
75-
clear,
93+
set(fn: () => void, delayMs = 0): void {
94+
if (!isMounted()) return
95+
96+
setTimeoutState({ fn, delayMs })
97+
},
98+
clear() {
99+
setTimeoutState(null)
100+
},
101+
isPending: !!timeout,
76102
handleRef,
77103
}
78-
}, [])
104+
})
105+
106+
return returnValue
79107
}

src/useWillUnmount.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEffect } from 'react'
55
* Attach a callback that fires when a component unmounts
66
*
77
* @param fn Handler to run when the component unmounts
8+
* @deprecated Use `useMounted` and normal effects, this is not StrictMode safe
89
* @category effects
910
*/
1011
export default function useWillUnmount(fn: () => void) {

test/useDebouncedCallback.test.tsx

+24-9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ describe('useDebouncedCallback', () => {
88
jest.useFakeTimers()
99
})
1010

11+
afterEach(() => {
12+
act(() => {
13+
jest.runAllTimers()
14+
})
15+
})
16+
1117
it('should return a function that debounces input callback', () => {
1218
const callback = jest.fn()
1319

@@ -21,7 +27,9 @@ describe('useDebouncedCallback', () => {
2127

2228
expect(callback).not.toHaveBeenCalled()
2329

24-
jest.runOnlyPendingTimers()
30+
act(() => {
31+
jest.runOnlyPendingTimers()
32+
})
2533

2634
expect(callback).toHaveBeenCalledTimes(1)
2735
expect(callback).toHaveBeenCalledWith(3)
@@ -47,7 +55,6 @@ describe('useDebouncedCallback', () => {
4755
act(() => {
4856
jest.runOnlyPendingTimers()
4957
})
50-
5158
expect(callback).toHaveBeenCalledTimes(1)
5259
})
5360

@@ -90,16 +97,13 @@ describe('useDebouncedCallback', () => {
9097
result.current()
9198
result.current()
9299
result.current()
93-
94-
setTimeout(() => {
95-
result.current()
96-
}, 1001)
97100
})
98101

99102
expect(callback).toHaveBeenCalledTimes(1)
100103

101104
act(() => {
102105
jest.advanceTimersByTime(1001)
106+
result.current()
103107
})
104108

105109
expect(callback).toHaveBeenCalledTimes(3)
@@ -137,17 +141,25 @@ describe('useDebouncedCallback', () => {
137141
const callback = jest.fn(() => 42)
138142

139143
const { result } = renderHook(() => useDebouncedCallback(callback, 1000))
144+
let retVal
145+
146+
act(() => {
147+
retVal = result.current()
148+
})
140149

141-
const retVal = result.current()
142150
expect(callback).toHaveBeenCalledTimes(0)
143151
expect(retVal).toBeUndefined()
144152

145153
act(() => {
146154
jest.runAllTimers()
147155
})
156+
148157
expect(callback).toHaveBeenCalledTimes(1)
149158

150-
const subsequentResult = result.current()
159+
let subsequentResult
160+
act(() => {
161+
subsequentResult = result.current()
162+
})
151163

152164
expect(callback).toHaveBeenCalledTimes(1)
153165
expect(subsequentResult).toBe(42)
@@ -160,7 +172,10 @@ describe('useDebouncedCallback', () => {
160172
useDebouncedCallback(callback, { wait: 1000, leading: true }),
161173
)
162174

163-
const retVal = result.current()
175+
let retVal
176+
act(() => {
177+
retVal = result.current()
178+
})
164179

165180
expect(callback).toHaveBeenCalledTimes(1)
166181
expect(retVal).toEqual(42)

test/useDebouncedState.test.tsx

+7-5
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ describe('useDebouncedState', () => {
1717
const wrapper = render(<Wrapper />)
1818
expect(wrapper.getByText('0')).toBeTruthy()
1919

20-
outerSetValue((cur: number) => cur + 1)
21-
outerSetValue((cur: number) => cur + 1)
22-
outerSetValue((cur: number) => cur + 1)
23-
outerSetValue((cur: number) => cur + 1)
24-
outerSetValue((cur: number) => cur + 1)
20+
act(() => {
21+
outerSetValue((cur: number) => cur + 1)
22+
outerSetValue((cur: number) => cur + 1)
23+
outerSetValue((cur: number) => cur + 1)
24+
outerSetValue((cur: number) => cur + 1)
25+
outerSetValue((cur: number) => cur + 1)
26+
})
2527

2628
expect(wrapper.getByText('0')).toBeTruthy()
2729

0 commit comments

Comments
 (0)