-
Notifications
You must be signed in to change notification settings - Fork 47k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add eslint-plugin-react-hooks/exhaustive-deps rule to check stale closure dependencies #14636
Conversation
Details of bundled changes.Comparing: 0e67969...8b554ca eslint-plugin-react-hooks
Generated by 🚫 dangerJS |
An advantage of the previous approach is that it gave you a way of marking a certain omission from the inputs array as false positives without disabling the lint rule for the entire array. ...however, the code sample I was about to paste as an example of that exception was actually not an exception. Here's a drastically simplified version of it: const derivedValue = useMemo(() => derive(propA), [propA])
useEffect(() => { doThing(derivedValue, propB) }, [propA, propB]) The second one would cause a lint warning, which is arguably a false positive. But using I do wonder though what would be the right thing to do if |
I'm curious about this item on the TO-DOs:
I didn't think React treated RefObject-s any differently as dependency / inputs for these hooks. This suggests that specifying a RefObject as a dependency will have it track the |
No; you're right that React treats a ref the same as any other in a dependency list. However, passing |
4d45e7e
to
7d4e944
Compare
One (maybe bad) idea: perhaps the lint rule could ignore manually added deps that are properties of other deps. This might let programmers who use mutable objects use the rule. For example, imagine this code:
And further imagine that environment is mutable and that you as the programmer know that doSomething() uses environment.userId. It’d sure be nice if the rule didn’t complain about:
But if you delete environment from the closure, I think it’s right for the rule to complain:
I may not have thought through all the cases, so feel free to tell me this is unworkable. And thanks so much for your work on this rule; I think it’ll help so many people! |
With this code: const Comp = ({ item, onClick }) => {
const handleClick = useCallback(
e => {
save(item)
onClick(item)
},
[item.id, onClick]
)
} the linter gives me the following:
My guess this is intentionally, because of:
But do I really need to introduce a deep compare effect for this, even if the (I need |
Note there's technically no deep comparison. It would still be referential: Yes, it seems like you’d need to have |
But I can guarantee the 'stability' of my |
Sure you can now — but the person using your component in a year might not know that the component makes this assumption. Generally components should be resistant to their props changing. |
Published
Please give it a try! Testing instructions are at the top: #14636 (comment). |
The One thing that I have notice is that the rule ask you to include all the possible dependencies in the Hook, no matter if is susceptible to change or not, but so far I have found that no matter the case always creates an infinite loop. |
Thanks for your explanation @gaearon, I see the point. Moving forward… following gives me a warning:
This one doesn't:
Other hooks seem to work just fine and correctly identify the missing the dependency with either |
Can you please provide a snippet of code? Unfortunately I can’t guess why it’s causing an infinite loop without seeing the code, and so I can’t judge whether the rule is faulty or something else. Ideally a CodeSandbox demo would be nice but even just a code snippet would be more helpful than nothing. |
Thanks. We’ll need to make the detection stronger. Currently it doesn’t check for |
Sure @gaearon, in fact you can have the whole code. https://github.com/davegomez/silky-charts/blob/master/src/hooks/useResize.js Those are the dependencies suggested by the rule. |
@davegomez I think you bumped into a case where you need a ref to keep the "current" callback. If you want to avoid resubscribing. Same for debouncing function — your debouncing doesn't work because it re-setups the debouncing function on every render. |
if ( | ||
reference.resolved != null && | ||
Array.isArray(reference.resolved.defs) | ||
) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section is messy. I'll extract a function in a refactor before merging.
I ran this rule against the work-in-progress DevTools rewrite. I didn't see any invalid warnings! (see bvaughn/react-devtools-experimental@bec0733) Very minor nits 😄
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't read the AST stuff super carefully, because it would take me a long time (since I don't read this stuff often). Between running it on the DevTools repo, and your unit tests, and your running it internally at FB– I feel pretty good about the rule. I also tested it a little in AST explorer for some cases I was curious about while reading.
node.name === 'useImperativeHandle' | ||
) { | ||
return 1; | ||
} else if (options && options.additionalHooks) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How is this intended to be used? (When would you need it?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When you have your own Hooks with deps like useMyCustomEffect
that you want to check. I'm open to removing this option (it was in first @calebmer's draft) but also seems like it isn't hard to support.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A possible heuristic is to guess that hooks which receive a callback followed by an array as their last argument are following the useMemo pattern
}); | ||
} else { | ||
declaredDependenciesNode.elements.forEach(declaredDependencyNode => { | ||
// Skip elided elements. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL "elided"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lol I don't remember writing this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I guess it's from Caleb's old code
} else if (i < arr.length - 1) { | ||
s += ', '; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😮 lol
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't resist
if (badRef !== null) { | ||
extraWarning = | ||
` Mutable values like '${badRef}' aren't valid dependencies ` + | ||
"because their mutation doesn't re-render the component."; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's an example of code that would fire this warning? The rule supports refs, and e.g. props objects. It seems to be okay with other mutable values you pass it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I understand what you're saying.
You can see the code causing this in tests, e.g.:
react/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleReactiveDeps-test.js
Lines 1306 to 1315 in 0c722bb
code: ` | |
function MyComponent(props) { | |
const ref1 = useRef(); | |
const ref2 = useRef(); | |
useEffect(() => { | |
ref1.current.focus(); | |
console.log(ref2.current.textContent); | |
alert(props.someOtherRefs.current.innerHTML); | |
fetch(props.color); | |
}, [ref1.current, ref2.current, props.someOtherRefs, props.color]); |
Passing [ref.current]
in deps is almost always a mistake (which leads to rather gnarly bugs) because it's mutated outside the render phase. So during render it will always be "one step behind".
Just like render result, deps should only depend on props/state/globals — not mutable values.
Array.from(set) | ||
.sort() | ||
.map(quote), | ||
) + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this could also be space for a future "sorted-deps" rule? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One simple compromise would be to only add at the end by default, but alphabetize if the current array is empty.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually we could alphabetize if existing list is alphabetized. 😏
errors: [ | ||
"React Hook useEffect has an unnecessary dependency: 'local2'. " + | ||
'Either exclude it or remove the dependency array.', | ||
], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This kinda runs into that issue I mentioned in an earlier comment. It may be valid to use the latest value of a local but only reacting to a specific value changing. I know I wrote something that used this property recently but I can't remember what...
At any rate, it would be good to opt out of specifying a specific value without disabling the rule for the entire array. Perhaps it could be possible to write something to let the rule know you know about it? My first idea would be just having the identifier present inside the array but commented out, but I'm not sure eslint lets you consume comments that easily
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you give me a specific example?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's some partial source code where I ended up having to break this rule. You can see it in the last useEffect
's effect list -- basically, I want this effect to run after the update is called by a dispatch to setCurrentState
. However, it should not run on initial mount, or when setCurrentState
was invoked by the previous effect which synchronizes the internal-only page
with the memoPage
.
function useCurrentPage({
location,
marker,
replace
}: Pick<Props, 'location' | 'marker' | 'replace'>) {
const { hash } = location
const memoPage = useMemo(() => {
if (!hash || !hash.startsWith('#')) {
return 1
}
return Math.max(
1,
parseInt(hash.slice(1), 10) || getPageFromChapterId(hash) || 1
)
}, [hash])
const [page, setCurrentPage] = useState(memoPage)
useEffect(() => {
setCurrentPage(memoPage)
}, [memoPage])
useEffect(() => {
// ignore if it was an update from the synchronization above
if (page === memoPage) {
return
}
if ((!page || page <= 1) && marker === null) {
replace({
...location,
hash: ''
})
} else {
replace({
...location,
hash: `#${page}`
})
}
// uses latest data but only triggered by setCurrentPage changes
}, [page])
return [memoPage, setCurrentPage] as [typeof memoPage, typeof setCurrentPage]
}
It's certainly possible I just didn't think this through well enough, but it looks unavoidable. Adding marker
to the dependencies list could cause unwanted updates because, on the initial pass, page
won't have been synchronized yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rubber ducky debugging might have struck again, because it looks like the page === memoPage
check in the effect is also sufficient to guard against unwanted updates to marker
or location
. However, it'd still be a problem to have memoPage
in the dependencies list -- the effect would run before the synchronization, used to detect whether setCurrentPage
was dispatched from inside or from outside, happens.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this use case I've got also relates to this:
function LinkInterceptor({ html }) {
const handleClick = e => {
e.preventDefault()
console.log(e.target.href)
}
const ref = useRef()
useEffect(
() => {
Array.from(ref.current.querySelectorAll('a')).forEach(node =>
node.addEventListener('click', handleClick)
)
return () => {
Array.from(ref.current.querySelectorAll('a')).forEach(node =>
node.removeEventListener('click', handleClick)
)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[html]
)
return <div ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please post both of these in #14920, with the guidelines I mentioned. Thanks.
May I ask why is that so? I got confused a few times now and I keep removing them anyway. Is there a reason to keep them? |
I just pushed a commit that doesn't add them by default. :-) |
Published I'll merge this in now but please feel free to add more comments to this PR as you test it. |
If this rule is unexpectedly firing for you, please post to #14920. I'll lock this PR to encourage sharing examples there from now on. |
For instructions and feedback, see the issue instead:
>>> #14920 <<<
There's some polish missing (see checklist below) but a basic version should be working.
Supersedes #14048 and later #14052. (Thanks @calebmer for starting this and @jamiebuilds for fixing the issues in previous PR.)
I've decided to largely start the reporting part from scratch. In particular, I feel like the previous reporting that was centered around usage sites was too noisy:
(^^ I don't this is the right approach)
It made it seem like there's something wrong with your function body, but the wrong part is in the dependency list. It was also unclear how autofix would work (would there be one autofix per missing dep?) Instead the ideal experience (IMO) is that you just make edits, and when the deps are wrong, only deps are highlighted. If you really know what you're trying, you just need to
eslint-disable
that line alone rather than many.With that insight, the approach I'm taking is to:
.current
) we should be right most cases.[props]
over[props.x]
, or[x, y]
over[y, x]
) as needing fixes. User should not need to "fight" with auto-fix except rare use cases with mutability.With those principles in mind, the algorithm is I'm gathering a list of ideal dependencies first. I collect violations into three buckets (missing, duplicate, unnecessary). Then I report them together. The autofix tries to do minimal possible changes — e.g. it leaves the current order intact for deps that are necessary, and only adds new ones at the end. This algorithm will need some changes as I deal with objects and properties. More on that in TODO below.
Adding the array:
Removing a dependency:
Introducing a dependency:
Editing dependencies:
There's a lot of missing stuff here but I feel like this is the right direction overall.
TODO
obj.prop
/obj.method()
obj
“satisfies”obj.prop
props.items.slice()
(don't suggest[props.items.slice]
)[ref.current]
(suggest[ref]
)Possible middle ground: generate them on autofix but don't complain if missinguseImperativeHandle
foo
orfoo.bar.baz
Potential follow-up: