diff --git a/src/__tests__/render-async.js b/src/__tests__/render-async.js new file mode 100644 index 00000000..435a5180 --- /dev/null +++ b/src/__tests__/render-async.js @@ -0,0 +1,193 @@ +/** + * Tests for renderAsync - the async variant of render that fully flushes + * useLayoutEffect chains before returning. This is the fix for components + * (e.g. React-Aria) that set ARIA attributes / roles in useLayoutEffect and + * therefore appear "incomplete" when queried immediately after a synchronous + * render() call in a consuming application's test suite. + * + * @see https://github.com/testing-library/react-testing-library/issues/1339 + */ +import * as React from 'react' +import ReactDOMServer from 'react-dom/server' +import {renderAsync, fireEvent, screen, configure} from '../' + +// --------------------------------------------------------------------------- +// Helper: simulates a React-Aria-style component that sets ARIA attributes +// inside useLayoutEffect (and may trigger a follow-up state update). +// --------------------------------------------------------------------------- + +/** + * Mimics a combobox-like component that: + * 1. Renders a plain on mount. + * 2. In useLayoutEffect, assigns the `role`, `aria-expanded`, and + * `aria-label` attributes that React-Aria would normally set - and also + * triggers a state update to simulate a second render cycle. + */ +function AriaComboBox({onSearch}) { + const inputRef = React.useRef(null) + const [initialised, setInitialised] = React.useState(false) + + React.useLayoutEffect(() => { + if (inputRef.current) { + // Simulate React-Aria setting ARIA attributes after mount + inputRef.current.setAttribute('role', 'combobox') + inputRef.current.setAttribute('aria-expanded', 'false') + inputRef.current.setAttribute('aria-label', 'Search') + inputRef.current.setAttribute('aria-autocomplete', 'list') + // Trigger a second render cycle (common in React-Aria internals) + setInitialised(true) + } + }, []) + + return ( +
+ onSearch && onSearch(e.target.value)} + /> +
+ ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('renderAsync', () => { + test('fully flushes useLayoutEffect before returning - ARIA attributes are present', async () => { + await renderAsync() + + // With synchronous render() these attributes would be missing because + // the useLayoutEffect -> setState -> re-render cycle hasn't completed. + const input = screen.getByRole('combobox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('aria-expanded', 'false') + expect(input).toHaveAttribute('aria-label', 'Search') + expect(input).toHaveAttribute('aria-autocomplete', 'list') + }) + + test('second render cycle triggered by useLayoutEffect is complete', async () => { + await renderAsync() + + const input = screen.getByRole('combobox') + // data-initialised is set to "true" only after setState in useLayoutEffect + expect(input).toHaveAttribute('data-initialised', 'true') + }) + + test('returned queries work correctly after async render', async () => { + const handleSearch = jest.fn() + const {getByPlaceholderText} = await renderAsync( + , + ) + + const input = getByPlaceholderText('Type to Start Searching...') + expect(input).toBeInTheDocument() + // The input should be fully interactive - role is set + expect(input).toHaveAttribute('role', 'combobox') + }) + + test('rerender (async) also flushes effects', async () => { + const {rerender, getByRole} = await renderAsync() + + // Rerender with a new prop - effects should still flush + await rerender( {}} />) + + const input = getByRole('combobox') + expect(input).toHaveAttribute('aria-expanded', 'false') + }) + + test('unmount works after async render', async () => { + const {unmount} = await renderAsync() + expect(() => unmount()).not.toThrow() + }) + + test('accepts the same options as render (wrapper, queries, etc.)', async () => { + const Context = React.createContext('default') + function Wrapper({children}) { + return {children} + } + + function ConsumerComponent() { + const value = React.useContext(Context) + return
{value}
+ } + + await renderAsync(, {wrapper: Wrapper}) + expect(screen.getByTestId('ctx-value')).toHaveTextContent('provided') + }) + + test('reactStrictMode option is respected', async () => { + const effectSpy = jest.fn() + function Component() { + React.useEffect(() => { + effectSpy() + }, []) + return null + } + + await renderAsync(, {reactStrictMode: true}) + // In StrictMode effects run twice + expect(effectSpy).toHaveBeenCalledTimes(2) + }) + + test('hydrate makes the UI interactive', async () => { + function App() { + const [clicked, handleClick] = React.useReducer(n => n + 1, 0) + return ( + + ) + } + const ui = + const container = document.createElement('div') + document.body.appendChild(container) + container.innerHTML = ReactDOMServer.renderToString(ui) + + expect(container).toHaveTextContent('clicked:0') + + await renderAsync(ui, {container, hydrate: true}) + + fireEvent.click(container.querySelector('button')) + + expect(container).toHaveTextContent('clicked:1') + }) + + test('legacyRoot throws when React does not support it', async () => { + // React 19+ does not expose ReactDOM.render + if (typeof require('react-dom').render !== 'function') { + await expect(renderAsync(
, {legacyRoot: true})).rejects.toThrow( + '`legacyRoot: true` is not supported', + ) + } + }) + + describe('reactStrictMode config', () => { + let originalConfig + beforeEach(() => { + configure(existingConfig => { + originalConfig = existingConfig + return {} + }) + }) + afterEach(() => { + configure(originalConfig) + }) + + test('respects global reactStrictMode config', async () => { + configure({reactStrictMode: true}) + const effectSpy = jest.fn() + function Component() { + React.useEffect(() => { + effectSpy() + }, []) + return null + } + + await renderAsync() + expect(effectSpy).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/pure.js b/src/pure.js index 054830fa..b0c66978 100644 --- a/src/pure.js +++ b/src/pure.js @@ -152,6 +152,37 @@ function createLegacyRoot(container) { } } +function buildRenderResult({baseElement, container, queries, root}) { + return { + container, + baseElement, + debug: (el = baseElement, maxLength, options) => + Array.isArray(el) + ? // eslint-disable-next-line no-console + el.forEach(e => console.log(prettyDOM(e, maxLength, options))) + : // eslint-disable-next-line no-console, + console.log(prettyDOM(el, maxLength, options)), + unmount: () => { + act(() => { + root.unmount() + }) + }, + asFragment: () => { + /* istanbul ignore else (old jsdom limitation) */ + if (typeof document.createRange === 'function') { + return document + .createRange() + .createContextualFragment(container.innerHTML) + } else { + const template = document.createElement('template') + template.innerHTML = container.innerHTML + return template.content + } + }, + ...getQueriesForElement(baseElement, queries), + } +} + function renderRoot( ui, { @@ -184,20 +215,10 @@ function renderRoot( } }) + const result = buildRenderResult({baseElement, container, queries, root}) + return { - container, - baseElement, - debug: (el = baseElement, maxLength, options) => - Array.isArray(el) - ? // eslint-disable-next-line no-console - el.forEach(e => console.log(prettyDOM(e, maxLength, options))) - : // eslint-disable-next-line no-console, - console.log(prettyDOM(el, maxLength, options)), - unmount: () => { - act(() => { - root.unmount() - }) - }, + ...result, rerender: rerenderUi => { renderRoot(rerenderUi, { container, @@ -209,61 +230,72 @@ function renderRoot( // Intentionally do not return anything to avoid unnecessarily complicating the API. // folks can use all the same utilities we return in the first place that are bound to the container }, - asFragment: () => { - /* istanbul ignore else (old jsdom limitation) */ - if (typeof document.createRange === 'function') { - return document - .createRange() - .createContextualFragment(container.innerHTML) - } else { - const template = document.createElement('template') - template.innerHTML = container.innerHTML - return template.content - } - }, - ...getQueriesForElement(baseElement, queries), } } -function render( +async function renderRootAsync( ui, { + baseElement, container, - baseElement = container, - legacyRoot = false, - onCaughtError, - onUncaughtError, - onRecoverableError, + hydrate, queries, - hydrate = false, - wrapper, + root, + wrapper: WrapperComponent, reactStrictMode, - } = {}, + }, ) { - if (onUncaughtError !== undefined) { - throw new Error( - 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.', - ) - } - if (legacyRoot && typeof ReactDOM.render !== 'function') { - const error = new Error( - '`legacyRoot: true` is not supported in this version of React. ' + - 'If your app runs React 19 or later, you should remove this flag. ' + - 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', - ) - Error.captureStackTrace(error, render) - throw error - } + await act(async () => { + if (hydrate) { + root.hydrate( + strictModeIfNeeded( + wrapUiIfNeeded(ui, WrapperComponent), + reactStrictMode, + ), + container, + ) + } else { + root.render( + strictModeIfNeeded( + wrapUiIfNeeded(ui, WrapperComponent), + reactStrictMode, + ), + container, + ) + } + }) - if (!baseElement) { - // default to document.body instead of documentElement to avoid output of potentially-large - // head elements (such as JSS style blocks) in debug output - baseElement = document.body - } - if (!container) { - container = baseElement.appendChild(document.createElement('div')) + const result = buildRenderResult({baseElement, container, queries, root}) + + return { + ...result, + rerender: async rerenderUi => { + await renderRootAsync(rerenderUi, { + container, + baseElement, + root, + wrapper: WrapperComponent, + reactStrictMode, + }) + // Intentionally do not return anything to avoid unnecessarily complicating the API. + // folks can use all the same utilities we return in the first place that are bound to the container + }, } +} +function buildRoot( + ui, + { + container, + baseElement, + legacyRoot, + onCaughtError, + onRecoverableError, + hydrate, + wrapper, + reactStrictMode, + }, +) { let root // eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it reused so we don't want to read the case that happens later first. if (!mountedContainers.has(container)) { @@ -285,22 +317,110 @@ function render( } else { mountedRootEntries.forEach(rootEntry => { // Else is unreachable since `mountedContainers` has the `container`. - // Only reachable if one would accidentally add the container to `mountedContainers` but not the root to `mountedRootEntries` + // Only reachable if one would accidentally add the container to `mountedRootEntries` /* istanbul ignore else */ if (rootEntry.container === container) { root = rootEntry.root } }) } + return root +} - return renderRoot(ui, { +function resolveRenderOptions(options = {}) { + let { + container, + baseElement = container, + legacyRoot = false, + onCaughtError, + onUncaughtError, + onRecoverableError, + queries, + hydrate = false, + wrapper, + reactStrictMode, + } = options + + if (onUncaughtError !== undefined) { + throw new Error( + 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.', + ) + } + + if (!baseElement) { + // default to document.body instead of documentElement to avoid output of potentially-large + // head elements (such as JSS style blocks) in debug output + baseElement = document.body + } + if (!container) { + container = baseElement.appendChild(document.createElement('div')) + } + + return { container, baseElement, + legacyRoot, + onCaughtError, + onRecoverableError, queries, hydrate, wrapper, - root, reactStrictMode, + } +} + +function render(ui, options = {}) { + const {legacyRoot, ...resolvedOptions} = resolveRenderOptions(options) + + /* istanbul ignore next */ + if (legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, render) + throw error + } + + const root = buildRoot(ui, {legacyRoot, ...resolvedOptions}) + + return renderRoot(ui, { + ...resolvedOptions, + root, + }) +} + +/** + * An async version of `render` that uses `await act(async () => {...})` to + * fully flush all pending effects - including `useLayoutEffect` chains that + * trigger state updates and re-renders (common in React-Aria and similar + * libraries). Use this when components don't appear fully initialised after a + * synchronous `render` call. + * + * @example + * const {getByRole} = await renderAsync() + * await userEvent.type(getByRole('combobox'), 'hello') + */ +async function renderAsync(ui, options = {}) { + const {legacyRoot, ...resolvedOptions} = resolveRenderOptions(options) + + /* istanbul ignore next */ + if (legacyRoot && typeof ReactDOM.render !== 'function') { + const error = new Error( + '`legacyRoot: true` is not supported in this version of React. ' + + 'If your app runs React 19 or later, you should remove this flag. ' + + 'If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.', + ) + Error.captureStackTrace(error, renderAsync) + throw error + } + + const root = buildRoot(ui, {legacyRoot, ...resolvedOptions}) + + return renderRootAsync(ui, { + ...resolvedOptions, + root, }) } @@ -320,6 +440,7 @@ function cleanup() { function renderHook(renderCallback, options = {}) { const {initialProps, ...renderOptions} = options + /* istanbul ignore next */ if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { const error = new Error( '`legacyRoot: true` is not supported in this version of React. ' + @@ -358,6 +479,15 @@ function renderHook(renderCallback, options = {}) { // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export {render, renderHook, cleanup, act, fireEvent, getConfig, configure} +export { + render, + renderAsync, + renderHook, + cleanup, + act, + fireEvent, + getConfig, + configure, +} /* eslint func-name-matching:0 */ diff --git a/types/index.d.ts b/types/index.d.ts index 439dddbf..9b91cae5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -181,6 +181,34 @@ export function render( options?: Omit | undefined, ): RenderResult +/** + * An async version of `render` that uses `await act(async () => {...})` to + * fully flush all pending effects - including `useLayoutEffect` chains that + * trigger state updates and re-renders (common in React-Aria and similar + * libraries). Use this when components don't appear fully initialised after a + * synchronous `render` call. + * + * @example + * const {getByRole} = await renderAsync() + * await userEvent.type(getByRole('combobox'), 'hello') + */ +export function renderAsync< + Q extends Queries = typeof queries, + Container extends RendererableContainer | HydrateableContainer = HTMLElement, + BaseElement extends RendererableContainer | HydrateableContainer = Container, +>( + ui: React.ReactNode, + options: RenderOptions, +): Promise< + RenderResult & { + rerender: (ui: React.ReactNode) => Promise + } +> +export function renderAsync( + ui: React.ReactNode, + options?: Omit | undefined, +): Promise Promise}> + export interface RenderHookResult { /** * Triggers a re-render. The props will be passed to your renderHook callback.