diff --git a/docs/api-reference.md b/docs/api-reference.md index ab91f42f..53c7623a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -152,50 +152,92 @@ variable to `true` before importing `@testing-library/react-hooks` will also dis ### `waitForNextUpdate` ```js -function waitForNextUpdate(options?: WaitOptions): Promise +function waitForNextUpdate(options?: { + timeout?: number +}): Promise ``` Returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as the result of an asynchronous update. -See the [`wait` Options](/reference/api#wait-options) section for more details on the available -`options`. +#### `timeout` -### `wait` +The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. + +### `waitFor` ```js -function wait(callback: function(): boolean|void, options?: WaitOptions): Promise +function waitFor(callback: function(): boolean|void, options?: { + interval?: number, + timeout?: number, + suppressErrors?: boolean +}): Promise ``` Returns a `Promise` that resolves if the provided callback executes without exception and returns a truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result) in the callback to perform assertion or to test values. -The callback is tested after each render of the hook. By default, errors raised from the callback -will be suppressed (`suppressErrors = true`). +#### `interval` -See the [`wait` Options](/reference/api#wait-options) section for more details on the available -`options`. +The amount of time in milliseconds (ms) to wait between checks of the callback if no renders occur. +Interval checking is disabled if `interval` is not provided in the options or provided as a `falsy` +value. By default, it is disabled. + +#### `timeout` + +The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. + +#### `suppressErrors` + +If this option is set to `true`, any errors that occur while waiting are treated as a failed check. +If this option is set to `false`, any errors that occur while waiting cause the promise to be +rejected. By default, errors are suppressed for this utility. ### `waitForValueToChange` ```js -function waitForValueToChange(selector: function(): any, options?: WaitOptions): Promise +function waitForValueToChange(selector: function(): any, options?: { + interval?: number, + timeout?: number, + suppressErrors?: boolean +}): Promise ``` Returns a `Promise` that resolves if the value returned from the provided selector changes. It expected that the [`result` of `renderHook`](/reference/api#result) to select the value for comparison. -The value is selected for comparison after each render of the hook. By default, errors raised from -selecting the value will not be suppressed (`suppressErrors = false`). +#### `interval` + +The amount of time in milliseconds (ms) to wait between checks of the callback if no renders occur. +Interval checking is disabled if `interval` is not provided in the options or provided as a `falsy` +value. By default, it is disabled. + +#### `timeout` + +The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. + +#### `suppressErrors` -See the [`wait` Options](/reference/api#wait-options) section for more details on the available -`options`. +If this option is set to `true`, any errors that occur while waiting are treated as a failed check. +If this option is set to `false`, any errors that occur while waiting cause the promise to be +rejected. By default, errors are not suppressed for this utility. -### `wait` Options +### `wait` -The async utilities accept the following options: +_(DEPRECATED, use [`waitFor`](/reference/api#waitfor) instead)_ + +```js +function wait(callback: function(): boolean|void, options?: { + timeout?: number, + suppressErrors?: boolean +}): Promise +``` + +Returns a `Promise` that resolves if the provided callback executes without exception and returns a +truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result) +in the callback to perform assertion or to test values. #### `timeout` @@ -205,5 +247,4 @@ The maximum amount of time in milliseconds (ms) to wait. By default, no timeout If this option is set to `true`, any errors that occur while waiting are treated as a failed check. If this option is set to `false`, any errors that occur while waiting cause the promise to be -rejected. Please refer to the [utility descriptions](/reference/api#async-utilities) for the default -values of this option (if applicable). +rejected. By default, errors are suppressed for this utility. diff --git a/src/asyncUtils.js b/src/asyncUtils.js index c3cf7ab9..260d13f0 100644 --- a/src/asyncUtils.js +++ b/src/asyncUtils.js @@ -6,6 +6,14 @@ function createTimeoutError(utilName, { timeout }) { return timeoutError } +function resolveAfter(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +let hasWarnedDeprecatedWait = false + function asyncUtils(addResolver) { let nextUpdatePromise = null @@ -30,7 +38,7 @@ function asyncUtils(addResolver) { await nextUpdatePromise } - const wait = async (callback, { timeout, suppressErrors = true } = {}) => { + const waitFor = async (callback, { interval, timeout, suppressErrors = true } = {}) => { const checkResult = () => { try { const callbackResult = callback() @@ -47,13 +55,18 @@ function asyncUtils(addResolver) { while (true) { const startTime = Date.now() try { - await waitForNextUpdate({ timeout }) + const nextCheck = interval + ? Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) + : waitForNextUpdate({ timeout }) + + await nextCheck + if (checkResult()) { return } } catch (e) { if (e.timeout) { - throw createTimeoutError('wait', { timeout: initialTimeout }) + throw createTimeoutError('waitFor', { timeout: initialTimeout }) } throw e } @@ -69,7 +82,7 @@ function asyncUtils(addResolver) { const waitForValueToChange = async (selector, options = {}) => { const initialValue = selector() try { - await wait(() => selector() !== initialValue, { + await waitFor(() => selector() !== initialValue, { suppressErrors: false, ...options }) @@ -81,8 +94,26 @@ function asyncUtils(addResolver) { } } + const wait = async (callback, { timeout, suppressErrors } = {}) => { + if (!hasWarnedDeprecatedWait) { + hasWarnedDeprecatedWait = true + console.warn( + '`wait` has been deprecated. Use `waitFor` instead: https://react-hooks-testing-library.com/reference/api#waitfor.' + ) + } + try { + await waitFor(callback, { timeout, suppressErrors }) + } catch (e) { + if (e.timeout) { + throw createTimeoutError('wait', { timeout }) + } + throw e + } + } + return { wait, + waitFor, waitForNextUpdate, waitForValueToChange } diff --git a/test/asyncHook.test.js b/test/asyncHook.test.js index 61a58cb6..2ba12b4b 100644 --- a/test/asyncHook.test.js +++ b/test/asyncHook.test.js @@ -67,25 +67,47 @@ describe('async hook tests', () => { }) test('should wait for expectation to pass', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') let complete = false - await wait(() => { + await waitFor(() => { expect(result.current).toBe('third') complete = true }) expect(complete).toBe(true) }) + test('should wait for arbitrary expectation to pass', async () => { + const { waitFor } = renderHook(() => null) + + let actual = 0 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + let complete = false + await waitFor( + () => { + expect(actual).toBe(expected) + complete = true + }, + { interval: 100 } + ) + + expect(complete).toBe(true) + }) + test('should not hang if expectation is already passing', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second')) expect(result.current).toBe('first') let complete = false - await wait(() => { + await waitFor(() => { expect(result.current).toBe('first') complete = true }) @@ -93,12 +115,12 @@ describe('async hook tests', () => { }) test('should reject if callback throws error', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') await expect( - wait( + waitFor( () => { if (result.current === 'second') { throw new Error('Something Unexpected') @@ -113,12 +135,12 @@ describe('async hook tests', () => { }) test('should reject if callback immediately throws error', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') await expect( - wait( + waitFor( () => { throw new Error('Something Unexpected') }, @@ -130,28 +152,43 @@ describe('async hook tests', () => { }) test('should wait for truthy value', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') - await wait(() => result.current === 'third') + await waitFor(() => result.current === 'third') expect(result.current).toBe('third') }) + test('should wait for arbitrary truthy value', async () => { + const { waitFor } = renderHook(() => null) + + let actual = 0 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitFor(() => actual === 1, { interval: 100 }) + + expect(actual).toBe(expected) + }) + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) expect(result.current).toBe('first') await expect( - wait( + waitFor( () => { expect(result.current).toBe('third') }, { timeout: 75 } ) - ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) }) test('should wait for value to change', async () => { @@ -166,6 +203,21 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) + test('should wait for arbitrary value to change', async () => { + const { waitForValueToChange } = renderHook(() => null) + + let actual = 0 + let expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitForValueToChange(() => actual, { interval: 100 }) + + expect(actual).toBe(expected) + }) + test('should reject if timeout exceeded when waiting for value to change', async () => { const { result, waitForValueToChange } = renderHook(() => useSequence('first', 'second', 'third') @@ -214,4 +266,92 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) + + test('should wait for expectation to pass (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('third') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should not hang if expectation is already passing (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('first') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should reject if callback throws error (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should reject if callback immediately throws error (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + throw new Error('Something Unexpected') + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should wait for truthy value (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await wait(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for expectation to pass (deprecated)', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + expect(result.current).toBe('third') + }, + { timeout: 75 } + ) + ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + }) })