From 8147fc375be59088e5cfd6af69f443cba2dece82 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 16 Jun 2026 12:58:00 +0000 Subject: [PATCH] Add eslint-plugin-react-you-might-not-need-an-effect Enable the plugin's recommended preset to catch unnecessary React effects. Existing violations are suppressed inline with eslint-disable-next-line comments so they can be refactored over time. --- eslint.config.mjs | 4 +++ package-lock.json | 32 +++++++++++++++---- package.json | 1 + packages/react/src/ActionList/Description.tsx | 2 ++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 1 + .../Autocomplete.features.stories.tsx | 1 + .../src/Autocomplete/AutocompleteInput.tsx | 1 + .../src/Autocomplete/AutocompleteMenu.tsx | 6 +++- .../react/src/AvatarStack/AvatarStack.tsx | 1 + .../react/src/Breadcrumbs/Breadcrumbs.tsx | 4 +++ .../react/src/DataTable/storybook/data.ts | 4 ++- .../src/Dialog/Dialog.features.stories.tsx | 1 + packages/react/src/Dialog/Dialog.tsx | 2 +- .../FilteredActionList/FilteredActionList.tsx | 4 +++ .../FilteredActionList/useAnnouncements.tsx | 1 + .../FormControl.features.stories.tsx | 8 +++-- packages/react/src/LabelGroup/LabelGroup.tsx | 4 +++ .../src/Overlay/Overlay.features.stories.tsx | 1 + .../src/Portal/Portal.features.stories.tsx | 2 ++ .../SelectPanel/SelectPanel.dev.stories.tsx | 2 ++ .../SelectPanel.examples.stories.tsx | 18 ++++++++--- .../SelectPanel.features.stories.tsx | 6 ++-- .../react/src/SelectPanel/SelectPanel.tsx | 24 ++++++++++++-- packages/react/src/Spinner/Spinner.tsx | 1 + packages/react/src/Textarea/Textarea.tsx | 2 ++ .../react/src/ToggleSwitch/ToggleSwitch.tsx | 3 +- packages/react/src/TooltipV2/Tooltip.tsx | 1 + packages/react/src/TreeView/TreeView.tsx | 4 ++- .../SelectPanel.examples.stories.tsx | 2 +- .../experimental/SelectPanel2/SelectPanel.tsx | 1 + .../react/src/hooks/useControllableState.ts | 2 ++ packages/react/src/hooks/useDetails.tsx | 1 + packages/react/src/hooks/useDialog.ts | 3 ++ packages/react/src/hooks/useFocusZone.ts | 1 + packages/react/src/hooks/useMedia.ts | 2 +- .../react/src/hooks/useMenuInitialFocus.ts | 1 + packages/react/src/hooks/useMnemonics.ts | 1 + .../ValidationAnimationContainer.tsx | 2 +- packages/react/src/utils/StressTest.tsx | 5 +++ .../react/src/utils/descendant-registry.tsx | 1 + 40 files changed, 138 insertions(+), 25 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ca4fcc0bd4f..37c4c0adeb0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ import storybook from 'eslint-plugin-storybook' import react from 'eslint-plugin-react' import reactHooks from 'eslint-plugin-react-hooks' import reactRefreshPlugin from 'eslint-plugin-react-refresh' +import reactYouMightNotNeedAnEffect from 'eslint-plugin-react-you-might-not-need-an-effect' import {unsupportedPatterns as reactCompilerUnsupported} from './packages/react/script/react-compiler.mjs' import playwright from 'eslint-plugin-playwright' import prettierRecommended from 'eslint-plugin-prettier/recommended' @@ -115,6 +116,9 @@ const config = defineConfig([ }, }, + // eslint-plugin-react-you-might-not-need-an-effect + reactYouMightNotNeedAnEffect.configs.recommended, + { extends: fixupConfigRules(compat.extends('plugin:clsx/recommended', 'plugin:ssr-friendly/recommended')), }, diff --git a/package-lock.json b/package-lock.json index acdf9056667..6338a37b93f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "eslint-plugin-react": "^7.35.5", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-you-might-not-need-an-effect": "1.0.1", "eslint-plugin-ssr-friendly": "1.3.0", "eslint-plugin-storybook": "^10.4.2", "eslint-plugin-testing-library": "^7.16.0", @@ -83,7 +84,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.27.0", + "@primer/react": "38.28.0", "@primer/styled-react": "1.1.0", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -97,7 +98,7 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.27.0", + "@primer/react": "38.28.0", "@primer/styled-react": "1.1.0", "next": "^16.1.7", "react": "^19.2.0", @@ -140,7 +141,7 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.27.0", + "@primer/react": "38.28.0", "@primer/styled-react": "1.1.0", "clsx": "^2.1.1", "next": "^16.1.7", @@ -15133,6 +15134,25 @@ } } }, + "node_modules/eslint-plugin-react-you-might-not-need-an-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.1.tgz", + "integrity": "sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^16.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/nickjvandyke" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "dev": true, @@ -28427,13 +28447,13 @@ }, "packages/mcp": { "name": "@primer/mcp", - "version": "0.3.2", + "version": "0.3.3", "dependencies": { "@babel/runtime": "^7.28.6", "@modelcontextprotocol/sdk": "^1.24.0", "@primer/octicons": "^19.15.5", "@primer/primitives": "10.x || 11.x", - "@primer/react": "^38.20.0", + "@primer/react": "^38.28.0", "cheerio": "^1.0.0", "turndown": "^7.2.0", "zod": "^4.3.5" @@ -28585,7 +28605,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.27.0", + "version": "38.28.0", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", diff --git a/package.json b/package.json index abd043841f1..c62b66c8eb4 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "eslint-plugin-ssr-friendly": "1.3.0", "eslint-plugin-storybook": "^10.4.2", "eslint-plugin-testing-library": "^7.16.0", + "eslint-plugin-react-you-might-not-need-an-effect": "1.0.1", "fast-glob": "^3.3.3", "globals": "^16.2.0", "markdownlint-cli2": "^0.19.0", diff --git a/packages/react/src/ActionList/Description.tsx b/packages/react/src/ActionList/Description.tsx index 07b0e4c3d5a..af3ce761f4f 100644 --- a/packages/react/src/ActionList/Description.tsx +++ b/packages/react/src/ActionList/Description.tsx @@ -35,9 +35,11 @@ export const Description: FCWithSlotMarker { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (truncate && containerRef.current) { const el = containerRef.current const textContent = el.textContent || '' + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setComputedTitle(textContent) if (setTruncatedText) { setTruncatedText( diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 239a7727355..0c224794a32 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -273,6 +273,7 @@ export const AnchoredOverlay: React.FC { // ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (!open && overlayRef.current) { updateOverlayRef(null) } diff --git a/packages/react/src/Autocomplete/Autocomplete.features.stories.tsx b/packages/react/src/Autocomplete/Autocomplete.features.stories.tsx index f5c8cc865bb..81b4045c37e 100644 --- a/packages/react/src/Autocomplete/Autocomplete.features.stories.tsx +++ b/packages/react/src/Autocomplete/Autocomplete.features.stories.tsx @@ -556,6 +556,7 @@ export const InADialog = () => { useEffect(() => { if (outerContainerRef.current instanceof HTMLElement) { registerPortalRoot(outerContainerRef.current, 'outerContainer') + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates setMounted(true) } }, [isDialogOpen]) diff --git a/packages/react/src/Autocomplete/AutocompleteInput.tsx b/packages/react/src/Autocomplete/AutocompleteInput.tsx index 065aa25481e..f7622d7013a 100644 --- a/packages/react/src/Autocomplete/AutocompleteInput.tsx +++ b/packages/react/src/Autocomplete/AutocompleteInput.tsx @@ -151,6 +151,7 @@ const AutocompleteInput = React.forwardRef( if ( isInputFocused && + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler highlightRemainingText && autocompleteSuggestion && (inputValue || isMenuDirectlyActivated) diff --git a/packages/react/src/Autocomplete/AutocompleteMenu.tsx b/packages/react/src/Autocomplete/AutocompleteMenu.tsx index 9d088ece9e9..57ecf08420c 100644 --- a/packages/react/src/Autocomplete/AutocompleteMenu.tsx +++ b/packages/react/src/Autocomplete/AutocompleteMenu.tsx @@ -316,6 +316,7 @@ function AutocompleteMenu(props: AutocompleteMe useEffect(() => { // Use deferredInputValue to avoid running this effect on every keystroke // The Input component guards against stale suggestions + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent, react-you-might-not-need-an-effect/no-pass-live-state-to-parent if (highlightedItem?.text?.startsWith(deferredInputValue) && !selectedItemIds.includes(highlightedItem.id)) { setAutocompleteSuggestion(highlightedItem.text) } else { @@ -331,15 +332,18 @@ function AutocompleteMenu(props: AutocompleteMe itemIdSortResult.length === sortedItemIds.length && itemIdSortResult.every((element, index) => element === sortedItemIds[index]) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (showMenu === false && !sortResultMatchesState) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state setSortedItemIds(itemIdSortResult) } + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent onOpenChange && onOpenChange(Boolean(showMenu)) }, [showMenu, onOpenChange, selectedItemIds, sortOnCloseFn, sortedItemIds]) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (selectedItemIds.length) { setSelectedItemLength(selectedItemIds.length) } diff --git a/packages/react/src/AvatarStack/AvatarStack.tsx b/packages/react/src/AvatarStack/AvatarStack.tsx index f7df678552a..83c0948e5d8 100644 --- a/packages/react/src/AvatarStack/AvatarStack.tsx +++ b/packages/react/src/AvatarStack/AvatarStack.tsx @@ -126,6 +126,7 @@ const AvatarStack = ({ observer.observe(stackContainer.current, {childList: true}) // Call on initial render, then call it again only if there's a mutation + // eslint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state interactiveChildren() return () => { diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index 2ddbc0a8be3..0c5c8555f5c 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -271,14 +271,18 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = ' useResizeObserver(handleResize, containerRef) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if ((overflow === 'menu' || overflow === 'menu-with-root') && childArray.length > 5 && menuItems.length === 0) { const containerWidth = containerRef.current?.offsetWidth || 800 const result = calculateOverflow(containerWidth) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change, react-you-might-not-need-an-effect/no-chain-state-updates setVisibleItems(result.visibleItems) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change, react-you-might-not-need-an-effect/no-chain-state-updates setMenuItems(result.menuItems) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates, react-you-might-not-need-an-effect/no-derived-state setEffectiveHideRoot(result.effectiveHideRoot) } }, [overflow, childArray, calculateOverflow, menuItems.length]) diff --git a/packages/react/src/DataTable/storybook/data.ts b/packages/react/src/DataTable/storybook/data.ts index 8497ab60607..ffb80c293c1 100644 --- a/packages/react/src/DataTable/storybook/data.ts +++ b/packages/react/src/DataTable/storybook/data.ts @@ -109,9 +109,11 @@ export function useQuery( React.useEffect(() => { const controller = new AbortController() - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setLoading(true) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setError(null) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setData(null) savedQueryFn diff --git a/packages/react/src/Dialog/Dialog.features.stories.tsx b/packages/react/src/Dialog/Dialog.features.stories.tsx index 09af094f0b4..649f3ce79fc 100644 --- a/packages/react/src/Dialog/Dialog.features.stories.tsx +++ b/packages/react/src/Dialog/Dialog.features.stories.tsx @@ -213,6 +213,7 @@ export const ReproMultistepDialogWithConditionalFooter = ({width, height}: Dialo React.useEffect(() => { // focus the close button when the step changes const focusTarget = dialogRef.current?.querySelector('button[aria-label="Close"]') as HTMLButtonElement + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (step === 2) { focusTarget.focus() } diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 39e7cc36960..de9cc4f8794 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -492,7 +492,7 @@ const Buttons: React.FC> if (hasRendered === 1) { autoFocusRef.current?.focus() } else { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state setHasRendered(hasRendered + 1) } }, [autoFocusRef, hasRendered]) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 9bb86274acb..29c6328f9cb 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -281,6 +281,7 @@ export function FilteredActionList({ [onListContainerRefChanged], ) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent onInputRefChanged?.(inputRef) }, [inputRef, onInputRefChanged]) @@ -369,6 +370,7 @@ export function FilteredActionList({ }, [items, inputRef, scrollContainerRef, scrollBehavior]) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (usingRovingTabindex) { const inputAndListContainerElement = inputAndListContainerRef.current if (!inputAndListContainerElement) return @@ -391,7 +393,9 @@ export function FilteredActionList({ }, [items, inputRef, listContainerElement, usingRovingTabindex]) // Re-run when items change to update active indicators useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (usingRovingTabindex && !loading) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setIsInputFocused(inputRef.current && inputRef.current === document.activeElement ? true : false) } }, [loading, inputRef, usingRovingTabindex]) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index dcc3a03062a..027e7b3d6e3 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -107,6 +107,7 @@ export const useAnnouncements = ( liveRegion?.clear() // clear previous announcements + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (items.length === 0 && !loading) { announce(`${message?.title}. ${message?.description}`, {delayMs}) return diff --git a/packages/react/src/FormControl/FormControl.features.stories.tsx b/packages/react/src/FormControl/FormControl.features.stories.tsx index 4b9a8ecc8ab..8c3e5e18dcb 100644 --- a/packages/react/src/FormControl/FormControl.features.stories.tsx +++ b/packages/react/src/FormControl/FormControl.features.stories.tsx @@ -128,9 +128,11 @@ export const WithCustomInput = () => { React.useEffect(() => { if (doesValueContainSpaces(value)) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates setValidationResult('noSpaces') + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler } else if (value) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates setValidationResult('validName') } }, [value]) @@ -241,9 +243,11 @@ export const ValidationExample = () => { React.useEffect(() => { if (doesValueContainSpaces(value)) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates setValidationResult('noSpaces') + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler } else if (value) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates setValidationResult('validName') } }, [value]) diff --git a/packages/react/src/LabelGroup/LabelGroup.tsx b/packages/react/src/LabelGroup/LabelGroup.tsx index 541d29081af..3fb0727b03f 100644 --- a/packages/react/src/LabelGroup/LabelGroup.tsx +++ b/packages/react/src/LabelGroup/LabelGroup.tsx @@ -205,6 +205,7 @@ const LabelGroup: React.FC> = ({ React.useEffect(() => { // If we're not truncating, we don't need to run this useEffect. + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (!visibleChildCount || isOverflowShown) { return } @@ -255,6 +256,7 @@ const LabelGroup: React.FC> = ({ // We need to keep track of this so we can focus the first hidden child when the overflow is shown inline. React.useEffect(() => { // If we're using an overlay, we don't need to keep track of the first hidden index. + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (overflowStyle === 'overlay') { return } @@ -267,12 +269,14 @@ const LabelGroup: React.FC> = ({ // We need to keep track of this so we can focus the first hidden child when the overflow is shown inline. React.useEffect(() => { // If we're using an overlay, we don't need to focus the first child that was previously hidden. + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (overflowStyle === 'overlay') { return } const firstHiddenChildDOM = document.querySelector(`[data-index="${firstHiddenIndexRef.current}"]`) const focusableChild = firstHiddenChildDOM ? getFocusableChild(firstHiddenChildDOM) : null + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (isOverflowShown) { // If the first hidden child is focusable, focus it. // Otherwise, focus the collapse button. diff --git a/packages/react/src/Overlay/Overlay.features.stories.tsx b/packages/react/src/Overlay/Overlay.features.stories.tsx index 275ecac51a9..a3d119b299d 100644 --- a/packages/react/src/Overlay/Overlay.features.stories.tsx +++ b/packages/react/src/Overlay/Overlay.features.stories.tsx @@ -418,6 +418,7 @@ export const MemexIssueOverlay = ({role, open}: Args) => { React.useEffect(() => { // If we just started editing, focus the newly rendered input + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (editing) inputRef.current?.focus() }, [editing]) diff --git a/packages/react/src/Portal/Portal.features.stories.tsx b/packages/react/src/Portal/Portal.features.stories.tsx index 1d42d06ba5a..85d066fa2d1 100644 --- a/packages/react/src/Portal/Portal.features.stories.tsx +++ b/packages/react/src/Portal/Portal.features.stories.tsx @@ -30,6 +30,7 @@ export const CustomPortalRootByRegistration: React.FC { if (outerContainerRef.current instanceof HTMLElement) { registerPortalRoot(outerContainerRef.current) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state setMounted(true) } }, []) @@ -92,6 +93,7 @@ export const WithPortalContext = () => { if (customContainerRef.current instanceof HTMLElement && overrideContainerRef.current instanceof HTMLElement) { registerPortalRoot(customContainerRef.current, 'custom-portal') registerPortalRoot(overrideContainerRef.current, 'override-portal') + // eslint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state setMounted(true) } }, []) diff --git a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx index e9f07bf1f94..9faab5de79a 100644 --- a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx @@ -344,8 +344,10 @@ export const LotsOfItems = () => { } useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open) { timeAfterOpen.current = performance.now() + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates if (timeBeforeOpen.current) setTimeTakenToOpen(timeAfterOpen.current - timeBeforeOpen.current) } }, [open]) diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 5d9058bcefd..7434581cee8 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -356,7 +356,7 @@ export const RepositionAfterLoading = () => { const [loading, setLoading] = useState(true) React.useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates, react-you-might-not-need-an-effect/no-event-handler if (!open) setLoading(true) window.setTimeout(() => { if (open) { @@ -368,8 +368,9 @@ export const RepositionAfterLoading = () => { }, [open]) React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (!loading) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -405,7 +406,7 @@ export const SelectPanelRepositionInsideDialog = () => { const [loading, setLoading] = useState(true) React.useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates, react-you-might-not-need-an-effect/no-event-handler if (!open) setLoading(true) window.setTimeout(() => { if (open) { @@ -417,8 +418,9 @@ export const SelectPanelRepositionInsideDialog = () => { }, [open]) React.useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (!loading) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -534,8 +536,10 @@ export const RenderMoreOnScroll = () => { useEffect( function measureTimeAfterOpen() { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open) { timeAfterOpen.current = performance.now() + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates if (timeBeforeOpen.current) setTimeTakenToOpen(timeAfterOpen.current - timeBeforeOpen.current) } }, @@ -616,8 +620,10 @@ export const VirtualizedConsumerSide = () => { } useEffect( function measureTimeAfterOpen() { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open) { timeAfterOpen.current = performance.now() + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates if (timeBeforeOpen.current) setTimeTakenToOpen(timeAfterOpen.current - timeBeforeOpen.current) } }, @@ -757,8 +763,10 @@ export const VirtualizedBuiltIn = () => { } useEffect( function measureA() { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (openA) { timeAfterOpenA.current = performance.now() + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates if (timeBeforeOpenA.current) setTimeTakenA(timeAfterOpenA.current - timeBeforeOpenA.current) } }, @@ -776,8 +784,10 @@ export const VirtualizedBuiltIn = () => { } useEffect( function measureB() { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (openB) { timeAfterOpenB.current = performance.now() + // eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates if (timeBeforeOpenB.current) setTimeTakenB(timeAfterOpenB.current - timeBeforeOpenB.current) } }, diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index 0d39049e088..ff1b7c2ae94 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -753,7 +753,7 @@ export const WithOnCancel = () => { const [open, setOpen] = useState(false) React.useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state, react-you-might-not-need-an-effect/no-event-handler if (!open) setInitialSelection(selected) // set initialSelection for next time }, [open, selected]) @@ -792,7 +792,7 @@ export const MultiSelectModal = () => { React.useEffect(() => { // Sync initialSelection with the last committed selection after the modal closes. // onCancel uses initialSelection to discard unsaved changes made while the modal is open. - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state, react-you-might-not-need-an-effect/no-event-handler if (!open) setInitialSelection(selected) }, [open, selected]) @@ -953,7 +953,7 @@ export const WithMessage = () => { const filteredItems = itemsToShow.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates setFilter('') }, [messageVariant]) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 95cbe2c6ad8..9bf81f39d8e 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -243,7 +243,7 @@ function Panel({ // Reset the intermediate selected item when the panel is open/closed useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-derived-state setIntermediateSelected(isSingleSelectModal ? selected : undefined) }, [isSingleSelectModal, open, selected]) @@ -365,6 +365,7 @@ function Panel({ // disable body scroll when the panel is open in modal mode or on narrow screens useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open && (variant === 'modal' || (isNarrowScreenSize && usingFullScreenOnNarrow))) { const bodyOverflowStyle = document.body.style.overflow || '' // If the body is already set to overflow: hidden, it likely means @@ -383,24 +384,32 @@ function Panel({ }, [isNarrowScreenSize, open, usingFullScreenOnNarrow, variant]) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (items.length === 0 && !(isLoading || loading)) { // we need to wait for the listContainerElement to disappear before announcing no items, otherwise it will be interrupted - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setNeedsNoItemsAnnouncement(true) } } + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (loadingManagedExternally) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (items.length > 0) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setDataLoadedOnce(true) } return } + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (isLoading || items.length > 0) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setIsLoading(false) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-adjust-state-on-prop-change setDataLoadedOnce(true) } @@ -413,12 +422,14 @@ function Panel({ }, [items]) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (inputRef?.current) { const ref = inputRef.current // We would normally expect AnchoredOverlay's focus trap to automatically focus the input, // but for some reason the ref isn't populated until _after_ the panel is open, which is // too late. So, we focus manually here. + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open) { ref.focus() } @@ -427,6 +438,7 @@ function Panel({ // Manage loading announcements when loadingManagedExternally useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (loadingManagedExternally) { if (isLoading) { // Delay the announcement a bit, just in case the loading is quick @@ -444,14 +456,18 @@ function Panel({ // Populate panel with items on first open useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (loadingManagedExternally) return // If data was already loaded once, do nothing + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (dataLoadedOnce) return // Only load data when the panel is open + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open) { // Only trigger filter change event if there are no items + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (items.length === 0) { // Trigger filter event to populate panel on first open // eslint-disable-next-line react-hooks/set-state-in-effect @@ -512,6 +528,7 @@ function Panel({ }) } + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (open && notice) { announceNotice() } @@ -697,7 +714,9 @@ function Panel({ // Track previous items and reset sort when items first load const prevItemsRef = useRef(items) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (prevItemsRef.current !== items) { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (prevItemsRef.current.length === 0 && items.length > 0) { resetSort() } @@ -708,6 +727,7 @@ function Panel({ // Reset sort when panel opens const prevOpenRef = useRef(open) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (prevOpenRef.current !== open) { resetSort() prevOpenRef.current = open diff --git a/packages/react/src/Spinner/Spinner.tsx b/packages/react/src/Spinner/Spinner.tsx index 5c9218b19a6..d4166fb665a 100644 --- a/packages/react/src/Spinner/Spinner.tsx +++ b/packages/react/src/Spinner/Spinner.tsx @@ -48,6 +48,7 @@ function Spinner({ })) useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (delay) { const delayDuration = typeof delay === 'number' ? delay : delay === 'short' ? 300 : 1000 const timeoutId = setTimeout(() => { diff --git a/packages/react/src/Textarea/Textarea.tsx b/packages/react/src/Textarea/Textarea.tsx index 90b00e641b7..56c0e94aadb 100644 --- a/packages/react/src/Textarea/Textarea.tsx +++ b/packages/react/src/Textarea/Textarea.tsx @@ -97,6 +97,7 @@ const Textarea = React.forwardRef( // Initialize character counter useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (characterLimit) { characterCounterRef.current = new CharacterCounter({ onCountUpdate: (count, overLimit, message) => { @@ -116,6 +117,7 @@ const Textarea = React.forwardRef( // Update character count when value changes useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (characterLimit && characterCounterRef.current) { const currentValue = value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : '' diff --git a/packages/react/src/ToggleSwitch/ToggleSwitch.tsx b/packages/react/src/ToggleSwitch/ToggleSwitch.tsx index f36c21dccb4..ebe8e7fc512 100644 --- a/packages/react/src/ToggleSwitch/ToggleSwitch.tsx +++ b/packages/react/src/ToggleSwitch/ToggleSwitch.tsx @@ -125,8 +125,9 @@ const ToggleSwitch = React.forwardRef(func useEffect(() => { if (!loading && isLoadingLabelVisible) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates setIsLoadingLabelVisible(false) + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler } else if (loading && !isLoadingLabelVisible) { safeSetTimeout(() => { setIsLoadingLabelVisible(true) diff --git a/packages/react/src/TooltipV2/Tooltip.tsx b/packages/react/src/TooltipV2/Tooltip.tsx index 42e0bb47242..33879d62892 100644 --- a/packages/react/src/TooltipV2/Tooltip.tsx +++ b/packages/react/src/TooltipV2/Tooltip.tsx @@ -239,6 +239,7 @@ export const Tooltip: ForwardRefExoticComponent< 'The `Tooltip` component expects a single React element that contains interactive content. Consider using a `