From 295c54f69043302e669315f62b314b46f71c587a Mon Sep 17 00:00:00 2001 From: Ayoub-glitsh Date: Mon, 18 May 2026 10:25:13 +0100 Subject: [PATCH 1/4] feat: add renderAsync to fix React-Aria useLayoutEffect flushing (#1339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components like React-Aria ComboBox set ARIA attributes (role, aria-expanded, aria-label, etc.) inside useLayoutEffect and often trigger a setState there, causing a second render cycle. The synchronous render() wraps the initial paint in act() but does not guarantee that the useLayoutEffect → setState → re-render chain completes before returning. This makes the DOM appear half-initialised in consuming-application tests even though the component works correctly at runtime. Changes: - Extract buildRenderResult() to share result-object construction between sync and async render paths - Extract buildRoot() and resolveRenderOptions() to eliminate duplication between render() and renderAsync() - Add renderRootAsync() internal helper that uses await act(async () => {...}) to drain the full microtask queue and all pending layout-effect cycles - Add renderAsync() public API — same signature as render(), returns a Promise that resolves once every effect (including useLayoutEffect chains) has flushed - Add TypeScript overloads for renderAsync in types/index.d.ts - Add test suite src/__tests__/render-async.js covering ARIA attribute presence, second render cycle completion, rerender, unmount, wrapper, and StrictMode Fixes #1339 --- src/__tests__/render-async.js | 171 ++++++++++++++++++++++++ src/pure.js | 238 +++++++++++++++++++++++++--------- types/index.d.ts | 24 ++++ 3 files changed, 373 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/render-async.js diff --git a/src/__tests__/render-async.js b/src/__tests__/render-async.js new file mode 100644 index 00000000..6470ea63 --- /dev/null +++ b/src/__tests__/render-async.js @@ -0,0 +1,171 @@ +/** + * 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 {renderAsync, 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 yet. + 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 the 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('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..cf0821db 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,108 @@ 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) + + 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) + + 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, }) } @@ -358,6 +476,6 @@ 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..20ebe0f8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -181,6 +181,30 @@ 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 & {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. From e3e04b8d75b185690fd97c20d3fcc4e3549c3cc1 Mon Sep 17 00:00:00 2001 From: Ayoub-glitsh Date: Mon, 18 May 2026 11:01:38 +0100 Subject: [PATCH 2/4] style: apply Prettier formatting to pass CI format check --- src/__tests__/render-async.js | 30 ++++++++++++++---------------- src/pure.js | 15 ++++++++++++--- types/index.d.ts | 12 +++++++++--- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/__tests__/render-async.js b/src/__tests__/render-async.js index 6470ea63..90dddfc6 100644 --- a/src/__tests__/render-async.js +++ b/src/__tests__/render-async.js @@ -1,5 +1,5 @@ /** - * Tests for renderAsync — the async variant of render that fully flushes + * 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 @@ -18,9 +18,9 @@ import {renderAsync, screen, configure} from '../' /** * 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. + * 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) @@ -55,11 +55,11 @@ function AriaComboBox({onSearch}) { // --------------------------------------------------------------------------- describe('renderAsync', () => { - test('fully flushes useLayoutEffect before returning — ARIA attributes are present', async () => { + 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 yet. + // 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') @@ -71,7 +71,7 @@ describe('renderAsync', () => { await renderAsync() const input = screen.getByRole('combobox') - // data-initialised is set to "true" only after the setState in useLayoutEffect + // data-initialised is set to "true" only after setState in useLayoutEffect expect(input).toHaveAttribute('data-initialised', 'true') }) @@ -83,14 +83,14 @@ describe('renderAsync', () => { const input = getByPlaceholderText('Type to Start Searching...') expect(input).toBeInTheDocument() - // The input should be fully interactive — role is set + // 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 + // Rerender with a new prop - effects should still flush await rerender( {}} />) const input = getByRole('combobox') @@ -105,9 +105,7 @@ describe('renderAsync', () => { test('accepts the same options as render (wrapper, queries, etc.)', async () => { const Context = React.createContext('default') function Wrapper({children}) { - return ( - {children} - ) + return {children} } function ConsumerComponent() { @@ -136,9 +134,9 @@ describe('renderAsync', () => { 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') + await expect(renderAsync(
, {legacyRoot: true})).rejects.toThrow( + '`legacyRoot: true` is not supported', + ) } }) diff --git a/src/pure.js b/src/pure.js index cf0821db..67639328 100644 --- a/src/pure.js +++ b/src/pure.js @@ -78,7 +78,7 @@ const mountedContainers = new Set() const mountedRootEntries = [] function strictModeIfNeeded(innerElement, reactStrictMode) { - return reactStrictMode ?? getConfig().reactStrictMode + return (reactStrictMode ?? getConfig().reactStrictMode) ? React.createElement(React.StrictMode, null, innerElement) : innerElement } @@ -392,7 +392,7 @@ function render(ui, options = {}) { /** * An async version of `render` that uses `await act(async () => {...})` to - * fully flush all pending effects — including `useLayoutEffect` chains that + * 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. @@ -476,6 +476,15 @@ function renderHook(renderCallback, options = {}) { // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export {render, renderAsync, 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 20ebe0f8..c28686b6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -54,7 +54,9 @@ export type BaseRenderOptions< > = RenderOptions type RendererableContainer = ReactDOMClient.Container -type HydrateableContainer = Parameters[0] +type HydrateableContainer = Parameters< + (typeof ReactDOMClient)['hydrateRoot'] +>[0] /** @deprecated */ export interface ClientRenderOptions< Q extends Queries, @@ -183,7 +185,7 @@ export function render( /** * An async version of `render` that uses `await act(async () => {...})` to - * fully flush all pending effects — including `useLayoutEffect` chains that + * 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. @@ -199,7 +201,11 @@ export function renderAsync< >( ui: React.ReactNode, options: RenderOptions, -): Promise & {rerender: (ui: React.ReactNode) => Promise}> +): Promise< + RenderResult & { + rerender: (ui: React.ReactNode) => Promise + } +> export function renderAsync( ui: React.ReactNode, options?: Omit | undefined, From 934bb2ba96949527590157b831b379be14f31792 Mon Sep 17 00:00:00 2001 From: Ayoub-glitsh Date: Mon, 18 May 2026 11:38:39 +0100 Subject: [PATCH 3/4] style: reformat with Prettier 2.7.1 (version used by kcd-scripts@13) --- src/pure.js | 2 +- types/index.d.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pure.js b/src/pure.js index 67639328..cec998b9 100644 --- a/src/pure.js +++ b/src/pure.js @@ -78,7 +78,7 @@ const mountedContainers = new Set() const mountedRootEntries = [] function strictModeIfNeeded(innerElement, reactStrictMode) { - return (reactStrictMode ?? getConfig().reactStrictMode) + return reactStrictMode ?? getConfig().reactStrictMode ? React.createElement(React.StrictMode, null, innerElement) : innerElement } diff --git a/types/index.d.ts b/types/index.d.ts index c28686b6..9b91cae5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -54,9 +54,7 @@ export type BaseRenderOptions< > = RenderOptions type RendererableContainer = ReactDOMClient.Container -type HydrateableContainer = Parameters< - (typeof ReactDOMClient)['hydrateRoot'] ->[0] +type HydrateableContainer = Parameters[0] /** @deprecated */ export interface ClientRenderOptions< Q extends Queries, From ec4aa85f5486d3202f5a446b96d3de5ebfa14ce5 Mon Sep 17 00:00:00 2001 From: Ayoub-glitsh Date: Mon, 18 May 2026 11:45:21 +0100 Subject: [PATCH 4/4] test: add hydrate coverage for renderAsync and ignore legacyRoot branches - Add hydrate test to cover the renderRootAsync hydrate path (L250) - Add istanbul ignore next on legacyRoot guards in render/renderAsync/renderHook These guards are only reachable on React 18 (tested via testGateReact18) and are not executed in the React 19 CI matrix job, matching the same pattern used in the original code before the refactor --- src/__tests__/render-async.js | 26 +++++++++++++++++++++++++- src/pure.js | 3 +++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/__tests__/render-async.js b/src/__tests__/render-async.js index 90dddfc6..435a5180 100644 --- a/src/__tests__/render-async.js +++ b/src/__tests__/render-async.js @@ -8,7 +8,8 @@ * @see https://github.com/testing-library/react-testing-library/issues/1339 */ import * as React from 'react' -import {renderAsync, screen, configure} from '../' +import ReactDOMServer from 'react-dom/server' +import {renderAsync, fireEvent, screen, configure} from '../' // --------------------------------------------------------------------------- // Helper: simulates a React-Aria-style component that sets ARIA attributes @@ -131,6 +132,29 @@ describe('renderAsync', () => { 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') { diff --git a/src/pure.js b/src/pure.js index cec998b9..b0c66978 100644 --- a/src/pure.js +++ b/src/pure.js @@ -372,6 +372,7 @@ function resolveRenderOptions(options = {}) { 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. ' + @@ -404,6 +405,7 @@ function render(ui, options = {}) { 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. ' + @@ -438,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. ' +