|
| 1 | +import type { ReactNode } from 'react'; |
| 2 | +import * as React from 'react'; |
| 3 | + |
| 4 | +import { act, renderHookAsync } from '..'; |
| 5 | +import { excludeConsoleMessage } from '../test-utils/console'; |
| 6 | + |
| 7 | +const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; |
| 8 | + |
| 9 | +// eslint-disable-next-line no-console |
| 10 | +const originalConsoleError = console.error; |
| 11 | +afterEach(() => { |
| 12 | + // eslint-disable-next-line no-console |
| 13 | + console.error = originalConsoleError; |
| 14 | +}); |
| 15 | + |
| 16 | +test('renderHookAsync renders hook asynchronously', async () => { |
| 17 | + const { result } = await renderHookAsync(() => { |
| 18 | + const [state, setState] = React.useState(1); |
| 19 | + |
| 20 | + React.useEffect(() => { |
| 21 | + setState(2); |
| 22 | + }, []); |
| 23 | + |
| 24 | + return state; |
| 25 | + }); |
| 26 | + |
| 27 | + expect(result.current).toEqual(2); |
| 28 | +}); |
| 29 | + |
| 30 | +test('renderHookAsync with wrapper option', async () => { |
| 31 | + const Context = React.createContext('default'); |
| 32 | + |
| 33 | + function useTestHook() { |
| 34 | + return React.useContext(Context); |
| 35 | + } |
| 36 | + |
| 37 | + function Wrapper({ children }: { children: ReactNode }) { |
| 38 | + return <Context.Provider value="provided">{children}</Context.Provider>; |
| 39 | + } |
| 40 | + |
| 41 | + const { result } = await renderHookAsync(useTestHook, { wrapper: Wrapper }); |
| 42 | + expect(result.current).toEqual('provided'); |
| 43 | +}); |
| 44 | + |
| 45 | +test('renderHookAsync supports legacy rendering option', async () => { |
| 46 | + function useTestHook() { |
| 47 | + return React.useState(42)[0]; |
| 48 | + } |
| 49 | + |
| 50 | + const { result } = await renderHookAsync(useTestHook, { concurrentRoot: false }); |
| 51 | + expect(result.current).toEqual(42); |
| 52 | +}); |
| 53 | + |
| 54 | +test('rerenderAsync function updates hook asynchronously', async () => { |
| 55 | + function useTestHook(props: { value: number }) { |
| 56 | + const [state, setState] = React.useState(props.value); |
| 57 | + |
| 58 | + React.useEffect(() => { |
| 59 | + setState(props.value * 2); |
| 60 | + }, [props.value]); |
| 61 | + |
| 62 | + return state; |
| 63 | + } |
| 64 | + |
| 65 | + const { result, rerenderAsync } = await renderHookAsync(useTestHook, { |
| 66 | + initialProps: { value: 5 }, |
| 67 | + }); |
| 68 | + expect(result.current).toEqual(10); |
| 69 | + |
| 70 | + await rerenderAsync({ value: 10 }); |
| 71 | + expect(result.current).toEqual(20); |
| 72 | +}); |
| 73 | + |
| 74 | +test('unmount function unmounts hook asynchronously', async () => { |
| 75 | + let cleanupCalled = false; |
| 76 | + |
| 77 | + function useTestHook() { |
| 78 | + React.useEffect(() => { |
| 79 | + return () => { |
| 80 | + cleanupCalled = true; |
| 81 | + }; |
| 82 | + }, []); |
| 83 | + |
| 84 | + return 'test'; |
| 85 | + } |
| 86 | + |
| 87 | + const { unmountAsync } = await renderHookAsync(useTestHook); |
| 88 | + expect(cleanupCalled).toBe(false); |
| 89 | + |
| 90 | + await unmountAsync(); |
| 91 | + expect(cleanupCalled).toBe(true); |
| 92 | +}); |
| 93 | + |
| 94 | +test('handles hook with state updates during effects', async () => { |
| 95 | + function useTestHook() { |
| 96 | + const [count, setCount] = React.useState(0); |
| 97 | + |
| 98 | + React.useEffect(() => { |
| 99 | + setCount((prev) => prev + 1); |
| 100 | + }, []); |
| 101 | + |
| 102 | + return count; |
| 103 | + } |
| 104 | + |
| 105 | + const { result } = await renderHookAsync(useTestHook); |
| 106 | + expect(result.current).toBe(1); |
| 107 | +}); |
| 108 | + |
| 109 | +test('handles multiple state updates in effects', async () => { |
| 110 | + function useTestHook() { |
| 111 | + const [first, setFirst] = React.useState(1); |
| 112 | + const [second, setSecond] = React.useState(2); |
| 113 | + |
| 114 | + React.useEffect(() => { |
| 115 | + setFirst(10); |
| 116 | + setSecond(20); |
| 117 | + }, []); |
| 118 | + |
| 119 | + return { first, second }; |
| 120 | + } |
| 121 | + |
| 122 | + const { result } = await renderHookAsync(useTestHook); |
| 123 | + expect(result.current).toEqual({ first: 10, second: 20 }); |
| 124 | +}); |
| 125 | + |
| 126 | +testGateReact19('handles hook with suspense', async () => { |
| 127 | + function useSuspendingHook(promise: Promise<string>) { |
| 128 | + return React.use(promise); |
| 129 | + } |
| 130 | + |
| 131 | + let resolvePromise: (value: string) => void; |
| 132 | + const promise = new Promise<string>((resolve) => { |
| 133 | + resolvePromise = resolve; |
| 134 | + }); |
| 135 | + |
| 136 | + const { result } = await renderHookAsync(useSuspendingHook, { |
| 137 | + initialProps: promise, |
| 138 | + wrapper: ({ children }) => <React.Suspense fallback="loading">{children}</React.Suspense>, |
| 139 | + }); |
| 140 | + |
| 141 | + // Initially suspended, result should not be available |
| 142 | + expect(result.current).toBeNull(); |
| 143 | + |
| 144 | + // eslint-disable-next-line require-await |
| 145 | + await act(async () => resolvePromise('resolved')); |
| 146 | + expect(result.current).toBe('resolved'); |
| 147 | +}); |
| 148 | + |
| 149 | +class ErrorBoundary extends React.Component< |
| 150 | + { children: React.ReactNode; fallback: string }, |
| 151 | + { hasError: boolean } |
| 152 | +> { |
| 153 | + constructor(props: { children: React.ReactNode; fallback: string }) { |
| 154 | + super(props); |
| 155 | + this.state = { hasError: false }; |
| 156 | + } |
| 157 | + |
| 158 | + static getDerivedStateFromError() { |
| 159 | + return { hasError: true }; |
| 160 | + } |
| 161 | + |
| 162 | + render() { |
| 163 | + return this.state.hasError ? this.props.fallback : this.props.children; |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +testGateReact19('handles hook suspense with error boundary', async () => { |
| 168 | + const ERROR_MESSAGE = 'Hook Promise Rejected In Test'; |
| 169 | + // eslint-disable-next-line no-console |
| 170 | + console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); |
| 171 | + |
| 172 | + function useSuspendingHook(promise: Promise<string>) { |
| 173 | + return React.use(promise); |
| 174 | + } |
| 175 | + |
| 176 | + let rejectPromise: (error: Error) => void; |
| 177 | + const promise = new Promise<string>((_resolve, reject) => { |
| 178 | + rejectPromise = reject; |
| 179 | + }); |
| 180 | + |
| 181 | + const { result } = await renderHookAsync(useSuspendingHook, { |
| 182 | + initialProps: promise, |
| 183 | + wrapper: ({ children }) => ( |
| 184 | + <ErrorBoundary fallback="error-fallback"> |
| 185 | + <React.Suspense fallback="loading">{children}</React.Suspense> |
| 186 | + </ErrorBoundary> |
| 187 | + ), |
| 188 | + }); |
| 189 | + |
| 190 | + // Initially suspended |
| 191 | + expect(result.current).toBeNull(); |
| 192 | + |
| 193 | + // eslint-disable-next-line require-await |
| 194 | + await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); |
| 195 | + |
| 196 | + // After error, result should still be null (error boundary caught it) |
| 197 | + expect(result.current).toBeNull(); |
| 198 | +}); |
| 199 | + |
| 200 | +test('handles custom hooks with complex logic', async () => { |
| 201 | + function useCounter(initialValue: number) { |
| 202 | + const [count, setCount] = React.useState(initialValue); |
| 203 | + |
| 204 | + const increment = React.useCallback(() => { |
| 205 | + setCount((prev) => prev + 1); |
| 206 | + }, []); |
| 207 | + |
| 208 | + const decrement = React.useCallback(() => { |
| 209 | + setCount((prev) => prev - 1); |
| 210 | + }, []); |
| 211 | + |
| 212 | + const reset = React.useCallback(() => { |
| 213 | + setCount(initialValue); |
| 214 | + }, [initialValue]); |
| 215 | + |
| 216 | + return { count, increment, decrement, reset }; |
| 217 | + } |
| 218 | + |
| 219 | + const { result, rerenderAsync } = await renderHookAsync(useCounter, { initialProps: 5 }); |
| 220 | + expect(result.current.count).toBe(5); |
| 221 | + |
| 222 | + result.current.increment(); |
| 223 | + await rerenderAsync(5); |
| 224 | + expect(result.current.count).toBe(6); |
| 225 | + |
| 226 | + result.current.reset(); |
| 227 | + await rerenderAsync(5); |
| 228 | + expect(result.current.count).toBe(5); |
| 229 | + |
| 230 | + result.current.decrement(); |
| 231 | + await rerenderAsync(5); |
| 232 | + expect(result.current.count).toBe(4); |
| 233 | +}); |
| 234 | + |
| 235 | +test('handles hook with cleanup and re-initialization', async () => { |
| 236 | + let effectCount = 0; |
| 237 | + let cleanupCount = 0; |
| 238 | + |
| 239 | + function useTestHook(props: { key: string }) { |
| 240 | + const [value, setValue] = React.useState(props.key); |
| 241 | + |
| 242 | + React.useEffect(() => { |
| 243 | + effectCount++; |
| 244 | + setValue(`${props.key}-effect`); |
| 245 | + |
| 246 | + return () => { |
| 247 | + cleanupCount++; |
| 248 | + }; |
| 249 | + }, [props.key]); |
| 250 | + |
| 251 | + return value; |
| 252 | + } |
| 253 | + |
| 254 | + const { result, rerenderAsync, unmountAsync } = await renderHookAsync(useTestHook, { |
| 255 | + initialProps: { key: 'initial' }, |
| 256 | + }); |
| 257 | + |
| 258 | + expect(result.current).toBe('initial-effect'); |
| 259 | + expect(effectCount).toBe(1); |
| 260 | + expect(cleanupCount).toBe(0); |
| 261 | + |
| 262 | + await rerenderAsync({ key: 'updated' }); |
| 263 | + expect(result.current).toBe('updated-effect'); |
| 264 | + expect(effectCount).toBe(2); |
| 265 | + expect(cleanupCount).toBe(1); |
| 266 | + |
| 267 | + await unmountAsync(); |
| 268 | + expect(effectCount).toBe(2); |
| 269 | + expect(cleanupCount).toBe(2); |
| 270 | +}); |
0 commit comments