Skip to content

Commit 2825dfd

Browse files
authored
fix(state): allow local updater parameter reassignment (#1872)
1 parent a293fac commit 2825dfd

4 files changed

Lines changed: 54 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"oxlint-plugin-react-doctor": patch
3+
"eslint-plugin-react-doctor": patch
4+
"react-doctor": patch
5+
---
6+
7+
Allow state updater callbacks to reassign their local parameter while still reporting writes to prior-state properties and captured values.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { useState } from "react";
2+
export const Counter = () => {
3+
const [count, setCount] = useState(0);
4+
return <button onClick={() => setCount((previous) => --previous)}>{count}</button>;
5+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { runRule } from "../../../test-utils/run-rule.js";
3+
import { noImpureStateUpdater } from "./no-impure-state-updater.js";
4+
5+
describe("updater parameter reassignment", () => {
6+
it.each(["++previous", "--previous", "previous++", "previous += 1", "previous = 2"])(
7+
"accepts local parameter operation %s",
8+
(operation) => {
9+
const result = runRule(
10+
noImpureStateUpdater,
11+
`
12+
import { useState } from 'react';
13+
const Counter = () => {
14+
const [count, setCount] = useState(0);
15+
return <button onClick={() => setCount(previous => ${operation})}>{count}</button>;
16+
};
17+
`,
18+
);
19+
expect(result.parseErrors).toEqual([]);
20+
expect(result.diagnostics).toHaveLength(0);
21+
},
22+
);
23+
24+
it.each(["++previous.count", "previous.count = 2", "++captured", "captured = 2"])(
25+
"still reports external mutation %s",
26+
(operation) => {
27+
const result = runRule(
28+
noImpureStateUpdater,
29+
`
30+
import { useState } from 'react';
31+
let captured = 0;
32+
const Counter = () => {
33+
const [value, setValue] = useState({ count: 0 });
34+
return <button onClick={() => setValue(previous => { ${operation}; return previous; })}>Change</button>;
35+
};
36+
`,
37+
);
38+
expect(result.diagnostics).toHaveLength(1);
39+
},
40+
);
41+
});

‎packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-impure-state-updater.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const getExternalAssignmentDescription = (
209209
const symbol = scopes.symbolFor(rootIdentifier);
210210
if (!symbol) return `the external value "${rootIdentifier.name}"`;
211211
if (symbol.kind === "parameter" && symbol.scope === updaterScope) {
212+
if (isNodeOfType(stripParenExpression(assignmentTarget), "Identifier")) return null;
212213
return `the updater argument "${rootIdentifier.name}"`;
213214
}
214215
return isDescendantScope(symbol.scope, updaterScope)

0 commit comments

Comments
 (0)