diff --git a/README.md b/README.md
index 6e334723..9985eae9 100644
--- a/README.md
+++ b/README.md
@@ -346,6 +346,7 @@ module.exports = [
| [no-render-in-lifecycle](docs/rules/no-render-in-lifecycle.md) | Disallow the use of `render` in testing frameworks setup functions |      | | |
| [no-test-id-queries](docs/rules/no-test-id-queries.md) | Ensure no `data-testid` queries are used | | | |
| [no-unnecessary-act](docs/rules/no-unnecessary-act.md) | Disallow wrapping Testing Library utils or empty callbacks in `act` |   | | |
+| [no-unsettled-absence-query](docs/rules/no-unsettled-absence-query.md) | Disallow absence assertions on `queryBy*` before the component has settled | | | |
| [no-wait-for-multiple-assertions](docs/rules/no-wait-for-multiple-assertions.md) | Disallow the use of multiple `expect` calls inside `waitFor` |       | | 🔧 |
| [no-wait-for-side-effects](docs/rules/no-wait-for-side-effects.md) | Disallow the use of side effects in `waitFor` |       | | 🔧 |
| [no-wait-for-snapshot](docs/rules/no-wait-for-snapshot.md) | Ensures no snapshot is generated inside of a `waitFor` call |       | | |
diff --git a/docs/rules/no-unsettled-absence-query.md b/docs/rules/no-unsettled-absence-query.md
new file mode 100644
index 00000000..a13e9afd
--- /dev/null
+++ b/docs/rules/no-unsettled-absence-query.md
@@ -0,0 +1,86 @@
+# testing-library/no-unsettled-absence-query
+
+📝 Disallow absence assertions on `queryBy*` before the component has settled.
+
+
+
+Asserting absence with `queryBy*` + `.not.toBeInTheDocument()` (or `.not.toBeVisible()`) immediately after `render()`, before the component has settled, can produce a false positive. The element isn't there _yet_, not because it _won't_ be there. This is commonly referred to as **Testing Ghosts**.
+
+## Rule details
+
+This rule fires when an absence assertion on a `queryBy*` / `queryAllBy*` query appears before any **settling expression** in the test body.
+
+**What counts as "settled":**
+
+1. Any `await` expression on a preceding statement - covers `findBy*`, `waitFor`, `act`, or custom async helpers.
+2. A `getBy*` / `getAllBy*` call on a preceding statement - proves the synchronous render produced expected output.
+
+**Additionally**, absence assertions inside a `waitFor` callback are always flagged, because `waitFor` retries until the assertion passes - an absence check passes on the first invocation before the component has settled.
+
+Examples of **incorrect** code for this rule:
+
+```js
+// Absence assertion before component has settled
+test('shows no error', () => {
+ render();
+ expect(screen.queryByText('error')).not.toBeInTheDocument();
+});
+
+// Absence assertion BEFORE the await - order matters
+test('shows no error', async () => {
+ render();
+ expect(screen.queryByText('error')).not.toBeInTheDocument();
+ await screen.findByText('loaded');
+});
+
+// queryAllBy variant
+test('shows no alerts', () => {
+ render();
+ expect(screen.queryAllByRole('alert')).not.toBeInTheDocument();
+});
+
+// Absence assertion inside waitFor - passes on first retry, still a ghost
+test('shows no error', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.queryByText('error')).not.toBeInTheDocument();
+ });
+});
+```
+
+Examples of **correct** code for this rule:
+
+```js
+// findBy* settles the component first
+test('shows no error', async () => {
+ render();
+ await screen.findByText('loaded');
+ expect(screen.queryByText('error')).not.toBeInTheDocument();
+});
+
+// waitFor settles the component first
+test('shows no error', async () => {
+ render();
+ await waitFor(() => expect(something).toBe(true));
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+});
+
+// act settles the component first
+test('shows no error', async () => {
+ await act(() => render());
+ expect(screen.queryByText('error')).not.toBeInTheDocument();
+});
+
+// getBy* proves sync render completed
+test('shows no error', () => {
+ render();
+ screen.getByText('visible heading');
+ expect(screen.queryByText('error')).not.toBeInTheDocument();
+});
+```
+
+## Further Reading
+
+- [Testing Library: Appearance and Disappearance guide](https://testing-library.com/docs/guide-disappearance/)
+- [Kent C. Dodds: Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)
+- [Gerardo Perrucci: Stop Testing Ghosts](https://www.gperrucci.com/blog/react/assert-non-existence-react-testing-library)
diff --git a/src/rules/index.ts b/src/rules/index.ts
index 06a671c4..c17c4046 100644
--- a/src/rules/index.ts
+++ b/src/rules/index.ts
@@ -14,6 +14,7 @@ import noPromiseInFireEvent from './no-promise-in-fire-event';
import noRenderInLifecycle from './no-render-in-lifecycle';
import noTestIdQueries from './no-test-id-queries';
import noUnnecessaryAct from './no-unnecessary-act';
+import noUnsettledAbsenceQuery from './no-unsettled-absence-query';
import noWaitForMultipleAssertions from './no-wait-for-multiple-assertions';
import noWaitForSideEffects from './no-wait-for-side-effects';
import noWaitForSnapshot from './no-wait-for-snapshot';
@@ -45,6 +46,7 @@ export const rules = {
'no-render-in-lifecycle': noRenderInLifecycle,
'no-test-id-queries': noTestIdQueries,
'no-unnecessary-act': noUnnecessaryAct,
+ 'no-unsettled-absence-query': noUnsettledAbsenceQuery,
'no-wait-for-multiple-assertions': noWaitForMultipleAssertions,
'no-wait-for-side-effects': noWaitForSideEffects,
'no-wait-for-snapshot': noWaitForSnapshot,
diff --git a/src/rules/no-unsettled-absence-query.ts b/src/rules/no-unsettled-absence-query.ts
new file mode 100644
index 00000000..c658235a
--- /dev/null
+++ b/src/rules/no-unsettled-absence-query.ts
@@ -0,0 +1,225 @@
+import { ASTUtils } from '@typescript-eslint/utils';
+
+import { createTestingLibraryRule } from '../create-testing-library-rule';
+import {
+ findClosestCallNode,
+ getAssertNodeInfo,
+ getDeepestIdentifierNode,
+ isArrowFunctionExpression,
+ isBlockStatement,
+ isCallExpression,
+ isFunctionDeclaration,
+ isFunctionExpression,
+ isMemberExpression,
+} from '../node-utils';
+
+import type { TSESTree } from '@typescript-eslint/utils';
+
+const RULE_NAME = 'no-unsettled-absence-query';
+export type MessageIds = 'noUnsettledAbsenceQuery';
+export type Options = [];
+
+const NEGATED_ABSENCE_MATCHERS = ['toBeVisible'];
+
+function isNestedFunction(node: TSESTree.Node): boolean {
+ return (
+ isArrowFunctionExpression(node) ||
+ isFunctionExpression(node) ||
+ isFunctionDeclaration(node)
+ );
+}
+
+function containsNode(
+ node: TSESTree.Node,
+ predicate: (n: TSESTree.Node) => boolean
+): boolean {
+ if (predicate(node)) {
+ return true;
+ }
+
+ if (isNestedFunction(node)) {
+ return false;
+ }
+
+ for (const key of Object.keys(node)) {
+ if (key === 'parent') continue;
+ const child = (node as unknown as Record)[key];
+ if (child && typeof child === 'object') {
+ if (Array.isArray(child)) {
+ for (const item of child) {
+ if (
+ item &&
+ typeof item === 'object' &&
+ 'type' in item &&
+ containsNode(item as TSESTree.Node, predicate)
+ ) {
+ return true;
+ }
+ }
+ } else if (
+ 'type' in child &&
+ containsNode(child as TSESTree.Node, predicate)
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+export default createTestingLibraryRule({
+ name: RULE_NAME,
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description:
+ 'Disallow absence assertions on `queryBy*` before the component has settled',
+ recommendedConfig: {
+ dom: false,
+ angular: false,
+ react: false,
+ vue: false,
+ svelte: false,
+ marko: false,
+ },
+ },
+ messages: {
+ noUnsettledAbsenceQuery:
+ 'Absence assertion on `{{queryMethod}}` appears before the component has settled. ' +
+ 'The element may not have rendered yet, resulting in a false positive. ' +
+ 'Add an `await` expression (e.g. `findBy*`, `waitFor`, `act`) or a `getBy*` call before this assertion.',
+ },
+ schema: [],
+ },
+ defaultOptions: [],
+
+ create(context, _, helpers) {
+ function isAbsenceAssertion(node: TSESTree.MemberExpression): boolean {
+ if (helpers.isAbsenceAssert(node)) {
+ return true;
+ }
+
+ const { matcher, isNegated } = getAssertNodeInfo(node);
+ return (
+ isNegated !== false &&
+ matcher !== null &&
+ NEGATED_ABSENCE_MATCHERS.includes(matcher)
+ );
+ }
+
+ function isInsideAsyncUtilCallback(node: TSESTree.Node): boolean {
+ let current: TSESTree.Node | undefined = node.parent;
+
+ while (current) {
+ if (
+ (isArrowFunctionExpression(current) ||
+ isFunctionExpression(current)) &&
+ isCallExpression(current.parent)
+ ) {
+ const calleeIdentifier = getDeepestIdentifierNode(
+ current.parent.callee
+ );
+ if (calleeIdentifier && helpers.isAsyncUtil(calleeIdentifier)) {
+ return true;
+ }
+ }
+ current = current.parent;
+ }
+ return false;
+ }
+
+ function findEnclosingFunctionBody(
+ node: TSESTree.Node
+ ): TSESTree.Statement[] | null {
+ let current: TSESTree.Node | undefined = node.parent;
+
+ while (current) {
+ if (
+ (isArrowFunctionExpression(current) ||
+ isFunctionExpression(current)) &&
+ isBlockStatement(current.body)
+ ) {
+ return current.body.body;
+ }
+ current = current.parent;
+ }
+ return null;
+ }
+
+ function findAncestorStatement(
+ node: TSESTree.Node,
+ statements: TSESTree.Statement[]
+ ): TSESTree.Statement | null {
+ let current: TSESTree.Node = node;
+ while (current.parent) {
+ if (statements.includes(current as TSESTree.Statement)) {
+ return current as TSESTree.Statement;
+ }
+ current = current.parent;
+ }
+ return null;
+ }
+
+ function hasSettlingExpression(statement: TSESTree.Statement): boolean {
+ const hasAwait = containsNode(statement, (n) =>
+ ASTUtils.isAwaitExpression(n)
+ );
+ const hasGetQuery = containsNode(
+ statement,
+ (n) => ASTUtils.isIdentifier(n) && helpers.isGetQueryVariant(n)
+ );
+ return hasAwait || hasGetQuery;
+ }
+
+ return {
+ 'CallExpression Identifier'(node: TSESTree.Identifier) {
+ if (!helpers.isQueryQueryVariant(node)) {
+ return;
+ }
+
+ const expectCallNode = findClosestCallNode(node, 'expect');
+ if (
+ !expectCallNode?.parent ||
+ !isMemberExpression(expectCallNode.parent)
+ ) {
+ return;
+ }
+
+ if (!isAbsenceAssertion(expectCallNode.parent)) {
+ return;
+ }
+
+ if (isInsideAsyncUtilCallback(node)) {
+ context.report({
+ node,
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: node.name },
+ });
+ return;
+ }
+
+ const functionBody = findEnclosingFunctionBody(node);
+ if (!functionBody) {
+ return;
+ }
+
+ const containingStatement = findAncestorStatement(node, functionBody);
+ if (!containingStatement) {
+ return;
+ }
+
+ const stmtIndex = functionBody.indexOf(containingStatement);
+ const precedingStatements = functionBody.slice(0, stmtIndex);
+ const hasSettled = precedingStatements.some(hasSettlingExpression);
+
+ if (!hasSettled) {
+ context.report({
+ node,
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: node.name },
+ });
+ }
+ },
+ };
+ },
+});
diff --git a/tests/rules/no-unsettled-absence-query.test.ts b/tests/rules/no-unsettled-absence-query.test.ts
new file mode 100644
index 00000000..a4ba7a25
--- /dev/null
+++ b/tests/rules/no-unsettled-absence-query.test.ts
@@ -0,0 +1,482 @@
+import rule from '../../src/rules/no-unsettled-absence-query';
+import { createRuleTester } from '../test-utils';
+
+import type {
+ MessageIds,
+ Options,
+} from '../../src/rules/no-unsettled-absence-query';
+import type {
+ InvalidTestCase,
+ ValidTestCase,
+} from '@typescript-eslint/rule-tester';
+
+const ruleTester = createRuleTester();
+
+type RuleValidTestCase = ValidTestCase;
+type RuleInvalidTestCase = InvalidTestCase;
+
+const SUPPORTED_TESTING_FRAMEWORKS = [
+ ['@testing-library/dom', 'DOM TL'],
+ ['@testing-library/react', 'React TL'],
+ ['@testing-library/vue', 'Vue TL'],
+ ['@testing-library/angular', 'Angular TL'],
+ ['@marko/testing-library', 'Marko TL'],
+] as const;
+
+ruleTester.run('no-unsettled-absence-query', rule, {
+ valid: [
+ ...SUPPORTED_TESTING_FRAMEWORKS.map(
+ ([testingFramework, label]) => ({
+ code: `
+ import { render, screen } from '${testingFramework}'
+
+ test('${label} - settled by findBy', async () => {
+ render()
+ await screen.findByText('loaded')
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ })
+ ),
+
+ {
+ code: `
+ import { render, screen, waitFor } from '@testing-library/react'
+
+ test('settled by waitFor', async () => {
+ render()
+ await waitFor(() => expect(something).toBe(true))
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen, act } from '@testing-library/react'
+
+ test('settled by act wrapping render', async () => {
+ await act(() => render())
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('settled by getByText', () => {
+ render()
+ screen.getByText('visible heading')
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('settled by getByRole inside expect', () => {
+ render()
+ expect(screen.getByRole('heading')).toBeVisible()
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('settled by getAllByText', () => {
+ render()
+ screen.getAllByText('item')
+ expect(screen.queryByRole('alert')).not.toBeVisible()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('.not.toBeVisible after findBy', async () => {
+ render()
+ await screen.findByText('loaded')
+ expect(screen.queryByRole('dialog')).not.toBeVisible()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('presence assertion is not flagged', () => {
+ render()
+ expect(screen.queryByText('exists')).toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('.not with unrelated matcher is not flagged', () => {
+ render()
+ expect(screen.queryByText('item')).not.toHaveClass('active')
+ })
+ `,
+ },
+
+ {
+ settings: { 'testing-library/utils-module': 'test-utils' },
+ code: `
+ import { render, screen } from 'other-library'
+
+ test('non-TL import is not reported', () => {
+ render()
+ expect(screen.queryByText('gone')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ settings: { 'testing-library/utils-module': 'test-utils' },
+ code: `
+ import { render, screen } from 'test-utils'
+
+ test('custom module settled', async () => {
+ render()
+ await screen.findByText('loaded')
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render } from '@testing-library/react'
+
+ test('destructured queryByText after settling', async () => {
+ const { queryByText, findByText } = render()
+ await findByText('loaded')
+ expect(queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('standalone queryBy without expect', () => {
+ render()
+ const el = screen.queryByText('error')
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { screen } from '@testing-library/react'
+
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('settled inside function expression', async function() {
+ render()
+ await screen.findByText('loaded')
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('settled by await nested inside expect arguments', async () => {
+ render()
+ expect(await screen.findByText('loaded')).toBeInTheDocument()
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ },
+ ],
+
+ invalid: [
+ ...SUPPORTED_TESTING_FRAMEWORKS.map(
+ ([testingFramework, label]) => ({
+ code: `
+ import { render, screen } from '${testingFramework}'
+
+ test('${label} - absence before settling', () => {
+ render()
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 20,
+ },
+ ],
+ })
+ ),
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('.not.toBeVisible before settling', () => {
+ render()
+ expect(screen.queryByRole('dialog')).not.toBeVisible()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByRole' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('queryAllByText before settling', () => {
+ render()
+ expect(screen.queryAllByText('gone')).not.toBeInTheDocument()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryAllByText' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('absence before await - order matters', async () => {
+ render()
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ await screen.findByText('loaded')
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('multiple absence assertions before settling', () => {
+ render()
+ expect(screen.queryByText('a')).not.toBeInTheDocument()
+ expect(screen.queryByText('b')).not.toBeInTheDocument()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 19,
+ },
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 7,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen, waitFor } from '@testing-library/react'
+
+ test('absence inside awaited waitFor', async () => {
+ render()
+ await waitFor(() => {
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 7,
+ column: 20,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen, waitFor } from '@testing-library/react'
+
+ test('absence inside unawaited waitFor', () => {
+ render()
+ waitFor(() => {
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 7,
+ column: 20,
+ },
+ ],
+ },
+
+ {
+ settings: { 'testing-library/utils-module': 'test-utils' },
+ code: `
+ import { render, screen } from 'test-utils'
+
+ test('custom module unsettled', () => {
+ render()
+ expect(screen.queryByText('gone')).not.toBeInTheDocument()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('.toBeNull before settling', () => {
+ render()
+ expect(screen.queryByText('error')).toBeNull()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('.toBeFalsy before settling', () => {
+ render()
+ expect(screen.queryByText('error')).toBeFalsy()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen, waitFor } from '@testing-library/react'
+
+ test('absence inside waitFor with function expression callback', async () => {
+ render()
+ await waitFor(function() {
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 7,
+ column: 20,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('unsettled in function expression', function() {
+ render()
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 6,
+ column: 19,
+ },
+ ],
+ },
+
+ {
+ code: `
+ import { render, screen } from '@testing-library/react'
+
+ test('await inside nested function does not settle outer scope', () => {
+ render()
+ const helper = async () => { await screen.findByText('loaded') }
+ expect(screen.queryByText('error')).not.toBeInTheDocument()
+ })
+ `,
+ errors: [
+ {
+ messageId: 'noUnsettledAbsenceQuery',
+ data: { queryMethod: 'queryByText' },
+ line: 7,
+ column: 19,
+ },
+ ],
+ },
+ ],
+});