Skip to content

JS-2014: Implement S8980: react testing library calls should not be wrapped in act()#7480

Open
nathsou wants to merge 9 commits into
masterfrom
new-rule/S8980
Open

JS-2014: Implement S8980: react testing library calls should not be wrapped in act()#7480
nathsou wants to merge 9 commits into
masterfrom
new-rule/S8980

Conversation

@nathsou

@nathsou nathsou commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements S8980: flags act(...) calls (from @testing-library/react or react-dom/test-utils) whose callback consists entirely of React Testing Library / user-event calls, since those utilities already wrap themselves in act() internally.

  • Decorates eslint-plugin-testing-library's no-unnecessary-act rule (implementation: 'decorated') rather than reimplementing Testing Library import/call resolution from scratch.
  • Forces isStrict: false (upstream default is true) so only callbacks made entirely of Testing Library calls are flagged; mixed content stays compliant.
  • Overrides context.settings locally in the rule's own decorator.ts (not the shared linter settings) to disable the upstream plugin's "aggressive" name-based heuristic detection, forcing precise import-resolution-only matching.
  • Drops the upstream rule's noUnnecessaryActEmptyFunction report (act(() => {})) via interceptReport, since that case is already covered by S1186.
  • Gated by requiredDependency: ['@testing-library/react'].
  • Added eslint-plugin-testing-library@7.16.2 as a devDependency (consistent with the recent eslint-plugin-vue precedent for S8950/JS-1952).

Known upstream limitation

Found and worked around (not patched) a real ordering bug in eslint-plugin-testing-library@7.16.2: importing @testing-library/user-event before react-dom/test-utils in the same file causes the plugin's own react-dom/test-utils-imported act detection to be silently skipped, due to a stray !importedUserEventLibraryNode guard instead of !importedReactDomTestUtilsNode. This is an inherited false-negative risk in that narrow scenario, tracked as a follow-up rather than a blocker.

Testing

