-
Notifications
You must be signed in to change notification settings - Fork 54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(react): improved useTimeout and added usePreservedCallback #966
Changes from all commits
c7808b0
0ac379d
89affd0
a4845c9
4cb88a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@suspensive/react": patch | ||
--- | ||
|
||
fix(react): improved useTimeout and added usePreservedCallback |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { renderHook, waitFor } from '@testing-library/react' | ||
import { usePreservedCallback } from './usePreservedCallback' | ||
|
||
describe('usePreservedCallback', () => { | ||
it('should preserve the callback function', () => { | ||
let counter = 0 | ||
|
||
const { result, rerender } = renderHook(({ callback }) => usePreservedCallback(callback), { | ||
initialProps: { | ||
callback: () => counter, | ||
}, | ||
}) | ||
|
||
expect(result.current()).toBe(0) | ||
|
||
counter = 1 | ||
rerender({ callback: () => counter }) | ||
|
||
expect(result.current()).toBe(1) | ||
}) | ||
|
||
it('should not recreate the callback on each render', () => { | ||
const callback = vi.fn() | ||
const { result, rerender } = renderHook(() => usePreservedCallback(callback)) | ||
|
||
const preservedCallback = result.current | ||
|
||
rerender() | ||
expect(result.current).toBe(preservedCallback) | ||
}) | ||
|
||
it('should call the latest callback', async () => { | ||
const callback1 = vi.fn() | ||
const callback2 = vi.fn() | ||
|
||
const { result, rerender } = renderHook(({ callback }) => usePreservedCallback(callback), { | ||
initialProps: { callback: callback1 }, | ||
}) | ||
|
||
await waitFor(() => { | ||
result.current() | ||
}) | ||
expect(callback1).toHaveBeenCalledTimes(1) | ||
|
||
rerender({ callback: callback2 }) | ||
|
||
await waitFor(() => { | ||
result.current() | ||
}) | ||
|
||
expect(callback2).toHaveBeenCalledTimes(1) | ||
expect(callback1).toHaveBeenCalledTimes(1) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { useCallback, useRef } from 'react' | ||
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' | ||
|
||
export const usePreservedCallback = <T extends (...args: unknown[]) => unknown>(callback: T) => { | ||
const callbackRef = useRef<T>(callback) | ||
|
||
useIsomorphicLayoutEffect(() => { | ||
callbackRef.current = callback | ||
}, [callback]) | ||
|
||
return useCallback((...args: unknown[]) => { | ||
return callbackRef.current(...args) | ||
}, []) as T | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,6 +16,25 @@ describe('useTimeout', () => { | |
expect(spy).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
it('should call the modified function when the given function is changed', async () => { | ||
const timeoutMockFn1 = vi.fn() | ||
const timeoutMockFn2 = vi.fn() | ||
|
||
const { rerender } = renderHook(({ fn }) => useTimeout(fn, ms('0.1s')), { | ||
initialProps: { fn: timeoutMockFn1 }, | ||
}) | ||
|
||
rerender({ fn: timeoutMockFn2 }) | ||
|
||
expect(timeoutMockFn1).toHaveBeenCalledTimes(0) | ||
expect(timeoutMockFn2).toHaveBeenCalledTimes(0) | ||
|
||
await sleep(ms('0.1s')) | ||
|
||
expect(timeoutMockFn1).toHaveBeenCalledTimes(0) | ||
expect(timeoutMockFn2).toHaveBeenCalledTimes(1) | ||
}) | ||
Comment on lines
+19
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's existing code, it won't call the changed function as intended, and the test will fail. 😭 |
||
|
||
it('should not re-call callback received as argument even if component using this hook is rerendered', async () => { | ||
const TestComponent = () => { | ||
const [number, setNumber] = useState(0) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,11 @@ | ||
import { useEffect, useRef } from 'react' | ||
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' | ||
import { useEffect } from 'react' | ||
import { usePreservedCallback } from './usePreservedCallback' | ||
|
||
export const useTimeout = (fn: () => void, ms: number) => { | ||
const fnRef = useRef(fn) | ||
|
||
useIsomorphicLayoutEffect(() => { | ||
fnRef.current = fn | ||
}, [fn]) | ||
const preservedCallback = usePreservedCallback(fn) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think Components(Delay) using useTimeout should use useCallback too. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ssi02014 I absolutely accept your opinion #966 (comment) but anyway I think Components(Delay) using useTimeout should use useCallback too. doesn't it right? TO-BEimport { type PropsWithChildren, type ReactNode, useContext, useState } from 'react'
import { DelayDefaultPropsContext } from './contexts'
import { useTimeout } from './hooks'
import { Message_Delay_ms_prop_should_be_greater_than_or_equal_to_0, SuspensiveError } from './models/SuspensiveError'
export interface DelayProps extends PropsWithChildren {
ms?: number
fallback?: ReactNode
}
export const Delay = (props: DelayProps) => {
if (process.env.NODE_ENV === 'development') {
if (typeof props.ms === 'number') {
SuspensiveError.assert(props.ms >= 0, Message_Delay_ms_prop_should_be_greater_than_or_equal_to_0)
}
}
const defaultProps = useContext(DelayDefaultPropsContext)
const ms = props.ms ?? defaultProps.ms ?? 0
const [isDelaying, setIsDelaying] = useState(ms > 0)
- useTimeout(() => setIsDelaying(false), ms)
+ useTimeout(useCallback(() => setIsDelaying(false), []), ms)
const fallback = typeof props.fallback === 'undefined' ? defaultProps.fallback : props.fallback
return <>{isDelaying ? fallback : props.children}</>
}
if (process.env.NODE_ENV === 'development') {
Delay.displayName = 'Delay'
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @manudeli Oh no, In useTimeout(() => setIsDelaying(false), ms) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah! I understood what you mean! Thanks |
||
useEffect(() => { | ||
const id = setTimeout(fnRef.current, ms) | ||
const id = setTimeout(preservedCallback, ms) | ||
return () => clearTimeout(id) | ||
}, [ms]) | ||
}, [preservedCallback, ms]) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is slightly different from
toss/slash's usePreservedCallback
.I used
useIsomorphicLayoutEffect
instead ofuseEffect
, and changed the type tounknown
instead ofany
.