Skip to content

Commit 7b2ec7d

Browse files
committed
fix: ignore effects with cleanup
not in every rule - just the ones that false positives have surfaced in. may introduce rare false negatives, but this is preferrable. #30 #36 #37
1 parent 6ab8381 commit 7b2ec7d

10 files changed

+143
-56
lines changed

src/no-chain-state-updates.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getCallExpr } from "./util/ast.js";
22
import {
33
getEffectDepsRefs,
44
getEffectFnRefs,
5+
hasCleanup,
56
isArgsAllLiterals,
67
isImmediateCall,
78
isState,
@@ -29,7 +30,10 @@ export default {
2930
const effectFnRefs = getEffectFnRefs(context, node);
3031
const depsRefs = getEffectDepsRefs(context, node);
3132
if (!effectFnRefs || !depsRefs) return;
33+
if (hasCleanup(node)) return;
3234

35+
// TODO: Should filter out setters before checking?
36+
// exhaustive-deps doesn't enforce one way or the other.
3337
const isAllDepsState = depsRefs.notEmptyEvery((ref) =>
3438
isState(context, ref),
3539
);

src/no-derived-state.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isProp,
88
getUpstreamReactVariables,
99
isState,
10+
hasCleanup,
1011
} from "./util/react.js";
1112
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
1213

@@ -31,6 +32,7 @@ export default {
3132
const effectFnRefs = getEffectFnRefs(context, node);
3233
const depsRefs = getEffectDepsRefs(context, node);
3334
if (!effectFnRefs || !depsRefs) return;
35+
if (hasCleanup(node)) return;
3436

3537
effectFnRefs
3638
.filter((ref) => isStateSetter(context, ref))

src/no-event-handler.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { getEffectFnRefs, getEffectDepsRefs } from "./util/react.js";
1+
import {
2+
getEffectFnRefs,
3+
getEffectDepsRefs,
4+
hasCleanup,
5+
} from "./util/react.js";
26
import { findDownstreamNodes, getDownstreamRefs } from "./util/ast.js";
37
import { isState } from "./util/react.js";
48

@@ -23,6 +27,7 @@ export default {
2327
const effectFnRefs = getEffectFnRefs(context, node);
2428
const depsRefs = getEffectDepsRefs(context, node);
2529
if (!effectFnRefs || !depsRefs) return;
30+
if (hasCleanup(node)) return;
2631

2732
// TODO: Can we also flag this when the deps are internal, and the body calls internal stuff?
2833
// That'd overlap with other rules though... maybe just useRefs?

src/no-pass-data-to-parent.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isState,
66
isRef,
77
isProp,
8+
hasCleanup,
89
} from "./util/react.js";
910
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
1011

@@ -29,10 +30,12 @@ export default {
2930
const effectFnRefs = getEffectFnRefs(context, node);
3031
const depsRefs = getEffectDepsRefs(context, node);
3132
if (!effectFnRefs || !depsRefs) return;
33+
if (hasCleanup(node)) return;
3234

3335
effectFnRefs
3436
.filter((ref) => isPropCallback(context, ref))
35-
// We don't check `isDirectCall` because it shouldn't matter - passing data to the parent is passing data to the parent.
37+
// We don't check `isDirectCall` because it shouldn't matter; passing data to the parent is passing data to the parent.
38+
// And misuses are often indirect, e.g. retrieving and passing up external data in a Promise chain.
3639
.forEach((ref) => {
3740
const callExpr = getCallExpr(ref);
3841

@@ -43,6 +46,8 @@ export default {
4346
(ref) =>
4447
!isState(context, ref) &&
4548
!isProp(context, ref) &&
49+
// TODO: Should warn to use `forwardRef` instead?
50+
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/22
4651
!isRef(context, ref),
4752
)
4853
) {

src/util/react.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ export const isUseState = (node) =>
4848
return !el || el.type === "Identifier";
4949
});
5050

51+
// While it *could* be an anti-pattern or unnecessary, effects *are* meant to synchronize systems.
52+
// So we presume that a "subscription effect" is usually valid, or at least may be more readable.
53+
// TODO: We might be able to use this more granularly, e.g. ignore state setters inside a subscription effect,
54+
// instead of ignoring the whole effect...? But it'd have to be more complicated, like also ignore the same state setters called in the body.
55+
export const hasCleanup = (node) => {
56+
const effectFn = node.arguments[0];
57+
return (
58+
(effectFn.type === "ArrowFunctionExpression" ||
59+
effectFn.type === "FunctionExpression") &&
60+
effectFn.body.type === "BlockStatement" &&
61+
effectFn.body.body.some(
62+
(stmt) => stmt.type === "ReturnStatement" && stmt.argument,
63+
)
64+
);
65+
};
66+
5167
export const isPropDef = (def) => {
5268
const declaringNode =
5369
def.node.type === "ArrowFunctionExpression"

test/no-adjust-state-on-prop-change.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ new MyRuleTester().run("no-adjust-state-on-prop-change", rule, {
2424
function Counter() {
2525
const [count, setCount] = useState(0);
2626
const [otherState, setOtherState] = useState();
27-
27+
2828
useEffect(() => {
2929
setOtherState('Hello World');
3030
}, [count]);
@@ -36,7 +36,7 @@ new MyRuleTester().run("no-adjust-state-on-prop-change", rule, {
3636
code: js`
3737
function Counter({ count }) {
3838
const [doubleCount, setDoubleCount] = useState(0);
39-
39+
4040
useEffect(() => {
4141
setDoubleCount(count * 2);
4242
}, [count]);

test/no-chain-state-updates.test.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ new MyRuleTester().run("no-chain-state-updates", rule, {
4141
}
4242
`,
4343
},
44+
{
45+
name: "Synchronize internal state with literal",
46+
code: js`
47+
function Component() {
48+
const [isMounted, setIsMounted] = useState(false);
49+
50+
useEffect(() => {
51+
setIsMounted(true);
52+
return () => setIsMounted(false);
53+
}, [setIsMounted]);
54+
}
55+
`,
56+
},
4457
],
4558
invalid: [
4659
{
@@ -49,7 +62,7 @@ new MyRuleTester().run("no-chain-state-updates", rule, {
4962
function Counter() {
5063
const [count, setCount] = useState(0);
5164
const [otherState, setOtherState] = useState();
52-
65+
5366
useEffect(() => {
5467
setOtherState('Hello World');
5568
}, [count]);

test/no-derived-state.test.js

Lines changed: 28 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ new MyRuleTester().run("no-derived-state", rule, {
103103
}
104104
`,
105105
},
106+
{
107+
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/35
108+
// While it *could* be an anti-pattern or unnecessary, effects *are* meant to synchronize systems.
109+
// So we guess that a "subscription effect" is usually valid, or may be more readable.
110+
name: "Synchronize internal state",
111+
code: js`
112+
function Component() {
113+
const [name, setName] = useState();
114+
const [model] = useState(
115+
() => new FormModel(props)
116+
);
117+
118+
useEffect(() => {
119+
model.setFieldDescriptor(name);
120+
return () => model.removeField(name);
121+
}, [model, name]);
122+
}
123+
`,
124+
},
106125
{
107126
name: "Subscribe to external state",
108127
code: js`
@@ -390,53 +409,6 @@ new MyRuleTester().run("no-derived-state", rule, {
390409
}
391410
`,
392411
},
393-
{
394-
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/35
395-
// TODO: Finally time to not consider state MemberExpressions as state setters
396-
todo: true,
397-
name: "Calling method on state",
398-
code: js`
399-
function Component() {
400-
const [name, setName] = useState();
401-
const [model] = useState(
402-
() => new FormModel(props)
403-
);
404-
405-
// Register field within the model
406-
useEffect(() => {
407-
model.setFieldDescriptor(name);
408-
}, [model, name]);
409-
}
410-
`,
411-
},
412-
{
413-
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/35
414-
name: "Cleanup function calls method on state",
415-
code: js`
416-
function Component() {
417-
const [name, setName] = useState();
418-
const [model] = useState(
419-
() => new FormModel(props)
420-
);
421-
422-
useEffect(() => {
423-
return () => model.removeField(name);
424-
}, [model, name]);
425-
}
426-
`,
427-
},
428-
{
429-
// TODO: Double-check whether this is valid; compared to calling a method on state
430-
name: "Cleanup function sets state",
431-
code: js`
432-
function Component() {
433-
const [isMounted, setIsMounted] = useState(true);
434-
useEffect(() => {
435-
return () => setIsMounted(false);
436-
}, []);
437-
}
438-
`,
439-
},
440412
],
441413
invalid: [
442414
{
@@ -518,7 +490,7 @@ new MyRuleTester().run("no-derived-state", rule, {
518490
],
519491
},
520492
{
521-
name: "From derived prop",
493+
name: "From intermediate prop",
522494
code: js`
523495
function Form({ firstName, lastName }) {
524496
const [fullName, setFullName] = useState('');
@@ -537,7 +509,7 @@ new MyRuleTester().run("no-derived-state", rule, {
537509
],
538510
},
539511
{
540-
name: "From props via member function",
512+
name: "From props via method",
541513
code: js`
542514
function DoubleList({ list }) {
543515
const [doubleList, setDoubleList] = useState([]);
@@ -555,8 +527,7 @@ new MyRuleTester().run("no-derived-state", rule, {
555527
],
556528
},
557529
{
558-
name: "From internal state via member function",
559-
todo: true,
530+
name: "From internal state via method",
560531
code: js`
561532
function DoubleList() {
562533
const [list, setList] = useState([]);
@@ -572,6 +543,12 @@ new MyRuleTester().run("no-derived-state", rule, {
572543
messageId: "avoidDerivedState",
573544
data: { state: "doubleList" },
574545
},
546+
// TODO: Kinda confusing to double-flag... ideally we'd ignore the `concat` call, given it's a setter arg.
547+
// Or even a different error message/rule for `no-mutate-state`...? If we can find a good heuristic.
548+
{
549+
messageId: "avoidDerivedState",
550+
data: { state: "list" },
551+
},
575552
],
576553
},
577554
{

test/no-pass-data-to-parent.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,37 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
135135
}
136136
`,
137137
},
138+
{
139+
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/37
140+
// Alternate solutions exist, but this is arguably the most readable.
141+
// TODO: There is probably a more granular opportunity here...
142+
name: "Pass cleanup function that depends on ref",
143+
code: js`
144+
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
145+
146+
function DeleteDropTarget({ onDelete }) {
147+
const ref = React.useRef(null);
148+
149+
React.useEffect(() => {
150+
const element = ref.current;
151+
if (!element) {
152+
return;
153+
}
154+
155+
const cleanup = dropTargetForElements({
156+
element,
157+
onDrop: ({ source }) => {
158+
onDelete(source.data);
159+
},
160+
});
161+
162+
return cleanup;
163+
}, [onDelete]);
164+
165+
return <div ref={ref}>Drop an item here to delete</div>;
166+
};
167+
`,
168+
},
138169
],
139170
invalid: [
140171
{
@@ -230,6 +261,9 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
230261
],
231262
},
232263
{
264+
// TODO: This could be done (possibly conditionally) in the parent because it doesn't depend on anything in the child?
265+
// May fit better as a new, more general rule.
266+
todo: true,
233267
name: "Pass window event data to parent",
234268
code: js`
235269
const Child = ({ onResized }) => {

test/real-world.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,37 @@ describe("recommended rules on real-world code", () => {
301301
}
302302
`,
303303
},
304+
{
305+
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/30
306+
name: "Ref callback",
307+
code: js`
308+
export const useOnScreen = () => {
309+
const [element, setElement] = useState(null);
310+
const [isIntersecting, setIntersecting] = useState(false);
311+
312+
const ref = useCallback((element) => {
313+
setElement(element);
314+
}, []);
315+
316+
useEffect(() => {
317+
if (!element) {
318+
return;
319+
}
320+
321+
const observer = new IntersectionObserver(([entry]) => {
322+
setIntersecting(entry?.isIntersecting ?? false);
323+
});
324+
325+
observer.observe(element);
326+
return () => {
327+
observer.disconnect();
328+
};
329+
}, [element]);
330+
331+
return { ref, isIntersecting };
332+
};
333+
`,
334+
},
304335
].forEach(({ name, code }) => {
305336
it(name, async () => {
306337
const results = await eslint.lintText(code);

0 commit comments

Comments
 (0)