Comment-based fixtures cover: render/fireEvent/implicit-return act(() => screen.getByRole(...))/userEvent/waitFor noncompliant cases, act imported from react-dom/test-utils (isolated fixture, see limitation above), plain RTL calls with no act wrapper (compliant), act wrapping only non-Testing-Library code (compliant — proves the rule won't mask genuine act warnings), mixed TL/non-TL content (compliant, verifies isStrict: false), and empty act(() => {}) (not reported by this rule, verifies no overlap with S1186).

Links

@nathsou nathsou self-assigned this Jul 7, 2026
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Jul 7, 2026

Copy link
Copy Markdown

JS-2014

@datadog-sonarsource

This comment has been minimized.

nathsou added 2 commits July 8, 2026 14:47
# Conflicts:
#	packages/analysis/src/jsts/rules/README.md
…eta.url usage

eslint-plugin-testing-library reads its own name/version off package.json via
createRequire(import.meta.url)(...). esbuild's CJS output makes import.meta.url
undefined, so createRequire(undefined) throws at bridge startup - the same
class of bug already patched here for @babel/core, css-tree, and stylelint.

Verified locally: reproduced the crash by building this branch's bridge bundle
and running bin/server.cjs directly (TypeError [ERR_INVALID_ARG_VALUE]:
Received undefined), then confirmed the patched bundle starts cleanly.
Comment thread esbuild-common.mjs
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Ruling Report

Code no longer flagged (3025 issues)

S2933

Joust/ts/MetaData.ts:5

     3 | 
     4 | export default class MetaData {
>    5 | 	constructor(private _type: MetaDataType, private _data: number, private _entities?: Immutable.Set<number>) {
     6 | 		if (!this._entities) {
     7 | 			this._entities = Immutable.Set<number>();

Joust/ts/MetaData.ts:5

     3 | 
     4 | export default class MetaData {
>    5 | 	constructor(private _type: MetaDataType, private _data: number, private _entities?: Immutable.Set<number>) {
     6 | 		if (!this._entities) {
     7 | 			this._entities = Immutable.Set<number>();

Joust/ts/MetaData.ts:5

     3 | 
     4 | export default class MetaData {
>    5 | 	constructor(private _type: MetaDataType, private _data: number, private _entities?: Immutable.Set<number>) {
     6 | 		if (!this._entities) {
     7 | 			this._entities = Immutable.Set<number>();

Joust/ts/components/LoadingScreen.tsx:9

     7 | 
     8 | export default class LoadingScreen extends React.Component<LoadingScreenProps, void> {
>    9 | 	private messages = [
    10 | 		// Cardgames
    11 | 		"Sorting decks...",

Joust/ts/components/Scrubber.tsx:41

    39 | export default class Scrubber extends React.Component<ScrubberProps, ScrubberState> {
    40 | 
>   41 | 	private static SPEEDS = [0.75, 1, 1.5, 2, 3, 4, 8];
    42 | 
    43 | 	constructor(props: ScrubberProps) {

Joust/ts/components/Scrubber.tsx:96

    94 | 	}
    95 | 
>   96 | 	private updateListener = () => this.updateState();
    97 | 
    98 | 	private registerListeners(props: ScrubberProps): void {

Joust/ts/components/SetupWidget.tsx:30

    28 | 
    29 | export default class SetupWidget extends React.Component<SetupWidgetProps, SetupWidgetState> {
>   30 | 	private forceWebsocket: boolean;
    31 | 
    32 | 	constructor(props: SetupWidgetProps) {

Joust/ts/components/Timeline.tsx:22

    20 | export default class Timeline extends React.Component<TimelineProps, TimelineState> implements StreamScrubberInhibitor {
    21 | 	private ref: HTMLDivElement;
>   22 | 	private mouseMove: (e) => void;
    23 | 	private mouseUp: (e) => void;
    24 | 	private touchMove: (e) => void;

Joust/ts/components/Timeline.tsx:23

    21 | 	private ref: HTMLDivElement;
    22 | 	private mouseMove: (e) => void;
>   23 | 	private mouseUp: (e) => void;
    24 | 	private touchMove: (e) => void;
    25 | 	private touchEnd: (e) => void;

Joust/ts/components/Timeline.tsx:24

    22 | 	private mouseMove: (e) => void;
    23 | 	private mouseUp: (e) => void;
>   24 | 	private touchMove: (e) => void;
    25 | 	private touchEnd: (e) => void;
    26 | 

...and 3015 more

New issues flagged (844 issues)

S2933

Joust/ts/MetaData.ts:4

     2 | import {MetaDataType} from "./enums";
     3 | 
>    4 | export default class MetaData {
     5 | 	constructor(private _type: MetaDataType, private _data: number, private _entities?: Immutable.Set<number>) {
     6 | 		if (!this._entities) {

Joust/ts/components/LoadingScreen.tsx:8

     6 | }
     7 | 
>    8 | export default class LoadingScreen extends React.Component<LoadingScreenProps, void> {
     9 | 	private messages = [
    10 | 		// Cardgames

Joust/ts/components/Scrubber.tsx:39

    37 | }
    38 | 
>   39 | export default class Scrubber extends React.Component<ScrubberProps, ScrubberState> {
    40 | 
    41 | 	private static SPEEDS = [0.75, 1, 1.5, 2, 3, 4, 8];

Joust/ts/components/SetupWidget.tsx:29

    27 | }
    28 | 
>   29 | export default class SetupWidget extends React.Component<SetupWidgetProps, SetupWidgetState> {
    30 | 	private forceWebsocket: boolean;
    31 | 

Joust/ts/components/Timeline.tsx:20

    18 | }
    19 | 
>   20 | export default class Timeline extends React.Component<TimelineProps, TimelineState> implements StreamScrubberInhibitor {
    21 | 	private ref: HTMLDivElement;
    22 | 	private mouseMove: (e) => void;

Joust/ts/components/game/EntityInPlay.tsx:7

     5 | import MetaData from "../../MetaData";
     6 | 
>    7 | abstract class EntityInPlay<P extends EntityInPlayProps> extends React.Component<P, EntityInPlayState> {
     8 | 
     9 | 	private baseClassName: string = '';

Joust/ts/components/game/visuals/CardArt.tsx:22

    20 | }
    21 | 
>   22 | class CardArt extends React.Component<CardArtProps, CardArtState> {
    23 | 
    24 | 	constructor(props: CardArtProps, context: any) {

Joust/ts/protocol/HSReplayDecoder.ts:33

    31 | }
    32 | 
>   33 | export default class HSReplayDecoder extends Stream.Transform implements CardOracle, MulliganOracle {
    34 | 
    35 | 	private sax: SAXStream;

Joust/ts/protocol/KettleEncoder.ts:8

     6 | import GameStateTracker from "../state/GameStateTracker";
     7 | 
>    8 | export default class KettleEncoder extends Stream.Readable implements InteractiveBackend {
     9 | 
    10 | 	private tracker: GameStateTracker;

Joust/ts/state/GameStateDescriptor.ts:5

     3 | import MetaData from "../MetaData";
     4 | 
>    5 | export default class GameStateDescriptor {
     6 | 
     7 | 	constructor(private _entityId: number, private _target: number, private _action: BlockType, private _metaData?: Immutable.Set<MetaData>) {

...and 834 more

📋 View full report

Code no longer flagged (3025)

S2933

@guillemsarda guillemsarda left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Image

Upstream's non-strict mode treats a callback as "entirely Testing
Library calls" whenever it finds no statement it can positively
classify as non-Testing-Library. Its per-statement identifier
extraction only understands a few shapes (a bare call, an awaited
call, a return); an awaited `new Promise(...)` (the shape of a manual
timer delay, e.g. a `sleep()` test helper) is invisible to that check,
so a callback made entirely of such statements was flagged even though
it contains no Testing Library call at all.

Found via ruling on ant-design (tests/utils.ts), which wraps a raw
`setTimeout`-backed Promise in `act()` from 'react-dom/test-utils' -
a legitimate, necessary use of act() with nothing to do with Testing
Library. Guard against this in the decorator by independently
confirming the callback contains at least one call that resolves to a
real Testing Library (or react-dom/test-utils) export before letting
the report through.

Also syncs the one genuine new ruling finding this rule introduces
(searchkit, a true positive: act() wrapping fireEvent.change/screen
both imported from @testing-library/react).
Comment on lines +59 to +73
function collectCallExpressions(
node: estree.Node,
results: estree.CallExpression[] = [],
): estree.CallExpression[] {
if (node.type === 'CallExpression') {
results.push(node);
}
for (const key of Object.keys(node)) {
if (key === 'parent' || key === 'loc' || key === 'range') {
continue;
}
const value = (node as unknown as Record<string, unknown>)[key];
if (Array.isArray(value)) {
for (const item of value) {
if (item && typeof (item as estree.Node).type === 'string') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: collectCallExpressions descends into nested callbacks

actCallbackHasNoRecognizedTestingLibraryCall calls collectCallExpressions(callback.body), which recurses into every nested node, including inner function/arrow expressions that are not executed synchronously as part of the act() body (e.g. a call passed to setTimeout, Array.forEach, a Promise executor, or an event handler). If such a nested, non-synchronous callback happens to contain a recognized Testing Library call while the act body's top-level statements do not, the guard would see a TL call and decline to suppress the upstream report, potentially re-introducing the exact false-positive class this commit is meant to remove.

In practice this is narrow: the upstream non-strict rule only emits noUnnecessaryActTestingLibraryUtil when it cannot positively classify any statement as non-Testing-Library, so most such shapes won't reach this guard. Still, restricting the scan to the callback's own top-level/synchronously-reachable statements (rather than all descendants) would make the guard's notion of "contains a TL call" match upstream's per-statement model more precisely. Consider documenting or tightening the traversal scope.

Was this helpful? React with 👍 / 👎

Extracts child-node iteration into a separate childNodesOf() helper to
fix S3776 (cognitive complexity 16 > 15) and S134 (nesting > 3 levels)
flagged by dogfooding on this PR.
@gitar-bot

gitar-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 1 resolved / 2 findings

Implements S8980 to flag redundant act() wrappers around React Testing Library calls, addressing issues with import resolution, test fixture coverage, and bridge server stability. Ensure collectCallExpressions correctly handles nested callbacks to avoid potential false positives.

💡 Edge Case: collectCallExpressions descends into nested callbacks

📄 packages/analysis/src/jsts/rules/S8980/decorator.ts:59-73 📄 packages/analysis/src/jsts/rules/S8980/decorator.ts:97-111

actCallbackHasNoRecognizedTestingLibraryCall calls collectCallExpressions(callback.body), which recurses into every nested node, including inner function/arrow expressions that are not executed synchronously as part of the act() body (e.g. a call passed to setTimeout, Array.forEach, a Promise executor, or an event handler). If such a nested, non-synchronous callback happens to contain a recognized Testing Library call while the act body's top-level statements do not, the guard would see a TL call and decline to suppress the upstream report, potentially re-introducing the exact false-positive class this commit is meant to remove.

In practice this is narrow: the upstream non-strict rule only emits noUnnecessaryActTestingLibraryUtil when it cannot positively classify any statement as non-Testing-Library, so most such shapes won't reach this guard. Still, restricting the scan to the callback's own top-level/synchronously-reachable statements (rather than all descendants) would make the guard's notion of "contains a TL call" match upstream's per-statement model more precisely. Consider documenting or tightening the traversal scope.

✅ 1 resolved
Quality: textReplace silently no-ops if upstream expression changes

📄 esbuild-common.mjs:170-181
The new textReplace uses an exact literal string pattern (createRequire(import.meta.url)(resolve(dirname(fileURLToPath(import.meta.url)), "../package.json"))) to patch eslint-plugin-testing-library's bundled dist/index.mjs. esbuild-plugin-text-replace performs a literal find/replace and does not error when a pattern is not found — it silently does nothing. If eslint-plugin-testing-library is upgraded past the currently pinned 7.16.2 and the emitted expression changes even slightly (whitespace, quote style, argument order), this replacement will silently stop applying and reintroduce the exact bridge-server startup crash this commit fixes, with no build-time warning. The exact version pin in package.json mitigates this today, but a future dependency bump could regress silently.

Suggested mitigation: add an assertion/guard that fails the build if the expected pattern is not present in the source (or add a comment tying the patch to the pinned version so a version bump triggers re-verification), so any drift surfaces at build time rather than at runtime.

🤖 Prompt for agents
Code Review: Implements S8980 to flag redundant act() wrappers around React Testing Library calls, addressing issues with import resolution, test fixture coverage, and bridge server stability. Ensure collectCallExpressions correctly handles nested callbacks to avoid potential false positives.

1. 💡 Edge Case: collectCallExpressions descends into nested callbacks
   Files: packages/analysis/src/jsts/rules/S8980/decorator.ts:59-73, packages/analysis/src/jsts/rules/S8980/decorator.ts:97-111

   `actCallbackHasNoRecognizedTestingLibraryCall` calls `collectCallExpressions(callback.body)`, which recurses into *every* nested node, including inner function/arrow expressions that are not executed synchronously as part of the `act()` body (e.g. a call passed to `setTimeout`, `Array.forEach`, a Promise executor, or an event handler). If such a nested, non-synchronous callback happens to contain a recognized Testing Library call while the act body's top-level statements do not, the guard would see a TL call and decline to suppress the upstream report, potentially re-introducing the exact false-positive class this commit is meant to remove.
   
   In practice this is narrow: the upstream non-strict rule only emits `noUnnecessaryActTestingLibraryUtil` when it cannot positively classify any statement as non-Testing-Library, so most such shapes won't reach this guard. Still, restricting the scan to the callback's own top-level/synchronously-reachable statements (rather than all descendants) would make the guard's notion of "contains a TL call" match upstream's per-statement model more precisely. Consider documenting or tightening the traversal scope.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqube-next

sonarqube-next Bot commented Jul 8, 2026

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants