diff --git a/docs/api/queries.md b/docs/api/queries.md
index 7388692a0..cd9b972bb 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 4549c54f6..6b829ab9d 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 46c4c48dc..f124f9bdf 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 3fbf5fdf2..305a1ba35 100644
--- a/src/__tests__/host-component-names.test.tsx
+++ b/src/__tests__/host-component-names.test.tsx
@@ -1,8 +1,11 @@
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 {
+ getCustomTextValue,
+ isCustomHostText,
isHostImage,
isHostModal,
isHostScrollView,
@@ -23,6 +26,29 @@ test('detects raw RCTText component', async () => {
expect(isHostText(screen.root)).toBe(true);
});
+// Custom text components, e.g. `` from `react-native-plain-text`,
+// hold their content in a prop instead of as string children.
+test('detects custom host text component', async () => {
+ await render(Hello);
+ expect(isHostText(screen.root)).toBe(true);
+ expect(isCustomHostText(screen.root)).toBe(true);
+});
+
+test('does not detect host Text component as custom host text', async () => {
+ await render(Hello);
+ expect(isCustomHostText(screen.root)).toBe(false);
+});
+
+test('reads custom host text value from its prop', async () => {
+ await render();
+ expect(getCustomTextValue(screen.root)).toBe('Hello');
+});
+
+test('reads no custom host text value from other components', async () => {
+ await render(Hello);
+ expect(getCustomTextValue(screen.root)).toBeUndefined();
+});
+
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 db52a43d1..a8f7dc193 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 0c773f699..50d1dd818 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 2638e06cb..1a573de2a 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 {
+ getCustomTextValue,
+ 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;
}
+ // Custom host text elements have no children, their content is in a prop.
+ const customText = getCustomTextValue(instance);
+ if (customText !== undefined) {
+ return customText;
+ }
+
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 2b7f59860..7430fb01b 100644
--- a/src/helpers/host-component-names.ts
+++ b/src/helpers/host-component-names.ts
@@ -1,18 +1,58 @@
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'];
+
const HOST_TEXT_INPUT_NAMES = ['TextInput'];
const HOST_IMAGE_NAMES = ['Image'];
const HOST_SWITCH_NAMES = ['RCTSwitch'];
const HOST_SCROLL_VIEW_NAMES = ['RCTScrollView'];
const HOST_MODAL_NAMES = ['Modal'];
+// Custom host text component holding its text content in the `text` prop
+// instead of as string children: `` from `react-native-plain-text`.
+const HOST_PLAIN_TEXT_NAME = 'RNPlainText';
+
+/**
+ * Checks if the given element is a custom host text element, i.e. one that
+ * holds its text content in a prop instead of as string children, e.g.
+ * `` from `react-native-plain-text`.
+ * @param instance The instance to check.
+ */
+export function isCustomHostText(instance: TestInstance | null) {
+ return instance?.type === HOST_PLAIN_TEXT_NAME;
+}
+
/**
- * Checks if the given element is a host Text element.
+ * Checks if the given element is a host text element: either a RN ``,
+ * holding its content as string children, or a custom one, holding it in a 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) || instance.type === HOST_PLAIN_TEXT_NAME;
+}
+
+/**
+ * Returns the text content held in a prop by a custom host text element, or
+ * `undefined` for any other element.
+ * @param instance The instance to read.
+ */
+export function getCustomTextValue(instance: TestInstance | null): string | undefined {
+ if (instance?.type !== HOST_PLAIN_TEXT_NAME) {
+ return undefined;
+ }
+
+ const { text } = instance.props;
+ if (typeof text === 'string') {
+ return text;
+ }
+
+ return typeof text === 'number' ? String(text) : undefined;
}
/**
diff --git a/src/helpers/text-content.ts b/src/helpers/text-content.ts
index ee6f368ae..4cc4a9d8f 100644
--- a/src/helpers/text-content.ts
+++ b/src/helpers/text-content.ts
@@ -1,5 +1,7 @@
import type { TestInstance } from 'test-renderer';
+import { getCustomTextValue } 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;
}
+ // Custom host text elements, e.g. `` from `react-native-plain-text`,
+ // hold their content in a prop rather than as string children.
+ const customText = getCustomTextValue(instance);
+ if (customText !== undefined) {
+ return customText;
+ }
+
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 3e20f1961..3fc452532 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 aac538bfb..65b09376d 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 9ef211dad..f8fe22582 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 28a3b16bc..4e9571994 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 c9e50977a..6e3e84124 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 0e92a49d3..64ab87639 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"