From 9addad8ce5e60702f32801b58d0b3d611093a480 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Mon, 10 Nov 2025 15:50:04 +0100 Subject: [PATCH] Update to latest `eslint-plugin-react-hooks` (#36702) Co-authored-by: diondiondion --- .../mastodon/components/alt_text_badge.tsx | 2 +- .../mastodon/components/carousel/index.tsx | 24 +- .../components/column_search_header.tsx | 10 +- .../mastodon/components/dropdown/index.tsx | 2 +- .../components/exit_animation_wrapper.tsx | 23 +- .../components/hover_card_controller.tsx | 8 +- .../mastodon/components/icon_button.tsx | 23 +- app/javascript/mastodon/components/poll.tsx | 6 +- .../status_action_bar/remove_quote_hint.tsx | 5 +- .../scroll_container/scroll_context.tsx | 1 + .../features/alt_text_modal/index.tsx | 15 +- .../mastodon/features/annual_report/index.tsx | 4 +- .../compose/components/language_dropdown.tsx | 19 +- .../features/compose/components/search.tsx | 340 +++++++++--------- .../mastodon/features/domain_blocks/index.tsx | 6 +- .../components/announcements/announcement.tsx | 17 +- .../mastodon/features/lists/members.tsx | 3 +- .../components/list_panel.tsx | 6 +- .../mastodon/features/search/index.tsx | 2 +- .../ui/components/domain_block_modal.tsx | 4 +- .../ui/components/hashtag_menu_controller.tsx | 47 +-- .../features/ui/components/media_modal.tsx | 2 +- .../features/ui/hooks/useBreakpoint.tsx | 29 +- app/javascript/mastodon/hooks/usePrevious.ts | 22 +- eslint.config.mjs | 4 +- package.json | 2 +- yarn.lock | 76 +++- 27 files changed, 382 insertions(+), 320 deletions(-) diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index c7fb0cd81b1..33dd3963d21 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -47,7 +47,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({ rootClose onHide={handleClose} show={open} - target={anchorRef.current} + target={anchorRef} placement='top-end' flip offset={offset} diff --git a/app/javascript/mastodon/components/carousel/index.tsx b/app/javascript/mastodon/components/carousel/index.tsx index f5d772fd38d..bc287aa9691 100644 --- a/app/javascript/mastodon/components/carousel/index.tsx +++ b/app/javascript/mastodon/components/carousel/index.tsx @@ -76,6 +76,11 @@ export const Carousel = < // Handle slide change const [slideIndex, setSlideIndex] = useState(0); const wrapperRef = useRef(null); + // Handle slide heights + const [currentSlideHeight, setCurrentSlideHeight] = useState( + () => wrapperRef.current?.scrollHeight ?? 0, + ); + const previousSlideHeight = usePrevious(currentSlideHeight); const handleSlideChange = useCallback( (direction: number) => { setSlideIndex((prev) => { @@ -101,16 +106,11 @@ export const Carousel = < [items.length, onChangeSlide], ); - // Handle slide heights - const [currentSlideHeight, setCurrentSlideHeight] = useState( - wrapperRef.current?.scrollHeight ?? 0, - ); - const previousSlideHeight = usePrevious(currentSlideHeight); - const observerRef = useRef( - new ResizeObserver(() => { - handleSlideChange(0); - }), - ); + const observerRef = useRef(null); + observerRef.current ??= new ResizeObserver(() => { + handleSlideChange(0); + }); + const wrapperStyles = useSpring({ x: `-${slideIndex * 100}%`, height: currentSlideHeight, @@ -200,7 +200,7 @@ export const Carousel = < }; type CarouselSlideWrapperProps = { - observer: ResizeObserver; + observer: ResizeObserver | null; className: string; active: boolean; item: SlideProps; @@ -217,7 +217,7 @@ const CarouselSlideWrapper = ({ }: CarouselSlideWrapperProps) => { const handleRef = useCallback( (instance: HTMLDivElement | null) => { - if (instance) { + if (observer && instance) { observer.observe(instance); } }, diff --git a/app/javascript/mastodon/components/column_search_header.tsx b/app/javascript/mastodon/components/column_search_header.tsx index 90b6c4d89f3..c74634e65e3 100644 --- a/app/javascript/mastodon/components/column_search_header.tsx +++ b/app/javascript/mastodon/components/column_search_header.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { useCallback, useState, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -12,11 +12,15 @@ export const ColumnSearchHeader: React.FC<{ const inputRef = useRef(null); const [value, setValue] = useState(''); - useEffect(() => { + // Reset the component when it turns from active to inactive. + // [More on this pattern](https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes) + const [previousActive, setPreviousActive] = useState(active); + if (active !== previousActive) { + setPreviousActive(active); if (!active) { setValue(''); } - }, [active]); + } const handleChange = useCallback( ({ target: { value } }: React.ChangeEvent) => { diff --git a/app/javascript/mastodon/components/dropdown/index.tsx b/app/javascript/mastodon/components/dropdown/index.tsx index b6a04b9027f..4edc78f50b8 100644 --- a/app/javascript/mastodon/components/dropdown/index.tsx +++ b/app/javascript/mastodon/components/dropdown/index.tsx @@ -109,7 +109,7 @@ export const Dropdown: FC< placement='bottom-start' onHide={handleClose} flip - target={buttonRef.current} + target={buttonRef} popperConfig={{ strategy: 'fixed', modifiers: [matchWidth], diff --git a/app/javascript/mastodon/components/exit_animation_wrapper.tsx b/app/javascript/mastodon/components/exit_animation_wrapper.tsx index ab0642b8b23..dba7d3e92c4 100644 --- a/app/javascript/mastodon/components/exit_animation_wrapper.tsx +++ b/app/javascript/mastodon/components/exit_animation_wrapper.tsx @@ -27,22 +27,23 @@ export const ExitAnimationWrapper: React.FC<{ */ children: (delayedIsActive: boolean) => React.ReactNode; }> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { - const [delayedIsActive, setDelayedIsActive] = useState(false); + const [delayedIsActive, setDelayedIsActive] = useState( + isActive && !withEntryDelay, + ); useEffect(() => { - if (isActive && !withEntryDelay) { - setDelayedIsActive(true); + const withDelay = !isActive || withEntryDelay; - return () => ''; - } else { - const timeout = setTimeout(() => { + const timeout = setTimeout( + () => { setDelayedIsActive(isActive); - }, delayMs); + }, + withDelay ? delayMs : 0, + ); - return () => { - clearTimeout(timeout); - }; - } + return () => { + clearTimeout(timeout); + }; }, [isActive, delayMs, withEntryDelay]); if (!isActive && !delayedIsActive) { diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index 38c3306f30a..81510e8bd6e 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -27,7 +27,6 @@ export const HoverCardController: React.FC = () => { const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); const [setScrollTimeout] = useTimeout(); - const location = useLocation(); const handleClose = useCallback(() => { cancelEnterTimeout(); @@ -36,9 +35,12 @@ export const HoverCardController: React.FC = () => { setAnchor(null); }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]); - useEffect(() => { + const location = useLocation(); + const [previousLocation, setPreviousLocation] = useState(location); + if (location !== previousLocation) { + setPreviousLocation(location); handleClose(); - }, [handleClose, location]); + } useEffect(() => { let isScrolling = false; diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 9d32ab1f528..de9cbc19bb4 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, forwardRef } from 'react'; +import { useCallback, forwardRef } from 'react'; import classNames from 'classnames'; @@ -55,23 +55,6 @@ export const IconButton = forwardRef( }, buttonRef, ) => { - const [activate, setActivate] = useState(false); - const [deactivate, setDeactivate] = useState(false); - - useEffect(() => { - if (!animate) { - return; - } - - if (activate && !active) { - setActivate(false); - setDeactivate(true); - } else if (!activate && active) { - setActivate(true); - setDeactivate(false); - } - }, [setActivate, setDeactivate, animate, active, activate]); - const handleClick: React.MouseEventHandler = useCallback( (e) => { e.preventDefault(); @@ -112,8 +95,8 @@ export const IconButton = forwardRef( active, disabled, inverted, - activate, - deactivate, + activate: animate && active, + deactivate: animate && !active, overlayed: overlay, 'icon-button--with-counter': typeof counter !== 'undefined', }); diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index 51668ec476d..2b7134185e3 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -35,6 +35,9 @@ const messages = defineMessages({ }, }); +const isPollExpired = (expiresAt: Model.Poll['expires_at']) => + new Date(expiresAt).getTime() < Date.now(); + interface PollProps { pollId: string; status: Status; @@ -58,8 +61,7 @@ export const Poll: React.FC = ({ pollId, disabled, status }) => { if (!poll) { return false; } - const expiresAt = poll.expires_at; - return poll.expired || new Date(expiresAt).getTime() < Date.now(); + return poll.expired || isPollExpired(poll.expires_at); }, [poll]); const timeRemaining = useMemo(() => { if (!poll) { diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx index dec9c3ef38c..1c5cfeddccc 100644 --- a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -44,6 +44,7 @@ export const RemoveQuoteHint: React.FC<{ if (!firstHintId) { firstHintId = uniqueId; + // eslint-disable-next-line react-hooks/set-state-in-effect setIsOnlyHint(true); } @@ -64,8 +65,8 @@ export const RemoveQuoteHint: React.FC<{ flip offset={[12, 10]} placement='bottom-end' - target={anchorRef.current} - container={anchorRef.current} + target={anchorRef} + container={anchorRef} > {({ props, placement }) => (
= ({ ) => // Hack to allow accessing scrollBehavior._stateStorage shouldUpdateScroll.call( + // eslint-disable-next-line react-hooks/immutability scrollBehavior, prevLocationContext, locationContext, diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx index c118c1812b6..a6b621e6d98 100644 --- a/app/javascript/mastodon/features/alt_text_modal/index.tsx +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -101,16 +101,17 @@ const Preview: React.FC<{ position: FocalPoint; onPositionChange: (arg0: FocalPoint) => void; }> = ({ mediaId, position, onPositionChange }) => { - const draggingRef = useRef(false); const nodeRef = useRef(null); + const [dragging, setDragging] = useState<'started' | 'moving' | null>(null); + const [x, y] = position; const style = useSpring({ to: { left: `${x * 100}%`, top: `${y * 100}%`, }, - immediate: draggingRef.current, + immediate: dragging === 'moving', }); const media = useAppSelector((state) => ( @@ -123,8 +124,6 @@ const Preview: React.FC<{ me ? state.accounts.get(me) : undefined, ); - const [dragging, setDragging] = useState(false); - const setRef = useCallback( (e: HTMLImageElement | HTMLVideoElement | null) => { nodeRef.current = e; @@ -140,20 +139,20 @@ const Preview: React.FC<{ const handleMouseMove = (e: MouseEvent) => { const { x, y } = getPointerPosition(nodeRef.current, e); - draggingRef.current = true; // This will disable the animation for quicker feedback, only do this if the mouse actually moves + + setDragging('moving'); // This will disable the animation for quicker feedback, only do this if the mouse actually moves onPositionChange([x, y]); }; const handleMouseUp = () => { - setDragging(false); - draggingRef.current = false; + setDragging(null); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousemove', handleMouseMove); }; const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent); - setDragging(true); + setDragging('started'); onPositionChange([x, y]); document.addEventListener('mouseup', handleMouseUp); diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx index 91f03330c01..1b41d9d8f24 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -31,15 +31,13 @@ export const AnnualReport: React.FC<{ year: string; }> = ({ year }) => { const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const currentAccount = useAppSelector((state) => me ? state.accounts.get(me) : undefined, ); const dispatch = useAppDispatch(); useEffect(() => { - setLoading(true); - apiRequestGet(`v1/annual_reports/${year}`) .then((data) => { dispatch(importFetchedStatuses(data.statuses)); diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.tsx b/app/javascript/mastodon/features/compose/components/language_dropdown.tsx index 72742153b1c..6ad0c367a9f 100644 --- a/app/javascript/mastodon/features/compose/components/language_dropdown.tsx +++ b/app/javascript/mastodon/features/compose/components/language_dropdown.tsx @@ -55,6 +55,8 @@ const getFrequentlyUsedLanguages = createSelector( .toArray(), ); +const isTextLongEnoughForGuess = (text: string) => text.length > 20; + const LanguageDropdownMenu: React.FC<{ value: string; guess?: string; @@ -375,14 +377,27 @@ export const LanguageDropdown: React.FC = () => { ); useEffect(() => { - if (text.length > 20) { + if (isTextLongEnoughForGuess(text)) { debouncedGuess(text, setGuess); } else { debouncedGuess.cancel(); - setGuess(''); } }, [text, setGuess]); + // Keeping track of the previous render's text length here + // to be able to reset the guess when the text length drops + // below the threshold needed to make a guess + const [wasLongText, setWasLongText] = useState(() => + isTextLongEnoughForGuess(text), + ); + if (wasLongText !== isTextLongEnoughForGuess(text)) { + setWasLongText(isTextLongEnoughForGuess(text)); + + if (wasLongText) { + setGuess(''); + } + } + return (