|
| 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('rerender 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, rerender } = await renderHookAsync(useTestHook, { initialProps: { value: 5 } }); |
| 66 | + expect(result.current).toEqual(10); |
| 67 | + |
| 68 | + await rerender({ value: 10 }); |
| 69 | + expect(result.current).toEqual(20); |
| 70 | +}); |
| 71 | + |
| 72 | +test('unmount function unmounts hook asynchronously', async () => { |
| 73 | + let cleanupCalled = false; |
| 74 | + |
| 75 | + function useTestHook() { |
| 76 | + React.useEffect(() => { |
| 77 | + return () => { |
| 78 | + cleanupCalled = true; |
| 79 | + }; |
| 80 | + }, []); |
| 81 | + |
| 82 | + return 'test'; |
| 83 | + } |
| 84 | + |
| 85 | + const { unmount } = await renderHookAsync(useTestHook); |
| 86 | + expect(cleanupCalled).toBe(false); |
| 87 | + |
| 88 | + await unmount(); |
| 89 | + expect(cleanupCalled).toBe(true); |
| 90 | +}); |
| 91 | + |
| 92 | +test('handles hook with state updates during effects', async () => { |
| 93 | + function useTestHook() { |
| 94 | + const [count, setCount] = React.useState(0); |
| 95 | + |
| 96 | + React.useEffect(() => { |
| 97 | + setCount((prev) => prev + 1); |
| 98 | + }, []); |
| 99 | + |
| 100 | + return count; |
| 101 | + } |
| 102 | + |
| 103 | + const { result } = await renderHookAsync(useTestHook); |
| 104 | + expect(result.current).toBe(1); |
| 105 | +}); |
| 106 | + |
| 107 | +test('handles multiple state updates in effects', async () => { |
| 108 | + function useTestHook() { |
| 109 | + const [first, setFirst] = React.useState(1); |
| 110 | + const [second, setSecond] = React.useState(2); |
| 111 | + |
| 112 | + React.useEffect(() => { |
| 113 | + setFirst(10); |
| 114 | + setSecond(20); |
| 115 | + }, []); |
| 116 | + |
| 117 | + return { first, second }; |
| 118 | + } |
| 119 | + |
| 120 | + const { result } = await renderHookAsync(useTestHook); |
| 121 | + expect(result.current).toEqual({ first: 10, second: 20 }); |
| 122 | +}); |
| 123 | + |
| 124 | +testGateReact19('handles hook with suspense', async () => { |
| 125 | + function useSuspendingHook(promise: Promise<string>) { |
| 126 | + return React.use(promise); |
| 127 | + } |
| 128 | + |
| 129 | + let resolvePromise: (value: string) => void; |
| 130 | + const promise = new Promise<string>((resolve) => { |
| 131 | + resolvePromise = resolve; |
| 132 | + }); |
| 133 | + |
| 134 | + const { result } = await renderHookAsync(useSuspendingHook, { |
| 135 | + initialProps: promise, |
| 136 | + wrapper: ({ children }) => <React.Suspense fallback="loading">{children}</React.Suspense>, |
| 137 | + }); |
| 138 | + |
| 139 | + // Initially suspended, result should not be available |
| 140 | + expect(result.current).toBeNull(); |
| 141 | + |
| 142 | + // eslint-disable-next-line require-await |
| 143 | + await act(async () => resolvePromise('resolved')); |
| 144 | + expect(result.current).toBe('resolved'); |
| 145 | +}); |
| 146 | + |
| 147 | +class ErrorBoundary extends React.Component< |
| 148 | + { children: React.ReactNode; fallback: string }, |
| 149 | + { hasError: boolean } |
| 150 | +> { |
| 151 | + constructor(props: { children: React.ReactNode; fallback: string }) { |
| 152 | + super(props); |
| 153 | + this.state = { hasError: false }; |
| 154 | + } |
| 155 | + |
| 156 | + static getDerivedStateFromError() { |
| 157 | + return { hasError: true }; |
| 158 | + } |
| 159 | + |
| 160 | + render() { |
| 161 | + return this.state.hasError ? this.props.fallback : this.props.children; |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +testGateReact19('handles hook suspense with error boundary', async () => { |
| 166 | + const ERROR_MESSAGE = 'Hook Promise Rejected In Test'; |
| 167 | + // eslint-disable-next-line no-console |
| 168 | + console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); |
| 169 | + |
| 170 | + function useSuspendingHook(promise: Promise<string>) { |
| 171 | + return React.use(promise); |
| 172 | + } |
| 173 | + |
| 174 | + let rejectPromise: (error: Error) => void; |
| 175 | + const promise = new Promise<string>((_resolve, reject) => { |
| 176 | + rejectPromise = reject; |
| 177 | + }); |
| 178 | + |
| 179 | + const { result } = await renderHookAsync(useSuspendingHook, { |
| 180 | + initialProps: promise, |
| 181 | + wrapper: ({ children }) => ( |
| 182 | + <ErrorBoundary fallback="error-fallback"> |
| 183 | + <React.Suspense fallback="loading">{children}</React.Suspense> |
| 184 | + </ErrorBoundary> |
| 185 | + ), |
| 186 | + }); |
| 187 | + |
| 188 | + // Initially suspended |
| 189 | + expect(result.current).toBeNull(); |
| 190 | + |
| 191 | + // eslint-disable-next-line require-await |
| 192 | + await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); |
| 193 | + |
| 194 | + // After error, result should still be null (error boundary caught it) |
| 195 | + expect(result.current).toBeNull(); |
| 196 | +}); |
| 197 | + |
| 198 | +test('handles custom hooks with complex logic', async () => { |
| 199 | + function useCounter(initialValue: number) { |
| 200 | + const [count, setCount] = React.useState(initialValue); |
| 201 | + |
| 202 | + const increment = React.useCallback(() => { |
| 203 | + setCount((prev) => prev + 1); |
| 204 | + }, []); |
| 205 | + |
| 206 | + const decrement = React.useCallback(() => { |
| 207 | + setCount((prev) => prev - 1); |
| 208 | + }, []); |
| 209 | + |
| 210 | + const reset = React.useCallback(() => { |
| 211 | + setCount(initialValue); |
| 212 | + }, [initialValue]); |
| 213 | + |
| 214 | + return { count, increment, decrement, reset }; |
| 215 | + } |
| 216 | + |
| 217 | + const { result, rerender } = await renderHookAsync(useCounter, { initialProps: 5 }); |
| 218 | + |
| 219 | + expect(result.current.count).toBe(5); |
| 220 | + |
| 221 | + // Test increment |
| 222 | + result.current.increment(); |
| 223 | + await rerender(5); |
| 224 | + expect(result.current.count).toBe(6); |
| 225 | + |
| 226 | + // Test decrement |
| 227 | + result.current.decrement(); |
| 228 | + await rerender(5); |
| 229 | + expect(result.current.count).toBe(5); |
| 230 | + |
| 231 | + // Test reset |
| 232 | + result.current.increment(); |
| 233 | + result.current.increment(); |
| 234 | + await rerender(5); |
| 235 | + expect(result.current.count).toBe(7); |
| 236 | + |
| 237 | + result.current.reset(); |
| 238 | + await rerender(5); |
| 239 | + expect(result.current.count).toBe(5); |
| 240 | +}); |
| 241 | + |
| 242 | +test('handles hook with cleanup and re-initialization', async () => { |
| 243 | + let effectCount = 0; |
| 244 | + let cleanupCount = 0; |
| 245 | + |
| 246 | + function useTestHook(props: { key: string }) { |
| 247 | + const [value, setValue] = React.useState(props.key); |
| 248 | + |
| 249 | + React.useEffect(() => { |
| 250 | + effectCount++; |
| 251 | + setValue(`${props.key}-effect`); |
| 252 | + |
| 253 | + return () => { |
| 254 | + cleanupCount++; |
| 255 | + }; |
| 256 | + }, [props.key]); |
| 257 | + |
| 258 | + return value; |
| 259 | + } |
| 260 | + |
| 261 | + const { result, rerender, unmount } = await renderHookAsync(useTestHook, { |
| 262 | + initialProps: { key: 'initial' }, |
| 263 | + }); |
| 264 | + |
| 265 | + expect(result.current).toBe('initial-effect'); |
| 266 | + expect(effectCount).toBe(1); |
| 267 | + expect(cleanupCount).toBe(0); |
| 268 | + |
| 269 | + await rerender({ key: 'updated' }); |
| 270 | + expect(result.current).toBe('updated-effect'); |
| 271 | + expect(effectCount).toBe(2); |
| 272 | + expect(cleanupCount).toBe(1); |
| 273 | + |
| 274 | + await unmount(); |
| 275 | + expect(effectCount).toBe(2); |
| 276 | + expect(cleanupCount).toBe(2); |
| 277 | +}); |
0 commit comments