diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 366ce7186..46c97b328 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -34,5 +34,5 @@ labels: 'bug report' diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index c12ff4d43..062508b0c 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -1,6 +1,14 @@ name: Setup deps description: Setup Node.js and install dependencies +inputs: + react-version: + description: React version to install (e.g., ^19.2.0) + required: false + react-native-version: + description: React Native version to install (e.g., 0.83.1) + required: false + runs: using: composite steps: @@ -25,3 +33,9 @@ runs: if: steps.yarn-cache.outputs.cache-hit != 'true' run: yarn install --immutable shell: bash + + - name: Switch to React and React Native versions + if: inputs.react-version != '' && inputs.react-native-version != '' + run: | + yarn add -D react@${{ inputs.react-version }} @types/react@${{ inputs.react-version }} react-native@${{ inputs.react-native-version }} @react-native/babel-preset@${{ inputs.react-native-version }} + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 293fc97a4..e9777ec96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,15 +43,98 @@ jobs: - name: Typecheck run: yarn typecheck - typecheck-react-18: + typecheck-rn-0-83-1: runs-on: ubuntu-latest - name: Typecheck React 18 + name: Typecheck RN 0.83.1 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js and deps - uses: ./.github/actions/setup-deps-react-18 + uses: ./.github/actions/setup-deps + with: + react-version: 19.2.0 + react-native-version: 0.83.1 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-82-1: + runs-on: ubuntu-latest + name: Typecheck RN 0.82.1 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.1 + react-native-version: 0.82.1 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-81-5: + runs-on: ubuntu-latest + name: Typecheck RN 0.81.5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.81.5 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-80-2: + runs-on: ubuntu-latest + name: Typecheck RN 0.80.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.80.2 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-79-2: + runs-on: ubuntu-latest + name: Typecheck RN 0.79.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.79.2 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-78-3: + runs-on: ubuntu-latest + name: Typecheck RN 0.78.3 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.78.3 - name: Typecheck run: yarn typecheck @@ -74,15 +157,98 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-react-18: + test-rn-0-83-1: runs-on: ubuntu-latest - name: Test React 18 + name: Test RN 0.83.1 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js and deps - uses: ./.github/actions/setup-deps-react-18 + uses: ./.github/actions/setup-deps + with: + react-version: 19.2.0 + react-native-version: 0.83.1 + + - name: Test + run: yarn test:ci + + test-rn-0-82-1: + runs-on: ubuntu-latest + name: Test RN 0.82.1 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.1 + react-native-version: 0.82.1 + + - name: Test + run: yarn test:ci + + test-rn-0-81-5: + runs-on: ubuntu-latest + name: Test RN 0.81.5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.81.5 + + - name: Test + run: yarn test:ci + + test-rn-0-80-2: + runs-on: ubuntu-latest + name: Test RN 0.80.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.80.2 + + - name: Test + run: yarn test:ci + + test-rn-0-79-2: + runs-on: ubuntu-latest + name: Test RN 0.79.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.79.2 + + - name: Test + run: yarn test:ci + + test-rn-0-78-3: + runs-on: ubuntu-latest + name: Test RN 0.78.3 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.78.3 - name: Test run: yarn test:ci diff --git a/.yarnrc.yml b/.yarnrc.yml index b6bff7067..96756dd3e 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,10 +5,10 @@ npmMinimalAgeGate: '3d' npmPreapprovedPackages: - react - react-native - - react-test-renderer + - universal-test-renderer - '@react-native/*' - '@types/react' - - '@types/react-test-renderer' + - '@types/universal-test-renderer' - hermes-compiler yarnPath: .yarn/releases/yarn-4.11.0.cjs diff --git a/CLAUDE.md b/CLAUDE.md index 9b2bdfde3..cfd0c1020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is the **React Native Testing Library (RNTL)** - a comprehensive testing solution for React Native applications that provides React Native runtime simulation on top of `react-test-renderer`. The library encourages better testing practices by focusing on testing behavior rather than implementation details. +This is the **React Native Testing Library (RNTL)** - a comprehensive testing solution for React Native applications that provides React Native runtime simulation on top of `universal-test-renderer`. The library encourages better testing practices by focusing on testing behavior rather than implementation details. ## Key Development Commands @@ -37,7 +37,7 @@ To test a specific file: `yarn test path/to/test.test.tsx` 1. **`src/index.ts`** - Main entry point that sets up auto-cleanup and extends Jest matchers 2. **`src/pure.ts`** - Pure exports without auto-cleanup for advanced use cases -3. **`src/render.tsx`** - Core rendering functionality using `react-test-renderer` +3. **`src/render.tsx`** - Core rendering functionality using `universal-test-renderer` 4. **`src/screen.ts`** - Global screen object providing access to rendered components ### Key Modules @@ -131,7 +131,7 @@ The build creates: ## Testing Environment -- Uses `react-test-renderer` for component rendering +- Uses `universal-test-renderer` for component rendering - Fake timers recommended for user events - String validation available for text rendering checks - Supports both concurrent and legacy React rendering modes diff --git a/README.md b/README.md index 9a2920e24..8cbe0e8fc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ You want to write maintainable tests for your React Native components. As a part ## This solution -The React Native Testing Library (RNTL) is a comprehensive solution for testing React Native components. It provides React Native runtime simulation on top of `react-test-renderer`, in a way that encourages better testing practices. Its primary guiding principle is: +The React Native Testing Library (RNTL) is a comprehensive solution for testing React Native components. It provides React Native runtime simulation on top of `universal-test-renderer`, in a way that encourages better testing practices. Its primary guiding principle is: > The more your tests resemble the way your software is used, the more confidence they can give you. @@ -36,7 +36,7 @@ yarn add --dev @testing-library/react-native npm install --save-dev @testing-library/react-native ``` -This library has a `peerDependencies` listing for `react-test-renderer`. Make sure that your `react-test-renderer` version matches exactly the `react` version, avoid using `^` in version number. +This library has a `peerDependencies` listing for `universal-test-renderer`. Make sure that your `universal-test-renderer` version matches exactly the `react` version, avoid using `^` in version number. ### Additional Jest matchers diff --git a/eslint.config.mjs b/eslint.config.mjs index c02a5a65b..067b5607c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -50,7 +50,6 @@ export default [ 'react-native-a11y/has-valid-accessibility-ignores-invert-colors': 'off', 'react-native-a11y/has-valid-accessibility-value': 'off', '@typescript-eslint/no-explicit-any': 'off', - 'jest/no-standalone-expect': ['error', { additionalTestBlockFunctions: ['testGateReact19'] }], }, }, ]; diff --git a/jest-setup.ts b/jest-setup.ts index 2d6dd3c1d..462a0d4ba 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -2,7 +2,4 @@ import { resetToDefaults, configure } from './src/pure'; beforeEach(() => { resetToDefaults(); - if (process.env.CONCURRENT_MODE === '0') { - configure({ concurrentRoot: false }); - } }); diff --git a/package.json b/package.json index 796f9739a..84c681810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@testing-library/react-native", - "version": "13.3.3", + "version": "14.0.0-alpha.3", "description": "Simple and complete React Native testing utilities that encourage good testing practices.", "main": "build/index.js", "types": "build/index.d.ts", @@ -36,6 +36,7 @@ "build": "yarn clean && yarn build:js && yarn build:ts && yarn copy-flowtypes", "release": "release-it", "release:rc": "release-it --preRelease=rc", + "release:alpha": "release-it --preRelease=alpha", "prettier": "prettier --check .", "prettier:write": "prettier --write ." }, @@ -56,9 +57,9 @@ }, "peerDependencies": { "jest": ">=29.0.0", - "react": ">=18.2.0", + "react": ">=19.0.0", "react-native": ">=0.71", - "react-test-renderer": ">=18.2.0" + "universal-test-renderer": "~0.10.1" }, "peerDependenciesMeta": { "jest": { @@ -80,7 +81,6 @@ "@types/jest": "^30.0.0", "@types/node": "^24.10.1", "@types/react": "^19.2.6", - "@types/react-test-renderer": "^19.1.0", "babel-jest": "^30.2.0", "babel-plugin-module-resolver": "^5.0.2", "del-cli": "^7.0.0", @@ -92,10 +92,10 @@ "react": "19.1.1", "react-native": "0.82.1", "react-native-gesture-handler": "^2.29.1", - "react-test-renderer": "19.1.1", "release-it": "^19.0.6", "typescript": "^5.9.3", - "typescript-eslint": "^8.47.0" + "typescript-eslint": "^8.47.0", + "universal-test-renderer": "0.10.1" }, "publishConfig": { "registry": "https://registry.npmjs.org" diff --git a/src/__tests__/__snapshots__/render-debug.test.tsx.snap b/src/__tests__/__snapshots__/render-debug.test.tsx.snap index b3a962a38..a83ba390a 100644 --- a/src/__tests__/__snapshots__/render-debug.test.tsx.snap +++ b/src/__tests__/__snapshots__/render-debug.test.tsx.snap @@ -1,297 +1,311 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`debug 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - +"<> + + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + - Change freshness! + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug changing component: bananaFresh button message should now be "fresh" 1`] = ` -" - - Is the banana fresh? - - - fresh - - - - - - + + + Is the banana fresh? + + + fresh + + + + + + + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function onBlur]} + onClick={[Function onClick]} + onFocus={[Function onFocus]} + onResponderGrant={[Function onResponderGrant]} + onResponderMove={[Function onResponderMove]} + onResponderRelease={[Function onResponderRelease]} + onResponderTerminate={[Function onResponderTerminate]} + onResponderTerminationRequest={[Function onResponderTerminationRequest]} + onStartShouldSetResponder={[Function onStartShouldSetResponder]} + role="button" + > + + Change freshness! + + + + First Text + + + Second Text + - Change freshness! + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug should use debugOptions from config when no option is specified 1`] = ` -" - - hello - -" +"<> + + + hello + + +" `; exports[`debug should use given options over config debugOptions 1`] = ` -" + - - hello - -" + > + + hello + + +" `; exports[`debug with only children prop 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - +"<> - Change freshness! + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug with only prop whose value is bananaChef 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - +"<> - Change freshness! + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug: All Props 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - + + + Is the banana fresh? + + + not fresh + + + + + + + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function onBlur]} + onClick={[Function onClick]} + onFocus={[Function onFocus]} + onResponderGrant={[Function onResponderGrant]} + onResponderMove={[Function onResponderMove]} + onResponderRelease={[Function onResponderRelease]} + onResponderTerminate={[Function onResponderTerminate]} + onResponderTerminationRequest={[Function onResponderTerminationRequest]} + onStartShouldSetResponder={[Function onStartShouldSetResponder]} + role="button" + > + + Change freshness! + + + + First Text + + + Second Text + - Change freshness! + 0 - - First Text - - - Second Text - - - 0 - - + undefined" `; @@ -299,53 +313,55 @@ exports[`debug: Option message 1`] = ` "another custom message - - - Is the banana fresh? - - - not fresh - - - - - - +<> + - Change freshness! + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 - - First Text - - - Second Text - - - 0 - -" +" `; diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index dc454bea9..fa18b9be8 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -16,7 +16,6 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - concurrentRoot: true, }); }); diff --git a/src/__tests__/event-handler.test.tsx b/src/__tests__/event-handler.test.tsx index 2b4eb577e..2c7f5ac43 100644 --- a/src/__tests__/event-handler.test.tsx +++ b/src/__tests__/event-handler.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '..'; -import { getEventHandler } from '../event-handler'; +import { getEventHandlerFromProps } from '../event-handler'; test('getEventHandler strict mode', () => { const onPress = jest.fn(); @@ -22,13 +22,13 @@ test('getEventHandler strict mode', () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandler(regular, 'press')).toBe(onPress); - expect(getEventHandler(testOnly, 'press')).toBe(testOnlyOnPress); - expect(getEventHandler(both, 'press')).toBe(onPress); + expect(getEventHandlerFromProps(regular.props, 'press')).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress); + expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress); - expect(getEventHandler(regular, 'onPress')).toBe(undefined); - expect(getEventHandler(testOnly, 'onPress')).toBe(undefined); - expect(getEventHandler(both, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(undefined); }); test('getEventHandler loose mode', () => { @@ -49,11 +49,13 @@ test('getEventHandler loose mode', () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandler(regular, 'press', { loose: true })).toBe(onPress); - expect(getEventHandler(testOnly, 'press', { loose: true })).toBe(testOnlyOnPress); - expect(getEventHandler(both, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyOnPress); + expect(getEventHandlerFromProps(both.props, 'press', { loose: true })).toBe(onPress); - expect(getEventHandler(regular, 'onPress', { loose: true })).toBe(onPress); - expect(getEventHandler(testOnly, 'onPress', { loose: true })).toBe(testOnlyOnPress); - expect(getEventHandler(both, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(regular.props, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'onPress', { loose: true })).toBe( + testOnlyOnPress, + ); + expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress); }); diff --git a/src/__tests__/host-text-nesting.test.tsx b/src/__tests__/host-text-nesting.test.tsx index 305120a81..6e5b11915 100644 --- a/src/__tests__/host-text-nesting.test.tsx +++ b/src/__tests__/host-text-nesting.test.tsx @@ -1,20 +1,12 @@ import * as React from 'react'; import { Pressable, Text, View } from 'react-native'; -import { render, screen, within } from '../pure'; +import { render, screen } from '../pure'; -/** - * Our queries interact differently with composite and host elements, and some specific cases require us - * to crawl up the tree to a Text composite element to be able to traverse it down again. Going up the tree - * is a dangerous behaviour because we could take the risk of then traversing a sibling node to the original one. - * This test suite is designed to be able to test as many different combinations, as a safety net. - * Specific cases should still be tested within the relevant file (for instance an edge case with `within` should have - * an explicit test in the within test suite) - */ describe('nested text handling', () => { - test('within same node', () => { + test('basic', () => { render(Hello); - expect(within(screen.getByTestId('subject')).getByText('Hello')).toBeTruthy(); + expect(screen.getByText('Hello')).toBeTruthy(); }); test('role with direct text children', () => { diff --git a/src/__tests__/react-native-api.test.tsx b/src/__tests__/react-native-api.test.tsx index 1c0d84b11..b4926ccc9 100644 --- a/src/__tests__/react-native-api.test.tsx +++ b/src/__tests__/react-native-api.test.tsx @@ -3,9 +3,7 @@ import { FlatList, Image, Modal, ScrollView, Switch, Text, TextInput, View } fro import { render, screen } from '..'; import { mapJsonProps } from '../test-utils/json'; - -const isReact19 = React.version.startsWith('19.'); -const testGateReact19 = isReact19 ? test : test.skip; +import { getReactNativeVersion } from '../test-utils/version'; /** * Tests in this file are intended to give us an proactive warning that React Native behavior has @@ -15,7 +13,7 @@ const testGateReact19 = isReact19 ? test : test.skip; test('React Native API assumption: renders a single host element', () => { render(); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -25,7 +23,7 @@ test('React Native API assumption: renders a single host element', () => test('React Native API assumption: renders a single host element', () => { render(Hello); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -45,7 +43,7 @@ test('React Native API assumption: nested renders a single host element', , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -78,7 +76,7 @@ test('React Native API assumption: renders a single host element', ( />, ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` with nested Text renders single h , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` renders a single host element', () = test('React Native API assumption: renders a single host element', () => { render(Alt text); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` Alt text renders a single host element', , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -168,7 +166,7 @@ test('React Native API assumption: renders a single host {item}} />, ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` renders a single host `); }); -testGateReact19('React Native API assumption: renders a single host element', () => { +test('React Native API assumption: renders a single host element', () => { render( Modal Content , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + const rnVersion = getReactNativeVersion(); + if (rnVersion.major == 0 && rnVersion.minor <= 79) { + // eslint-disable-next-line jest/no-conditional-expect + expect(screen).toMatchInlineSnapshot(` + + + Modal Content + + + `); + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(screen).toMatchInlineSnapshot(` @@ -233,6 +247,7 @@ testGateReact19('React Native API assumption: renders a single host elem `); + } }); test('React Native API assumption: aria-* props render directly on host View', () => { @@ -259,7 +274,7 @@ test('React Native API assumption: aria-* props render directly on host View', ( />, ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` { expect(screen.getByTestId('inner')).toBeTruthy(); }); -test('renderAsync supports legacy rendering option', async () => { - await renderAsync(, { concurrentRoot: false }); - expect(screen.root).toBeOnTheScreen(); -}); - test('rerender function throws error when used with renderAsync', async () => { await renderAsync(); @@ -91,13 +86,3 @@ test('unmountAsync function unmounts component asynchronously', async () => { await screen.unmountAsync(); expect(fn).toHaveBeenCalled(); }); - -test('container property displays deprecation message', async () => { - await renderAsync(); - - expect(() => (screen as any).container).toThrowErrorMatchingInlineSnapshot(` - "'container' property has been renamed to 'UNSAFE_root'. - - Consider using 'root' property which returns root host element." - `); -}); diff --git a/src/__tests__/render-hook-async.test.tsx b/src/__tests__/render-hook-async.test.tsx deleted file mode 100644 index 8cc398ee6..000000000 --- a/src/__tests__/render-hook-async.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import type { ReactNode } from 'react'; -import * as React from 'react'; - -import { act, renderHookAsync } from '..'; -import { excludeConsoleMessage } from '../test-utils/console'; - -const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; - -// eslint-disable-next-line no-console -const originalConsoleError = console.error; -afterEach(() => { - // eslint-disable-next-line no-console - console.error = originalConsoleError; -}); - -function useSuspendingHook(promise: Promise) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore: React 18 does not have `use` hook - return React.use(promise); -} - -test('renderHookAsync renders hook asynchronously', async () => { - const { result } = await renderHookAsync(() => { - const [state, setState] = React.useState(1); - - React.useEffect(() => { - setState(2); - }, []); - - return state; - }); - - expect(result.current).toEqual(2); -}); - -test('renderHookAsync with wrapper option', async () => { - const Context = React.createContext('default'); - - function useTestHook() { - return React.useContext(Context); - } - - function Wrapper({ children }: { children: ReactNode }) { - return {children}; - } - - const { result } = await renderHookAsync(useTestHook, { wrapper: Wrapper }); - expect(result.current).toEqual('provided'); -}); - -test('renderHookAsync supports legacy rendering option', async () => { - function useTestHook() { - return React.useState(42)[0]; - } - - const { result } = await renderHookAsync(useTestHook, { concurrentRoot: false }); - expect(result.current).toEqual(42); -}); - -test('rerenderAsync function updates hook asynchronously', async () => { - function useTestHook(props: { value: number }) { - const [state, setState] = React.useState(props.value); - - React.useEffect(() => { - setState(props.value * 2); - }, [props.value]); - - return state; - } - - const { result, rerenderAsync } = await renderHookAsync(useTestHook, { - initialProps: { value: 5 }, - }); - expect(result.current).toEqual(10); - - await rerenderAsync({ value: 10 }); - expect(result.current).toEqual(20); -}); - -test('unmount function unmounts hook asynchronously', async () => { - let cleanupCalled = false; - - function useTestHook() { - React.useEffect(() => { - return () => { - cleanupCalled = true; - }; - }, []); - - return 'test'; - } - - const { unmountAsync } = await renderHookAsync(useTestHook); - expect(cleanupCalled).toBe(false); - - await unmountAsync(); - expect(cleanupCalled).toBe(true); -}); - -test('handles hook with state updates during effects', async () => { - function useTestHook() { - const [count, setCount] = React.useState(0); - - React.useEffect(() => { - setCount((prev) => prev + 1); - }, []); - - return count; - } - - const { result } = await renderHookAsync(useTestHook); - expect(result.current).toBe(1); -}); - -test('handles multiple state updates in effects', async () => { - function useTestHook() { - const [first, setFirst] = React.useState(1); - const [second, setSecond] = React.useState(2); - - React.useEffect(() => { - setFirst(10); - setSecond(20); - }, []); - - return { first, second }; - } - - const { result } = await renderHookAsync(useTestHook); - expect(result.current).toEqual({ first: 10, second: 20 }); -}); - -testGateReact19('handles hook with suspense', async () => { - let resolvePromise: (value: string) => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - const { result } = await renderHookAsync(useSuspendingHook, { - initialProps: promise, - wrapper: ({ children }) => {children}, - }); - - // Initially suspended, result should not be available - expect(result.current).toBeNull(); - - // eslint-disable-next-line require-await - await act(async () => resolvePromise('resolved')); - expect(result.current).toBe('resolved'); -}); - -class ErrorBoundary extends React.Component< - { children: React.ReactNode; fallback: string }, - { hasError: boolean } -> { - constructor(props: { children: React.ReactNode; fallback: string }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - render() { - return this.state.hasError ? this.props.fallback : this.props.children; - } -} - -testGateReact19('handles hook suspense with error boundary', async () => { - const ERROR_MESSAGE = 'Hook Promise Rejected In Test'; - // eslint-disable-next-line no-console - console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); - - let rejectPromise: (error: Error) => void; - const promise = new Promise((_resolve, reject) => { - rejectPromise = reject; - }); - - const { result } = await renderHookAsync(useSuspendingHook, { - initialProps: promise, - wrapper: ({ children }) => ( - - {children} - - ), - }); - - // Initially suspended - expect(result.current).toBeNull(); - - // eslint-disable-next-line require-await - await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); - - // After error, result should still be null (error boundary caught it) - expect(result.current).toBeNull(); -}); - -test('handles custom hooks with complex logic', async () => { - function useCounter(initialValue: number) { - const [count, setCount] = React.useState(initialValue); - - const increment = React.useCallback(() => { - setCount((prev) => prev + 1); - }, []); - - const decrement = React.useCallback(() => { - setCount((prev) => prev - 1); - }, []); - - const reset = React.useCallback(() => { - setCount(initialValue); - }, [initialValue]); - - return { count, increment, decrement, reset }; - } - - const { result } = await renderHookAsync(useCounter, { initialProps: 5 }); - expect(result.current.count).toBe(5); - - // eslint-disable-next-line require-await - await act(async () => { - result.current.increment(); - }); - expect(result.current.count).toBe(6); - - // eslint-disable-next-line require-await - await act(async () => { - result.current.reset(); - }); - expect(result.current.count).toBe(5); - - // eslint-disable-next-line require-await - await act(async () => { - result.current.decrement(); - }); - expect(result.current.count).toBe(4); -}); - -test('handles hook with cleanup and re-initialization', async () => { - let effectCount = 0; - let cleanupCount = 0; - - function useTestHook(props: { key: string }) { - const [value, setValue] = React.useState(props.key); - - React.useEffect(() => { - effectCount++; - setValue(`${props.key}-effect`); - - return () => { - cleanupCount++; - }; - }, [props.key]); - - return value; - } - - const { result, rerenderAsync, unmountAsync } = await renderHookAsync(useTestHook, { - initialProps: { key: 'initial' }, - }); - - expect(result.current).toBe('initial-effect'); - expect(effectCount).toBe(1); - expect(cleanupCount).toBe(0); - - await rerenderAsync({ key: 'updated' }); - expect(result.current).toBe('updated-effect'); - expect(effectCount).toBe(2); - expect(cleanupCount).toBe(1); - - await unmountAsync(); - expect(effectCount).toBe(2); - expect(cleanupCount).toBe(2); -}); diff --git a/src/__tests__/render-hook.test.tsx b/src/__tests__/render-hook.test.tsx index 9cdc6618b..01ca3ea6c 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -1,11 +1,19 @@ import type { ReactNode } from 'react'; -import React from 'react'; -import TestRenderer from 'react-test-renderer'; +import * as React from 'react'; +import { Text } from 'react-native'; -import { renderHook } from '../pure'; +import { act, renderHook } from '..'; +import { excludeConsoleMessage } from '../test-utils/console'; -test('gives committed result', () => { - const { result } = renderHook(() => { +// eslint-disable-next-line no-console +const originalConsoleError = console.error; +afterEach(() => { + // eslint-disable-next-line no-console + console.error = originalConsoleError; +}); + +test('renders hook and waits for effects to complete', async () => { + const { result } = await renderHook(() => { const [state, setState] = React.useState(1); React.useEffect(() => { @@ -18,8 +26,42 @@ test('gives committed result', () => { expect(result.current).toEqual([2, expect.any(Function)]); }); -test('allows rerendering', () => { - const { result, rerender } = renderHook( +test('handles multiple state updates in single effect', async () => { + function useTestHook() { + const [first, setFirst] = React.useState(1); + const [second, setSecond] = React.useState(2); + + React.useEffect(() => { + setFirst(10); + setSecond(20); + }, []); + + return { first, second }; + } + + const { result } = await renderHook(useTestHook); + expect(result.current).toEqual({ first: 10, second: 20 }); +}); + +test('renders hook with initialProps', async () => { + function useTestHook(props: { value: number }) { + const [state, setState] = React.useState(props.value); + + React.useEffect(() => { + setState(props.value * 2); + }, [props.value]); + + return state; + } + + const { result } = await renderHook(useTestHook, { + initialProps: { value: 5 }, + }); + expect(result.current).toEqual(10); +}); + +test('rerenders hook with new props', async () => { + const { result, rerender } = await renderHook( (props: { branch: 'left' | 'right' }) => { const [left, setLeft] = React.useState('left'); const [right, setRight] = React.useState('right'); @@ -38,17 +80,37 @@ test('allows rerendering', () => { expect(result.current).toEqual(['left', expect.any(Function)]); - rerender({ branch: 'right' }); + await rerender({ branch: 'right' }); expect(result.current).toEqual(['right', expect.any(Function)]); }); -test('allows wrapper components', () => { +test('rerender updates hook state based on props', async () => { + function useTestHook(props: { value: number }) { + const [state, setState] = React.useState(props.value); + + React.useEffect(() => { + setState(props.value * 2); + }, [props.value]); + + return state; + } + + const { result, rerender } = await renderHook(useTestHook, { + initialProps: { value: 5 }, + }); + expect(result.current).toEqual(10); + + await rerender({ value: 10 }); + expect(result.current).toEqual(20); +}); + +test('supports wrapper option for context providers', async () => { const Context = React.createContext('default'); function Wrapper({ children }: { children: ReactNode }) { return {children}; } - const { result } = renderHook( + const { result } = await renderHook( () => { return React.useContext(Context); }, @@ -60,44 +122,199 @@ test('allows wrapper components', () => { expect(result.current).toEqual('provided'); }); +test('unmount triggers cleanup effects', async () => { + let cleanupCalled = false; + + function useTestHook() { + React.useEffect(() => { + return () => { + cleanupCalled = true; + }; + }, []); + + return 'test'; + } + + const { unmount } = await renderHook(useTestHook); + expect(cleanupCalled).toBe(false); + + await unmount(); + expect(cleanupCalled).toBe(true); +}); + +test('handles cleanup effects on rerender and unmount', async () => { + let effectCount = 0; + let cleanupCount = 0; + + function useTestHook(props: { key: string }) { + const [value, setValue] = React.useState(props.key); + + React.useEffect(() => { + effectCount++; + setValue(`${props.key}-effect`); + + return () => { + cleanupCount++; + }; + }, [props.key]); + + return value; + } + + const { result, rerender, unmount } = await renderHook(useTestHook, { + initialProps: { key: 'initial' }, + }); + + expect(result.current).toBe('initial-effect'); + expect(effectCount).toBe(1); + expect(cleanupCount).toBe(0); + + await rerender({ key: 'updated' }); + expect(result.current).toBe('updated-effect'); + expect(effectCount).toBe(2); + expect(cleanupCount).toBe(1); + + await unmount(); + expect(effectCount).toBe(2); + expect(cleanupCount).toBe(2); +}); + function useMyHook(param: T) { return { param }; } -test('props type is inferred correctly when initial props is defined', () => { - const { result, rerender } = renderHook((num: number) => useMyHook(num), { +test('infers props type from initialProps', async () => { + const { result, rerender } = await renderHook((num: number) => useMyHook(num), { initialProps: 5, }); expect(result.current.param).toBe(5); - rerender(6); + await rerender(6); expect(result.current.param).toBe(6); }); -test('props type is inferred correctly when initial props is explicitly undefined', () => { - const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), { +test('infers props type when initialProps is undefined', async () => { + const { result, rerender } = await renderHook((num: number | undefined) => useMyHook(num), { initialProps: undefined, }); expect(result.current.param).toBeUndefined(); - rerender(6); + await rerender(6); expect(result.current.param).toBe(6); }); -/** - * This test makes sure that calling renderHook does - * not try to detect host component names in any form. - * But since there are numerous methods that could trigger that - * we check the count of renders using React Test Renderers. - */ -test('does render only once', () => { - jest.spyOn(TestRenderer, 'create'); +function useSuspendingHook(promise: Promise) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: React 18 does not have `use` hook + return React.use(promise); +} - renderHook(() => { - const [state, setState] = React.useState(1); - return [state, setState]; +test('supports React Suspense', async () => { + let resolvePromise: (value: string) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; }); - expect(TestRenderer.create).toHaveBeenCalledTimes(1); + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + Loading...}>{children} + ), + }); + + // Initially suspended, result should not be available + expect(result.current).toBeNull(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise('resolved')); + expect(result.current).toBe('resolved'); +}); + +class ErrorBoundary extends React.Component< + { children: React.ReactNode; fallback: string }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode; fallback: string }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + return this.state.hasError ? this.props.fallback : this.props.children; + } +} + +test('handles Suspense errors with error boundary', async () => { + const ERROR_MESSAGE = 'Hook Promise Rejected In Test'; + // eslint-disable-next-line no-console + console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); + + let rejectPromise: (error: Error) => void; + const promise = new Promise((_resolve, reject) => { + rejectPromise = reject; + }); + + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + + Loading...}>{children} + + ), + }); + + // Initially suspended + expect(result.current).toBeNull(); + + // eslint-disable-next-line require-await + await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); + + // After error, result should still be null (error boundary caught it) + expect(result.current).toBeNull(); +}); + +test('handles hooks with callbacks and complex state', async () => { + function useCounter(initialValue: number) { + const [count, setCount] = React.useState(initialValue); + + const increment = React.useCallback(() => { + setCount((prev) => prev + 1); + }, []); + + const decrement = React.useCallback(() => { + setCount((prev) => prev - 1); + }, []); + + const reset = React.useCallback(() => { + setCount(initialValue); + }, [initialValue]); + + return { count, increment, decrement, reset }; + } + + const { result } = await renderHook(useCounter, { initialProps: 5 }); + expect(result.current.count).toBe(5); + + // eslint-disable-next-line require-await + await act(async () => { + result.current.increment(); + }); + expect(result.current.count).toBe(6); + + // eslint-disable-next-line require-await + await act(async () => { + result.current.reset(); + }); + expect(result.current.count).toBe(5); + + // eslint-disable-next-line require-await + await act(async () => { + result.current.decrement(); + }); + expect(result.current.count).toBe(4); }); diff --git a/src/__tests__/render-string-validation.test.tsx b/src/__tests__/render-string-validation.test.tsx deleted file mode 100644 index 9ac25a01f..000000000 --- a/src/__tests__/render-string-validation.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; - -import { fireEvent, render, screen } from '..'; -import { excludeConsoleMessage } from '../test-utils/console'; - -// eslint-disable-next-line no-console -const originalConsoleError = console.error; - -const VALIDATION_ERROR = - 'Invariant Violation: Text strings must be rendered within a component'; -const PROFILER_ERROR = 'The above error occurred in the component'; - -beforeEach(() => { - // eslint-disable-next-line no-console - console.error = excludeConsoleMessage(console.error, PROFILER_ERROR); -}); -afterEach(() => { - // eslint-disable-next-line no-console - console.error = originalConsoleError; -}); - -test('should throw when rendering a string outside a text component', () => { - expect(() => - render(hello, { - unstable_validateStringsRenderedWithinText: true, - }), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test('should throw an error when rerendering with text outside of Text component', () => { - render(, { - unstable_validateStringsRenderedWithinText: true, - }); - - expect(() => screen.rerender(hello)).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -const InvalidTextAfterPress = () => { - const [showText, setShowText] = React.useState(false); - - if (!showText) { - return ( - setShowText(true)}> - Show text - - ); - } - - return text rendered outside text component; -}; - -test('should throw an error when strings are rendered outside Text', () => { - render(, { - unstable_validateStringsRenderedWithinText: true, - }); - - expect(() => fireEvent.press(screen.getByText('Show text'))).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "text rendered outside text component" string within a component.`, - ); -}); - -test('should not throw for texts nested in fragments', () => { - expect(() => - render( - - <>hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).not.toThrow(); -}); - -test('should not throw if option validateRenderedString is false', () => { - expect(() => render(hello)).not.toThrow(); -}); - -test(`should throw when one of the children is a text and the parent is not a Text component`, () => { - expect(() => - render( - - hello - hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test(`should throw when a string is rendered within a fragment rendered outside a Text`, () => { - expect(() => - render( - - <>hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test('should throw if a number is rendered outside a text', () => { - expect(() => - render(0, { unstable_validateStringsRenderedWithinText: true }), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "0" string within a component.`, - ); -}); - -const Trans = ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}; - -test('should throw with components returning string value not rendered in Text', () => { - expect(() => - render( - - - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test('should not throw with components returning string value rendered in Text', () => { - expect(() => - render( - - - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).not.toThrow(); -}); - -test('should throw when rendering string in a View in a Text', () => { - expect(() => - render( - - hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -const UseEffectComponent = () => { - const [showText, setShowText] = React.useState(false); - - React.useEffect(() => { - setShowText(true); - }, []); - - if (!showText) { - return Text is hidden; - } - - return ( - - Text is visible - - ); -}; - -test('should render immediate setState in useEffect properly', async () => { - render(, { unstable_validateStringsRenderedWithinText: true }); - - expect(await screen.findByText('Text is visible')).toBeTruthy(); -}); - -const InvalidUseEffectComponent = () => { - const [showText, setShowText] = React.useState(false); - - React.useEffect(() => { - setShowText(true); - }, []); - - if (!showText) { - return Text is hidden; - } - - return Text is visible; -}; - -test('should throw properly for immediate setState in useEffect', () => { - expect(() => - render(, { unstable_validateStringsRenderedWithinText: true }), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "Text is visible" string within a component.`, - ); -}); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 48151662b..ba4c21fe5 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -74,40 +74,9 @@ class Banana extends React.Component { } } -test('UNSAFE_getAllByType, UNSAFE_queryAllByType', () => { - render(); - const [text, status, button] = screen.UNSAFE_getAllByType(Text); - const InExistent = () => null; - - expect(text.props.children).toBe('Is the banana fresh?'); - expect(status.props.children).toBe('not fresh'); - expect(button.props.children).toBe('Change freshness!'); - expect(() => screen.UNSAFE_getAllByType(InExistent)).toThrow('No instances found'); - - expect(screen.UNSAFE_queryAllByType(Text)[1]).toBe(status); - expect(screen.UNSAFE_queryAllByType(InExistent)).toHaveLength(0); -}); - -test('UNSAFE_getByProps, UNSAFE_queryByProps', () => { - render(); - const primaryType = screen.UNSAFE_getByProps({ type: 'primary' }); - - expect(primaryType.props.children).toBe('Change freshness!'); - expect(() => screen.UNSAFE_getByProps({ type: 'inexistent' })).toThrow('No instances found'); - - expect(screen.UNSAFE_queryByProps({ type: 'primary' })).toBe(primaryType); - expect(screen.UNSAFE_queryByProps({ type: 'inexistent' })).toBeNull(); -}); - -test('UNSAFE_getAllByProp, UNSAFE_queryAllByProps', () => { - render(); - const primaryTypes = screen.UNSAFE_getAllByProps({ type: 'primary' }); - - expect(primaryTypes).toHaveLength(1); - expect(() => screen.UNSAFE_getAllByProps({ type: 'inexistent' })).toThrow('No instances found'); - - expect(screen.UNSAFE_queryAllByProps({ type: 'primary' })).toEqual(primaryTypes); - expect(screen.UNSAFE_queryAllByProps({ type: 'inexistent' })).toHaveLength(0); +test('supports basic rendering', () => { + render(); + expect(screen.root).toBeOnTheScreen(); }); test('rerender', () => { @@ -145,7 +114,7 @@ test('unmount should handle cleanup functions', () => { test('toJSON renders host output', () => { render(press me); - expect(screen.toJSON()).toMatchSnapshot(); + expect(screen).toMatchSnapshot(); }); test('renders options.wrapper around node', () => { @@ -159,7 +128,7 @@ test('renders options.wrapper around node', () => { }); expect(screen.getByTestId('wrapper')).toBeTruthy(); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -183,7 +152,7 @@ test('renders options.wrapper around updated node', () => { screen.rerender(); expect(screen.getByTestId('wrapper')).toBeTruthy(); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -200,26 +169,8 @@ test('returns host root', () => { render(); expect(screen.root).toBeDefined(); - expect(screen.root.type).toBe('View'); - expect(screen.root.props.testID).toBe('inner'); -}); - -test('returns composite UNSAFE_root', () => { - render(); - - expect(screen.UNSAFE_root).toBeDefined(); - expect(screen.UNSAFE_root.type).toBe(View); - expect(screen.UNSAFE_root.props.testID).toBe('inner'); -}); - -test('container displays deprecation', () => { - render(); - - expect(() => (screen as any).container).toThrowErrorMatchingInlineSnapshot(` - "'container' property has been renamed to 'UNSAFE_root'. - - Consider using 'root' property which returns root host element." - `); + expect(screen.root?.type).toBe('View'); + expect(screen.root?.props.testID).toBe('inner'); }); test('RenderAPI type', () => { @@ -234,16 +185,6 @@ test('returned output can be spread using rest operator', () => { expect(rest).toBeTruthy(); }); -test('supports legacy rendering', () => { - render(, { concurrentRoot: false }); - expect(screen.root).toBeOnTheScreen(); -}); - -test('supports concurrent rendering', () => { - render(, { concurrentRoot: true }); - expect(screen.root).toBeOnTheScreen(); -}); - test('rerenderAsync updates the component asynchronously', async () => { const fn = jest.fn(); const result = render(); diff --git a/src/__tests__/screen.test.tsx b/src/__tests__/screen.test.tsx index de5d72c23..d41bd4e29 100644 --- a/src/__tests__/screen.test.tsx +++ b/src/__tests__/screen.test.tsx @@ -53,8 +53,8 @@ test('screen works with nested re-mounting rerender', () => { }); test('screen throws without render', () => { + expect(() => screen.container).toThrow('`render` method has not been called'); expect(() => screen.root).toThrow('`render` method has not been called'); - expect(() => screen.UNSAFE_root).toThrow('`render` method has not been called'); expect(() => screen.debug()).toThrow('`render` method has not been called'); expect(() => screen.getByText('Mt. Everest')).toThrow('`render` method has not been called'); }); diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index a3ac0c07f..efb09a8ec 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -6,8 +6,6 @@ import { excludeConsoleMessage } from '../test-utils/console'; jest.useFakeTimers(); -const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; - // eslint-disable-next-line no-console const originalConsoleError = console.error; afterEach(() => { @@ -22,7 +20,7 @@ function Suspending({ promise, testID }: { promise: Promise; testID: st return ; } -testGateReact19('resolves manually-controlled promise', async () => { +test('resolves manually-controlled promise', async () => { let resolvePromise: (value: unknown) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; @@ -47,7 +45,7 @@ testGateReact19('resolves manually-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('resolves timer-controlled promise', async () => { +test('resolves timer-controlled promise', async () => { const promise = new Promise((resolve) => { setTimeout(() => resolve(null), 100); }); @@ -88,7 +86,7 @@ class ErrorBoundary extends React.Component< } } -testGateReact19('handles promise rejection with error boundary', async () => { +test('handles promise rejection with error boundary', async () => { const ERROR_MESSAGE = 'Promise Rejected In Test'; // eslint-disable-next-line no-console console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); @@ -117,7 +115,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspending components', async () => { +test('handles multiple suspending components', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -154,7 +152,7 @@ testGateReact19('handles multiple suspending components', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspense boundaries independently', async () => { +test('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index ac794c7a6..c84e93b12 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -4,8 +4,6 @@ import { Text, View } from 'react-native'; import { act, renderAsync, screen } from '..'; import { excludeConsoleMessage } from '../test-utils/console'; -const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; - // eslint-disable-next-line no-console const originalConsoleError = console.error; afterEach(() => { @@ -20,7 +18,7 @@ function Suspending({ promise, testID }: { promise: Promise; testID: st return ; } -testGateReact19('resolves manually-controlled promise', async () => { +test('resolves manually-controlled promise', async () => { let resolvePromise: (value: unknown) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; @@ -45,7 +43,7 @@ testGateReact19('resolves manually-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('resolves timer-controlled promise', async () => { +test('resolves timer-controlled promise', async () => { const promise = new Promise((resolve) => { setTimeout(() => resolve(null), 100); }); @@ -85,7 +83,7 @@ class ErrorBoundary extends React.Component< } } -testGateReact19('handles promise rejection with error boundary', async () => { +test('handles promise rejection with error boundary', async () => { const ERROR_MESSAGE = 'Promise Rejected In Test'; // eslint-disable-next-line no-console console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); @@ -114,7 +112,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspending components', async () => { +test('handles multiple suspending components', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -151,7 +149,7 @@ testGateReact19('handles multiple suspending components', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspense boundaries independently', async () => { +test('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; diff --git a/src/act.ts b/src/act.ts index 8d503b5cf..61481f8ea 100644 --- a/src/act.ts +++ b/src/act.ts @@ -1,10 +1,9 @@ // This file and the act() implementation is sourced from react-testing-library // https://github.com/testing-library/react-testing-library/blob/3dcd8a9649e25054c0e650d95fca2317b7008576/types/index.d.ts import * as React from 'react'; -import { act as reactTestRendererAct } from 'react-test-renderer'; -const reactAct = typeof React.act === 'function' ? React.act : reactTestRendererAct; -type ReactAct = 0 extends 1 & typeof React.act ? typeof reactTestRendererAct : typeof React.act; +const reactAct = React.act; +type ReactAct = typeof React.act; // See https://github.com/reactwg/react-18/discussions/102 for more context on global.IS_REACT_ACT_ENVIRONMENT declare global { @@ -67,7 +66,6 @@ function withGlobalActEnvironment(actImplementation: ReactAct) { }; } -// @ts-expect-error: typings get too complex const act = withGlobalActEnvironment(reactAct) as ReactAct; export default act; diff --git a/src/config.ts b/src/config.ts index e861d0eb1..121e33bc4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,12 +13,6 @@ export type Config = { /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; - - /** - * Set to `false` to disable concurrent rendering. - * Otherwise `render` will default to concurrent rendering. - */ - concurrentRoot: boolean; }; export type ConfigAliasOptions = { @@ -29,7 +23,6 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - concurrentRoot: true, }; let config = { ...defaultConfig }; diff --git a/src/event-handler.ts b/src/event-handler.ts index 8f275c6b4..f2c6ec83e 100644 --- a/src/event-handler.ts +++ b/src/event-handler.ts @@ -1,30 +1,30 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +export type EventHandler = (...args: unknown[]) => unknown; export type EventHandlerOptions = { /** Include check for event handler named without adding `on*` prefix. */ loose?: boolean; }; -export function getEventHandler( - element: ReactTestInstance, +export function getEventHandlerFromProps( + props: Record, eventName: string, options?: EventHandlerOptions, -) { +): EventHandler | undefined { const handlerName = getEventHandlerName(eventName); - if (typeof element.props[handlerName] === 'function') { - return element.props[handlerName]; + if (typeof props[handlerName] === 'function') { + return props[handlerName] as EventHandler; } - if (options?.loose && typeof element.props[eventName] === 'function') { - return element.props[eventName]; + if (options?.loose && typeof props[eventName] === 'function') { + return props[eventName] as EventHandler; } - if (typeof element.props[`testOnly_${handlerName}`] === 'function') { - return element.props[`testOnly_${handlerName}`]; + if (typeof props[`testOnly_${handlerName}`] === 'function') { + return props[`testOnly_${handlerName}`] as EventHandler; } - if (options?.loose && typeof element.props[`testOnly_${eventName}`] === 'function') { - return element.props[`testOnly_${eventName}`]; + if (options?.loose && typeof props[`testOnly_${eventName}`] === 'function') { + return props[`testOnly_${eventName}`] as EventHandler; } return undefined; diff --git a/src/fire-event.ts b/src/fire-event.ts index 981e6e649..71c32127b 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -5,10 +5,11 @@ import type { TextProps, ViewProps, } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; +import type { Fiber, HostElement } from 'universal-test-renderer'; import act from './act'; -import { getEventHandler } from './event-handler'; +import type { EventHandler } from './event-handler'; +import { getEventHandlerFromProps } from './event-handler'; import { isElementMounted, isHostElement } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; @@ -16,9 +17,7 @@ import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; import type { Point, StringWithAutocomplete } from './types'; -type EventHandler = (...args: unknown[]) => unknown; - -export function isTouchResponder(element: ReactTestInstance) { +export function isTouchResponder(element: HostElement) { if (!isHostElement(element)) { return false; } @@ -50,9 +49,9 @@ const textInputEventsIgnoringEditableProp = new Set([ ]); export function isEventEnabled( - element: ReactTestInstance, + element: HostElement, eventName: string, - nearestTouchResponder?: ReactTestInstance, + nearestTouchResponder?: HostElement, ) { if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { return ( @@ -75,13 +74,15 @@ export function isEventEnabled( } function findEventHandler( - element: ReactTestInstance, + element: HostElement, eventName: string, - nearestTouchResponder?: ReactTestInstance, + nearestTouchResponder?: HostElement, ): EventHandler | null { const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder; - const handler = getEventHandler(element, eventName, { loose: true }); + const handler = + getEventHandlerFromProps(element.props, eventName, { loose: true }) ?? + findEventHandlerFromFiber(element.unstable_fiber, eventName); if (handler && isEventEnabled(element, eventName, touchResponder)) { return handler; } @@ -93,6 +94,25 @@ function findEventHandler( return findEventHandler(element.parent, eventName, touchResponder); } +function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { + // Container fibers have memoizedProps set to null + if (!fiber?.memoizedProps) { + return null; + } + + const handler = getEventHandlerFromProps(fiber.memoizedProps, eventName, { loose: true }); + if (handler) { + return handler; + } + + // No parent fiber or we reached another host element + if (fiber.return === null || typeof fiber.return.type === 'string') { + return null; + } + + return findEventHandlerFromFiber(fiber.return, eventName); +} + // String union type of keys of T that start with on, stripped of 'on' type EventNameExtractor = keyof { [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; @@ -106,7 +126,7 @@ type EventName = StringWithAutocomplete< | EventNameExtractor >; -function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { +function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -126,20 +146,16 @@ function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: un return returnValue; } -fireEvent.press = (element: ReactTestInstance, ...data: unknown[]) => +fireEvent.press = (element: HostElement, ...data: unknown[]) => fireEvent(element, 'press', ...data); -fireEvent.changeText = (element: ReactTestInstance, ...data: unknown[]) => +fireEvent.changeText = (element: HostElement, ...data: unknown[]) => fireEvent(element, 'changeText', ...data); -fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => +fireEvent.scroll = (element: HostElement, ...data: unknown[]) => fireEvent(element, 'scroll', ...data); -async function fireEventAsync( - element: ReactTestInstance, - eventName: EventName, - ...data: unknown[] -) { +async function fireEventAsync(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -160,13 +176,13 @@ async function fireEventAsync( return returnValue; } -fireEventAsync.press = async (element: ReactTestInstance, ...data: unknown[]) => +fireEventAsync.press = async (element: HostElement, ...data: unknown[]) => await fireEventAsync(element, 'press', ...data); -fireEventAsync.changeText = async (element: ReactTestInstance, ...data: unknown[]) => +fireEventAsync.changeText = async (element: HostElement, ...data: unknown[]) => await fireEventAsync(element, 'changeText', ...data); -fireEventAsync.scroll = async (element: ReactTestInstance, ...data: unknown[]) => +fireEventAsync.scroll = async (element: HostElement, ...data: unknown[]) => await fireEventAsync(element, 'scroll', ...data); export { fireEventAsync }; @@ -180,7 +196,7 @@ const scrollEventNames = new Set([ 'momentumScrollEnd', ]); -function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, value: unknown) { +function setNativeStateIfNeeded(element: HostElement, eventName: string, value: unknown) { if (eventName === 'changeText' && typeof value === 'string' && isEditableTextInput(element)) { nativeState.valueForElement.set(element, value); } diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index c8a33036b..8ec9715b2 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -1,18 +1,8 @@ import React from 'react'; -import { Text, TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { render, screen } from '../..'; -import { - getHostChildren, - getHostParent, - getHostSelves, - getHostSiblings, - getUnsafeRootElement, -} from '../component-tree'; - -function ZeroHostChildren() { - return <>; -} +import { getContainerElement, getHostSiblings } from '../component-tree'; function MultipleHostChildren() { return ( @@ -24,155 +14,6 @@ function MultipleHostChildren() { ); } -describe('getHostParent()', () => { - it('returns host parent for host component', () => { - render( - - - - - - , - ); - - const hostParent = getHostParent(screen.getByTestId('subject')); - expect(hostParent).toBe(screen.getByTestId('parent')); - - const hostGrandparent = getHostParent(hostParent); - expect(hostGrandparent).toBe(screen.getByTestId('grandparent')); - - expect(getHostParent(hostGrandparent)).toBe(null); - }); - - it('returns host parent for null', () => { - expect(getHostParent(null)).toBe(null); - }); - - it('returns host parent for composite component', () => { - render( - - - - , - ); - - const compositeComponent = screen.UNSAFE_getByType(MultipleHostChildren); - const hostParent = getHostParent(compositeComponent); - expect(hostParent).toBe(screen.getByTestId('parent')); - }); -}); - -describe('getHostChildren()', () => { - it('returns host children for host component', () => { - render( - - - - Hello - - , - ); - - const hostSubject = screen.getByTestId('subject'); - expect(getHostChildren(hostSubject)).toEqual([]); - - const hostSibling = screen.getByTestId('sibling'); - expect(getHostChildren(hostSibling)).toEqual([]); - - const hostParent = screen.getByTestId('parent'); - expect(getHostChildren(hostParent)).toEqual([hostSubject, hostSibling]); - - const hostGrandparent = screen.getByTestId('grandparent'); - expect(getHostChildren(hostGrandparent)).toEqual([hostParent]); - }); - - it('returns host children for composite component', () => { - render( - - - - - , - ); - - expect(getHostChildren(screen.getByTestId('parent'))).toEqual([ - screen.getByTestId('child1'), - screen.getByTestId('child2'), - screen.getByTestId('child3'), - screen.getByTestId('subject'), - screen.getByTestId('sibling'), - ]); - }); -}); - -describe('getHostSelves()', () => { - it('returns passed element for host components', () => { - render( - - - - - - , - ); - - const hostSubject = screen.getByTestId('subject'); - expect(getHostSelves(hostSubject)).toEqual([hostSubject]); - - const hostSibling = screen.getByTestId('sibling'); - expect(getHostSelves(hostSibling)).toEqual([hostSibling]); - - const hostParent = screen.getByTestId('parent'); - expect(getHostSelves(hostParent)).toEqual([hostParent]); - - const hostGrandparent = screen.getByTestId('grandparent'); - expect(getHostSelves(hostGrandparent)).toEqual([hostGrandparent]); - }); - - test('returns single host element for React Native composite components', () => { - render( - - Text - - , - ); - - const compositeText = screen.getByText('Text'); - const hostText = screen.getByTestId('text'); - expect(getHostSelves(compositeText)).toEqual([hostText]); - - const compositeTextInputByValue = screen.getByDisplayValue('TextInputValue'); - const compositeTextInputByPlaceholder = screen.getByPlaceholderText('TextInputPlaceholder'); - - const hostTextInput = screen.getByTestId('textInput'); - expect(getHostSelves(compositeTextInputByValue)).toEqual([hostTextInput]); - expect(getHostSelves(compositeTextInputByPlaceholder)).toEqual([hostTextInput]); - }); - - test('returns host children for custom composite components', () => { - render( - - - - - , - ); - - const zeroCompositeComponent = screen.UNSAFE_getByType(ZeroHostChildren); - expect(getHostSelves(zeroCompositeComponent)).toEqual([]); - - const multipleCompositeComponent = screen.UNSAFE_getByType(MultipleHostChildren); - const hostChild1 = screen.getByTestId('child1'); - const hostChild2 = screen.getByTestId('child2'); - const hostChild3 = screen.getByTestId('child3'); - expect(getHostSelves(multipleCompositeComponent)).toEqual([hostChild1, hostChild2, hostChild3]); - }); -}); - describe('getHostSiblings()', () => { it('returns host siblings for host component', () => { render( @@ -195,31 +36,10 @@ describe('getHostSiblings()', () => { screen.getByTestId('child3'), ]); }); - - it('returns host siblings for composite component', () => { - render( - - - - - - - - , - ); - - const compositeComponent = screen.UNSAFE_getByType(MultipleHostChildren); - const hostSiblings = getHostSiblings(compositeComponent); - expect(hostSiblings).toEqual([ - screen.getByTestId('siblingBefore'), - screen.getByTestId('subject'), - screen.getByTestId('siblingAfter'), - ]); - }); }); -describe('getUnsafeRootElement()', () => { - it('returns UNSAFE_root for mounted view', () => { +describe('getContainerElement()', () => { + it('returns container for mounted view', () => { render( @@ -227,6 +47,6 @@ describe('getUnsafeRootElement()', () => { ); const view = screen.getByTestId('view'); - expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root); + expect(getContainerElement(view)).toEqual(screen.container); }); }); diff --git a/src/helpers/__tests__/ensure-peer-deps.test.ts b/src/helpers/__tests__/ensure-peer-deps.test.ts deleted file mode 100644 index 354eab004..000000000 --- a/src/helpers/__tests__/ensure-peer-deps.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ - -// Mock the require calls -jest.mock('react/package.json', () => ({ version: '19.0.0' })); -jest.mock('react-test-renderer/package.json', () => ({ version: '19.0.0' })); - -describe('ensurePeerDeps', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...originalEnv }; - delete process.env.RNTL_SKIP_DEPS_CHECK; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('should not throw when versions match', () => { - expect(() => require('../ensure-peer-deps')).not.toThrow(); - }); - - it('should throw when react-test-renderer is missing', () => { - jest.mock('react-test-renderer/package.json', () => { - throw new Error('Module not found'); - }); - - expect(() => require('../ensure-peer-deps')).toThrow( - 'Missing dev dependency "react-test-renderer@19.0.0"', - ); - }); - - it('should throw when react-test-renderer version mismatches', () => { - jest.mock('react-test-renderer/package.json', () => ({ version: '18.2.0' })); - - expect(() => require('../ensure-peer-deps')).toThrow( - 'Incorrect version of "react-test-renderer" detected. Expected "19.0.0", but found "18.2.0"', - ); - }); - - it('should skip dependency check when RNTL_SKIP_DEPS_CHECK is set', () => { - process.env.RNTL_SKIP_DEPS_CHECK = '1'; - jest.mock('react-test-renderer/package.json', () => { - throw new Error('Module not found'); - }); - - expect(() => require('../ensure-peer-deps')).not.toThrow(); - }); -}); diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts index 95ab9166f..f0b792dea 100644 --- a/src/helpers/accessibility.ts +++ b/src/helpers/accessibility.ts @@ -1,15 +1,15 @@ import type { AccessibilityRole, AccessibilityState, AccessibilityValue, Role } from 'react-native'; import { StyleSheet } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; -import { getHostSiblings, getUnsafeRootElement, isHostElement } from './component-tree'; +import { getContainerElement, getHostSiblings, isHostElement } from './component-tree'; import { findAll } from './find-all'; import { isHostImage, isHostSwitch, isHostText, isHostTextInput } from './host-component-names'; import { getTextContent } from './text-content'; import { isEditableTextInput } from './text-input'; type IsInaccessibleOptions = { - cache?: WeakMap; + cache?: WeakMap; }; export const accessibilityStateKeys: (keyof AccessibilityState)[] = [ @@ -23,14 +23,14 @@ export const accessibilityStateKeys: (keyof AccessibilityState)[] = [ export const accessibilityValueKeys: (keyof AccessibilityValue)[] = ['min', 'max', 'now', 'text']; export function isHiddenFromAccessibility( - element: ReactTestInstance | null, + element: HostElement | null, { cache }: IsInaccessibleOptions = {}, ): boolean { if (element == null) { return true; } - let current: ReactTestInstance | null = element; + let current: HostElement | null = element; while (current) { let isCurrentSubtreeInaccessible = cache?.get(current); @@ -52,7 +52,7 @@ export function isHiddenFromAccessibility( /** RTL-compatibility alias for `isHiddenFromAccessibility` */ export const isInaccessible = isHiddenFromAccessibility; -function isSubtreeInaccessible(element: ReactTestInstance): boolean { +function isSubtreeInaccessible(element: HostElement): boolean { // Null props can happen for React.Fragments if (element.props == null) { return false; @@ -89,7 +89,7 @@ function isSubtreeInaccessible(element: ReactTestInstance): boolean { return false; } -export function isAccessibilityElement(element: ReactTestInstance | null): boolean { +export function isAccessibilityElement(element: HostElement | null): boolean { if (element == null) { return false; } @@ -119,7 +119,7 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole * @param element * @returns */ -export function getRole(element: ReactTestInstance): Role | AccessibilityRole { +export function getRole(element: HostElement): Role | AccessibilityRole { const explicitRole = element.props.role ?? element.props.accessibilityRole; if (explicitRole) { return normalizeRole(explicitRole); @@ -150,16 +150,16 @@ export function normalizeRole(role: string): Role | AccessibilityRole { return role as Role | AccessibilityRole; } -export function computeAriaModal(element: ReactTestInstance): boolean | undefined { +export function computeAriaModal(element: HostElement): boolean | undefined { return element.props['aria-modal'] ?? element.props.accessibilityViewIsModal; } -export function computeAriaLabel(element: ReactTestInstance): string | undefined { +export function computeAriaLabel(element: HostElement): string | undefined { const labelElementId = element.props['aria-labelledby'] ?? element.props.accessibilityLabelledBy; if (labelElementId) { - const rootElement = getUnsafeRootElement(element); + const container = getContainerElement(element); const labelElement = findAll( - rootElement, + container, (node) => isHostElement(node) && node.props.nativeID === labelElementId, { includeHiddenElements: true }, ); @@ -182,12 +182,12 @@ export function computeAriaLabel(element: ReactTestInstance): string | undefined } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#busy-state -export function computeAriaBusy({ props }: ReactTestInstance): boolean { +export function computeAriaBusy({ props }: HostElement): boolean { return props['aria-busy'] ?? props.accessibilityState?.busy ?? false; } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#checked-state -export function computeAriaChecked(element: ReactTestInstance): AccessibilityState['checked'] { +export function computeAriaChecked(element: HostElement): AccessibilityState['checked'] { const { props } = element; if (isHostSwitch(element)) { @@ -203,7 +203,7 @@ export function computeAriaChecked(element: ReactTestInstance): AccessibilitySta } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#disabled-state -export function computeAriaDisabled(element: ReactTestInstance): boolean { +export function computeAriaDisabled(element: HostElement): boolean { if (isHostTextInput(element) && !isEditableTextInput(element)) { return true; } @@ -218,16 +218,16 @@ export function computeAriaDisabled(element: ReactTestInstance): boolean { } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#expanded-state -export function computeAriaExpanded({ props }: ReactTestInstance): boolean | undefined { +export function computeAriaExpanded({ props }: HostElement): boolean | undefined { return props['aria-expanded'] ?? props.accessibilityState?.expanded; } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#selected-state -export function computeAriaSelected({ props }: ReactTestInstance): boolean { +export function computeAriaSelected({ props }: HostElement): boolean { return props['aria-selected'] ?? props.accessibilityState?.selected ?? false; } -export function computeAriaValue(element: ReactTestInstance): AccessibilityValue { +export function computeAriaValue(element: HostElement): AccessibilityValue { const { accessibilityValue, 'aria-valuemax': ariaValueMax, @@ -244,7 +244,7 @@ export function computeAriaValue(element: ReactTestInstance): AccessibilityValue }; } -export function computeAccessibleName(element: ReactTestInstance): string | undefined { +export function computeAccessibleName(element: HostElement): string | undefined { return computeAriaLabel(element) ?? getTextContent(element); } diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 9b2c99afd..651ed4fe7 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -1,98 +1,42 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement, HostNode } from 'universal-test-renderer'; import { screen } from '../screen'; -/** - * ReactTestInstance referring to host element. - */ -export type HostTestInstance = ReactTestInstance & { type: string }; /** * Checks if the given element is a host element. * @param element The element to check. */ -export function isHostElement(element?: ReactTestInstance | null): element is HostTestInstance { - return typeof element?.type === 'string'; +export function isHostElement(element?: HostNode | null): element is HostElement { + return typeof element !== 'string' && typeof element?.type === 'string'; } -export function isElementMounted(element: ReactTestInstance) { - return getUnsafeRootElement(element) === screen.UNSAFE_root; +export function isElementMounted(element: HostElement) { + return getContainerElement(element) === screen.container; } /** - * Returns first host ancestor for given element. - * @param element The element start traversing from. - */ -export function getHostParent(element: ReactTestInstance | null): HostTestInstance | null { - if (element == null) { - return null; - } - - let current = element.parent; - while (current) { - if (isHostElement(current)) { - return current; - } - - current = current.parent; - } - - return null; -} - -/** - * Returns host children for given element. + * Returns host siblings for given element. * @param element The element start traversing from. */ -export function getHostChildren(element: ReactTestInstance | null): HostTestInstance[] { - if (element == null) { +export function getHostSiblings(element: HostElement): HostElement[] { + // Should not happen + const parent = element.parent; + if (!parent) { return []; } - const hostChildren: HostTestInstance[] = []; - - element.children.forEach((child) => { - if (typeof child !== 'object') { - return; - } - - if (isHostElement(child)) { - hostChildren.push(child); - } else { - hostChildren.push(...getHostChildren(child)); - } - }); - - return hostChildren; -} - -/** - * Return the array of host elements that represent the passed element. - * - * @param element The element start traversing from. - * @returns If the passed element is a host element, it will return an array containing only that element, - * if the passed element is a composite element, it will return an array containing its host children (zero, one or many). - */ -export function getHostSelves(element: ReactTestInstance | null): HostTestInstance[] { - return isHostElement(element) ? [element] : getHostChildren(element); -} - -/** - * Returns host siblings for given element. - * @param element The element start traversing from. - */ -export function getHostSiblings(element: ReactTestInstance | null): HostTestInstance[] { - const hostParent = getHostParent(element); - const hostSelves = getHostSelves(element); - return getHostChildren(hostParent).filter((sibling) => !hostSelves.includes(sibling)); + return parent.children.filter( + (sibling) => typeof sibling !== 'string' && sibling !== element, + ) as HostElement[]; } /** - * Returns the unsafe root element of the tree (probably composite). + * Returns the containerelement of the tree. * * @param element The element start traversing from. - * @returns The root element of the tree (host or composite). + * @returns The container element of the tree. */ -export function getUnsafeRootElement(element: ReactTestInstance) { +export function getContainerElement(element: HostElement) { let current = element; while (current.parent) { current = current.parent; diff --git a/src/helpers/debug.ts b/src/helpers/debug.ts index 4ec242f61..f58016d79 100644 --- a/src/helpers/debug.ts +++ b/src/helpers/debug.ts @@ -1,4 +1,4 @@ -import type { ReactTestRendererJSON } from 'react-test-renderer'; +import type { JsonNode } from 'universal-test-renderer'; import type { FormatElementOptions } from './format-element'; import { formatJson } from './format-element'; @@ -12,12 +12,12 @@ export type DebugOptions = { * Log pretty-printed deep test component instance */ export function debug( - instance: ReactTestRendererJSON | ReactTestRendererJSON[], + node: JsonNode | JsonNode[], { message, ...formatOptions }: DebugOptions = {}, ) { if (message) { - logger.info(`${message}\n\n`, formatJson(instance, formatOptions)); + logger.info(`${message}\n\n`, formatJson(node, formatOptions)); } else { - logger.info(formatJson(instance, formatOptions)); + logger.info(formatJson(node, formatOptions)); } } diff --git a/src/helpers/ensure-peer-deps.ts b/src/helpers/ensure-peer-deps.ts deleted file mode 100644 index b06507bfc..000000000 --- a/src/helpers/ensure-peer-deps.ts +++ /dev/null @@ -1,37 +0,0 @@ -function ensurePeerDeps() { - const reactVersion = getPackageVersion('react'); - ensurePackage('react-test-renderer', reactVersion); -} - -function ensurePackage(name: string, expectedVersion: string) { - const actualVersion = getPackageVersion(name); - if (!actualVersion) { - const error = new Error( - `Missing dev dependency "${name}@${expectedVersion}".\n\nFix it by running:\nnpm install -D ${name}@${expectedVersion}`, - ); - Error.captureStackTrace(error, ensurePeerDeps); - throw error; - } - - if (expectedVersion !== actualVersion) { - const error = new Error( - `Incorrect version of "${name}" detected. Expected "${expectedVersion}", but found "${actualVersion}".\n\nFix it by running:\nnpm install -D ${name}@${expectedVersion}`, - ); - Error.captureStackTrace(error, ensurePeerDeps); - throw error; - } -} - -function getPackageVersion(name: string) { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const packageJson = require(`${name}/package.json`); - return packageJson.version; - } catch { - return null; - } -} - -if (!process.env.RNTL_SKIP_DEPS_CHECK) { - ensurePeerDeps(); -} diff --git a/src/helpers/find-all.ts b/src/helpers/find-all.ts index 4b476dfdb..e18944c52 100644 --- a/src/helpers/find-all.ts +++ b/src/helpers/find-all.ts @@ -1,9 +1,7 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; import { getConfig } from '../config'; import { isHiddenFromAccessibility } from './accessibility'; -import type { HostTestInstance } from './component-tree'; -import { isHostElement } from './component-tree'; interface FindAllOptions { /** Match elements hidden from accessibility */ @@ -17,11 +15,12 @@ interface FindAllOptions { } export function findAll( - root: ReactTestInstance, - predicate: (element: ReactTestInstance) => boolean, - options?: FindAllOptions, -): HostTestInstance[] { - const results = findAllInternal(root, predicate, options); + root: HostElement, + predicate: (element: HostElement) => boolean, + options: FindAllOptions = {}, +): HostElement[] { + const { matchDeepestOnly } = options; + const results = root.queryAll(predicate, { matchDeepestOnly }); const includeHiddenElements = options?.includeHiddenElements ?? options?.hidden ?? getConfig()?.defaultIncludeHiddenElements; @@ -30,39 +29,6 @@ export function findAll( return results; } - const cache = new WeakMap(); + const cache = new WeakMap(); return results.filter((element) => !isHiddenFromAccessibility(element, { cache })); } - -// Extracted from React Test Renderer -// src: https://github.com/facebook/react/blob/8e2bde6f2751aa6335f3cef488c05c3ea08e074a/packages/react-test-renderer/src/ReactTestRenderer.js#L402 -function findAllInternal( - root: ReactTestInstance, - predicate: (element: ReactTestInstance) => boolean, - options?: FindAllOptions, -): HostTestInstance[] { - const results: HostTestInstance[] = []; - - // Match descendants first but do not add them to results yet. - const matchingDescendants: HostTestInstance[] = []; - root.children.forEach((child) => { - if (typeof child === 'string') { - return; - } - matchingDescendants.push(...findAllInternal(child, predicate, options)); - }); - - if ( - // When matchDeepestOnly = true: add current element only if no descendants match - (!options?.matchDeepestOnly || matchingDescendants.length === 0) && - isHostElement(root) && - predicate(root) - ) { - results.push(root); - } - - // Add matching descendants after element to preserve original tree walk order. - results.push(...matchingDescendants); - - return results; -} diff --git a/src/helpers/format-element.ts b/src/helpers/format-element.ts index 295636db2..b917fc6ef 100644 --- a/src/helpers/format-element.ts +++ b/src/helpers/format-element.ts @@ -1,6 +1,6 @@ -import type { ReactTestInstance, ReactTestRendererJSON } from 'react-test-renderer'; import type { NewPlugin } from 'pretty-format'; import prettyFormat, { plugins } from 'pretty-format'; +import type { HostElement, JsonNode } from 'universal-test-renderer'; import type { MapPropsFunction } from './map-props'; import { defaultMapProps } from './map-props'; @@ -22,7 +22,7 @@ export type FormatElementOptions = { * @param element Element to format. */ export function formatElement( - element: ReactTestInstance | null, + element: HostElement | null, { compact, highlight = true, mapProps = defaultMapProps }: FormatElementOptions = {}, ) { if (element == null) { @@ -35,7 +35,7 @@ export function formatElement( return prettyFormat( { // This prop is needed persuade the prettyFormat that the element is - // a ReactTestRendererJSON instance, so it is formatted as JSX. + // a JsonNode instance, so it is formatted as JSX. $$typeof: Symbol.for('react.test.json'), type: `${element.type}`, props: mapProps ? mapProps(props) : props, @@ -52,7 +52,7 @@ export function formatElement( ); } -export function formatElementList(elements: ReactTestInstance[], options?: FormatElementOptions) { +export function formatElementList(elements: HostElement[], options?: FormatElementOptions) { if (elements.length === 0) { return '(no elements)'; } @@ -61,7 +61,7 @@ export function formatElementList(elements: ReactTestInstance[], options?: Forma } export function formatJson( - json: ReactTestRendererJSON | ReactTestRendererJSON[], + json: JsonNode | JsonNode[], { compact, highlight = true, mapProps = defaultMapProps }: FormatElementOptions = {}, ) { return prettyFormat(json, { diff --git a/src/helpers/host-component-names.ts b/src/helpers/host-component-names.ts index 45e019bc8..7eea2e9c5 100644 --- a/src/helpers/host-component-names.ts +++ b/src/helpers/host-component-names.ts @@ -1,6 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; - -import type { HostTestInstance } from './component-tree'; +import type { HostElement } from 'universal-test-renderer'; const HOST_TEXT_NAMES = ['Text', 'RCTText']; const HOST_TEXT_INPUT_NAMES = ['TextInput']; @@ -13,7 +11,7 @@ const HOST_MODAL_NAMES = ['Modal']; * Checks if the given element is a host Text element. * @param element The element to check. */ -export function isHostText(element: ReactTestInstance): element is HostTestInstance { +export function isHostText(element: HostElement | null) { return typeof element?.type === 'string' && HOST_TEXT_NAMES.includes(element.type); } @@ -21,7 +19,7 @@ export function isHostText(element: ReactTestInstance): element is HostTestInsta * Checks if the given element is a host TextInput element. * @param element The element to check. */ -export function isHostTextInput(element: ReactTestInstance): element is HostTestInstance { +export function isHostTextInput(element: HostElement | null) { return typeof element?.type === 'string' && HOST_TEXT_INPUT_NAMES.includes(element.type); } @@ -29,7 +27,7 @@ export function isHostTextInput(element: ReactTestInstance): element is HostTest * Checks if the given element is a host Image element. * @param element The element to check. */ -export function isHostImage(element: ReactTestInstance): element is HostTestInstance { +export function isHostImage(element: HostElement | null) { return typeof element?.type === 'string' && HOST_IMAGE_NAMES.includes(element.type); } @@ -37,7 +35,7 @@ export function isHostImage(element: ReactTestInstance): element is HostTestInst * Checks if the given element is a host Switch element. * @param element The element to check. */ -export function isHostSwitch(element: ReactTestInstance): element is HostTestInstance { +export function isHostSwitch(element: HostElement | null) { return typeof element?.type === 'string' && HOST_SWITCH_NAMES.includes(element.type); } @@ -45,7 +43,7 @@ export function isHostSwitch(element: ReactTestInstance): element is HostTestIns * Checks if the given element is a host ScrollView element. * @param element The element to check. */ -export function isHostScrollView(element: ReactTestInstance): element is HostTestInstance { +export function isHostScrollView(element: HostElement | null) { return typeof element?.type === 'string' && HOST_SCROLL_VIEW_NAMES.includes(element.type); } @@ -53,6 +51,6 @@ export function isHostScrollView(element: ReactTestInstance): element is HostTes * Checks if the given element is a host Modal element. * @param element The element to check. */ -export function isHostModal(element: ReactTestInstance): element is HostTestInstance { +export function isHostModal(element: HostElement | null) { return typeof element?.type === 'string' && HOST_MODAL_NAMES.includes(element.type); } diff --git a/src/helpers/matchers/match-accessibility-state.ts b/src/helpers/matchers/match-accessibility-state.ts index 0aabf216b..9cf1be21d 100644 --- a/src/helpers/matchers/match-accessibility-state.ts +++ b/src/helpers/matchers/match-accessibility-state.ts @@ -1,4 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaBusy, @@ -20,10 +20,7 @@ export interface AccessibilityStateMatcher { expanded?: boolean; } -export function matchAccessibilityState( - node: ReactTestInstance, - matcher: AccessibilityStateMatcher, -) { +export function matchAccessibilityState(node: HostElement, matcher: AccessibilityStateMatcher) { if (matcher.busy !== undefined && matcher.busy !== computeAriaBusy(node)) { return false; } diff --git a/src/helpers/matchers/match-accessibility-value.ts b/src/helpers/matchers/match-accessibility-value.ts index 6fe281d32..9e332f49f 100644 --- a/src/helpers/matchers/match-accessibility-value.ts +++ b/src/helpers/matchers/match-accessibility-value.ts @@ -1,4 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; import type { TextMatch } from '../../matches'; import { computeAriaValue } from '../accessibility'; @@ -12,7 +12,7 @@ export interface AccessibilityValueMatcher { } export function matchAccessibilityValue( - node: ReactTestInstance, + node: HostElement, matcher: AccessibilityValueMatcher, ): boolean { const value = computeAriaValue(node); diff --git a/src/helpers/matchers/match-label-text.ts b/src/helpers/matchers/match-label-text.ts index ce1fef4c0..b30197892 100644 --- a/src/helpers/matchers/match-label-text.ts +++ b/src/helpers/matchers/match-label-text.ts @@ -1,11 +1,11 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; import type { TextMatch, TextMatchOptions } from '../../matches'; import { matches } from '../../matches'; import { computeAriaLabel } from '../accessibility'; export function matchAccessibilityLabel( - element: ReactTestInstance, + element: HostElement, expectedLabel: TextMatch, options?: TextMatchOptions, ) { diff --git a/src/helpers/matchers/match-text-content.ts b/src/helpers/matchers/match-text-content.ts index dd5e7d90e..b193f6d25 100644 --- a/src/helpers/matchers/match-text-content.ts +++ b/src/helpers/matchers/match-text-content.ts @@ -1,4 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; import type { TextMatch, TextMatchOptions } from '../../matches'; import { matches } from '../../matches'; @@ -12,7 +12,7 @@ import { getTextContent } from '../text-content'; * @returns - Whether the node's text content matches the given string or regex. */ export function matchTextContent( - node: ReactTestInstance, + node: HostElement, text: TextMatch, options: TextMatchOptions = {}, ) { diff --git a/src/helpers/pointer-events.ts b/src/helpers/pointer-events.ts index 5992669c7..1e19526bc 100644 --- a/src/helpers/pointer-events.ts +++ b/src/helpers/pointer-events.ts @@ -1,7 +1,5 @@ import { StyleSheet } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; - -import { getHostParent } from './component-tree'; +import type { HostElement } from 'universal-test-renderer'; /** * pointerEvents controls whether the View can be the target of touch events. @@ -10,7 +8,7 @@ import { getHostParent } from './component-tree'; * 'box-none': The View is never the target of touch events but its subviews can be * 'box-only': The view can be the target of touch events but its subviews cannot be * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ -export const isPointerEventEnabled = (element: ReactTestInstance, isParent?: boolean): boolean => { +export const isPointerEventEnabled = (element: HostElement, isParent?: boolean): boolean => { // Check both props.pointerEvents and props.style.pointerEvents const pointerEvents = element?.props.pointerEvents ?? StyleSheet.flatten(element?.props.style)?.pointerEvents; @@ -21,8 +19,9 @@ export const isPointerEventEnabled = (element: ReactTestInstance, isParent?: boo return false; } - const hostParent = getHostParent(element); - if (!hostParent) return true; + if (!element.parent) { + return true; + } - return isPointerEventEnabled(hostParent, true); + return isPointerEventEnabled(element.parent, true); }; diff --git a/src/helpers/string-validation.ts b/src/helpers/string-validation.ts deleted file mode 100644 index 17864c8e1..000000000 --- a/src/helpers/string-validation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ReactTestRendererNode } from 'react-test-renderer'; - -export const validateStringsRenderedWithinText = ( - rendererJSON: ReactTestRendererNode | Array | null, -) => { - if (!rendererJSON) return; - - if (Array.isArray(rendererJSON)) { - rendererJSON.forEach(validateStringsRenderedWithinTextForNode); - return; - } - - return validateStringsRenderedWithinTextForNode(rendererJSON); -}; - -const validateStringsRenderedWithinTextForNode = (node: ReactTestRendererNode) => { - if (typeof node === 'string') { - return; - } - - if (node.type !== 'Text') { - node.children?.forEach((child) => { - if (typeof child === 'string') { - throw new Error( - `Invariant Violation: Text strings must be rendered within a component. Detected attempt to render "${child}" string within a <${node.type}> component.`, - ); - } - }); - } - - if (node.children) { - node.children.forEach(validateStringsRenderedWithinTextForNode); - } -}; diff --git a/src/helpers/text-content.ts b/src/helpers/text-content.ts index 126dca44f..208160d35 100644 --- a/src/helpers/text-content.ts +++ b/src/helpers/text-content.ts @@ -1,6 +1,6 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; -export function getTextContent(element: ReactTestInstance | string | null): string { +export function getTextContent(element: HostElement | string | null): string { if (!element) { return ''; } diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index 682043992..29fa000b3 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -1,13 +1,13 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'universal-test-renderer'; import { nativeState } from '../native-state'; import { isHostTextInput } from './host-component-names'; -export function isEditableTextInput(element: ReactTestInstance) { +export function isEditableTextInput(element: HostElement) { return isHostTextInput(element) && element.props.editable !== false; } -export function getTextInputValue(element: ReactTestInstance) { +export function getTextInputValue(element: HostElement) { if (!isHostTextInput(element)) { throw new Error(`Element is not a "TextInput", but it has type "${element.type}".`); } diff --git a/src/index.ts b/src/index.ts index 1ab373257..6a2e4ec4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import './helpers/ensure-peer-deps'; import './matchers/extend-expect'; import { getIsReactActEnvironment, setReactActEnvironment } from './act'; diff --git a/src/matchers/__tests__/to-be-disabled.test.tsx b/src/matchers/__tests__/to-be-disabled.test.tsx index 66eca2673..9a3a6dedf 100644 --- a/src/matchers/__tests__/to-be-disabled.test.tsx +++ b/src/matchers/__tests__/to-be-disabled.test.tsx @@ -159,7 +159,8 @@ test.each([ ['TouchableNativeFeedback', TouchableNativeFeedback], ] as const)('toBeDisabled()/toBeEnabled() supports %s with "disabled" prop', (_, Component) => { render( - // @ts-expect-error - JSX element type 'Component' does not have any construct or call signatures. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - JSX element type 'Component' does not have any construct or call signatures. Button , @@ -191,7 +192,8 @@ test.each([ 'toBeDisabled()/toBeEnabled() supports %s with "aria-disabled" prop', (_, Component) => { render( - // @ts-expect-error - JSX element type 'Component' does not have any construct or call signatures. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - JSX element type 'Component' does not have any construct or call signatures. Hello , @@ -218,7 +220,8 @@ test.each([ 'toBeDisabled()/toBeEnabled() supports %s with "accessibilityState.disabled" prop', (_, Component) => { render( - // @ts-expect-error - JSX element type 'Component' does not have any construct or call signatures. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - JSX element type 'Component' does not have any construct or call signatures. Hello , diff --git a/src/matchers/__tests__/to-have-accessible-name.test.tsx b/src/matchers/__tests__/to-have-accessible-name.test.tsx index 0337f2ba3..1f433d857 100644 --- a/src/matchers/__tests__/to-have-accessible-name.test.tsx +++ b/src/matchers/__tests__/to-have-accessible-name.test.tsx @@ -119,14 +119,14 @@ test('toHaveAccessibleName() handles a view without name when called without exp }); it('toHaveAccessibleName() rejects non-host element', () => { - const nonElement = 'This is not a ReactTestInstance'; + const nonElement = 'This is not a HostElement'; expect(() => expect(nonElement).toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(` "expect(received).toHaveAccessibleName() received value must be a host element. Received has type: string - Received has value: "This is not a ReactTestInstance"" + Received has value: "This is not a HostElement"" `); expect(() => expect(nonElement).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(` @@ -134,6 +134,6 @@ it('toHaveAccessibleName() rejects non-host element', () => { received value must be a host element. Received has type: string - Received has value: "This is not a ReactTestInstance"" + Received has value: "This is not a HostElement"" `); }); diff --git a/src/matchers/__tests__/utils.test.tsx b/src/matchers/__tests__/utils.test.tsx index 7c95138da..def0c84c8 100644 --- a/src/matchers/__tests__/utils.test.tsx +++ b/src/matchers/__tests__/utils.test.tsx @@ -17,15 +17,6 @@ test('checkHostElement allows host element', () => { }).not.toThrow(); }); -test('checkHostElement allows rejects composite element', () => { - render(); - - expect(() => { - // @ts-expect-error: intentionally passing wrong element shape - checkHostElement(screen.UNSAFE_root, fakeMatcher, {}); - }).toThrow(/value must be a host element./); -}); - test('checkHostElement allows rejects null element', () => { expect(() => { // @ts-expect-error: intentionally passing wrong element shape diff --git a/src/matchers/to-be-busy.ts b/src/matchers/to-be-busy.ts index 6af30d9bc..969c99c6c 100644 --- a/src/matchers/to-be-busy.ts +++ b/src/matchers/to-be-busy.ts @@ -1,12 +1,12 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaBusy } from '../helpers/accessibility'; import { formatElement } from '../helpers/format-element'; import { checkHostElement } from './utils'; -export function toBeBusy(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeBusy(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeBusy, this); return { diff --git a/src/matchers/to-be-checked.ts b/src/matchers/to-be-checked.ts index 2a5dbacd1..0dcdb1d06 100644 --- a/src/matchers/to-be-checked.ts +++ b/src/matchers/to-be-checked.ts @@ -1,6 +1,6 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaChecked, @@ -13,7 +13,7 @@ import { formatElement } from '../helpers/format-element'; import { isHostSwitch } from '../helpers/host-component-names'; import { checkHostElement } from './utils'; -export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeChecked(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeChecked, this); if (!isHostSwitch(element) && !isSupportedAccessibilityElement(element)) { @@ -37,7 +37,7 @@ export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstanc }; } -function isSupportedAccessibilityElement(element: ReactTestInstance) { +function isSupportedAccessibilityElement(element: HostElement) { if (!isAccessibilityElement(element)) { return false; } diff --git a/src/matchers/to-be-disabled.ts b/src/matchers/to-be-disabled.ts index 96b5dadab..3640a2938 100644 --- a/src/matchers/to-be-disabled.ts +++ b/src/matchers/to-be-disabled.ts @@ -1,13 +1,12 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaDisabled } from '../helpers/accessibility'; -import { getHostParent } from '../helpers/component-tree'; import { formatElement } from '../helpers/format-element'; import { checkHostElement } from './utils'; -export function toBeDisabled(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeDisabled(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeDisabled, this); const isDisabled = computeAriaDisabled(element) || isAncestorDisabled(element); @@ -26,7 +25,7 @@ export function toBeDisabled(this: jest.MatcherContext, element: ReactTestInstan }; } -export function toBeEnabled(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeEnabled(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeEnabled, this); const isEnabled = !computeAriaDisabled(element) && !isAncestorDisabled(element); @@ -45,8 +44,8 @@ export function toBeEnabled(this: jest.MatcherContext, element: ReactTestInstanc }; } -function isAncestorDisabled(element: ReactTestInstance): boolean { - const parent = getHostParent(element); +function isAncestorDisabled(element: HostElement): boolean { + const parent = element.parent; if (parent == null) { return false; } diff --git a/src/matchers/to-be-empty-element.ts b/src/matchers/to-be-empty-element.ts index 31c1d9e08..c8d6946d6 100644 --- a/src/matchers/to-be-empty-element.ts +++ b/src/matchers/to-be-empty-element.ts @@ -1,24 +1,25 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; -import { getHostChildren } from '../helpers/component-tree'; +import { isHostElement } from '../helpers/component-tree'; import { formatElementList } from '../helpers/format-element'; import { checkHostElement } from './utils'; -export function toBeEmptyElement(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeEmptyElement(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeEmptyElement, this); - const hostChildren = getHostChildren(element); + // TODO check + const children = element.children.filter((child) => isHostElement(child)); return { - pass: hostChildren.length === 0, + pass: children.length === 0, message: () => { return [ matcherHint(`${this.isNot ? '.not' : ''}.toBeEmptyElement`, 'element', ''), '', 'Received:', - `${RECEIVED_COLOR(redent(formatElementList(hostChildren), 2))}`, + `${RECEIVED_COLOR(redent(formatElementList(children), 2))}`, ].join('\n'); }, }; diff --git a/src/matchers/to-be-expanded.ts b/src/matchers/to-be-expanded.ts index 4fd6a656e..632e872f4 100644 --- a/src/matchers/to-be-expanded.ts +++ b/src/matchers/to-be-expanded.ts @@ -1,12 +1,12 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaExpanded } from '../helpers/accessibility'; import { formatElement } from '../helpers/format-element'; import { checkHostElement } from './utils'; -export function toBeExpanded(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeExpanded(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeExpanded, this); return { @@ -23,7 +23,7 @@ export function toBeExpanded(this: jest.MatcherContext, element: ReactTestInstan }; } -export function toBeCollapsed(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeCollapsed(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeCollapsed, this); return { diff --git a/src/matchers/to-be-on-the-screen.ts b/src/matchers/to-be-on-the-screen.ts index cbdbdf378..76c0682cf 100644 --- a/src/matchers/to-be-on-the-screen.ts +++ b/src/matchers/to-be-on-the-screen.ts @@ -1,18 +1,18 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; -import { getUnsafeRootElement } from '../helpers/component-tree'; +import { getContainerElement } from '../helpers/component-tree'; import { formatElement } from '../helpers/format-element'; import { screen } from '../screen'; import { checkHostElement } from './utils'; -export function toBeOnTheScreen(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeOnTheScreen(this: jest.MatcherContext, element: HostElement) { if (element !== null || !this.isNot) { checkHostElement(element, toBeOnTheScreen, this); } - const pass = element === null ? false : screen.UNSAFE_root === getUnsafeRootElement(element); + const pass = element === null ? false : screen.container === getContainerElement(element); const errorFound = () => { return `expected element tree not to contain element, but found\n${redent( diff --git a/src/matchers/to-be-partially-checked.ts b/src/matchers/to-be-partially-checked.ts index 1224de1aa..166faa703 100644 --- a/src/matchers/to-be-partially-checked.ts +++ b/src/matchers/to-be-partially-checked.ts @@ -1,13 +1,13 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaChecked, getRole, isAccessibilityElement } from '../helpers/accessibility'; import { ErrorWithStack } from '../helpers/errors'; import { formatElement } from '../helpers/format-element'; import { checkHostElement } from './utils'; -export function toBePartiallyChecked(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBePartiallyChecked(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBePartiallyChecked, this); if (!hasValidAccessibilityRole(element)) { @@ -31,7 +31,7 @@ export function toBePartiallyChecked(this: jest.MatcherContext, element: ReactTe }; } -function hasValidAccessibilityRole(element: ReactTestInstance) { +function hasValidAccessibilityRole(element: HostElement) { const role = getRole(element); return isAccessibilityElement(element) && role === 'checkbox'; } diff --git a/src/matchers/to-be-selected.ts b/src/matchers/to-be-selected.ts index f33fe8449..834d642e2 100644 --- a/src/matchers/to-be-selected.ts +++ b/src/matchers/to-be-selected.ts @@ -1,12 +1,12 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaSelected } from '../helpers/accessibility'; import { formatElement } from '../helpers/format-element'; import { checkHostElement } from './utils'; -export function toBeSelected(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeSelected(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeSelected, this); return { diff --git a/src/matchers/to-be-visible.ts b/src/matchers/to-be-visible.ts index d21b112e9..c0655bf22 100644 --- a/src/matchers/to-be-visible.ts +++ b/src/matchers/to-be-visible.ts @@ -1,15 +1,14 @@ import { StyleSheet } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { isHiddenFromAccessibility } from '../helpers/accessibility'; -import { getHostParent } from '../helpers/component-tree'; import { formatElement } from '../helpers/format-element'; import { isHostModal } from '../helpers/host-component-names'; import { checkHostElement } from './utils'; -export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeVisible(this: jest.MatcherContext, element: HostElement) { if (element !== null || !this.isNot) { checkHostElement(element, toBeVisible, this); } @@ -29,11 +28,11 @@ export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstanc } function isElementVisible( - element: ReactTestInstance, - accessibilityCache?: WeakMap, + element: HostElement, + accessibilityCache?: WeakMap, ): boolean { // Use cache to speed up repeated searches by `isHiddenFromAccessibility`. - const cache = accessibilityCache ?? new WeakMap(); + const cache = accessibilityCache ?? new WeakMap(); if (isHiddenFromAccessibility(element, { cache })) { return false; } @@ -48,15 +47,15 @@ function isElementVisible( return false; } - const hostParent = getHostParent(element); - if (hostParent === null) { + const parent = element.parent; + if (parent === null) { return true; } - return isElementVisible(hostParent, cache); + return isElementVisible(parent, cache); } -function isHiddenForStyles(element: ReactTestInstance) { +function isHiddenForStyles(element: HostElement) { const flatStyle = StyleSheet.flatten(element.props.style); return flatStyle?.display === 'none' || flatStyle?.opacity === 0; } diff --git a/src/matchers/to-contain-element.ts b/src/matchers/to-contain-element.ts index c891cf7a3..b70891f70 100644 --- a/src/matchers/to-contain-element.ts +++ b/src/matchers/to-contain-element.ts @@ -1,14 +1,14 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; import redent from 'redent'; +import type { HostElement } from 'universal-test-renderer'; import { formatElement } from '../helpers/format-element'; import { checkHostElement } from './utils'; export function toContainElement( this: jest.MatcherContext, - container: ReactTestInstance, - element: ReactTestInstance | null, + container: HostElement, + element: HostElement | null, ) { checkHostElement(container, toContainElement, this); @@ -16,9 +16,9 @@ export function toContainElement( checkHostElement(element, toContainElement, this); } - let matches: ReactTestInstance[] = []; + let matches: HostElement[] = []; if (element) { - matches = container.findAll((node) => node === element); + matches = container.queryAll((node) => node === element); } return { diff --git a/src/matchers/to-have-accessibility-value.ts b/src/matchers/to-have-accessibility-value.ts index 6c5ec423b..eff8b4edf 100644 --- a/src/matchers/to-have-accessibility-value.ts +++ b/src/matchers/to-have-accessibility-value.ts @@ -1,5 +1,5 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, stringify } from 'jest-matcher-utils'; +import type { HostElement } from 'universal-test-renderer'; import { computeAriaValue } from '../helpers/accessibility'; import type { AccessibilityValueMatcher } from '../helpers/matchers/match-accessibility-value'; @@ -9,7 +9,7 @@ import { checkHostElement, formatMessage } from './utils'; export function toHaveAccessibilityValue( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, expectedValue: AccessibilityValueMatcher, ) { checkHostElement(element, toHaveAccessibilityValue, this); diff --git a/src/matchers/to-have-accessible-name.ts b/src/matchers/to-have-accessible-name.ts index 6cdf9b07a..71e09f190 100644 --- a/src/matchers/to-have-accessible-name.ts +++ b/src/matchers/to-have-accessible-name.ts @@ -1,5 +1,5 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; +import type { HostElement } from 'universal-test-renderer'; import { computeAccessibleName } from '../helpers/accessibility'; import type { TextMatch, TextMatchOptions } from '../matches'; @@ -8,7 +8,7 @@ import { checkHostElement, formatMessage } from './utils'; export function toHaveAccessibleName( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, expectedName?: TextMatch, options?: TextMatchOptions, ) { diff --git a/src/matchers/to-have-display-value.ts b/src/matchers/to-have-display-value.ts index d7284b3e5..e8a3b4496 100644 --- a/src/matchers/to-have-display-value.ts +++ b/src/matchers/to-have-display-value.ts @@ -1,5 +1,5 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; +import type { HostElement } from 'universal-test-renderer'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; @@ -10,7 +10,7 @@ import { checkHostElement, formatMessage } from './utils'; export function toHaveDisplayValue( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, expectedValue: TextMatch, options?: TextMatchOptions, ) { diff --git a/src/matchers/to-have-prop.ts b/src/matchers/to-have-prop.ts index ce0b6204b..c72ef6cc8 100644 --- a/src/matchers/to-have-prop.ts +++ b/src/matchers/to-have-prop.ts @@ -1,11 +1,11 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, printExpected, stringify } from 'jest-matcher-utils'; +import type { HostElement } from 'universal-test-renderer'; import { checkHostElement, formatMessage } from './utils'; export function toHaveProp( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, name: string, expectedValue: unknown, ) { diff --git a/src/matchers/to-have-style.ts b/src/matchers/to-have-style.ts index cdf486b39..2bd464c27 100644 --- a/src/matchers/to-have-style.ts +++ b/src/matchers/to-have-style.ts @@ -1,7 +1,7 @@ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native'; import { StyleSheet } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; import { diff, matcherHint } from 'jest-matcher-utils'; +import type { HostElement } from 'universal-test-renderer'; import { checkHostElement, formatMessage } from './utils'; @@ -11,7 +11,7 @@ type StyleLike = Record; export function toHaveStyle( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, style: StyleProp