Skip to content

feat: add no-unsettled-absence-query rule#1278

Open
JMiltier wants to merge 7 commits intotesting-library:mainfrom
JMiltier:pr/no-unsettled-absence-query
Open

feat: add no-unsettled-absence-query rule#1278
JMiltier wants to merge 7 commits intotesting-library:mainfrom
JMiltier:pr/no-unsettled-absence-query

Conversation

@JMiltier
Copy link
Copy Markdown

@JMiltier JMiltier commented Mar 31, 2026

Checks

Changes

  1. Add no-unsettled-absence-query rule that detects absence assertions (queryBy* + .not.toBeInTheDocument() / .not.toBeVisible() / .toBeNull() / .toBeFalsy()) before the component has settled
  2. Flags absence assertions inside waitFor callbacks (pass on first invocation)
  3. Recognizes settling via await expressions (findBy*, waitFor, act) or getBy*/getAllBy* calls
  4. Scope-aware tree-walking: await inside a nested function does not settle the outer scope
  5. 37 test cases across React, Vue, Angular, DOM, and Marko
  6. recommendedConfig set to false for all frameworks (opt-in only) - open to adding as warn in presets if preferred
  7. Documentation: https://github.com/JMiltier/eslint-plugin-testing-library/blob/pr/no-unsettled-absence-query/docs/rules/no-unsettled-absence-query.md

Context

Closes #1237

@Belco90 Belco90 self-requested a review March 31, 2026 05:19
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 31, 2026

Codecov Report

❌ Patch coverage is 98.91892% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.40%. Comparing base (2002150) to head (8b383b6).

Files with missing lines Patch % Lines
src/rules/no-unsettled-absence-query.ts 98.90% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1278      +/-   ##
==========================================
+ Coverage   97.35%   97.40%   +0.04%     
==========================================
  Files          49       50       +1     
  Lines        6020     6205     +185     
  Branches     1544     1610      +66     
==========================================
+ Hits         5861     6044     +183     
- Misses        157      159       +2     
  Partials        2        2              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@JMiltier JMiltier marked this pull request as draft April 1, 2026 02:27
@JMiltier JMiltier changed the title feat: introduce new rule to allow eslint to pick up on potential ghos… feat: add no-unsettled-absence-query rule Apr 1, 2026
@Belco90 Belco90 added the new rule New rule to be included in the plugin label Apr 1, 2026
@JMiltier JMiltier marked this pull request as ready for review April 1, 2026 20:31
@JMiltier JMiltier changed the title feat: add no-unsettled-absence-query rule feat: add no-unsettled-absence-query rule Apr 2, 2026
@Belco90
Copy link
Copy Markdown
Member

Belco90 commented Apr 7, 2026

@JMiltier thanks for your PR, all checks passing now! I'll try to review this during the week.

const calleeIdentifier = getDeepestIdentifierNode(
current.parent.callee
);
if (calleeIdentifier && helpers.isAsyncUtil(calleeIdentifier)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if isAsyncUtil is the right util to use here. This helper matches both waitFor and waitForElementToBeRemoved by default, but the latter shouldn't be reported, right? It's not even mentioned in the docs, and IIRC waitForElementToBeRemoved throws an error if the first argument (the element that should be removed) is already empty (i.e. it's a settling expression on its own).

You can limit the subset of async utils checked passing a second arg to isAsyncUtil (other rules like no-wait-for-side-effects are doing this).

Additionally, mention this in the docs, and add extra tests to check the usage of waitForElementToBeRemoved is valid. Something like:

{
    code: `
    import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'

    test('waitForElementToBeRemoved callback is not a waitFor', async () => {
      render(<Component />)
      await waitForElementToBeRemoved(() => screen.queryByText('loading'))
      expect(screen.queryByText('error')).not.toBeInTheDocument()
    })
    `,
  },

expect(screen.queryByText('error')).not.toBeInTheDocument();
});
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a "Limitations" section to mention that some untracked helpers (custom setup, fake timers, etc) might produce false positives.

);
}

function containsNode(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm worried about the performance of this util because of the following issues:

  1. It revisits the tree per assertion. hasSettlingExpression calls this util twice for every preceding statement of every absence assertion, resulting in O(n²) complexity.
  2. It iterates over non-AST keys. Skipping only parent means that it also traverse into loc, range, comment arrays, etc. It works because those don't carry a type but it's fragile, especially in terms of maintenance.
  3. It's not consistent with the rest of the rules in the repo. We have a bunch of utils to walk a subtree in node-utils. You should be able to report awaited expressions with ESLint's visitor pattern instead (take a look at https://github.com/testing-library/eslint-plugin-testing-library/blob/main/src/rules/no-await-sync-events.ts#L99 or
    'AwaitExpression > CallExpression'(
    for reference).

The rule implementation must be refactored to get rid of this containsNode function and let ESLint do the single pass, collect all settling nodes (AwaitExpression + expression, checking they are getQuery with the corresponding helper), then check what absence assertions must be reported at Program:exit.

Comment on lines +88 to +90
'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.',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make the error message shorter. Right now it includes the "what" + "why" + "how to fix", but with the "what" + "how to fix" should be enough. If the user wants to know more about the why, they can go to the docs to get more info.

Suggested change
'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.',
'Absence assertion on `{{queryMethod}}` appears before the component has settled. ' +
'Add an `await` expression (e.g. `findBy*`, `waitFor`, `act`) or a `getBy*` call before this assertion.',

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new rule New rule to be included in the plugin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Rule Proposal: no-unsettled-absence-query

2 participants