-
Notifications
You must be signed in to change notification settings - Fork 104
Fixes #38899 - Enable react-hooks/exhaustive-deps eslint rule #1016
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
Changes from all commits
edf00db
19df596
d79e3bb
edb3ae9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,7 +24,13 @@ import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableInde | |
| import { getControllerSearchProps } from 'foremanReact/constants'; | ||
| import { Icon } from 'patternfly-react'; | ||
| import PropTypes from 'prop-types'; | ||
| import React, { useEffect, useMemo, useState, useRef } from 'react'; | ||
| import React, { | ||
| useEffect, | ||
| useMemo, | ||
| useState, | ||
| useRef, | ||
| useCallback, | ||
| } from 'react'; | ||
| import { FormattedMessage } from 'react-intl'; | ||
| import { useHistory } from 'react-router-dom'; | ||
| import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext'; | ||
|
|
@@ -93,19 +99,22 @@ const JobInvocationHostTable = ({ | |
| }); | ||
|
|
||
| // Search filter | ||
| const constructFilter = (filter = initialFilter, search = urlSearchQuery) => { | ||
| const dropdownFilterClause = | ||
| filter && filter !== 'all_statuses' | ||
| ? `job_invocation.result = ${filter}` | ||
| : null; | ||
| const parts = [dropdownFilterClause, search]; | ||
| return parts | ||
| .filter(x => x) | ||
| .map(fragment => `(${fragment})`) | ||
| .join(' AND '); | ||
| }; | ||
| const constructFilter = useCallback( | ||
| (filter = initialFilter, search = urlSearchQuery) => { | ||
| const dropdownFilterClause = | ||
| filter && filter !== 'all_statuses' | ||
| ? `job_invocation.result = ${filter}` | ||
| : null; | ||
| const parts = [dropdownFilterClause, search]; | ||
| return parts | ||
| .filter(x => x) | ||
| .map(fragment => `(${fragment})`) | ||
| .join(' AND '); | ||
| }, | ||
| [initialFilter, urlSearchQuery] | ||
| ); | ||
|
|
||
| const handleResponse = (data, key) => { | ||
| const handleResponse = useCallback((data, key) => { | ||
| if (key === JOB_INVOCATION_HOSTS) { | ||
| const ids = data.data.results.map(i => i.id); | ||
|
|
||
|
|
@@ -114,76 +123,94 @@ const JobInvocationHostTable = ({ | |
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stabilizes reference for use in makeApiCall dependency array. |
||
|
|
||
| setStatus(STATUS_UPPERCASE.RESOLVED); | ||
| }; | ||
| }, []); | ||
|
|
||
| // Call hosts data with params | ||
| const makeApiCall = (requestParams, callParams = {}) => { | ||
| dispatch( | ||
| APIActions.get({ | ||
| key: callParams.key ?? ALL_JOB_HOSTS, | ||
| url: callParams.url ?? `/api/job_invocations/${id}/hosts`, | ||
| params: requestParams, | ||
| handleSuccess: data => handleResponse(data, callParams.key), | ||
| handleError: () => setStatus(STATUS_UPPERCASE.ERROR), | ||
| errorToast: ({ response }) => | ||
| response?.data?.error?.full_messages?.[0] || response, | ||
| }) | ||
| ); | ||
| }; | ||
| const makeApiCall = useCallback( | ||
| (requestParams, callParams = {}) => { | ||
| dispatch( | ||
| APIActions.get({ | ||
| key: callParams.key ?? ALL_JOB_HOSTS, | ||
| url: callParams.url ?? `/api/job_invocations/${id}/hosts`, | ||
| params: requestParams, | ||
| handleSuccess: data => handleResponse(data, callParams.key), | ||
| handleError: () => setStatus(STATUS_UPPERCASE.ERROR), | ||
| errorToast: ({ response }) => | ||
| response?.data?.error?.full_messages?.[0] || response, | ||
| }) | ||
| ); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Used as a dependency in filterApiCall and initialization useEffect. |
||
| }, | ||
| [dispatch, id, handleResponse] | ||
| ); | ||
|
|
||
| const filterApiCall = newAPIOptions => { | ||
| const newParams = newAPIOptions?.params ?? newAPIOptions ?? {}; | ||
| const filterApiCall = useCallback( | ||
| newAPIOptions => { | ||
| const newParams = newAPIOptions?.params ?? newAPIOptions ?? {}; | ||
|
|
||
| const filterSearch = constructFilter( | ||
| initialFilter, | ||
| newParams.search ?? urlSearchQuery | ||
| ); | ||
|
|
||
| const finalParams = { | ||
| ...defaultParams, | ||
| ...newParams, | ||
| }; | ||
|
|
||
| if (filterSearch === AWAITING_STATUS_FILTER) { | ||
| finalParams.awaiting = 'true'; | ||
| } else if (filterSearch !== '') { | ||
| finalParams.search = filterSearch; | ||
| } | ||
| const filterSearch = constructFilter( | ||
| initialFilter, | ||
| newParams.search ?? urlSearchQuery | ||
| ); | ||
|
|
||
| makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS }); | ||
| const finalParams = { | ||
| ...defaultParams, | ||
| ...newParams, | ||
| }; | ||
|
|
||
| const urlSearchParams = new URLSearchParams(window.location.search); | ||
| if (filterSearch === AWAITING_STATUS_FILTER) { | ||
| finalParams.awaiting = 'true'; | ||
| } else if (filterSearch !== '') { | ||
| finalParams.search = filterSearch; | ||
| } | ||
|
|
||
| ['page', 'per_page', 'order'].forEach(key => { | ||
| if (finalParams[key]) urlSearchParams.set(key, finalParams[key]); | ||
| }); | ||
| makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS }); | ||
|
|
||
| history.push({ search: urlSearchParams.toString() }); | ||
| }; | ||
| const urlSearchParams = new URLSearchParams(window.location.search); | ||
|
|
||
| ['page', 'per_page', 'order'].forEach(key => { | ||
| if (finalParams[key]) urlSearchParams.set(key, finalParams[key]); | ||
| }); | ||
|
|
||
| history.push({ search: urlSearchParams.toString() }); | ||
| }, | ||
| [ | ||
| initialFilter, | ||
| urlSearchQuery, | ||
| defaultParams, | ||
| makeApiCall, | ||
| history, | ||
| constructFilter, | ||
| ] | ||
| ); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Used as a dependency in useEffect below. |
||
|
|
||
| // Filter change | ||
| const handleFilterChange = newFilter => { | ||
| onFilterUpdate(newFilter); | ||
| }; | ||
| const handleFilterChange = useCallback( | ||
| newFilter => { | ||
| onFilterUpdate(newFilter); | ||
| }, | ||
| [onFilterUpdate] | ||
| ); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevents unnecessary re-renders when passed to child components. |
||
|
|
||
| // Effects | ||
| // run after mount | ||
| const initializedRef = useRef(false); | ||
| useEffect(() => { | ||
| // Job Invo template load | ||
| makeApiCall( | ||
| {}, | ||
| { | ||
| url: `/job_invocations/${id}/hosts`, | ||
| key: LIST_TEMPLATE_INVOCATIONS, | ||
| if (!initializedRef.current) { | ||
| // Job Invo template load | ||
| makeApiCall( | ||
| {}, | ||
| { | ||
| url: `/job_invocations/${id}/hosts`, | ||
| key: LIST_TEMPLATE_INVOCATIONS, | ||
| } | ||
| ); | ||
|
|
||
| if (initialFilter === '') { | ||
| onFilterUpdate('all_statuses'); | ||
| } | ||
| ); | ||
|
|
||
| if (initialFilter === '') { | ||
| onFilterUpdate('all_statuses'); | ||
| initializedRef.current = true; | ||
| } | ||
|
|
||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); | ||
| }, [makeApiCall, id, initialFilter, onFilterUpdate]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The initializedRef pattern allows mount-only behavior while satisfying exhaustive-deps. |
||
|
|
||
| useEffect(() => { | ||
| if (initialFilter !== '') filterApiCall(); | ||
|
|
@@ -192,8 +219,7 @@ const JobInvocationHostTable = ({ | |
| prevStatusLabel.current = statusLabel; | ||
| filterApiCall(); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [initialFilter, statusLabel, id]); | ||
| }, [initialFilter, statusLabel, id, filterApiCall]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without filterApiCall in deps, the effect could capture a stale closure. |
||
|
|
||
| const { | ||
| updateSearchQuery: updateSearchQueryBulk, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,15 +79,14 @@ const JobInvocationDetailPage = ({ | |
| return () => { | ||
| dispatch(stopInterval(JOB_INVOCATION_KEY)); | ||
| }; | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [dispatch, id, finished, autoRefresh]); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jeremylenz Here there are also functions that are used and not in the dependency array (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, if they are functions imported from another file or created from outside the component, the rule doesn't care about them. |
||
|
|
||
| const taskId = task?.id; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Complex expressions in dependency arrays can't be statically analyzed by the linter. |
||
| useEffect(() => { | ||
| if (task?.id !== undefined) { | ||
| dispatch(getTask(`${task?.id}`)); | ||
| if (taskId !== undefined) { | ||
| dispatch(getTask(`${taskId}`)); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [dispatch, task?.id]); | ||
| }, [dispatch, taskId]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Allows tracking task ID changes without depending on the entire task object. |
||
|
|
||
| const pageStatus = | ||
| items.id === undefined | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,9 +89,6 @@ export const JobWizard = ({ rerunData }) => { | |
| concurrency_control = {}, | ||
| }, | ||
| }) => { | ||
| if (category !== job_category) { | ||
| setCategory(job_category); | ||
| } | ||
| const advancedTemplateValues = {}; | ||
| const defaultTemplateValues = {}; | ||
| const inputs = template_inputs; | ||
|
|
@@ -131,8 +128,7 @@ export const JobWizard = ({ rerunData }) => { | |
| }; | ||
| }); | ||
| }, | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| [category.length] | ||
| [setTemplateValues, setAdvancedValues] | ||
| ); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Used as a dependency in two useEffect hooks below. |
||
| useEffect(() => { | ||
| if (rerunData) { | ||
|
|
@@ -153,8 +149,7 @@ export const JobWizard = ({ rerunData }) => { | |
| }, | ||
| }); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [rerunData]); | ||
| }, [rerunData, setDefaults]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without setDefaults in deps, the effect could use a stale version. |
||
| useEffect(() => { | ||
| if (jobTemplateID) { | ||
| dispatch( | ||
|
|
@@ -199,8 +194,14 @@ export const JobWizard = ({ rerunData }) => { | |
| }) | ||
| ); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [rerunData, jobTemplateID, dispatch]); | ||
| }, [ | ||
| rerunData, | ||
| jobTemplateID, | ||
| dispatch, | ||
| setDefaults, | ||
| setTemplateValues, | ||
| setAdvancedValues, | ||
| ]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All three functions are called within the effect and need to be tracked to prevent stale closures. |
||
|
|
||
| const [isStartsBeforeError, setIsStartsBeforeError] = useState(false); | ||
| const [isStartsAtError, setIsStartsAtError] = useState(false); | ||
|
|
@@ -514,7 +515,7 @@ JobWizard.propTypes = { | |
| }), | ||
| execution_timeout_interval: PropTypes.number, | ||
| time_to_pickup: PropTypes.number, | ||
| remote_execution_feature_id: PropTypes.string, | ||
| remote_execution_feature_id: PropTypes.number, | ||
| template_invocations: PropTypes.arrayOf( | ||
| PropTypes.shape({ | ||
| template_id: PropTypes.number, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,14 @@ export const useAutoFill = ({ | |
| }); | ||
| } | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [fills]); | ||
| }, [ | ||
| fills, | ||
| setFills, | ||
| setSelectedTargets, | ||
| setHostsSearchQuery, | ||
| setJobTemplateID, | ||
| setTemplateValues, | ||
| setAdvancedValues, | ||
| dispatch, | ||
| ]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All setters and dispatch are called within the effect and must be tracked to prevent stale closures. |
||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,8 +55,13 @@ const ConnectedCategoryAndTemplate = ({ | |
| }) | ||
| ); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [jobCategoriesStatus]); | ||
| }, [ | ||
| jobCategoriesStatus, | ||
| dispatch, | ||
| isCategoryPreselected, | ||
| setCategory, | ||
| setJobTemplate, | ||
| ]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are used within the effect's callbacks and need to be tracked. |
||
|
|
||
| const jobCategories = useSelector(selectJobCategories); | ||
| const jobTemplatesSearch = useSelector(selectJobTemplatesSearch); | ||
|
|
@@ -73,22 +78,29 @@ const ConnectedCategoryAndTemplate = ({ | |
| per_page: 'all', | ||
| }), | ||
| handleSuccess: response => { | ||
| if (!jobTemplate) | ||
| setJobTemplate( | ||
| current => | ||
| current || | ||
| Number( | ||
| filterJobTemplates(response?.data?.results)[0]?.id | ||
| ) || | ||
| null | ||
| ); | ||
| const filteredTemplates = filterJobTemplates( | ||
| response?.data?.results | ||
| ); | ||
| setJobTemplate(current => { | ||
| // Check if current template is in the new category's template list. | ||
| // This preserves the user's selection when changing categories on rerun, | ||
| // preventing the category from flashing and reverting back (Issue #38899). | ||
| // We check the state value (current) rather than the prop to avoid race conditions. | ||
| if ( | ||
| current && | ||
| filteredTemplates.some(template => template.id === current) | ||
| ) { | ||
| return current; | ||
| } | ||
| // Otherwise, select the first template from the new category | ||
| return Number(filteredTemplates[0]?.id) || null; | ||
| }); | ||
| }, | ||
| }) | ||
| ); | ||
| } | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [category, dispatch]); | ||
| }, [category, dispatch, jobTemplatesSearch, setJobTemplate]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: jobTemplate is intentionally NOT in deps. The functional setState handles undefined, and including it would cause unnecessary re-renders when the template changes. |
||
|
|
||
| const jobTemplates = useSelector(selectJobTemplates); | ||
|
|
||
|
|
||
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.
Prevents filterApiCall from changing on every render.