diff --git a/docs/api-reference.md b/docs/api-reference.md index b6d946aa..22ab6df4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -201,7 +201,7 @@ removed, the provided callback will no longer execute as part of running ### `waitForNextUpdate` ```ts -function waitForNextUpdate(options?: { timeout?: number }): Promise +function waitForNextUpdate(options?: { timeout?: number | false }): Promise ``` Returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as @@ -209,7 +209,9 @@ the result of an asynchronous update. #### `timeout` -The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. +_Default: 1000_ + +The maximum amount of time in milliseconds (ms) to wait. ### `waitFor` @@ -217,9 +219,8 @@ The maximum amount of time in milliseconds (ms) to wait. By default, no timeout function waitFor( callback: () => boolean | void, options?: { - interval?: number - timeout?: number - suppressErrors?: boolean + interval?: number | false + timeout?: number | false } ): Promise ``` @@ -230,19 +231,16 @@ in the callback to perform assertion or to test values. #### `interval` +_Default: 50_ + 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. +Interval checking is disabled if `interval` is not provided as a `falsy`. #### `timeout` -The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. - -#### `suppressErrors` +_Default: 1000_ -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. +The maximum amount of time in milliseconds (ms) to wait. ### `waitForValueToChange` @@ -250,9 +248,8 @@ rejected. By default, errors are suppressed for this utility. function waitForValueToChange( selector: () => any, options?: { - interval?: number - timeout?: number - suppressErrors?: boolean + interval?: number | false + timeout?: number | false } ): Promise ``` @@ -263,16 +260,13 @@ for comparison. #### `interval` +_Default: 50_ + 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. +Interval checking is disabled if `interval` is not provided as a `falsy`. #### `timeout` -The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. - -#### `suppressErrors` +_Default: 1000_ -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. +The maximum amount of time in milliseconds (ms) to wait. diff --git a/package.json b/package.json index 86aee639..729ac612 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,6 @@ "react-test-renderer": ">=16.9.0" }, "peerDependenciesMeta": { - "react": { - "optional": true - }, "react-dom": { "optional": true }, diff --git a/src/core/asyncUtils.ts b/src/core/asyncUtils.ts index d12e468c..fe44c715 100644 --- a/src/core/asyncUtils.ts +++ b/src/core/asyncUtils.ts @@ -1,97 +1,106 @@ -import { Act, WaitOptions, AsyncUtils } from '../types' +import { + Act, + WaitOptions, + WaitForOptions, + WaitForValueToChangeOptions, + WaitForNextUpdateOptions, + AsyncUtils +} from '../types' -import { resolveAfter } from '../helpers/promises' +import { resolveAfter, callAfter } from '../helpers/promises' import { TimeoutError } from '../helpers/error' +const DEFAULT_INTERVAL = 50 +const DEFAULT_TIMEOUT = 1000 + function asyncUtils(act: Act, addResolver: (callback: () => void) => void): AsyncUtils { - let nextUpdatePromise: Promise | null = null - - const waitForNextUpdate = async ({ timeout }: Pick = {}) => { - if (nextUpdatePromise) { - await nextUpdatePromise - } else { - nextUpdatePromise = new Promise((resolve, reject) => { - let timeoutId: ReturnType - if (timeout && timeout > 0) { - timeoutId = setTimeout( - () => reject(new TimeoutError(waitForNextUpdate, timeout)), - timeout - ) + const wait = async (callback: () => boolean | void, { interval, timeout }: WaitOptions) => { + const checkResult = () => { + const callbackResult = callback() + return callbackResult ?? callbackResult === undefined + } + + const waitForResult = async () => { + while (true) { + await Promise.race( + [ + new Promise((resolve) => addResolver(resolve)), + interval && resolveAfter(interval) + ].filter(Boolean) + ) + + if (checkResult()) { + return } - addResolver(() => { - clearTimeout(timeoutId) - nextUpdatePromise = null - resolve() - }) - }) - await act(() => nextUpdatePromise as Promise) + } } + + let timedOut = false + + if (!checkResult()) { + if (timeout) { + const timeoutPromise = () => + callAfter(() => { + timedOut = true + }, timeout) + + await act(() => Promise.race([waitForResult(), timeoutPromise()])) + } else { + await act(waitForResult) + } + } + + return !timedOut } const waitFor = async ( callback: () => boolean | void, - { interval, timeout, suppressErrors = true }: WaitOptions = {} + { interval = DEFAULT_INTERVAL, timeout = DEFAULT_TIMEOUT }: WaitForOptions = {} ) => { - const checkResult = () => { + const safeCallback = () => { try { - const callbackResult = callback() - return callbackResult ?? callbackResult === undefined + return callback() } catch (error: unknown) { - if (!suppressErrors) { - throw error - } - return undefined + return false } } - const waitForResult = async () => { - const initialTimeout = timeout - while (true) { - const startTime = Date.now() - try { - const nextCheck = interval - ? Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) - : waitForNextUpdate({ timeout }) - - await nextCheck - - if (checkResult()) { - return - } - } catch (error: unknown) { - if (error instanceof TimeoutError && initialTimeout) { - throw new TimeoutError(waitFor, initialTimeout) - } - throw error - } - if (timeout) timeout -= Date.now() - startTime - } + const result = await wait(safeCallback, { interval, timeout }) + if (!result && timeout) { + throw new TimeoutError(waitFor, timeout) } + } - if (!checkResult()) { - await waitForResult() + const waitForValueToChange = async ( + selector: () => unknown, + { interval = DEFAULT_INTERVAL, timeout = DEFAULT_TIMEOUT }: WaitForValueToChangeOptions = {} + ) => { + const initialValue = selector() + + const result = await wait(() => selector() !== initialValue, { interval, timeout }) + if (!result && timeout) { + throw new TimeoutError(waitForValueToChange, timeout) } } - const waitForValueToChange = async (selector: () => unknown, options: WaitOptions = {}) => { - const initialValue = selector() - try { - await waitFor(() => selector() !== initialValue, { - suppressErrors: false, - ...options - }) - } catch (error: unknown) { - if (error instanceof TimeoutError && options.timeout) { - throw new TimeoutError(waitForValueToChange, options.timeout) - } - throw error + const waitForNextUpdate = async ({ + timeout = DEFAULT_TIMEOUT + }: WaitForNextUpdateOptions = {}) => { + let updated = false + addResolver(() => { + updated = true + }) + + const result = await wait(() => updated, { interval: false, timeout }) + if (!result && timeout) { + throw new TimeoutError(waitForNextUpdate, timeout) } } return { waitFor, - waitForNextUpdate, - waitForValueToChange + waitForValueToChange, + waitForNextUpdate } } diff --git a/src/dom/__tests__/asyncHook.test.ts b/src/dom/__tests__/asyncHook.test.ts index 4484b81e..d460d35f 100644 --- a/src/dom/__tests__/asyncHook.test.ts +++ b/src/dom/__tests__/asyncHook.test.ts @@ -2,28 +2,29 @@ import { useState, useRef, useEffect } from 'react' import { renderHook } from '..' describe('async hook tests', () => { - const useSequence = (...values: string[]) => { + const useSequence = (values: string[], intervalMs = 50) => { const [first, ...otherValues] = values - const [value, setValue] = useState(first) + const [value, setValue] = useState(() => first) const index = useRef(0) useEffect(() => { const interval = setInterval(() => { setValue(otherValues[index.current++]) - if (index.current === otherValues.length) { + if (index.current >= otherValues.length) { clearInterval(interval) } - }, 50) + }, intervalMs) return () => { clearInterval(interval) } - }, [otherValues]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, otherValues) return value } test('should wait for next update', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -33,7 +34,9 @@ describe('async hook tests', () => { }) test('should wait for multiple updates', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitForNextUpdate } = renderHook(() => + useSequence(['first', 'second', 'third']) + ) expect(result.current).toBe('first') @@ -46,28 +49,28 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) - test('should resolve all when updating', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') - await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()]) - - expect(result.current).toBe('second') + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) }) - test('should reject if timeout exceeded when waiting for next update', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + test('should not reject when waiting for next update if timeout has been disabled', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'], 1100)) expect(result.current).toBe('first') - await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( - Error('Timed out in waitForNextUpdate after 10ms.') - ) + await waitForNextUpdate({ timeout: false }) + + expect(result.current).toBe('second') }) test('should wait for expectation to pass', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -90,19 +93,16 @@ describe('async hook tests', () => { }, 200) let complete = false - await waitFor( - () => { - expect(actual).toBe(expected) - complete = true - }, - { interval: 100 } - ) + await waitFor(() => { + expect(actual).toBe(expected) + complete = true + }) expect(complete).toBe(true) }) test('should not hang if expectation is already passing', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -114,45 +114,8 @@ describe('async hook tests', () => { expect(complete).toBe(true) }) - test('should reject if callback throws error', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - waitFor( - () => { - 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', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - waitFor( - () => { - throw new Error('Something Unexpected') - }, - { - suppressErrors: false - } - ) - ).rejects.toThrow(Error('Something Unexpected')) - }) - test('should wait for truthy value', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -171,13 +134,13 @@ describe('async hook tests', () => { actual = expected }, 200) - await waitFor(() => actual === 1, { interval: 100 }) + await waitFor(() => actual === 1) expect(actual).toBe(expected) }) test('should reject if timeout exceeded when waiting for expectation to pass', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -191,9 +154,42 @@ describe('async hook tests', () => { ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) }) + test('should not reject when waiting for expectation to pass if timeout has been disabled', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'], 550)) + + expect(result.current).toBe('first') + + await waitFor( + () => { + expect(result.current).toBe('third') + }, + { timeout: false } + ) + + expect(result.current).toBe('third') + }) + + test('should check on interval when waiting for expectation to pass', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) + + let checks = 0 + + try { + await waitFor( + () => { + checks++ + return result.current === 'third' + }, + { interval: 100 } + ) + } catch {} + + expect(checks).toBe(3) + }) + test('should wait for value to change', async () => { const { result, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -213,14 +209,14 @@ describe('async hook tests', () => { actual = expected }, 200) - await waitForValueToChange(() => actual, { interval: 100 }) + await waitForValueToChange(() => actual) 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') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -232,8 +228,22 @@ describe('async hook tests', () => { ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) }) + test('should not reject when waiting for value to change if timeout is disabled', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence(['first', 'second', 'third'], 550) + ) + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third', { + timeout: false + }) + + expect(result.current).toBe('third') + }) + test('should reject if selector throws error', async () => { - const { result, waitForValueToChange } = renderHook(() => useSequence('first', 'second')) + const { result, waitForValueToChange } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -246,24 +256,4 @@ describe('async hook tests', () => { }) ).rejects.toThrow(Error('Something Unexpected')) }) - - test('should not reject if selector throws error and suppress errors option is enabled', async () => { - const { result, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') - ) - - expect(result.current).toBe('first') - - await waitForValueToChange( - () => { - if (result.current === 'second') { - throw new Error('Something Unexpected') - } - return result.current === 'third' - }, - { suppressErrors: true } - ) - - expect(result.current).toBe('third') - }) }) diff --git a/src/helpers/promises.ts b/src/helpers/promises.ts index 8f7612ba..632a513c 100644 --- a/src/helpers/promises.ts +++ b/src/helpers/promises.ts @@ -1,7 +1,10 @@ function resolveAfter(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function callAfter(callback: () => void, ms: number) { + await resolveAfter(ms) + callback() } function isPromise(value: unknown): boolean { diff --git a/src/native/__tests__/asyncHook.test.ts b/src/native/__tests__/asyncHook.test.ts index 4484b81e..d460d35f 100644 --- a/src/native/__tests__/asyncHook.test.ts +++ b/src/native/__tests__/asyncHook.test.ts @@ -2,28 +2,29 @@ import { useState, useRef, useEffect } from 'react' import { renderHook } from '..' describe('async hook tests', () => { - const useSequence = (...values: string[]) => { + const useSequence = (values: string[], intervalMs = 50) => { const [first, ...otherValues] = values - const [value, setValue] = useState(first) + const [value, setValue] = useState(() => first) const index = useRef(0) useEffect(() => { const interval = setInterval(() => { setValue(otherValues[index.current++]) - if (index.current === otherValues.length) { + if (index.current >= otherValues.length) { clearInterval(interval) } - }, 50) + }, intervalMs) return () => { clearInterval(interval) } - }, [otherValues]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, otherValues) return value } test('should wait for next update', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -33,7 +34,9 @@ describe('async hook tests', () => { }) test('should wait for multiple updates', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitForNextUpdate } = renderHook(() => + useSequence(['first', 'second', 'third']) + ) expect(result.current).toBe('first') @@ -46,28 +49,28 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) - test('should resolve all when updating', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') - await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()]) - - expect(result.current).toBe('second') + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) }) - test('should reject if timeout exceeded when waiting for next update', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + test('should not reject when waiting for next update if timeout has been disabled', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence(['first', 'second'], 1100)) expect(result.current).toBe('first') - await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( - Error('Timed out in waitForNextUpdate after 10ms.') - ) + await waitForNextUpdate({ timeout: false }) + + expect(result.current).toBe('second') }) test('should wait for expectation to pass', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -90,19 +93,16 @@ describe('async hook tests', () => { }, 200) let complete = false - await waitFor( - () => { - expect(actual).toBe(expected) - complete = true - }, - { interval: 100 } - ) + await waitFor(() => { + expect(actual).toBe(expected) + complete = true + }) expect(complete).toBe(true) }) test('should not hang if expectation is already passing', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -114,45 +114,8 @@ describe('async hook tests', () => { expect(complete).toBe(true) }) - test('should reject if callback throws error', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - waitFor( - () => { - 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', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - waitFor( - () => { - throw new Error('Something Unexpected') - }, - { - suppressErrors: false - } - ) - ).rejects.toThrow(Error('Something Unexpected')) - }) - test('should wait for truthy value', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -171,13 +134,13 @@ describe('async hook tests', () => { actual = expected }, 200) - await waitFor(() => actual === 1, { interval: 100 }) + await waitFor(() => actual === 1) expect(actual).toBe(expected) }) test('should reject if timeout exceeded when waiting for expectation to pass', async () => { - const { result, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -191,9 +154,42 @@ describe('async hook tests', () => { ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) }) + test('should not reject when waiting for expectation to pass if timeout has been disabled', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'], 550)) + + expect(result.current).toBe('first') + + await waitFor( + () => { + expect(result.current).toBe('third') + }, + { timeout: false } + ) + + expect(result.current).toBe('third') + }) + + test('should check on interval when waiting for expectation to pass', async () => { + const { result, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) + + let checks = 0 + + try { + await waitFor( + () => { + checks++ + return result.current === 'third' + }, + { interval: 100 } + ) + } catch {} + + expect(checks).toBe(3) + }) + test('should wait for value to change', async () => { const { result, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -213,14 +209,14 @@ describe('async hook tests', () => { actual = expected }, 200) - await waitForValueToChange(() => actual, { interval: 100 }) + await waitForValueToChange(() => actual) 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') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -232,8 +228,22 @@ describe('async hook tests', () => { ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) }) + test('should not reject when waiting for value to change if timeout is disabled', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence(['first', 'second', 'third'], 550) + ) + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third', { + timeout: false + }) + + expect(result.current).toBe('third') + }) + test('should reject if selector throws error', async () => { - const { result, waitForValueToChange } = renderHook(() => useSequence('first', 'second')) + const { result, waitForValueToChange } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -246,24 +256,4 @@ describe('async hook tests', () => { }) ).rejects.toThrow(Error('Something Unexpected')) }) - - test('should not reject if selector throws error and suppress errors option is enabled', async () => { - const { result, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') - ) - - expect(result.current).toBe('first') - - await waitForValueToChange( - () => { - if (result.current === 'second') { - throw new Error('Something Unexpected') - } - return result.current === 'third' - }, - { suppressErrors: true } - ) - - expect(result.current).toBe('third') - }) }) diff --git a/src/server/__tests__/asyncHook.test.ts b/src/server/__tests__/asyncHook.test.ts index 09ae1871..7d23a981 100644 --- a/src/server/__tests__/asyncHook.test.ts +++ b/src/server/__tests__/asyncHook.test.ts @@ -1,30 +1,32 @@ import { useState, useRef, useEffect } from 'react' - import { renderHook } from '..' describe('async hook tests', () => { - const useSequence = (...values: string[]) => { + const useSequence = (values: string[], intervalMs = 50) => { const [first, ...otherValues] = values - const [value, setValue] = useState(first) + const [value, setValue] = useState(() => first) const index = useRef(0) useEffect(() => { const interval = setInterval(() => { setValue(otherValues[index.current++]) - if (index.current === otherValues.length) { + if (index.current >= otherValues.length) { clearInterval(interval) } - }, 50) + }, intervalMs) return () => { clearInterval(interval) } - }, [otherValues]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, otherValues) return value } test('should wait for next update', async () => { - const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + const { result, hydrate, waitForNextUpdate } = renderHook(() => + useSequence(['first', 'second']) + ) expect(result.current).toBe('first') @@ -39,7 +41,7 @@ describe('async hook tests', () => { test('should wait for multiple updates', async () => { const { result, hydrate, waitForNextUpdate } = renderHook(() => - useSequence('first', 'second', 'third') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -57,8 +59,10 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) - test('should resolve all when updating', async () => { - const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => + useSequence(['first', 'second']) + ) expect(result.current).toBe('first') @@ -66,13 +70,15 @@ describe('async hook tests', () => { expect(result.current).toBe('first') - await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()]) - - expect(result.current).toBe('second') + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) }) - test('should reject if timeout exceeded when waiting for next update', async () => { - const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + test('should not reject when waiting for next update if timeout has been disabled', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => + useSequence(['first', 'second'], 1100) + ) expect(result.current).toBe('first') @@ -80,13 +86,13 @@ describe('async hook tests', () => { expect(result.current).toBe('first') - await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( - Error('Timed out in waitForNextUpdate after 10ms.') - ) + await waitForNextUpdate({ timeout: false }) + + expect(result.current).toBe('second') }) test('should wait for expectation to pass', async () => { - const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + const { result, hydrate, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -115,19 +121,16 @@ describe('async hook tests', () => { }, 200) let complete = false - await waitFor( - () => { - expect(actual).toBe(expected) - complete = true - }, - { interval: 100 } - ) + await waitFor(() => { + expect(actual).toBe(expected) + complete = true + }) expect(complete).toBe(true) }) test('should not hang if expectation is already passing', async () => { - const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second')) + const { result, hydrate, waitFor } = renderHook(() => useSequence(['first', 'second'])) expect(result.current).toBe('first') @@ -143,8 +146,8 @@ describe('async hook tests', () => { expect(complete).toBe(true) }) - test('should reject if callback throws error', async () => { - const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + test('should wait for truthy value', async () => { + const { result, hydrate, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -152,23 +155,28 @@ describe('async hook tests', () => { expect(result.current).toBe('first') - await expect( - waitFor( - () => { - if (result.current === 'second') { - throw new Error('Something Unexpected') - } - return result.current === 'third' - }, - { - suppressErrors: false - } - ) - ).rejects.toThrow(Error('Something Unexpected')) + await waitFor(() => result.current === 'third') + + expect(result.current).toBe('third') }) - test('should reject if callback immediately throws error', async () => { - const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + test('should wait for arbitrary truthy value', async () => { + const { waitFor } = renderHook(() => null) + + let actual = 0 + const expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitFor(() => actual === 1) + + expect(actual).toBe(expected) + }) + + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { + const { result, hydrate, waitFor } = renderHook(() => useSequence(['first', 'second', 'third'])) expect(result.current).toBe('first') @@ -179,17 +187,17 @@ describe('async hook tests', () => { await expect( waitFor( () => { - throw new Error('Something Unexpected') + expect(result.current).toBe('third') }, - { - suppressErrors: false - } + { timeout: 75 } ) - ).rejects.toThrow(Error('Something Unexpected')) + ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) }) - test('should wait for truthy value', async () => { - const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) + test('should not reject when waiting for expectation to pass if timeout has been disabled', async () => { + const { result, hydrate, waitFor } = renderHook(() => + useSequence(['first', 'second', 'third'], 550) + ) expect(result.current).toBe('first') @@ -197,33 +205,39 @@ describe('async hook tests', () => { expect(result.current).toBe('first') - await waitFor(() => result.current === 'third') + await waitFor( + () => { + expect(result.current).toBe('third') + }, + { timeout: false } + ) expect(result.current).toBe('third') }) - test('should reject if timeout exceeded when waiting for expectation to pass', async () => { - const { result, hydrate, waitFor } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') + test('should check on interval when waiting for expectation to pass', async () => { + const { result, waitFor, hydrate } = renderHook(() => useSequence(['first', 'second', 'third'])) hydrate() - expect(result.current).toBe('first') + let checks = 0 - await expect( - waitFor( + try { + await waitFor( () => { - expect(result.current).toBe('third') + checks++ + return result.current === 'third' }, - { timeout: 75 } + { interval: 100 } ) - ).rejects.toThrow(Error('Timed out in waitFor after 75ms.')) + } catch {} + + expect(checks).toBe(3) }) test('should wait for value to change', async () => { const { result, hydrate, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -237,9 +251,24 @@ 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 + const expected = 1 + + setTimeout(() => { + actual = expected + }, 200) + + await waitForValueToChange(() => actual) + + expect(actual).toBe(expected) + }) + test('should reject if timeout exceeded when waiting for value to change', async () => { const { result, hydrate, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') + useSequence(['first', 'second', 'third']) ) expect(result.current).toBe('first') @@ -255,9 +284,9 @@ describe('async hook tests', () => { ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) }) - test('should reject if selector throws error', async () => { + test('should not reject when waiting for value to change if timeout is disabled', async () => { const { result, hydrate, waitForValueToChange } = renderHook(() => - useSequence('first', 'second') + useSequence(['first', 'second', 'third'], 550) ) expect(result.current).toBe('first') @@ -266,19 +295,16 @@ describe('async hook tests', () => { expect(result.current).toBe('first') - await expect( - waitForValueToChange(() => { - if (result.current === 'second') { - throw new Error('Something Unexpected') - } - return result.current - }) - ).rejects.toThrow(Error('Something Unexpected')) + await waitForValueToChange(() => result.current === 'third', { + timeout: false + }) + + expect(result.current).toBe('third') }) - test('should not reject if selector throws error and suppress errors option is enabled', async () => { + test('should reject if selector throws error', async () => { const { result, hydrate, waitForValueToChange } = renderHook(() => - useSequence('first', 'second', 'third') + useSequence(['first', 'second']) ) expect(result.current).toBe('first') @@ -287,16 +313,13 @@ describe('async hook tests', () => { expect(result.current).toBe('first') - await waitForValueToChange( - () => { + await expect( + waitForValueToChange(() => { if (result.current === 'second') { throw new Error('Something Unexpected') } - return result.current === 'third' - }, - { suppressErrors: true } - ) - - expect(result.current).toBe('third') + return result.current + }) + ).rejects.toThrow(Error('Something Unexpected')) }) }) diff --git a/src/types/index.ts b/src/types/index.ts index cdfe5aac..d847e1de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,16 +28,26 @@ export type ResultContainer = { result: RenderResult } -export interface WaitOptions { - interval?: number - timeout?: number - suppressErrors?: boolean +export type WaitOptions = { + interval?: number | false + timeout?: number | false } +export type WaitForOptions = WaitOptions +export type WaitForValueToChangeOptions = WaitOptions +export type WaitForNextUpdateOptions = Pick + +export type WaitFor = (callback: () => boolean | void, options?: WaitForOptions) => Promise +export type WaitForValueToChange = ( + selector: () => unknown, + options?: WaitForValueToChangeOptions +) => Promise +export type WaitForNextUpdate = (options?: WaitForNextUpdateOptions) => Promise + export type AsyncUtils = { - waitFor: (callback: () => boolean | void, opts?: WaitOptions) => Promise - waitForNextUpdate: (opts?: Pick) => Promise - waitForValueToChange: (selector: () => unknown, options?: WaitOptions) => Promise + waitFor: WaitFor + waitForValueToChange: WaitForValueToChange + waitForNextUpdate: WaitForNextUpdate } export type RenderHookResult<