Skip to content

Commit 9c0a523

Browse files
committed
feat(no-pass-ref-to-parent): new rule, and skip ref event handlers in no-pass-data-to-parent
#47
1 parent 31cf281 commit 9c0a523

File tree

7 files changed

+286
-77
lines changed

7 files changed

+286
-77
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,31 @@ function Child({ onDataFetched }) {
174174
}
175175
```
176176

177+
### `no-pass-ref-to-parent`[docs](https://react.dev/reference/react/forwardRef)
178+
179+
Disallow passing refs, or data from callbacks registered on them, to parents in an effect. Use `forwardRef` instead:
180+
181+
```js
182+
function Child({ onRef }) {
183+
const ref = useRef();
184+
185+
useEffect(() => {
186+
onRef(ref.current);
187+
}, [onRef, ref.current]);
188+
}
189+
```
190+
191+
```js
192+
const Child = ({ onClicked }) => {
193+
const ref = useRef();
194+
useEffect(() => {
195+
ref.current.addEventListener('click', (event) => {
196+
onClicked(event);
197+
});
198+
}, [onClicked]);
199+
}
200+
```
201+
177202
### `no-initialize-state`
178203

179204
Disallow initializing state in an effect:

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import noChainStateUpdates from "./rules/no-chain-state-updates.js";
88
import noDerivedState from "./rules/no-derived-state.js";
99
import noPassDataToParent from "./rules/no-pass-data-to-parent.js";
1010
import noManageParent from "./rules/no-manage-parent.js";
11+
import noPassRefToParent from "./rules/no-pass-ref-to-parent.js";
1112
import globals from "globals";
1213

1314
/**
@@ -26,6 +27,7 @@ const plugin = {
2627
"no-pass-live-state-to-parent": noPassLiveStateToParent,
2728
"no-pass-data-to-parent": noPassDataToParent,
2829
"no-manage-parent": noManageParent,
30+
"no-pass-ref-to-parent": noPassRefToParent,
2931
"no-initialize-state": noInitializeState,
3032
"no-chain-state-updates": noChainStateUpdates,
3133
"no-derived-state": noDerivedState,

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

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
hasCleanup,
99
isUseEffect,
1010
getUpstreamRefs,
11+
isImmediateCall,
1112
} from "../util/ast.js";
1213
import { getCallExpr, getDownstreamRefs } from "../util/ast.js";
1314

@@ -36,8 +37,7 @@ export default {
3637

3738
effectFnRefs
3839
.filter((ref) => isPropCallback(context, ref))
39-
// NOTE: We don't check `isDirectCall` because passing data to the parent is passing data to the parent.
40-
// And misuses are often indirect, e.g. retrieving and passing up external data in a Promise chain.
40+
.filter((ref) => isImmediateCall(ref.identifier))
4141
.forEach((ref) => {
4242
const callExpr = getCallExpr(ref);
4343

@@ -46,19 +46,8 @@ export default {
4646
callExpr.arguments
4747
.flatMap((arg) => getDownstreamRefs(context, arg))
4848
.flatMap((ref) => getUpstreamRefs(context, ref))
49-
// `every` instead of the usual `notEmptyEvery` because `getUpstreamRefs` filters out
50-
// parameters, e.g. in Promise chains or callbacks, but we want to flag passing those.
51-
// Ideally we'd identify and check the parameter's "source" though...
52-
.every(
53-
(ref) =>
54-
!isState(ref) &&
55-
!isProp(ref) &&
56-
// TODO: Should advise to use `forwardRef` instead?
57-
// Not always the best solution, but usually, and outliers can silence the warning.
58-
// Could possibly check for refs on the "path" to this callback too.
59-
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/22
60-
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/37
61-
!isRef(ref),
49+
.notEmptyEvery(
50+
(ref) => !isState(ref) && !isProp(ref) && !isRef(ref),
6251
);
6352

6453
if (isAllData) {

src/rules/no-pass-ref-to-parent.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
getEffectFnRefs,
3+
getEffectDepsRefs,
4+
isPropCallback,
5+
isRef,
6+
hasCleanup,
7+
isUseEffect,
8+
getUpstreamRefs,
9+
isRefCall,
10+
} from "../util/ast.js";
11+
import { getCallExpr, getDownstreamRefs } from "../util/ast.js";
12+
13+
/**
14+
* @type {import("eslint").Rule.RuleModule}
15+
*/
16+
export default {
17+
meta: {
18+
type: "suggestion",
19+
docs: {
20+
description:
21+
"Disallow passing refs, or data from callbacks registered on them, to parents in an effect. Use `forwardRef` instead.",
22+
url: "https://react.dev/reference/react/forwardRef",
23+
},
24+
schema: [],
25+
messages: {
26+
avoidPassingRefToParent:
27+
"Avoid passing refs to parents in an effect. Use `forwardRef` instead.",
28+
avoidPropCallbackInRefCallback:
29+
"Avoid calling props inside callbacks registered on refs in an effect. Use `forwardRef` to register the callback in the parent instead.",
30+
},
31+
},
32+
create: (context) => ({
33+
CallExpression: (node) => {
34+
if (!isUseEffect(node) || hasCleanup(node)) return;
35+
const effectFnRefs = getEffectFnRefs(context, node);
36+
const depsRefs = getEffectDepsRefs(context, node);
37+
if (!effectFnRefs || !depsRefs) return;
38+
39+
effectFnRefs
40+
.filter((ref) => isPropCallback(context, ref))
41+
.forEach((ref) => {
42+
const callExpr = getCallExpr(ref);
43+
44+
const hasRefArg = callExpr.arguments
45+
.flatMap((arg) => getDownstreamRefs(context, arg))
46+
.flatMap((ref) => getUpstreamRefs(context, ref))
47+
.some((ref) => isRef(ref));
48+
49+
if (hasRefArg) {
50+
context.report({
51+
node: callExpr,
52+
messageId: "avoidPassingRefToParent",
53+
});
54+
}
55+
});
56+
57+
effectFnRefs
58+
.filter((ref) => isRefCall(context, ref))
59+
.forEach((ref) => {
60+
const callExpr = getCallExpr(ref);
61+
62+
const passesCallbackDataToParent = callExpr.arguments
63+
.flatMap((arg) => getDownstreamRefs(context, arg))
64+
.flatMap((ref) => getUpstreamRefs(context, ref))
65+
.some((ref) => isPropCallback(context, ref));
66+
67+
if (passesCallbackDataToParent) {
68+
context.report({
69+
node: getCallExpr(ref),
70+
messageId: "avoidPropCallbackInRefCallback",
71+
});
72+
}
73+
});
74+
},
75+
}),
76+
};

src/util/ast.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ export const isStateSetter = (context, ref) =>
134134
export const isPropCallback = (context, ref) =>
135135
getCallExpr(ref) !== undefined &&
136136
getUpstreamRefs(context, ref).some((ref) => isProp(ref));
137+
export const isRefCall = (context, ref) =>
138+
getCallExpr(ref) !== undefined &&
139+
getUpstreamRefs(context, ref).some((ref) => isRef(ref));
137140

138141
// NOTE: Global variables (like `JSON` in `JSON.stringify()`) have an empty `defs`; fortunately `[].some() === false`.
139142
// Also, I'm not sure so far when `defs.length > 1`... haven't seen it with shadowed variables or even redeclared variables with `var`.

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

Lines changed: 60 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import rule from "../src/rules/no-pass-data-to-parent.js";
33

44
new MyRuleTester().run("no-pass-data-to-parent", rule, {
55
valid: [
6+
{
7+
name: "Pass literal value",
8+
code: js`
9+
const Child = ({ onTextChanged }) => {
10+
useEffect(() => {
11+
onTextChanged("Hello World");
12+
}, [onTextChanged]);
13+
}
14+
`,
15+
},
616
{
717
name: "Pass internal state",
818
code: js`
@@ -102,11 +112,6 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
102112
`,
103113
},
104114
{
105-
// TODO: Definitely an anti-pattern, but may fit better elsewhere
106-
// because refs are not quite state, e.g. don't cause re-renders.
107-
// However they *are* local to the component...
108-
// At the least, could use a different message when flagged.
109-
// Or maybe user should be advised to use `forwardRef` instead? I think that's idiomatic.
110115
name: "Pass ref to parent",
111116
code: js`
112117
const Child = ({ onRef }) => {
@@ -115,8 +120,6 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
115120
useEffect(() => {
116121
onRef(ref.current);
117122
}, [onRef, ref.current]);
118-
119-
return <div ref={ref}>Child</div>;
120123
}
121124
`,
122125
},
@@ -165,57 +168,35 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
165168
}
166169
`,
167170
},
168-
],
169-
invalid: [
170-
{
171-
name: "Pass literal value",
172-
code: js`
173-
const Child = ({ onTextChanged }) => {
174-
useEffect(() => {
175-
onTextChanged("Hello World");
176-
}, [onTextChanged]);
177-
}
178-
`,
179-
errors: [
180-
{
181-
messageId: "avoidPassingDataToParent",
182-
},
183-
],
184-
},
185171
{
186-
name: "Pass external state",
172+
name: "Register callback on ref to pass data to parent",
187173
code: js`
188-
const Child = ({ onFetched }) => {
189-
const data = useSomeAPI();
174+
const Child = ({ onClicked }) => {
175+
const ref = useRef();
190176
191177
useEffect(() => {
192-
onFetched(data);
193-
}, [onFetched, data]);
178+
ref.current.addEventListener('click', (event) => {
179+
onClicked(event);
180+
});
181+
}, [onFetched]);
194182
}
195183
`,
196-
errors: [
197-
{
198-
messageId: "avoidPassingDataToParent",
199-
},
200-
],
201184
},
202185
{
203-
name: "Pass derived external state",
186+
// We get lucky in this example that `getUpstreamRefs` ignores parameter-declared variables,
187+
// and thus we don't flag `addEventListener`. But not certain that's totally reliable.
188+
// TODO: We might want to catch this but it's tricky to identify the prop as a ref w/o type info.
189+
// Probably with a different message or even rule, about the child controlling the parent and idiomatic React control flow.
190+
name: "Register callback on ref prop",
204191
code: js`
205-
const Child = ({ onFetched }) => {
206-
const data = useSomeAPI();
207-
const firstElement = data[0];
208-
192+
const Child = ({ ref }) => {
209193
useEffect(() => {
210-
onFetched(firstElement);
211-
}, [onFetched, firstElement]);
194+
ref.current.addEventListener('click', (event) => {
195+
console.log('Clicked', event);
196+
});
197+
}, [ref]);
212198
}
213199
`,
214-
errors: [
215-
{
216-
messageId: "avoidPassingDataToParent",
217-
},
218-
],
219200
},
220201
{
221202
name: "Pass external state that's retrieved in effect via .then",
@@ -227,11 +208,13 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
227208
}, []);
228209
}
229210
`,
230-
errors: [
231-
{
232-
messageId: "avoidPassingDataToParent",
233-
},
234-
],
211+
// TODO: Difficult because `getUpstreamRefs` ignores parameter-declared variables,
212+
// and the above test case relies on that behavior.
213+
// errors: [
214+
// {
215+
// messageId: "avoidPassingDataToParent",
216+
// },
217+
// ],
235218
},
236219
{
237220
name: "Pass external state that's retrieved in effect via async/await",
@@ -245,27 +228,42 @@ new MyRuleTester().run("no-pass-data-to-parent", rule, {
245228
}, []);
246229
}
247230
`,
231+
// TODO:
232+
// errors: [
233+
// {
234+
// messageId: "avoidPassingDataToParent",
235+
// },
236+
// ],
237+
},
238+
],
239+
invalid: [
240+
{
241+
name: "Pass external state",
242+
code: js`
243+
const Child = ({ onFetched }) => {
244+
const data = useSomeAPI();
245+
246+
useEffect(() => {
247+
onFetched(data);
248+
}, [onFetched, data]);
249+
}
250+
`,
248251
errors: [
249252
{
250253
messageId: "avoidPassingDataToParent",
251254
},
252255
],
253256
},
254257
{
255-
// If parent needs to listen to child's DOM events, it should set up the listener itself.
256-
// TODO: Better message? Advise to use `forwardRef`.
257-
name: "Register callback on ref to pass data to parent",
258+
name: "Pass derived external state",
258259
code: js`
259-
const Child = ({ onClicked }) => {
260-
const ref = useRef();
260+
const Child = ({ onFetched }) => {
261+
const data = useSomeAPI();
262+
const firstElement = data[0];
261263
262264
useEffect(() => {
263-
ref.current.addEventListener('click', (event) => {
264-
onClicked(event);
265-
});
266-
}, [onFetched]);
267-
268-
return <SomeComponent ref={ref} />;
265+
onFetched(firstElement);
266+
}, [onFetched, firstElement]);
269267
}
270268
`,
271269
errors: [

0 commit comments

Comments
 (0)