diff --git a/docs/api/queries.md b/docs/api/queries.md
index 7388692a..cd9b972b 100644
--- a/docs/api/queries.md
+++ b/docs/api/queries.md
@@ -318,6 +318,8 @@ await render();
const element = screen.getByText('banana');
```
+Besides RN's ``, this query also matches `` from [`react-native-plain-text`](https://github.com/mdjastrzebski/react-native-plain-text), which holds its content in the `text` prop instead of string children.
+
### `*ByHintText`
> getByA11yHint, getAllByA11yHint, queryByA11yHint, queryAllByA11yHint, findByA11yHint, findAllByA11yHint
diff --git a/jest.config.js b/jest.config.js
index 4549c54f..6b829ab9 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,7 +3,9 @@ module.exports = {
setupFilesAfterEnv: ['./jest-setup.ts'],
testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'],
testTimeout: 60000,
- transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'],
+ transformIgnorePatterns: [
+ '/node_modules/(?!(@react-native|react-native|react-native-plain-text)/).*/',
+ ],
snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'],
clearMocks: true,
collectCoverageFrom: [
diff --git a/package.json b/package.json
index 46c4c48d..f124f9bd 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
"oxfmt": "^0.52.0",
"react": "19.2.3",
"react-native": "0.85.3",
+ "react-native-plain-text": "^0.8.2",
"release-it": "^20.0.1",
"test-renderer": "1.2.0",
"tsx": "^4.22.3",
diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx
index 3fbf5fdf..7efefc1c 100644
--- a/src/__tests__/host-component-names.test.tsx
+++ b/src/__tests__/host-component-names.test.tsx
@@ -1,10 +1,12 @@
import * as React from 'react';
import { Image, Modal, ScrollView, Switch, Text, TextInput } from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { render, screen } from '..';
import {
isHostImage,
isHostModal,
+ isHostPlainText,
isHostScrollView,
isHostSwitch,
isHostText,
@@ -23,6 +25,19 @@ test('detects raw RCTText component', async () => {
expect(isHostText(screen.root)).toBe(true);
});
+// Plain text components, e.g. `` from `react-native-plain-text`,
+// hold their content in the `text` prop instead of string children.
+test('detects host plain text component', async () => {
+ await render(Hello);
+ expect(isHostText(screen.root)).toBe(true);
+ expect(isHostPlainText(screen.root)).toBe(true);
+});
+
+test('does not detect host Text component as plain text', async () => {
+ await render(Hello);
+ expect(isHostPlainText(screen.root)).toBe(false);
+});
+
test('detects host TextInput component', async () => {
await render();
expect(isHostTextInput(screen.root)).toBe(true);
diff --git a/src/helpers/__tests__/accessibility.test.tsx b/src/helpers/__tests__/accessibility.test.tsx
index db52a43d..a8f7dc19 100644
--- a/src/helpers/__tests__/accessibility.test.tsx
+++ b/src/helpers/__tests__/accessibility.test.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Image, Pressable, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { isHiddenFromAccessibility, isInaccessible, render, screen } from '../..';
import {
@@ -915,3 +916,44 @@ describe('computeAccessibleName', () => {
expect(computeAccessibleName(screen.getByTestId('parent-no-text'))).toBe('');
});
});
+
+describe('plain text elements', () => {
+ test('isAccessibilityElement() returns true', async () => {
+ await render(Hello);
+ expect(isAccessibilityElement(screen.getByTestId('text'))).toBe(true);
+ });
+
+ test('getRole() returns "text"', async () => {
+ await render(Hello);
+ expect(getRole(screen.getByTestId('text'))).toBe('text');
+ });
+
+ test('computeAccessibleName() uses the text content', async () => {
+ await render(Hello);
+ expect(computeAccessibleName(screen.getByTestId('text'))).toBe('Hello');
+ });
+
+ test('computeAccessibleName() prefers explicit accessibility label', async () => {
+ await render(
+
+ Hello
+ ,
+ );
+ expect(computeAccessibleName(screen.getByTestId('text'))).toBe('Label');
+ });
+
+ test('computeAccessibleName() returns empty string for empty text', async () => {
+ await render();
+ expect(computeAccessibleName(screen.getByTestId('text'))).toBe('');
+ });
+
+ test('computeAccessibleName() includes plain text children', async () => {
+ await render(
+
+ Hello
+
+ ,
+ );
+ expect(computeAccessibleName(screen.getByTestId('view'))).toBe('Hello World');
+ });
+});
diff --git a/src/helpers/__tests__/text-content.test.tsx b/src/helpers/__tests__/text-content.test.tsx
index 0c773f69..50d1dd81 100644
--- a/src/helpers/__tests__/text-content.test.tsx
+++ b/src/helpers/__tests__/text-content.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
-import { Text } from 'react-native';
+import { Text, View } from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { render, screen } from '../..';
import { getTextContent } from '../text-content';
@@ -48,3 +49,28 @@ test('getTextContent with multiple boolean content', async () => {
);
expect(getTextContent(screen.root)).toBe('Hello world');
});
+
+test('getTextContent with plain text content', async () => {
+ await render(Hello world);
+ expect(getTextContent(screen.root)).toBe('Hello world');
+});
+
+test('getTextContent with plain text `text` prop', async () => {
+ await render();
+ expect(getTextContent(screen.root)).toBe('Hello world');
+});
+
+test('getTextContent with empty plain text', async () => {
+ await render();
+ expect(getTextContent(screen.root)).toBe('');
+});
+
+test('getTextContent with nested plain text content', async () => {
+ await render(
+
+ Hello
+ world
+ ,
+ );
+ expect(getTextContent(screen.root)).toBe('Hello world');
+});
diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts
index 2638e06c..638456d7 100644
--- a/src/helpers/accessibility.ts
+++ b/src/helpers/accessibility.ts
@@ -4,7 +4,13 @@ import type { TestInstance } from 'test-renderer';
import { getContainerInstance, getInstanceSiblings, isTestInstance } from './component-tree';
import { findAll } from './find-all';
-import { isHostImage, isHostSwitch, isHostText, isHostTextInput } from './host-component-names';
+import {
+ getHostPlainTextValue,
+ isHostImage,
+ isHostSwitch,
+ isHostText,
+ isHostTextInput,
+} from './host-component-names';
import { getTextContent } from './text-content';
import { isEditableTextInput } from './text-input';
@@ -286,6 +292,12 @@ export function computeAccessibleName(
return instance.props.placeholder;
}
+ // Plain text host elements have no children, their content is in `text` prop.
+ const plainText = getHostPlainTextValue(instance);
+ if (plainText !== undefined) {
+ return plainText;
+ }
+
const parts: AccessibleNamePart[] = [];
for (const child of instance.children) {
if (typeof child === 'string') {
diff --git a/src/helpers/host-component-names.ts b/src/helpers/host-component-names.ts
index 2b7f5986..359cac29 100644
--- a/src/helpers/host-component-names.ts
+++ b/src/helpers/host-component-names.ts
@@ -1,6 +1,12 @@
import type { TestInstance } from 'test-renderer';
+// Host components that render RN ``, i.e. text elements holding their
+// content as string children.
export const HOST_TEXT_NAMES = ['Text', 'RCTText'];
+
+// Host components that hold their text content in the `text` prop instead of
+// string children, e.g. `` from `react-native-plain-text`.
+const HOST_PLAIN_TEXT_NAMES = ['RNPlainText'];
const HOST_TEXT_INPUT_NAMES = ['TextInput'];
const HOST_IMAGE_NAMES = ['Image'];
const HOST_SWITCH_NAMES = ['RCTSwitch'];
@@ -8,11 +14,40 @@ const HOST_SCROLL_VIEW_NAMES = ['RCTScrollView'];
const HOST_MODAL_NAMES = ['Modal'];
/**
- * Checks if the given element is a host Text element.
+ * Checks if the given element is a host plain text element, i.e. one that holds
+ * its text content in the `text` prop, e.g. `` from
+ * `react-native-plain-text`.
+ * @param instance The instance to check.
+ */
+export function isHostPlainText(instance: TestInstance | null) {
+ return typeof instance?.type === 'string' && HOST_PLAIN_TEXT_NAMES.includes(instance.type);
+}
+
+/**
+ * Checks if the given element is a host text element: either a RN ``,
+ * holding its content as string children, or a plain text one, holding it in
+ * the `text` prop.
* @param instance The instance to check.
*/
export function isHostText(instance: TestInstance | null) {
- return typeof instance?.type === 'string' && HOST_TEXT_NAMES.includes(instance.type);
+ if (typeof instance?.type !== 'string') {
+ return false;
+ }
+
+ return HOST_TEXT_NAMES.includes(instance.type) || HOST_PLAIN_TEXT_NAMES.includes(instance.type);
+}
+
+/**
+ * Returns the text held directly by a host plain text element, if any.
+ * @param instance The instance to read.
+ */
+export function getHostPlainTextValue(instance: TestInstance | null): string | undefined {
+ if (instance == null || !isHostPlainText(instance)) {
+ return undefined;
+ }
+
+ const { text } = instance.props;
+ return typeof text === 'string' || typeof text === 'number' ? String(text) : undefined;
}
/**
diff --git a/src/helpers/text-content.ts b/src/helpers/text-content.ts
index ee6f368a..9cc5a8b2 100644
--- a/src/helpers/text-content.ts
+++ b/src/helpers/text-content.ts
@@ -1,5 +1,7 @@
import type { TestInstance } from 'test-renderer';
+import { getHostPlainTextValue } from './host-component-names';
+
export function getTextContent(instance: TestInstance | string | null): string {
if (!instance) {
return '';
@@ -9,6 +11,13 @@ export function getTextContent(instance: TestInstance | string | null): string {
return instance;
}
+ // Plain text host elements, e.g. `` from `react-native-plain-text`,
+ // hold their content in the `text` prop rather than as string children.
+ const plainText = getHostPlainTextValue(instance);
+ if (plainText !== undefined) {
+ return plainText;
+ }
+
const result: string[] = [];
instance.children?.forEach((child) => {
result.push(getTextContent(child));
diff --git a/src/matchers/__tests__/to-have-accessible-name.test.tsx b/src/matchers/__tests__/to-have-accessible-name.test.tsx
index 3e20f196..3fc45253 100644
--- a/src/matchers/__tests__/to-have-accessible-name.test.tsx
+++ b/src/matchers/__tests__/to-have-accessible-name.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Image, Text, TextInput, View } from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { render, screen } from '../..';
@@ -137,3 +138,11 @@ it('toHaveAccessibleName() rejects non-host element', () => {
Received has value: "This is not a TestInstance""
`);
});
+
+test('toHaveAccessibleName() handles plain text element', async () => {
+ await render(Hello);
+ const element = screen.getByTestId('text');
+ expect(element).toHaveAccessibleName('Hello');
+ expect(element).toHaveAccessibleName();
+ expect(element).not.toHaveAccessibleName('World');
+});
diff --git a/src/matchers/__tests__/to-have-text-content.test.tsx b/src/matchers/__tests__/to-have-text-content.test.tsx
index aac538bf..65b09376 100644
--- a/src/matchers/__tests__/to-have-text-content.test.tsx
+++ b/src/matchers/__tests__/to-have-text-content.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Text, View } from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { render, screen } from '../..';
@@ -80,3 +81,15 @@ test('toHaveTextContent() on null element', () => {
Received has value: null"
`);
});
+
+test('toHaveTextContent() supports plain text', async () => {
+ await render(
+
+ Hello
+
+ ,
+ );
+
+ expect(screen.getByTestId('view')).toHaveTextContent('Hello World');
+ expect(screen.getByTestId('view')).not.toHaveTextContent('Hello there');
+});
diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx
index 9ef211da..f8fe2258 100644
--- a/src/queries/__tests__/role.test.tsx
+++ b/src/queries/__tests__/role.test.tsx
@@ -10,6 +10,7 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { render, screen } from '../..';
@@ -994,3 +995,11 @@ test('error message renders the element tree, preserving only helpful props', as
/>"
`);
});
+
+test('supports plain text elements', async () => {
+ await render(Hello);
+
+ expect(screen.getByRole('text').props.testID).toBe('text');
+ expect(screen.getByRole('text', { name: 'Hello' }).props.testID).toBe('text');
+ expect(screen.queryByRole('text', { name: 'World' })).toBeNull();
+});
diff --git a/src/queries/__tests__/text.test.tsx b/src/queries/__tests__/text.test.tsx
index 28a3b16b..4e957199 100644
--- a/src/queries/__tests__/text.test.tsx
+++ b/src/queries/__tests__/text.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Button, Image, Text, TextInput, TouchableOpacity, View } from 'react-native';
+import { PlainText } from 'react-native-plain-text';
import { getDefaultNormalizer, render, screen, within } from '../..';
@@ -540,3 +541,25 @@ test('byText should return host component', async () => {
await render(hello);
expect(screen.getByText('hello').type).toBe('Text');
});
+
+test('byText matches plain text', async () => {
+ await render(Hello World);
+ expect(screen.getByText('Hello World').props.testID).toBe('text');
+});
+
+test('byText matches plain text passed by `text` prop', async () => {
+ await render();
+ expect(screen.getByText('Hello World').props.testID).toBe('text');
+});
+
+test('byText supports text match options for plain text', async () => {
+ await render(Hello World);
+ expect(screen.getByText('hello world', { exact: false }).props.testID).toBe('text');
+ expect(screen.getByText(/hello/i).props.testID).toBe('text');
+ expect(screen.queryByText('Hello')).toBeNull();
+});
+
+test('byText does not match plain text without content', async () => {
+ await render();
+ expect(screen.queryByText('Hello World')).toBeNull();
+});
diff --git a/website/docs/14.x/docs/api/queries.mdx b/website/docs/14.x/docs/api/queries.mdx
index c9e50977..6e3e8412 100644
--- a/website/docs/14.x/docs/api/queries.mdx
+++ b/website/docs/14.x/docs/api/queries.mdx
@@ -322,6 +322,8 @@ await render();
const element = screen.getByText('banana');
```
+Besides RN's ``, this query also matches `` from [`react-native-plain-text`](https://github.com/mdjastrzebski/react-native-plain-text), which holds its content in the `text` prop instead of string children.
+
### `*ByHintText` {#by-hint-text}
> getByA11yHint, getAllByA11yHint, queryByA11yHint, queryAllByA11yHint, findByA11yHint, findAllByA11yHint
diff --git a/yarn.lock b/yarn.lock
index 0e92a49d..64ab8763 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3259,6 +3259,7 @@ __metadata:
pretty-format: "npm:^30.4.1"
react: "npm:19.2.3"
react-native: "npm:0.85.3"
+ react-native-plain-text: "npm:^0.8.2"
redent: "npm:^3.0.0"
release-it: "npm:^20.0.1"
test-renderer: "npm:1.2.0"
@@ -9454,6 +9455,16 @@ __metadata:
languageName: node
linkType: hard
+"react-native-plain-text@npm:^0.8.2":
+ version: 0.8.2
+ resolution: "react-native-plain-text@npm:0.8.2"
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: 10c0/1be4bbd466fa6326b87c7605a3def1ab88748e79fb0f6c66a670166b18770f6d0c7d069afd86dfab1bcc2772d3de99644a99f06421f3d8c3f52a846fe84d1db2
+ languageName: node
+ linkType: hard
+
"react-native@npm:0.85.3":
version: 0.85.3
resolution: "react-native@npm:0.85.3"