diff --git a/package.json b/package.json
index 99420c6c2..f8b5fe97f 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,9 @@
"typescript": "^5.0.2"
},
"dependencies": {
- "pretty-format": "^29.0.0"
+ "jest-matcher-utils": "^29.6.2",
+ "pretty-format": "^29.6.2",
+ "redent": "^3.0.0"
},
"peerDependencies": {
"jest": ">=28.0.0",
diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx
index e275a58be..4e64250bd 100644
--- a/src/helpers/__tests__/component-tree.test.tsx
+++ b/src/helpers/__tests__/component-tree.test.tsx
@@ -6,6 +6,7 @@ import {
getHostParent,
getHostSelves,
getHostSiblings,
+ getUnsafeRootElement,
} from '../component-tree';
function ZeroHostChildren() {
@@ -224,3 +225,20 @@ describe('getHostSiblings()', () => {
]);
});
});
+
+describe('getUnsafeRootElement()', () => {
+ it('returns UNSAFE_root for mounted view', () => {
+ const screen = render(
+
+
+
+ );
+
+ const view = screen.getByTestId('view');
+ expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root);
+ });
+
+ it('returns null for null', () => {
+ expect(getUnsafeRootElement(null)).toEqual(null);
+ });
+});
diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts
index 46993e956..6fe6fd13b 100644
--- a/src/helpers/component-tree.ts
+++ b/src/helpers/component-tree.ts
@@ -92,3 +92,22 @@ export function getHostSiblings(
(sibling) => !hostSelves.includes(sibling)
);
}
+
+/**
+ * Returns the unsafe root element of the tree (probably composite).
+ *
+ * @param element The element start traversing from.
+ * @returns The root element of the tree (host or composite).
+ */
+export function getUnsafeRootElement(element: ReactTestInstance | null) {
+ if (element == null) {
+ return null;
+ }
+
+ let current = element;
+ while (current.parent) {
+ current = current.parent;
+ }
+
+ return current;
+}
diff --git a/src/matchers/__tests__/extend-expect.test.tsx b/src/matchers/__tests__/extend-expect.test.tsx
new file mode 100644
index 000000000..1889926aa
--- /dev/null
+++ b/src/matchers/__tests__/extend-expect.test.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import { View } from 'react-native';
+
+// Note: that must point to root of the /src to reliably replicate default import.
+import { render } from '../..';
+
+// This is check that RNTL does not extend "expect" by default, until we actually want to expose Jest matchers publically.
+test('does not extend "expect" by default', () => {
+ render();
+
+ // @ts-expect-error
+ expect(expect.toBeOnTheScreen).toBeUndefined();
+});
diff --git a/src/matchers/__tests__/to-be-on-the-screen.test.tsx b/src/matchers/__tests__/to-be-on-the-screen.test.tsx
new file mode 100644
index 000000000..cfb6c8c8a
--- /dev/null
+++ b/src/matchers/__tests__/to-be-on-the-screen.test.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { View, Text } from 'react-native';
+import { render, screen } from '../..';
+import '../extend-expect';
+
+test('example test', () => {
+ render(
+
+
+
+ );
+
+ const child = screen.getByTestId('child');
+ expect(child).toBeOnTheScreen();
+
+ screen.update();
+ expect(child).not.toBeOnTheScreen();
+});
+
+test('toBeOnTheScreen() on attached element', () => {
+ render();
+
+ const element = screen.getByTestId('test');
+ expect(element).toBeOnTheScreen();
+ expect(() => expect(element).not.toBeOnTheScreen())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).not.toBeOnTheScreen()
+
+ expected element tree not to contain element, but found
+ "
+ `);
+});
+
+function ShowChildren({ show }: { show: boolean }) {
+ return show ? (
+
+ Hello
+
+ ) : (
+
+ );
+}
+
+test('toBeOnTheScreen() on detached element', () => {
+ render();
+
+ const element = screen.getByTestId('text');
+ // Next line will unmount the element, yet `element` variable will still hold reference to it.
+ screen.update();
+
+ expect(element).toBeTruthy();
+ expect(element).not.toBeOnTheScreen();
+ expect(() => expect(element).toBeOnTheScreen())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).toBeOnTheScreen()
+
+ element could not be found in the element tree"
+ `);
+});
+
+test('toBeOnTheScreen() on null element', () => {
+ expect(null).not.toBeOnTheScreen();
+ expect(() => expect(null).toBeOnTheScreen())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(received).toBeOnTheScreen()
+
+ received value must be a host element.
+ Received has value: null"
+ `);
+});
diff --git a/src/matchers/__tests__/utils.test.tsx b/src/matchers/__tests__/utils.test.tsx
new file mode 100644
index 000000000..0e210459d
--- /dev/null
+++ b/src/matchers/__tests__/utils.test.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { View } from 'react-native';
+import { render } from '../..';
+import { formatElement, checkHostElement } from '../utils';
+
+function fakeMatcher() {
+ // Do nothing.
+}
+
+test('formatElement', () => {
+ expect(formatElement(null)).toMatchInlineSnapshot(`"null"`);
+});
+
+test('checkHostElement allows host element', () => {
+ const screen = render();
+
+ expect(() => {
+ // @ts-expect-error
+ checkHostElement(screen.getByTestId('view'), fakeMatcher, {});
+ }).not.toThrow();
+});
+
+test('checkHostElement allows rejects composite element', () => {
+ const screen = render();
+
+ expect(() => {
+ // @ts-expect-error
+ checkHostElement(screen.UNSAFE_root, fakeMatcher, {});
+ }).toThrow(/value must be a host element./);
+});
+
+test('checkHostElement allows rejects null element', () => {
+ expect(() => {
+ // @ts-expect-error
+ checkHostElement(null, fakeMatcher, {});
+ }).toThrowErrorMatchingInlineSnapshot(`
+ "expect(received).fakeMatcher()
+
+ received value must be a host element.
+ Received has value: null"
+ `);
+});
diff --git a/src/matchers/extend-expect.ts b/src/matchers/extend-expect.ts
new file mode 100644
index 000000000..4eb7b373f
--- /dev/null
+++ b/src/matchers/extend-expect.ts
@@ -0,0 +1,5 @@
+import { toBeOnTheScreen } from './to-be-on-the-screen';
+
+expect.extend({
+ toBeOnTheScreen,
+});
diff --git a/src/matchers/index.tsx b/src/matchers/index.tsx
new file mode 100644
index 000000000..8d1540b14
--- /dev/null
+++ b/src/matchers/index.tsx
@@ -0,0 +1 @@
+export { toBeOnTheScreen } from './to-be-on-the-screen';
diff --git a/src/matchers/to-be-on-the-screen.tsx b/src/matchers/to-be-on-the-screen.tsx
new file mode 100644
index 000000000..3fd1ce615
--- /dev/null
+++ b/src/matchers/to-be-on-the-screen.tsx
@@ -0,0 +1,44 @@
+import type { ReactTestInstance } from 'react-test-renderer';
+import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils';
+import { getUnsafeRootElement } from '../helpers/component-tree';
+import { screen } from '../screen';
+import { checkHostElement, formatElement } from './utils';
+
+export function toBeOnTheScreen(
+ this: jest.MatcherContext,
+ element: ReactTestInstance
+) {
+ if (element !== null || !this.isNot) {
+ checkHostElement(element, toBeOnTheScreen, this);
+ }
+
+ const pass =
+ element === null
+ ? false
+ : screen.UNSAFE_root === getUnsafeRootElement(element);
+
+ const errorFound = () => {
+ return `expected element tree not to contain element, but found\n${formatElement(
+ element
+ )}`;
+ };
+
+ const errorNotFound = () => {
+ return `element could not be found in the element tree`;
+ };
+
+ return {
+ pass,
+ message: () => {
+ return [
+ matcherHint(
+ `${this.isNot ? '.not' : ''}.toBeOnTheScreen`,
+ 'element',
+ ''
+ ),
+ '',
+ RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()),
+ ].join('\n');
+ },
+ };
+}
diff --git a/src/matchers/utils.tsx b/src/matchers/utils.tsx
new file mode 100644
index 000000000..51d9f7d1f
--- /dev/null
+++ b/src/matchers/utils.tsx
@@ -0,0 +1,91 @@
+import { ReactTestInstance } from 'react-test-renderer';
+import {
+ RECEIVED_COLOR,
+ matcherHint,
+ printWithType,
+ printReceived,
+} from 'jest-matcher-utils';
+import prettyFormat, { plugins } from 'pretty-format';
+import redent from 'redent';
+import { isHostElement } from '../helpers/component-tree';
+
+class HostElementTypeError extends Error {
+ constructor(
+ received: unknown,
+ matcherFn: jest.CustomMatcher,
+ context: jest.MatcherContext
+ ) {
+ super();
+
+ /* istanbul ignore next */
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, matcherFn);
+ }
+
+ let withType = '';
+ try {
+ withType = printWithType('Received', received, printReceived);
+ /* istanbul ignore next */
+ } catch (e) {
+ // Deliberately empty.
+ }
+
+ this.message = [
+ matcherHint(
+ `${context.isNot ? '.not' : ''}.${matcherFn.name}`,
+ 'received',
+ ''
+ ),
+ '',
+ `${RECEIVED_COLOR('received')} value must be a host element.`,
+ withType,
+ ].join('\n');
+ }
+}
+
+/**
+ * Throws HostElementTypeError if passed element is not a host element.
+ *
+ * @param element ReactTestInstance to check.
+ * @param matcherFn Matcher function calling the check used for formatting error.
+ * @param context Jest matcher context used for formatting error.
+ */
+export function checkHostElement(
+ element: ReactTestInstance | null | undefined,
+ matcherFn: jest.CustomMatcher,
+ context: jest.MatcherContext
+): asserts element is ReactTestInstance {
+ if (!isHostElement(element)) {
+ throw new HostElementTypeError(element, matcherFn, context);
+ }
+}
+
+/***
+ * Format given element as a pretty-printed string.
+ *
+ * @param element Element to format.
+ */
+export function formatElement(element: ReactTestInstance | null) {
+ if (element == null) {
+ return 'null';
+ }
+
+ return redent(
+ prettyFormat(
+ {
+ // This prop is needed persuade the prettyFormat that the element is
+ // a ReactTestRendererJSON instance, so it is formatted as JSX.
+ $$typeof: Symbol.for('react.test.json'),
+ type: element.type,
+ props: element.props,
+ },
+ {
+ plugins: [plugins.ReactTestComponent, plugins.ReactElement],
+ printFunctionName: false,
+ printBasicPrototype: false,
+ highlight: true,
+ }
+ ),
+ 2
+ );
+}
diff --git a/yarn.lock b/yarn.lock
index d0b3bedc8..86e8789dd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4664,6 +4664,16 @@ jest-diff@^29.0.1, jest-diff@^29.6.1:
jest-get-type "^29.4.3"
pretty-format "^29.6.1"
+jest-diff@^29.6.2:
+ version "29.6.2"
+ resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46"
+ integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA==
+ dependencies:
+ chalk "^4.0.0"
+ diff-sequences "^29.4.3"
+ jest-get-type "^29.4.3"
+ pretty-format "^29.6.2"
+
jest-docblock@^29.4.3:
version "29.4.3"
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.4.3.tgz#90505aa89514a1c7dceeac1123df79e414636ea8"
@@ -4741,6 +4751,16 @@ jest-matcher-utils@^29.0.1, jest-matcher-utils@^29.6.1:
jest-get-type "^29.4.3"
pretty-format "^29.6.1"
+jest-matcher-utils@^29.6.2:
+ version "29.6.2"
+ resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.2.tgz#39de0be2baca7a64eacb27291f0bd834fea3a535"
+ integrity sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ==
+ dependencies:
+ chalk "^4.0.0"
+ jest-diff "^29.6.2"
+ jest-get-type "^29.4.3"
+ pretty-format "^29.6.2"
+
jest-message-util@^29.6.1:
version "29.6.1"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.6.1.tgz#d0b21d87f117e1b9e165e24f245befd2ff34ff8d"
@@ -6279,6 +6299,15 @@ pretty-format@^29.0.0, pretty-format@^29.0.3, pretty-format@^29.6.1:
ansi-styles "^5.0.0"
react-is "^18.0.0"
+pretty-format@^29.6.2:
+ version "29.6.2"
+ resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47"
+ integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==
+ dependencies:
+ "@jest/schemas" "^29.6.0"
+ ansi-styles "^5.0.0"
+ react-is "^18.0.0"
+
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"