diff --git a/CLAUDE.md b/CLAUDE.md index 56cf2a60a..5707699e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,7 +237,12 @@ Located at `libraries/@mattermost/`: - **Prefer const objects over enums** for better tree-shaking: `export const Status = { Pending: 0, Done: 1 } as const;` with companion type `export type Status = typeof Status[keyof typeof Status];` ### React Hooks -- **Always explain why dependencies are omitted** from exhaustive-deps with specific comments +- **`react-hooks/exhaustive-deps` is enforced as error** - incomplete or incorrect dependency arrays will fail CI. Fix deps or use the dedicated hooks below when the omission is intentional. +- **Always explain why dependencies are omitted** from exhaustive-deps with specific comments (and prefer the dedicated hooks below for common cases). +- **Run-on-mount effects**: Use **`useDidMount`** from `@hooks/did_mount` instead of `useEffect(callback, [])` with an eslint-disable. The hook encapsulates the "run only on mount" intent and the single exhaustive-deps exception. +- **Initial value only (no updates)**: Use **`useInitialValue`** from `@hooks/initial_value` instead of `useMemo(factory, [])` with an eslint-disable when the value must be computed once and never recomputed. +- **Nested property as dependency**: When the meaningful dependency is a nested property (e.g. `currentUser?.notifyProps`) because the parent object reference may not change when that property changes, depend on that property in the array and add an eslint-disable with a comment explaining why. See `useNotificationProps` and `useUserTimezoneProps` in `app/hooks/` for the pattern. +- **`useDidUpdate`**: The hook takes a dependency list from the caller; the implementation uses an eslint-disable with the comment that dependencies are provided by the caller. - Don't over-optimize with `useMemo` - only use for expensive calculations or objects passed as props to children - Use `useCallback` for render functions passed to components, not for functions called within render - Move module-level constants outside components diff --git a/app/components/app_version/index.tsx b/app/components/app_version/index.tsx index 8536cf64d..035ec80ec 100644 --- a/app/components/app_version/index.tsx +++ b/app/components/app_version/index.tsx @@ -2,12 +2,13 @@ // See LICENSE.txt for license information. import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'; -import React, {useEffect} from 'react'; +import React from 'react'; import {defineMessages} from 'react-intl'; import {Keyboard, StyleSheet, type TextStyle, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FormattedText from '@components/formatted_text'; +import useDidMount from '@hooks/did_mount'; const style = StyleSheet.create({ info: { @@ -41,7 +42,7 @@ const AppVersion = ({isWrapped = true, textStyle = {}}: AppVersionProps) => { }; }); - useEffect(() => { + useDidMount(() => { const willHide = Keyboard.addListener('keyboardDidHide', () => { opacity.value = 1; }); @@ -53,7 +54,7 @@ const AppVersion = ({isWrapped = true, textStyle = {}}: AppVersionProps) => { willHide.remove(); willShow.remove(); }; - }, []); + }); const appVersion = ( { const completeMention = useCallback((u: UserModel | UserProfile) => { onPress?.(u.username); - }, []); + }, [onPress]); return ( { await dismissBottomSheet(); toggleMuteChannel(serverUrl, channelId, showSnackBar); - }, [channelId, isMuted, serverUrl, showSnackBar]); + }, [channelId, serverUrl, showSnackBar]); const muteActionTestId = isMuted ? `${testID}.unmute.action` : `${testID}.mute.action`; diff --git a/app/components/channel_actions/set_header_box/set_header.tsx b/app/components/channel_actions/set_header_box/set_header.tsx index 54ad73f87..dc78a0c2f 100644 --- a/app/components/channel_actions/set_header_box/set_header.tsx +++ b/app/components/channel_actions/set_header_box/set_header.tsx @@ -30,7 +30,7 @@ const SetHeaderBox = ({channelId, containerStyle, isHeaderSet, inModal, testID}: await dismissBottomSheet(); showModal(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId, headerOnly: true}); - }, [intl, channelId]); + }, [intl, inModal, channelId]); let text; if (isHeaderSet) { diff --git a/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx index c995467ef..077e48d4a 100644 --- a/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx +++ b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx @@ -107,6 +107,10 @@ const ChannelBookmark = ({ if (action === 'none' && bookmark.id) { dismissOverlay(bookmark.id); } + + // We don't care about `bookmark.id` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [action]); if (isDocumentFile) { diff --git a/app/components/channel_icon/dm_avatar/dm_avatar.tsx b/app/components/channel_icon/dm_avatar/dm_avatar.tsx index fdab4e7ee..2841cd577 100644 --- a/app/components/channel_icon/dm_avatar/dm_avatar.tsx +++ b/app/components/channel_icon/dm_avatar/dm_avatar.tsx @@ -1,13 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect} from 'react'; +import React from 'react'; import {fetchUserByIdBatched} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; import ProfilePicture from '@components/profile_picture'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import type UserModel from '@typings/database/models/servers/user'; @@ -49,11 +50,11 @@ const DmAvatar = ({ const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); - useEffect(() => { + useDidMount(() => { if (authorId && !author) { fetchUserByIdBatched(serverUrl, authorId); } - }, []); + }); if (author?.deleteAt) { return ( { onPress(channel); - }, [channel.id]); + }, [channel, onPress]); const textStyles = useMemo(() => [ isBolded && !isMuted ? textStyle.bold : textStyle.regular, diff --git a/app/components/files/image_file_overlay.tsx b/app/components/files/image_file_overlay.tsx index 66dda7a6b..f31bb1fd2 100644 --- a/app/components/files/image_file_overlay.tsx +++ b/app/components/files/image_file_overlay.tsx @@ -38,7 +38,7 @@ const ImageFileOverlay = ({value}: ImageFileOverlayProps) => { style.moreImagesText, {fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale))}, ]; - }, [isTablet]); + }, [dimensions.scale, isTablet, style]); return ( diff --git a/app/components/files_search/file_options/tablet_options.tsx b/app/components/files_search/file_options/tablet_options.tsx index b60c0da9f..50c4e25ac 100644 --- a/app/components/files_search/file_options/tablet_options.tsx +++ b/app/components/files_search/file_options/tablet_options.tsx @@ -58,7 +58,7 @@ const TabletOptions = ({ const toggleOverlay = useCallback(() => { setShowOptions(false); - }, []); + }, [setShowOptions]); const overlayStyle = useMemo(() => ({ marginTop: openUp ? 0 : openDownMargin, diff --git a/app/components/files_search/file_result.tsx b/app/components/files_search/file_result.tsx index 1ffe067f8..46db61cc5 100644 --- a/app/components/files_search/file_result.tsx +++ b/app/components/files_search/file_result.tsx @@ -64,7 +64,7 @@ const FileResult = ({ setShowOptions(true); onOptionsPress(fInfo); }); - }, []); + }, [height, onOptionsPress]); const handleSetAction = useCallback((action: GalleryAction) => { setAction(action); diff --git a/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx b/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx index cacbacfb0..082ef3fe5 100644 --- a/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx +++ b/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {type IntlShape, useIntl} from 'react-intl'; import {Text, View, type StyleProp, type TextStyle, type ViewStyle} from 'react-native'; @@ -10,6 +10,7 @@ import {Screens, View as ViewConstants} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; +import useDidMount from '@hooks/did_mount'; import {usePreventDoubleTap} from '@hooks/utils'; import {getChannelById} from '@queries/servers/channel'; import {getUserById} from '@queries/servers/user'; @@ -155,7 +156,9 @@ function AutoCompleteSelector({ }), [title, dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect])); // Handle the text for the default value. - useEffect(() => { + // We want to run this only in the first render, since it is only for the default value. + // Future changes in the selected value will update the itemText accordingly. + useDidMount(() => { if (!selected) { return; } @@ -172,11 +175,7 @@ function AutoCompleteSelector({ Promise.all(namePromises).then((names) => { setItemText(names.join(', ')); }); - - // We want to run this only in the first render, since it is only for the default value. - // Future changes in the selected value will update the itemText accordingly. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const inputStyle = useMemo(() => { const res: StyleProp = [style.input]; diff --git a/app/components/formatted_relative_time/index.tsx b/app/components/formatted_relative_time/index.tsx index edaa97a12..d3b0c9f4d 100644 --- a/app/components/formatted_relative_time/index.tsx +++ b/app/components/formatted_relative_time/index.tsx @@ -37,6 +37,10 @@ const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...pro return function cleanup() { return null; }; + + // We don't care about `getFormattedRelativeTime` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateIntervalInSeconds]); return ( diff --git a/app/components/input_accessory_view/input_accessory_view_container.tsx b/app/components/input_accessory_view/input_accessory_view_container.tsx index 2447e458b..3e3fd8afd 100644 --- a/app/components/input_accessory_view/input_accessory_view_container.tsx +++ b/app/components/input_accessory_view/input_accessory_view_container.tsx @@ -34,9 +34,6 @@ const InputAccessoryViewContainer = ({ return { height: animatedHeight.value, }; - - // Shared values don't need to be in dependencies - they're stable references - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/app/components/keyboard_aware_post_draft_container.tsx b/app/components/keyboard_aware_post_draft_container.tsx index 4531600aa..17f297aff 100644 --- a/app/components/keyboard_aware_post_draft_container.tsx +++ b/app/components/keyboard_aware_post_draft_container.tsx @@ -488,10 +488,7 @@ export const KeyboardAwarePostDraftContainer = ({ animated: false, }); } - - // ref is not required to be in deps because it is a stable reference - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [listRef]); // Android: Watch for emoji picker closing and restore scroll position when both height and bottomInset reach 0 const isAndroid = Platform.OS === 'android'; @@ -558,25 +555,35 @@ export const KeyboardAwarePostDraftContainer = ({ updateValue: updateValueRef.current, updateCursorPosition: updateCursorPositionRef.current, registerPostInputCallbacks, - - // Shared values don't need to be in dependencies - they're stable references - // Only include non-shared-value dependencies that can actually change - // eslint-disable-next-line react-hooks/exhaustive-deps }), [ + keyboardCurrentHeight, + bottomInset, + scrollOffset, + keyboardHeight, + scrollPosition, onScroll, postInputContainerHeight, inputRef, blurInput, focusInput, blurAndDismissKeyboard, + isKeyboardFullyOpen, + isKeyboardFullyClosed, + isKeyboardInTransition, + isInputAccessoryViewMode, showInputAccessoryView, setShowInputAccessoryView, lastKeyboardHeight, + inputAccessoryViewAnimatedHeight, + isTransitioningFromCustomView, closeInputAccessoryView, scrollToEnd, isEmojiSearchFocused, - setIsEmojiSearchFocused, registerCursorPosition, + preserveCursorPositionForEmojiPicker, + clearCursorPositionPreservation, + isInEmojiPickerTransition, + getPreservedCursorPosition, registerPostInputCallbacks, ]); diff --git a/app/components/markdown/at_mention/at_mention.tsx b/app/components/markdown/at_mention/at_mention.tsx index 5e57bde62..96076e4da 100644 --- a/app/components/markdown/at_mention/at_mention.tsx +++ b/app/components/markdown/at_mention/at_mention.tsx @@ -3,7 +3,7 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import Clipboard from '@react-native-clipboard/clipboard'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {type GestureResponderEvent, type StyleProp, StyleSheet, Text, type TextStyle, View} from 'react-native'; @@ -11,6 +11,7 @@ import {fetchUserOrGroupsByMentionsInBatch} from '@actions/remote/user'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {useServerUrl} from '@context/server'; import GroupModel from '@database/models/server/group'; +import useDidMount from '@hooks/did_mount'; import {useMemoMentionedGroup, useMemoMentionedUser} from '@hooks/markdown'; import {bottomSheet, dismissBottomSheet, openUserProfileModal} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; @@ -81,15 +82,12 @@ const AtMention = ({ const group = useMemoMentionedGroup(groups, user, mentionName); // Effects - useEffect(() => { + useDidMount(() => { // Fetches and updates the local db store with the mention if (!user?.username && !group?.name) { fetchUserOrGroupsByMentionsInBatch(serverUrl, mentionName); } - - // Only fetch the user or group on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const openUserProfile = () => { if (!user) { diff --git a/app/components/math_view/index.tsx b/app/components/math_view/index.tsx index 057ec3578..739dc81ee 100644 --- a/app/components/math_view/index.tsx +++ b/app/components/math_view/index.tsx @@ -17,7 +17,7 @@ type Props = { const MathView = (props: Props) => { const id = useId(); - const config = useMemo(() => ({id}), []); + const config = useMemo(() => ({id}), [id]); return ( (({ const onFocus = useCallback((e: NativeSyntheticEvent) => { hideHeader?.(); searchProps.onFocus?.(e); - }, [hideHeader, searchProps.onFocus]); + }, [hideHeader, searchProps]); const showEmitter = useCallback(() => { if (Platform.OS === 'android') { diff --git a/app/components/option_box/index.tsx b/app/components/option_box/index.tsx index 7b800bbaa..da4a57408 100644 --- a/app/components/option_box/index.tsx +++ b/app/components/option_box/index.tsx @@ -81,7 +81,7 @@ const OptionBox = ({ } return style; - }, [activated, containerStyle, theme, isDestructive]); + }, [styles, containerStyle, isDestructive, theme.dndIndicator, theme.buttonBg, activated]); const handleOnPress = useCallback(() => { if (activeIconName || activeText) { diff --git a/app/components/other_mentions_badge/index.tsx b/app/components/other_mentions_badge/index.tsx index 0556af363..c4efd43c9 100644 --- a/app/components/other_mentions_badge/index.tsx +++ b/app/components/other_mentions_badge/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import Badge from '@components/badge'; @@ -9,6 +9,7 @@ import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {subscribeAllServers} from '@database/subscription/servers'; import {subscribeMentionsByServer} from '@database/subscription/unreads'; +import useDidMount from '@hooks/did_mount'; import type ServersModel from '@typings/database/models/app/servers'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -86,7 +87,7 @@ const OtherMentionsBadge = ({channelId}: Props) => { } }; - useEffect(() => { + useDidMount(() => { const subscription = subscribeAllServers(serversObserver); return () => { @@ -96,7 +97,7 @@ const OtherMentionsBadge = ({channelId}: Props) => { }); subscriptions.clear(); }; - }, []); + }); return ( diff --git a/app/components/post_draft/draft_handler/draft_handler.tsx b/app/components/post_draft/draft_handler/draft_handler.tsx index 4ea1cf3aa..a10e39ec8 100644 --- a/app/components/post_draft/draft_handler/draft_handler.tsx +++ b/app/components/post_draft/draft_handler/draft_handler.tsx @@ -118,6 +118,10 @@ export default function DraftHandler(props: Props) { uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError); } } + + // We don't care about `newUploadError` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [files]); return ( diff --git a/app/components/post_draft/post_input/post_input.tsx b/app/components/post_draft/post_input/post_input.tsx index cae2c40b0..b409302cb 100644 --- a/app/components/post_draft/post_input/post_input.tsx +++ b/app/components/post_draft/post_input/post_input.tsx @@ -312,9 +312,6 @@ export default function PostInput({ } }, 1000); } - - // Shared values don't need to be in dependencies - they're stable references - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isDismissingEmojiPicker, focusTimeoutRef, @@ -322,13 +319,19 @@ export default function PostInput({ isEmojiSearchFocused, setIsFocused, setIsEmojiSearchFocused, - setShowInputAccessoryView, showInputAccessoryView, - lastKeyboardHeight, + setShowInputAccessoryView, updateCursorPosition, + value.length, clearCursorPositionPreservation, - value, - cursorPosition, + keyboardTranslateY, + inputAccessoryViewAnimatedHeight, + isInputAccessoryViewMode, + isTransitioningFromCustomView, + bottomInset, + scrollOffset, + keyboardHeight, + lastKeyboardHeight, ]); const handleAndroidKeyboardHide = useCallback(() => { diff --git a/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx b/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx index e7577f5f8..f067c4c03 100644 --- a/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx +++ b/app/components/post_draft/quick_actions/emoji_quick_action/index.tsx @@ -77,10 +77,14 @@ export default function EmojiQuickAction({ }; checkKeyboard(); - - // Shared values don't need to be in dependencies - they're stable references - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastKeyboardHeight, showEmojiPicker]); + }, [ + inputAccessoryViewAnimatedHeight, + isInputAccessoryViewMode, + isKeyboardFullyClosed, + keyboardHeight, + lastKeyboardHeight, + showEmojiPicker, + ]); const handleButtonPress = usePreventDoubleTap(useCallback(() => { // Prevent opening if already showing or transitioning @@ -129,10 +133,18 @@ export default function EmojiQuickAction({ // Dismiss keyboard runOnJS(dismissKeyboard)(); })(); - - // Shared values don't need to be in dependencies - they're stable references - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disabled, showInputAccessoryView, lastKeyboardHeight, setShowInputAccessoryView, scheduleKeyboardCheck, preserveCursorPositionForEmojiPicker])); + }, [ + disabled, + showInputAccessoryView, + isTransitioningFromCustomView, + preserveCursorPositionForEmojiPicker, + scheduleKeyboardCheck, + keyboardHeight, + lastKeyboardHeight, + isInputAccessoryViewMode, + inputAccessoryViewAnimatedHeight, + setShowInputAccessoryView, + ])); const actionTestID = disabled ? `${testID}.disabled` : testID; const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64); diff --git a/app/components/post_draft/send_button/send_button.tsx b/app/components/post_draft/send_button/send_button.tsx index ca6515041..49b5a62b3 100644 --- a/app/components/post_draft/send_button/send_button.tsx +++ b/app/components/post_draft/send_button/send_button.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import Tooltip from 'react-native-walkthrough-tooltip'; @@ -10,6 +10,7 @@ import CompassIcon from '@components/compass_icon'; import ScheduledPostTooltip from '@components/post_draft/send_button/scheduled_post_tooltip'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import {usePreventDoubleTap} from '@hooks/utils'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -65,7 +66,7 @@ const SendButton: React.FC = ({ const [scheduledPostTooltipVisible, setScheduledPostTooltipVisible] = useState(false); - useEffect(() => { + useDidMount(() => { if (scheduledPostFeatureTooltipWatched || !scheduledPostEnabled) { return; } @@ -73,10 +74,7 @@ const SendButton: React.FC = ({ InteractionManager.runAfterInteractions(() => { setScheduledPostTooltipVisible(true); }); - - // This effect is intended to run only on the first mount, so dependencies are omitted intentionally. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const onCloseScheduledPostTooltip = useCallback(() => { setScheduledPostTooltipVisible(false); diff --git a/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx b/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx index 2584b8cb5..209fda87c 100644 --- a/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx +++ b/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {View} from 'react-native'; import {updateDraftFile} from '@actions/local/draft'; @@ -9,6 +9,7 @@ import UploadItemShared from '@components/upload_item_shared'; import {fileInfoToUploadItemFile} from '@components/upload_item_shared/adapters'; import {useEditPost} from '@context/edit_post'; import {useServerUrl} from '@context/server'; +import useDidMount from '@hooks/did_mount'; import useDidUpdate from '@hooks/did_update'; import {useGalleryItem} from '@hooks/gallery'; import DraftEditPostUploadManager from '@managers/draft_upload_manager'; @@ -40,7 +41,7 @@ export default function UploadItemWrapper({ openGallery(file); }, [openGallery, file]); - useEffect(() => { + useDidMount(() => { if (file.clientId) { removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress); } @@ -48,7 +49,7 @@ export default function UploadItemWrapper({ removeCallback.current?.(); removeCallback.current = undefined; }; - }, []); + }); useDidUpdate(() => { if (loading && file.clientId) { diff --git a/app/components/post_list/more_messages/more_messages.tsx b/app/components/post_list/more_messages/more_messages.tsx index 6760b2f22..e9483c324 100644 --- a/app/components/post_list/more_messages/more_messages.tsx +++ b/app/components/post_list/more_messages/more_messages.tsx @@ -14,6 +14,7 @@ import TouchableWithFeedback from '@components/touchable_with_feedback'; import {Events} from '@constants'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import useDidUpdate from '@hooks/did_update'; import EphemeralStore from '@store/ephemeral_store'; import {makeStyleSheetFromTheme, hexToHue, changeOpacity} from '@utils/theme'; @@ -162,49 +163,6 @@ const MoreMessages = ({ localUnreadCount.current = unreadCount; }, [unreadCount]); - const resetCount = async () => { - localUnreadCount.current = 0; - - if (resetting.current || (isCRTEnabled && rootId)) { - return; - } - - resetting.current = true; - await resetMessageCount(serverUrl, channelId); - resetting.current = false; - }; - - const onViewableItemsChanged = (viewableItems: ViewToken[]) => { - pressed.current = false; - - if (newMessageLineIndex <= 0 || viewableItems.length === 0 || isManualUnread || resetting.current) { - return; - } - - const lastViewableIndex = viewableItems.filter((v) => v.isViewable)[viewableItems.length - 1]?.index || 0; - const nextViewableIndex = lastViewableIndex + 1; - if (viewableItems[0].index === 0 && nextViewableIndex > newMessageLineIndex && !initialScroll.current) { - // Auto scroll if the first post is viewable and - // * the new message line is viewable OR - // * the new message line will be the first next viewable item - scrollToIndex(newMessageLineIndex, true, false); - resetCount(); - top.value = 0; - initialScroll.current = true; - return; - } - - const readCount = posts.slice(0, lastViewableIndex).filter((v) => v.type === 'post').length; - const totalUnread = localUnreadCount.current - readCount; - if (lastViewableIndex >= newMessageLineIndex) { - resetCount(); - top.value = 0; - } else if (totalUnread > 0) { - setRemaining(totalUnread); - top.value = 1; - } - }; - const onScrollEndIndex = () => { pressed.current = false; }; @@ -239,17 +197,60 @@ const MoreMessages = ({ return () => listener.remove(); }, [serverUrl, channelId]); - useEffect(() => { + useDidMount(() => { const unregister = registerScrollEndIndexListener(onScrollEndIndex); return () => unregister(); - }, []); + }); useEffect(() => { + async function resetCount() { + localUnreadCount.current = 0; + + if (resetting.current || (isCRTEnabled && rootId)) { + return; + } + + resetting.current = true; + await resetMessageCount(serverUrl, channelId); + resetting.current = false; + } + + function onViewableItemsChanged(viewableItems: ViewToken[]) { + pressed.current = false; + + if (newMessageLineIndex <= 0 || viewableItems.length === 0 || isManualUnread || resetting.current) { + return; + } + + const lastViewableIndex = viewableItems.filter((v) => v.isViewable)[viewableItems.length - 1]?.index || 0; + const nextViewableIndex = lastViewableIndex + 1; + if (viewableItems[0].index === 0 && nextViewableIndex > newMessageLineIndex && !initialScroll.current) { + // Auto scroll if the first post is viewable and + // * the new message line is viewable OR + // * the new message line will be the first next viewable item + scrollToIndex(newMessageLineIndex, true, false); + resetCount(); + top.value = 0; + initialScroll.current = true; + return; + } + + const readCount = posts.slice(0, lastViewableIndex).filter((v) => v.type === 'post').length; + const totalUnread = localUnreadCount.current - readCount; + if (lastViewableIndex >= newMessageLineIndex) { + resetCount(); + top.value = 0; + } else if (totalUnread > 0) { + setRemaining(totalUnread); + top.value = 1; + } + } + const unregister = registerViewableItemsListener(onViewableItemsChanged); return () => unregister(); - }, [channelId, unreadCount, newMessageLineIndex, posts]); + }, [channelId, unreadCount, newMessageLineIndex, posts, registerViewableItemsListener, isCRTEnabled, rootId, serverUrl, isManualUnread, scrollToIndex, top]); useEffect(() => { resetting.current = false; diff --git a/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx b/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx index ad82e2305..6772709e1 100644 --- a/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx +++ b/app/components/post_list/post/body/acknowledgements/acknowledgements.tsx @@ -81,7 +81,7 @@ const Acknowledgements = ({currentUserId, currentUserTimezone, hasReactions, loc const style = getStyleSheet(theme); const isCurrentAuthor = post.userId === currentUserId; - const acknowledgements = post.metadata?.acknowledgements || []; + const acknowledgements = useMemo(() => post.metadata?.acknowledgements || [], [post.metadata?.acknowledgements]); const acknowledgedAt = useMemo(() => { if (acknowledgements.length > 0) { @@ -92,7 +92,7 @@ const Acknowledgements = ({currentUserId, currentUserTimezone, hasReactions, loc } } return 0; - }, [acknowledgements]); + }, [acknowledgements, currentUserId]); const handleOnPress = useCallback(() => { if ((acknowledgedAt && moreThan5minAgo(acknowledgedAt)) || isCurrentAuthor) { diff --git a/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx b/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx index ff48e52da..7680c8d3d 100644 --- a/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx +++ b/app/components/post_list/post/body/acknowledgements/users_list/users_list.tsx @@ -33,7 +33,7 @@ const UsersList = ({channelId, location, users, userAcknowledgements, timezone}: userAcknowledgement={userAcknowledgements[item.id]} timezone={timezone} /> - ), [channelId, location, timezone]); + ), [channelId, location, timezone, userAcknowledgements]); if (isTablet) { return ( diff --git a/app/components/post_list/post/body/reactions/reaction.tsx b/app/components/post_list/post/body/reactions/reaction.tsx index 377653406..cce9abe8a 100644 --- a/app/components/post_list/post/body/reactions/reaction.tsx +++ b/app/components/post_list/post/body/reactions/reaction.tsx @@ -58,15 +58,15 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re const containerStyle = useMemo(() => { const minWidth = MIN_WIDTH + (digits * DIGIT_WIDTH); return [styles.reaction, (highlight && styles.highlight), {minWidth}]; - }, [styles.reaction, highlight, digits]); + }, [digits, styles, highlight]); const handleLongPress = useCallback(() => { onLongPress(emojiName); - }, []); + }, [emojiName, onLongPress]); const handlePress = useCallback(() => { onPress(emojiName, highlight); - }, [highlight]); + }, [emojiName, highlight, onPress]); const fontStyle = useMemo(() => [styles.count, (highlight && styles.countHighlight)], [styles, highlight]); diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index b7e30bd1b..cd8e7043d 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -148,12 +148,11 @@ const Header = ({ const usernameOverride = ensureString(post.props?.override_username); const intl = useIntl(); - /* eslint-disable react-hooks/exhaustive-deps -- expire_at triggers recomputation when post metadata changes */ - const showBoRIcon = useMemo( - () => isUnrevealedBoRPost(post), - [post, post.metadata?.expire_at], - ); - /* eslint-enable react-hooks/exhaustive-deps */ + // We need to depend on the expire_at directly, + // since changes in it may not be reflected in the post object + // (it is still the same object reference). + // eslint-disable-next-line react-hooks/exhaustive-deps + const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]); const borExpireAt = post.metadata?.expire_at; const serverUrl = useServerUrl(); diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 1f877132b..b6e60d617 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -22,6 +22,7 @@ import {useKeyboardAnimationContext} from '@context/keyboard_animation'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {openAsBottomSheet} from '@screens/navigation'; import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor'; @@ -273,10 +274,11 @@ const Post = ({ } }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- Timer only needs to reset when post.id changes, not on other prop updates + // Timer only needs to reset when post.id changes, not on other prop updates + // eslint-disable-next-line react-hooks/exhaustive-deps }, [post.id]); - useEffect(() => { + useDidMount(() => { if (!isLastPost) { return; } @@ -287,9 +289,7 @@ const Post = ({ PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl); PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl); - - // eslint-disable-next-line react-hooks/exhaustive-deps -- Performance metrics should only run once on mount - }, []); + }); const onLayout = useCallback((e: LayoutChangeEvent) => { setLayoutWidth(e.nativeEvent.layout.width); diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index fe268ef44..6a8210c7e 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -183,10 +183,7 @@ const PostList = ({ listRef?.current?.scrollToOffset({offset: targetOffset, animated: true}); setShowScrollToEndBtn(false); - - // Shared values don't need to be in dependencies - they're stable references - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [listRef]); + }, [inputAccessoryViewAnimatedHeight, keyboardHeight, listRef]); useEffect(() => { const t = setTimeout(() => { diff --git a/app/components/profile_picture/index.tsx b/app/components/profile_picture/index.tsx index fb6f4e518..bd7c74d6a 100644 --- a/app/components/profile_picture/index.tsx +++ b/app/components/profile_picture/index.tsx @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {type StyleProp, View, type ViewStyle} from 'react-native'; import {fetchStatusInBatch} from '@actions/remote/user'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import Image from './image'; @@ -75,11 +76,11 @@ const ProfilePicture = ({ const style = getStyleSheet(theme); const isBot = author && (('isBot' in author) ? author.isBot : author.is_bot); - useEffect(() => { + useDidMount(() => { if (!isBot && author && !author.status && showStatus) { fetchStatusInBatch(serverUrl, author.id); } - }, []); + }); const viewStyle = useMemo( () => [style.container, {width: size, height: size}, containerStyle], diff --git a/app/components/profile_picture/status.tsx b/app/components/profile_picture/status.tsx index 75ac62870..06613bfe9 100644 --- a/app/components/profile_picture/status.tsx +++ b/app/components/profile_picture/status.tsx @@ -43,7 +43,7 @@ const Status = ({author, statusSize, statusStyle, theme}: Props) => { styles.statusWrapper, statusStyle, {borderRadius: statusSize / 2}, - ]), [statusStyle, styles]); + ]), [statusSize, statusStyle, styles]); const isBot = author && (('isBot' in author) ? author.isBot : author.is_bot); if (author?.status && !isBot) { return ( diff --git a/app/components/progress_bar/index.tsx b/app/components/progress_bar/index.tsx index 15df066b7..8acd28996 100644 --- a/app/components/progress_bar/index.tsx +++ b/app/components/progress_bar/index.tsx @@ -108,11 +108,14 @@ const ProgressBar = ({color, containerStyle, progress, withCursor, style, onSeek useEffect(() => { progressValue.value = progress; + + // Update the shared value only when the progress changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [progress]); const onLayout = useCallback((e: LayoutChangeEvent) => { widthValue.value = e.nativeEvent.layout.width; - }, []); + }, [widthValue]); return ( diff --git a/app/components/remove_markdown/at_mention/at_mention.tsx b/app/components/remove_markdown/at_mention/at_mention.tsx index a859d0592..530b24bbf 100644 --- a/app/components/remove_markdown/at_mention/at_mention.tsx +++ b/app/components/remove_markdown/at_mention/at_mention.tsx @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect} from 'react'; +import React from 'react'; import {type StyleProp, Text, type TextStyle} from 'react-native'; import {fetchUserOrGroupsByMentionsInBatch} from '@actions/remote/user'; import {useServerUrl} from '@context/server'; import GroupModel from '@database/models/server/group'; +import useDidMount from '@hooks/did_mount'; import {useMemoMentionedGroup, useMemoMentionedUser} from '@hooks/markdown'; import {displayUsername} from '@utils/user'; @@ -33,15 +34,12 @@ const AtMention = ({ const group = useMemoMentionedGroup(groups, user, mentionName); // Effects - useEffect(() => { + useDidMount(() => { // Fetches and updates the local db store with the mention if (!user?.username && !group?.name) { fetchUserOrGroupsByMentionsInBatch(serverUrl, mentionName); } - - // Only fetch the user or group on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); let mention; diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx index c78d3067e..8c99f739e 100644 --- a/app/components/search/index.tsx +++ b/app/components/search/index.tsx @@ -84,21 +84,24 @@ const Search = forwardRef((props: SearchProps, ref) => { const searchCancelButtonTestID = `${props.testID}.search.cancel.button`; const searchInputTestID = `${props.testID}.search.input`; + const onCancelProp = props.onCancel; + const onClearProp = props.onClear; + const onChangeTextProp = props.onChangeText; const onCancel = useCallback(() => { Keyboard.dismiss(); setValue(''); - props.onCancel?.(); - }, [props.onCancel]); + onCancelProp?.(); + }, [onCancelProp]); const onClear = useCallback(() => { setValue(''); - props.onClear?.(); - }, [props.onClear]); + onClearProp?.(); + }, [onClearProp]); const onChangeText = useCallback((text: string) => { setValue(text); - props.onChangeText?.(text); - }, [props.onChangeText]); + onChangeTextProp?.(text); + }, [onChangeTextProp]); const cancelButtonProps = useMemo(() => ({ buttonTextStyle: { diff --git a/app/components/server_user_list/index.tsx b/app/components/server_user_list/index.tsx index 079c82931..ad8ee4345 100644 --- a/app/components/server_user_list/index.tsx +++ b/app/components/server_user_list/index.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import UserList from '@components/user_list'; import {General} from '@constants'; +import useDidMount from '@hooks/did_mount'; import {useDebounce} from '@hooks/utils'; import {filterProfilesMatchingTerm} from '@utils/user'; @@ -96,16 +97,13 @@ export default function ServerUserList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [term]); - useEffect(() => { + useDidMount(() => { mounted.current = true; getProfiles(); return () => { mounted.current = false; }; - - // We only want to get the profiles on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const data = useMemo(() => { if (isSearch) { diff --git a/app/components/server_version/index.tsx b/app/components/server_version/index.tsx index e119ee6e6..63179dcfc 100644 --- a/app/components/server_version/index.tsx +++ b/app/components/server_version/index.tsx @@ -42,6 +42,10 @@ const ServerVersion = ({isAdmin, lastChecked, version}: ServerVersionProps) => { // Only display the Alert if the TOS does not need to show first handleUnsupportedServer(serverUrl, isAdmin, intl); } + + // We don't care about `intl` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [version, isAdmin, lastChecked, serverUrl]); return null; diff --git a/app/components/settings/text_setting.tsx b/app/components/settings/text_setting.tsx index 7fde9e868..897e36f40 100644 --- a/app/components/settings/text_setting.tsx +++ b/app/components/settings/text_setting.tsx @@ -85,7 +85,7 @@ function TextSetting({ const style = getStyleSheet(theme); const inputContainerStyle = useMemo(() => (disabled ? [style.inputContainer, style.disabled] : style.inputContainer), [style, disabled]); - const inputStyle = useMemo(() => (multiline ? style.multiline : style.input), [multiline]); + const inputStyle = useMemo(() => (multiline ? style.multiline : style.input), [multiline, style]); const actualKeyboardType: KeyboardTypeOptions = keyboardType === 'url' ? Platform.select({android: 'default', default: 'url'}) : keyboardType; diff --git a/app/components/syntax_highlight/index.tsx b/app/components/syntax_highlight/index.tsx index e528e3107..f938b4db2 100644 --- a/app/components/syntax_highlight/index.tsx +++ b/app/components/syntax_highlight/index.tsx @@ -60,7 +60,7 @@ const Highlighter = ({code, language, textStyle, selectable = false}: SyntaxHigl selectable={selectable} /> ); - }, [textStyle, theme, style]); + }, [style.hljs.color, theme.centerChannelColor, textStyle, selectable]); const preTag = useCallback((info: any) => ( { marginTop.value = iconPad ? 44 : 0; + + // Update the shared value only when the iconPad changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [iconPad]); useEffect(() => { width.value = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0; + + // Update the shared value only when the hasMoreThanOneTeam changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasMoreThanOneTeam]); return ( diff --git a/app/components/toast/index.tsx b/app/components/toast/index.tsx index 85920a6ec..07a5cc818 100644 --- a/app/components/toast/index.tsx +++ b/app/components/toast/index.tsx @@ -62,7 +62,7 @@ const Toast = ({animatedStyle, children, style, iconName, message, textStyle, te const toast_width = isTablet ? WIDTH_TABLET : WIDTH_MOBILE; const width = Math.min(dim.height, dim.width, toast_width) - TOAST_MARGIN; return [styles.container, {width}, style]; - }, [dim, styles.container, style]); + }, [isTablet, dim, styles, style]); return ( void, d } callback(...args); + + // The dependencies should be passed by the caller. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [keyboardContext, ...dependencies])); }; diff --git a/app/context/gallery/index.tsx b/app/context/gallery/index.tsx index aedf6334f..0e5c2552c 100644 --- a/app/context/gallery/index.tsx +++ b/app/context/gallery/index.tsx @@ -1,9 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useLayoutEffect} from 'react'; +import {useLayoutEffect} from 'react'; import {makeMutable, runOnUI, type AnimatedRef, type SharedValue} from 'react-native-reanimated'; +import useDidMount from '@hooks/did_mount'; + import type {GalleryManagerSharedValues} from '@typings/screens/gallery'; export interface GalleryManagerItem { @@ -155,13 +157,16 @@ export function GalleryInit({children, galleryIdentifier}: GalleryInitProps) { return () => { gallery.reset(); }; + + // Execute only on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { + useDidMount(() => { return () => { galleryManager.remove(galleryIdentifier); }; - }, []); + }); return children; } diff --git a/app/context/keyboard_animation.tsx b/app/context/keyboard_animation.tsx index e9733e58a..2d0a90b14 100644 --- a/app/context/keyboard_animation.tsx +++ b/app/context/keyboard_animation.tsx @@ -161,20 +161,26 @@ export const useKeyboardAnimationContext = () => { updateValue: defaultUpdateValue.current, updateCursorPosition: defaultUpdateCursorPosition.current, registerPostInputCallbacks: defaultRegisterPostInputCallbacks, - - // Shared values don't need to be in dependencies - they're stable references - // eslint-disable-next-line react-hooks/exhaustive-deps }), [ + defaultKeyboardTranslateY, + defaultBottomInset, + defaultScrollOffset, + defaultKeyboardHeight, + defaultScrollPosition, defaultOnScroll, - defaultInputRef, defaultBlurInput, defaultFocusInput, defaultBlurAndDismissKeyboard, + defaultIsKeyboardFullyOpen, + defaultIsKeyboardFullyClosed, + defaultIsKeyboardInTransition, defaultSetShowInputAccessoryView, + defaultIsInputAccessoryViewMode, + defaultInputAccessoryViewAnimatedHeight, + defaultIsTransitioningFromCustomView, defaultCloseInputAccessoryView, defaultScrollToEnd, defaultSetIsEmojiSearchFocused, - defaultCursorPositionRef, defaultRegisterCursorPosition, defaultPreserveCursorPositionForEmojiPicker, defaultClearCursorPositionPreservation, diff --git a/app/hooks/device.ts b/app/hooks/device.ts index abca58490..8f645b123 100644 --- a/app/hooks/device.ts +++ b/app/hooks/device.ts @@ -108,6 +108,9 @@ export function useViewPosition(viewRef: RefObject, deps: React.Dependency } }); } + + // The deps should be passed by the caller. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, isTablet, height, viewRef, modalPosition]); return modalPosition; diff --git a/app/hooks/did_mount.test.ts b/app/hooks/did_mount.test.ts new file mode 100644 index 000000000..d0ce1c85a --- /dev/null +++ b/app/hooks/did_mount.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; + +import useDidMount from './did_mount'; + +describe('useDidMount', () => { + test('should call callback on mount', () => { + const callback = jest.fn(); + renderHook(() => useDidMount(callback)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('should not call callback on re-renders', () => { + const callback = jest.fn(); + const {rerender} = renderHook(() => useDidMount(callback)); + + expect(callback).toHaveBeenCalledTimes(1); + + act(() => { + rerender(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + act(() => { + rerender(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('should call cleanup function on unmount when callback returns one', () => { + const cleanup = jest.fn(); + const callback = jest.fn(() => cleanup); + const {unmount} = renderHook(() => useDidMount(callback)); + + expect(callback).toHaveBeenCalledTimes(1); + expect(cleanup).not.toHaveBeenCalled(); + + unmount(); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + test('should not throw when callback returns undefined', () => { + const callback = jest.fn(() => undefined); + expect(() => { + renderHook(() => useDidMount(callback)); + }).not.toThrow(); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/hooks/did_mount.ts b/app/hooks/did_mount.ts new file mode 100644 index 000000000..4629a170b --- /dev/null +++ b/app/hooks/did_mount.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, type EffectCallback} from 'react'; + +function useDidMount(callback: EffectCallback) { + // We only want to run this effect on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(callback, []); +} + +export default useDidMount; diff --git a/app/hooks/did_update.ts b/app/hooks/did_update.ts index 3567c03c3..6f756258c 100644 --- a/app/hooks/did_update.ts +++ b/app/hooks/did_update.ts @@ -12,6 +12,9 @@ function useDidUpdate(callback: EffectCallback, deps?: DependencyList) { } else { hasMount.current = true; } + + // The dependencies should be provided by the caller. + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } diff --git a/app/hooks/fetching_thread.ts b/app/hooks/fetching_thread.ts index 60fa44bd1..891b499eb 100644 --- a/app/hooks/fetching_thread.ts +++ b/app/hooks/fetching_thread.ts @@ -1,22 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {subject} from '@store/fetching_thread_store'; +import useDidMount from './did_mount'; + export const useFetchingThreadState = (rootId: string) => { const [isFetching, setIsFetching] = useState(false); - useEffect(() => { + useDidMount(() => { const sub = subject.pipe( switchMap((s) => of$(s[rootId] || false)), distinctUntilChanged(), ).subscribe(setIsFetching); return () => sub.unsubscribe(); - }, []); + }); return isFetching; }; diff --git a/app/hooks/gallery.ts b/app/hooks/gallery.ts index a2ec98c51..66e638a27 100644 --- a/app/hooks/gallery.ts +++ b/app/hooks/gallery.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useCallback, useEffect} from 'react'; +import {useCallback} from 'react'; import { Easing, runOnJS, useAnimatedRef, useAnimatedStyle, useSharedValue, @@ -10,6 +10,8 @@ import { import {useGallery} from '@context/gallery'; +import useDidMount from './did_mount'; + export function diff(context: any, name: string, value: any) { 'worklet'; @@ -105,12 +107,9 @@ export function useGalleryItem( }; }, []); - useEffect(() => { + useDidMount(() => { gallery.registerItem(index, ref); - - // Only register item once on mount - gallery, index, and ref are stable references - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const onGestureEvent = () => { 'worklet'; diff --git a/app/hooks/header.ts b/app/hooks/header.ts index afe36430c..116b11db1 100644 --- a/app/hooks/header.ts +++ b/app/hooks/header.ts @@ -153,12 +153,12 @@ export const useCollapsibleHeader = (isLargeTitle: boolean, onSnap?: (offset: // No scroll for section lists? } } - }, [largeHeight, defaultHeight]); + }, [headerOffset, animatedRef, scrollValue, insets.top, scrollEnabled, defaultHeight, autoScroll]); const unlock = useCallback(() => { scrollEnabled.value = true; setLockValue(0); - }, []); + }, [scrollEnabled]); return { defaultHeight, diff --git a/app/hooks/initial_value.test.ts b/app/hooks/initial_value.test.ts new file mode 100644 index 000000000..774f2e128 --- /dev/null +++ b/app/hooks/initial_value.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; + +import useInitialValue from './initial_value'; + +describe('useInitialValue', () => { + test('should return the value from the factory on first render', () => { + const factory = jest.fn(() => 42); + const {result} = renderHook(() => useInitialValue(factory)); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.current).toBe(42); + }); + + test('should call factory only once and keep same value on re-renders', () => { + let callCount = 0; + const factory = jest.fn(() => { + callCount += 1; + return callCount; + }); + const {result, rerender} = renderHook(() => useInitialValue(factory)); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.current).toBe(1); + + act(() => { + rerender(); + }); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.current).toBe(1); + + act(() => { + rerender(); + }); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.current).toBe(1); + }); + + test('should work with object values', () => { + const initial = {id: 1, name: 'test'}; + const factory = jest.fn(() => initial); + const {result} = renderHook(() => useInitialValue(factory)); + + expect(result.current).toEqual(initial); + expect(result.current).toBe(initial); + }); + + test('should work with string values', () => { + const factory = jest.fn(() => 'hello'); + const {result} = renderHook(() => useInitialValue(factory)); + + expect(result.current).toBe('hello'); + }); +}); diff --git a/app/hooks/initial_value.ts b/app/hooks/initial_value.ts new file mode 100644 index 000000000..2badb0f51 --- /dev/null +++ b/app/hooks/initial_value.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; + +function useInitialValue(factory: () => T) { + // We only want the initial value, no updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(factory, []); +} + +export default useInitialValue; diff --git a/app/hooks/navigation_button_pressed.ts b/app/hooks/navigation_button_pressed.ts index c8a7ac24d..86c584c50 100644 --- a/app/hooks/navigation_button_pressed.ts +++ b/app/hooks/navigation_button_pressed.ts @@ -18,6 +18,9 @@ const useNavButtonPressed = (navButtonId: string, componentId: string, callback: return () => { unsubscribe.remove(); }; + + // The dependencies should be passed by the caller. + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); }; diff --git a/app/hooks/notification_props.test.ts b/app/hooks/notification_props.test.ts new file mode 100644 index 000000000..fbac0db0e --- /dev/null +++ b/app/hooks/notification_props.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; + +import TestHelper from '@test/test_helper'; +import {getNotificationProps} from '@utils/user'; + +import useNotificationProps from './notification_props'; + +describe('useNotificationProps', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return result of getNotificationProps when given a user', () => { + const notifyProps = TestHelper.fakeUserNotifyProps({channel: 'true', push: 'all'}); + const user = TestHelper.fakeUserModel({notifyProps}); + const expectedProps = notifyProps; + + const {result} = renderHook(() => useNotificationProps(user)); + + expect(result.current).toEqual(expectedProps); + }); + + test('should call getNotificationProps with undefined when currentUser is undefined', () => { + const defaultProps = getNotificationProps(undefined); + + const {result} = renderHook(() => useNotificationProps(undefined)); + + expect(result.current).toEqual(defaultProps); + }); + + test('should memoize based on notifyProps and not recompute when user reference changes but notifyProps is same', () => { + const notifyProps = TestHelper.fakeUserNotifyProps({channel: 'false'}); + const user1 = TestHelper.fakeUserModel({notifyProps}); + const user2 = TestHelper.fakeUserModel({...user1, id: 'other-id'}); + user2.notifyProps = notifyProps; + const expectedProps = notifyProps; + + const {result, rerender} = renderHook( + ({currentUser}) => useNotificationProps(currentUser), + {initialProps: {currentUser: user1}}, + ); + + expect(result.current).toEqual(expectedProps); + + act(() => { + rerender({currentUser: user2}); + }); + + expect(result.current).toEqual(expectedProps); + }); + + test('should recompute when notifyProps changes', () => { + const notifyProps1 = TestHelper.fakeUserNotifyProps({channel: 'false'}); + const notifyProps2 = TestHelper.fakeUserNotifyProps({channel: 'true'}); + const user1 = TestHelper.fakeUserModel({notifyProps: notifyProps1}); + const user2 = TestHelper.fakeUserModel({...user1, notifyProps: notifyProps2}); + + const {result, rerender} = renderHook( + ({currentUser}) => useNotificationProps(currentUser), + {initialProps: {currentUser: user1}}, + ); + + expect(result.current).toEqual(notifyProps1); + + act(() => { + rerender({currentUser: user2}); + }); + + expect(result.current).toEqual(notifyProps2); + }); + + test('should recompute when notifyProps changes even if user reference doesnt change', () => { + const notifyProps1 = TestHelper.fakeUserNotifyProps({channel: 'false'}); + const notifyProps2 = TestHelper.fakeUserNotifyProps({channel: 'true'}); + const user1 = TestHelper.fakeUserModel({notifyProps: notifyProps1}); + + const {result, rerender} = renderHook( + ({currentUser}) => useNotificationProps(currentUser), + {initialProps: {currentUser: user1}}, + ); + + expect(result.current).toEqual(notifyProps1); + + user1.notifyProps = notifyProps2; + act(() => { + rerender({currentUser: user1}); + }); + + expect(result.current).toEqual(notifyProps2); + }); +}); diff --git a/app/hooks/notification_props.ts b/app/hooks/notification_props.ts new file mode 100644 index 000000000..0c9dfca6b --- /dev/null +++ b/app/hooks/notification_props.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; + +import {getNotificationProps} from '@utils/user'; + +import type UserModel from '@typings/database/models/servers/user'; + +function useNotificationProps(currentUser?: UserModel) { + // We need to depend on the notifyProps object directly, + // since changes in it may not be reflected in the currentUser object + // (it is still the same object reference). + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); +} + +export default useNotificationProps; diff --git a/app/hooks/teams_loading.ts b/app/hooks/teams_loading.ts index 661282bd8..6d661b93b 100644 --- a/app/hooks/teams_loading.ts +++ b/app/hooks/teams_loading.ts @@ -1,24 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {getLoadingTeamChannelsSubject} from '@store/team_load_store'; +import useDidMount from './did_mount'; + export const useTeamsLoading = (serverUrl: string) => { // const subject = getLoadingTeamChannelsSubject(serverUrl); // const [loading, setLoading] = useState(subject.getValue() !== 0); const [loading, setLoading] = useState(false); - useEffect(() => { + useDidMount(() => { const sub = getLoadingTeamChannelsSubject(serverUrl).pipe( switchMap((v) => of$(v !== 0)), distinctUntilChanged(), ).subscribe(setLoading); return () => sub.unsubscribe(); - }, []); + }); return loading; }; diff --git a/app/hooks/user_timezone.test.tsx b/app/hooks/user_timezone.test.tsx new file mode 100644 index 000000000..09e381799 --- /dev/null +++ b/app/hooks/user_timezone.test.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act} from '@testing-library/react-hooks'; + +import TestHelper from '@test/test_helper'; + +import useUserTimezoneProps from './user_timezone'; + +describe('useUserTimezoneProps', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return result of getUserTimezoneProps when given a user with timezone', () => { + const timezone = {useAutomaticTimezone: 'true', automaticTimezone: 'America/New_York', manualTimezone: 'America/Los_Angeles'}; + const user = TestHelper.fakeUserModel({timezone}); + const expectedProps = {useAutomaticTimezone: true, automaticTimezone: 'America/New_York', manualTimezone: 'America/Los_Angeles'}; + + const {result} = renderHook(() => useUserTimezoneProps(user)); + + expect(result.current).toEqual(expectedProps); + }); + + test('should call getUserTimezoneProps with undefined when currentUser is undefined', () => { + const defaultProps = {useAutomaticTimezone: true, automaticTimezone: '', manualTimezone: ''}; + + const {result} = renderHook(() => useUserTimezoneProps(undefined)); + + expect(result.current).toEqual(defaultProps); + }); + + test('should not recompute on rerender when same user is passed', () => { + const timezone = {useAutomaticTimezone: 'false', automaticTimezone: '', manualTimezone: 'Europe/London'}; + const user = TestHelper.fakeUserModel({timezone}); + const expectedProps = {useAutomaticTimezone: false, automaticTimezone: '', manualTimezone: 'Europe/London'}; + + const {result, rerender} = renderHook(() => useUserTimezoneProps(user)); + + expect(result.current).toEqual(expectedProps); + + act(() => { + rerender(); + }); + + expect(result.current).toEqual(expectedProps); + }); + + test('should recompute when timezone changes', () => { + const timezone1 = {useAutomaticTimezone: 'true', automaticTimezone: 'America/New_York', manualTimezone: ''}; + const timezone2 = {useAutomaticTimezone: 'false', automaticTimezone: '', manualTimezone: 'Europe/London'}; + const user1 = TestHelper.fakeUserModel({timezone: timezone1}); + const user2 = TestHelper.fakeUserModel({...user1, timezone: timezone2}); + + const {result, rerender} = renderHook( + ({currentUser}) => useUserTimezoneProps(currentUser), + {initialProps: {currentUser: user1}}, + ); + + expect(result.current).toEqual({useAutomaticTimezone: true, automaticTimezone: 'America/New_York', manualTimezone: ''}); + + act(() => { + rerender({currentUser: user2}); + }); + + expect(result.current).toEqual({useAutomaticTimezone: false, automaticTimezone: '', manualTimezone: 'Europe/London'}); + }); + + test('should not recompute when user reference changes but timezone is the same', () => { + const timezone = {useAutomaticTimezone: 'true', automaticTimezone: 'America/Chicago', manualTimezone: ''}; + const user1 = TestHelper.fakeUserModel({timezone}); + const user2 = TestHelper.fakeUserModel({...user1, id: 'other-id'}); + (user2 as typeof user1).timezone = timezone; + const expectedProps = {useAutomaticTimezone: true, automaticTimezone: 'America/Chicago', manualTimezone: ''}; + + const {result, rerender} = renderHook( + ({currentUser}) => useUserTimezoneProps(currentUser), + {initialProps: {currentUser: user1}}, + ); + + expect(result.current).toEqual(expectedProps); + + act(() => { + rerender({currentUser: user2}); + }); + + expect(result.current).toEqual(expectedProps); + }); + + test('should recompute when timezone changes even if user reference doesnt change', () => { + const timezone1 = {useAutomaticTimezone: 'true', automaticTimezone: 'America/New_York', manualTimezone: ''}; + const timezone2 = {useAutomaticTimezone: 'false', automaticTimezone: '', manualTimezone: 'Europe/London'}; + const user1 = TestHelper.fakeUserModel({timezone: timezone1}); + + const {result, rerender} = renderHook( + ({currentUser}) => useUserTimezoneProps(currentUser), + {initialProps: {currentUser: user1}}, + ); + + expect(result.current).toEqual({useAutomaticTimezone: true, automaticTimezone: 'America/New_York', manualTimezone: ''}); + + user1.timezone = timezone2; + + act(() => { + rerender({currentUser: user1}); + }); + + expect(result.current).toEqual({useAutomaticTimezone: false, automaticTimezone: '', manualTimezone: 'Europe/London'}); + }); +}); diff --git a/app/hooks/user_timezone.tsx b/app/hooks/user_timezone.tsx new file mode 100644 index 000000000..8083e4721 --- /dev/null +++ b/app/hooks/user_timezone.tsx @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; + +import {getUserTimezoneProps} from '@utils/user'; + +import type UserModel from '@typings/database/models/servers/user'; + +function useUserTimezoneProps(currentUser?: UserModel) { + // We need to depend on the timezone object directly, + // since changes in it may not be reflected in the currentUser object + // (it is still the same object reference). + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => getUserTimezoneProps(currentUser), [currentUser?.timezone]); +} + +export default useUserTimezoneProps; diff --git a/app/products/calls/components/call_duration.tsx b/app/products/calls/components/call_duration.tsx index 6fc8dfeda..915bcbfbe 100644 --- a/app/products/calls/components/call_duration.tsx +++ b/app/products/calls/components/call_duration.tsx @@ -51,6 +51,10 @@ const CallDuration = ({value, style, truncateWhenLong, updateIntervalInSeconds}: return function cleanup() { return null; }; + + // We don't care about `getCallDuration` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateIntervalInSeconds]); return ( diff --git a/app/products/calls/components/call_notification/call_notification.tsx b/app/products/calls/components/call_notification/call_notification.tsx index 238cf1ae1..c264518bc 100644 --- a/app/products/calls/components/call_notification/call_notification.tsx +++ b/app/products/calls/components/call_notification/call_notification.tsx @@ -18,6 +18,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import {useAppState} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import WebsocketManager from '@managers/websocket_manager'; import {getServerDisplayName} from '@queries/app/servers'; import ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; @@ -132,15 +133,19 @@ export const CallNotification = ({ const [serverName, setServerName] = useState(''); const moreThanOneServer = servers.length > 1; - useEffect(() => { + useDidMount(() => { const channelMembers = members?.filter((m) => m.userId !== currentUserId); if (!channelMembers?.length) { fetchProfilesInChannel(serverUrl, incomingCall.channelID, currentUserId, undefined, false); } - }, []); + }); useEffect(() => { playIncomingCallsRinging(incomingCall.serverUrl, incomingCall.callID, userStatus || ''); + + // We don't care about `userStatus` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [incomingCall.serverUrl, incomingCall.callID, appState]); // We only need to getServerDisplayName once @@ -165,7 +170,7 @@ export const CallNotification = ({ const onDismissPress = useCallback(() => { removeIncomingCall(serverUrl, incomingCall.callID, incomingCall.channelID); dismissIncomingCall(incomingCall.serverUrl, incomingCall.channelID); - }, [incomingCall]); + }, [incomingCall, serverUrl]); let message: React.ReactElement; if (incomingCall.type === ChannelType.DM) { diff --git a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx index 13e34fded..2193e9783 100644 --- a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx +++ b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx @@ -159,7 +159,7 @@ export const CallsCustomMessage = ({ setJoiningChannelId(post.channelId); await leaveAndJoinWithAlert(intl, serverUrl, post.channelId); setJoiningChannelId(null); - }, [limitRestrictedInfo, intl, serverUrl, post.channelId]); + }, [isLimitRestricted, post.channelId, intl, serverUrl, limitRestrictedInfo]); const leaveCallHandler = useCallback(() => { leaveCallConfirmation(intl, otherParticipants, isAdmin, isHost, serverUrl, post.channelId); diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index b440f52b1..e1943fb87 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -131,6 +131,10 @@ export const usePermissionsChecker = (micPermissionsGranted: boolean) => { if (!micPermissionsGranted) { asyncFn(); } + + // We don't care about `micPermissionsGranted` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [appState]); return hasPermission; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index de346c0de..097085ab8 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -57,6 +57,7 @@ import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; import { @@ -363,14 +364,10 @@ const CallScreen = ({ id: 'mobile.calls_stop_recording', defaultMessage: 'Stop Recording', }); - const openChannelOptionTitle = intl.formatMessage({ - id: 'mobile.calls_open_channel', - defaultMessage: 'Open Channel', - }); const showCCTitle = intl.formatMessage({id: 'mobile.calls_show_cc', defaultMessage: 'Show live captions'}); const hideCCTitle = intl.formatMessage({id: 'mobile.calls_hide_cc', defaultMessage: 'Hide live captions'}); - useEffect(() => { + useDidMount(() => { const setOrientation = () => { mergeNavigationOptions('Call', { layout: { @@ -418,7 +415,7 @@ const CallScreen = ({ unsetOrientation(); }; - }, []); + }); const leaveCallHandler = useCallback(() => { leaveCallConfirmation(intl, otherParticipants, isAdmin, isHost, serverUrl, currentCall?.channelId || '', popTopScreen); @@ -448,7 +445,7 @@ const CallScreen = ({ } await startCallRecording(currentCall.serverUrl, currentCall.channelId); - }, [currentCall?.channelId, currentCall?.serverUrl]); + }, [currentCall]); const stopRecording = useCallback(async () => { const stop = await stopRecordingConfirmationAlert(intl, EnableTranscriptions); @@ -464,7 +461,7 @@ const CallScreen = ({ } await stopCallRecording(currentCall.serverUrl, currentCall.channelId); - }, [currentCall?.channelId, currentCall?.serverUrl, EnableTranscriptions]); + }, [intl, EnableTranscriptions, currentCall]); const toggleCC = useCallback(async () => { Keyboard.dismiss(); @@ -495,7 +492,7 @@ const CallScreen = ({ await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl); WebsocketManager.initializeClient(currentCall.serverUrl, 'Server Switch'); await goToScreen(Screens.THREAD, callThreadOptionTitle, {rootId: currentCall.threadId}); - }, [currentCall?.serverUrl, currentCall?.threadId, fromThreadScreen, componentId, callThreadOptionTitle]); + }, [currentCall, componentId, fromThreadScreen, callThreadOptionTitle]); // The user should receive a recording alert if all of the following conditions apply: // - Recording has started, recording has not ended @@ -581,10 +578,26 @@ const CallScreen = ({ title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}), theme, }); - }, [intl, theme, isHost, EnableRecordings, waitingForRecording, recording, startRecording, - recordOptionTitle, stopRecording, stopRecordingOptionTitle, style, switchToThread, - callThreadOptionTitle, openChannelOptionTitle, ccAvailable, toggleCC, showCC, hideCCTitle, - showCCTitle]); + }, [ + isHost, + EnableRecordings, + ccAvailable, + intl, + theme, + showStartRecording, + startRecording, + recordOptionTitle, + showStopRecording, + style, + stopRecording, + stopRecordingOptionTitle, + switchToThread, + callThreadOptionTitle, + toggleCC, + showCC, + hideCCTitle, + showCCTitle, + ]); const collapse = useCallback(() => { popTopScreen(componentId); @@ -611,6 +624,10 @@ const CallScreen = ({ } else { setCenterUsers(true); } + + // We don't care about `avatarSize`, `screenShareOn`, and `smallerAvatar` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [layout, numSessions]); const onLayout = useCallback((e: LayoutChangeEvent) => { diff --git a/app/products/calls/state/calls_config.ts b/app/products/calls/state/calls_config.ts index db18bb038..250f54012 100644 --- a/app/products/calls/state/calls_config.ts +++ b/app/products/calls/state/calls_config.ts @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {BehaviorSubject} from 'rxjs'; import {type CallsConfigState, DefaultCallsConfig} from '@calls/types/calls'; +import useDidMount from '@hooks/did_mount'; const callsConfigSubjects: Dictionary> = {}; @@ -33,7 +34,7 @@ export const useCallsConfig = (serverUrl: string) => { const callsConfigSubject = getCallsConfigSubject(serverUrl); - useEffect(() => { + useDidMount(() => { const subscription = callsConfigSubject.subscribe((callsConfig) => { setState(callsConfig); }); @@ -41,7 +42,7 @@ export const useCallsConfig = (serverUrl: string) => { return () => { subscription?.unsubscribe(); }; - }, []); + }); return state; }; diff --git a/app/products/calls/state/calls_state.ts b/app/products/calls/state/calls_state.ts index 273d58f91..036c556c1 100644 --- a/app/products/calls/state/calls_state.ts +++ b/app/products/calls/state/calls_state.ts @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {BehaviorSubject} from 'rxjs'; import {type CallsState, DefaultCallsState} from '@calls/types/calls'; +import useDidMount from '@hooks/did_mount'; const callsStateSubjects: Dictionary> = {}; @@ -33,7 +34,7 @@ export const useCallsState = (serverUrl: string) => { const callsStateSubject = getCallsStateSubject(serverUrl); - useEffect(() => { + useDidMount(() => { const subscription = callsStateSubject.subscribe((callsState) => { setState(callsState); }); @@ -41,7 +42,7 @@ export const useCallsState = (serverUrl: string) => { return () => { subscription?.unsubscribe(); }; - }, []); + }); return state; }; diff --git a/app/products/calls/state/channels_with_calls.ts b/app/products/calls/state/channels_with_calls.ts index f16a16dea..14b177862 100644 --- a/app/products/calls/state/channels_with_calls.ts +++ b/app/products/calls/state/channels_with_calls.ts @@ -1,9 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {BehaviorSubject} from 'rxjs'; +import useDidMount from '@hooks/did_mount'; + import type {ChannelsWithCalls} from '@calls/types/calls'; const channelsWithCallsSubject: Dictionary> = {}; @@ -31,7 +33,7 @@ export const observeChannelsWithCalls = (serverUrl: string) => { export const useChannelsWithCalls = (serverUrl: string) => { const [state, setState] = useState({}); - useEffect(() => { + useDidMount(() => { const subscription = getChannelsWithCallsSubject(serverUrl).subscribe((channelsWithCalls) => { setState(channelsWithCalls); }); @@ -39,7 +41,7 @@ export const useChannelsWithCalls = (serverUrl: string) => { return () => { subscription?.unsubscribe(); }; - }, []); + }); return state; }; diff --git a/app/products/playbooks/components/status_update_post/index.tsx b/app/products/playbooks/components/status_update_post/index.tsx index 92dbaed09..92a0a123e 100644 --- a/app/products/playbooks/components/status_update_post/index.tsx +++ b/app/products/playbooks/components/status_update_post/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect} from 'react'; +import React from 'react'; import {useIntl} from 'react-intl'; import {Text, View} from 'react-native'; @@ -9,6 +9,7 @@ import {fetchUsersByIds} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; import Markdown from '@components/markdown'; import {useServerUrl} from '@context/server'; +import useDidMount from '@hooks/did_mount'; import {isPostStatusUpdateProps} from '@playbooks/utils/types'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -87,14 +88,11 @@ const StatusUpdatePost = ({location, post, theme}: Props) => { const style = getStyleSheet(theme); const intl = useIntl(); - useEffect(() => { + useDidMount(() => { if (statusUpdateProps) { fetchUsersByIds(serverUrl, statusUpdateProps.participantIds); } - - // Only do this on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); if (!statusUpdateProps) { return ( diff --git a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx index 27cc21a5c..d72693291 100644 --- a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx +++ b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import {fetchPlaybookRunsPageForParticipant} from '@playbooks/actions/remote/runs'; import RunList from '@playbooks/components/run_list'; import {isRunFinished} from '@playbooks/utils/run'; @@ -86,12 +87,9 @@ const ParticipantPlaybooks = ({ } }, [loadingMore, hasMore, currentPage, fetchData]); - useEffect(() => { + useDidMount(() => { fetchData(); - - // Only fetch the data on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const showMoreButton = useCallback(() => { return hasMore; diff --git a/app/products/playbooks/screens/post_update/post_update.tsx b/app/products/playbooks/screens/post_update/post_update.tsx index 9c8432cef..9f7467936 100644 --- a/app/products/playbooks/screens/post_update/post_update.tsx +++ b/app/products/playbooks/screens/post_update/post_update.tsx @@ -15,6 +15,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useAvoidKeyboard} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import SecurityManager from '@managers/security_manager'; import {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@playbooks/actions/remote/runs'; @@ -121,7 +122,7 @@ const PostUpdate = ({ }); }, [rightButton, componentId]); - useEffect(() => { + useDidMount(() => { async function initialLoad() { let calculatedFollowersCount = 0; let calculatedBroadcastChannelCount = 0; @@ -161,10 +162,7 @@ const PostUpdate = ({ } initialLoad(); - - // This is the initial load, so we don't need to re-run it on every change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const onNextUpdateSelected = useCallback((value: SelectedDialogOption) => { if (!value) { diff --git a/app/products/playbooks/screens/select_playbook/select_playbook.tsx b/app/products/playbooks/screens/select_playbook/select_playbook.tsx index 1456363b7..3d9f86336 100644 --- a/app/products/playbooks/screens/select_playbook/select_playbook.tsx +++ b/app/products/playbooks/screens/select_playbook/select_playbook.tsx @@ -14,6 +14,7 @@ import {General, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import {fetchPlaybooks} from '@playbooks/actions/remote/playbooks'; import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs'; @@ -185,12 +186,9 @@ function SelectPlaybook({ }; }, []); - useEffect(() => { + useDidMount(() => { loadMore(); - - // We only want to load the playbooks once on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const renderNoResults = useCallback((): JSX.Element | null => { if (loading && page.current === -1) { diff --git a/app/screens/browse_channels/browse_channels.tsx b/app/screens/browse_channels/browse_channels.tsx index a7efbbf96..e1d82ed88 100644 --- a/app/screens/browse_channels/browse_channels.tsx +++ b/app/screens/browse_channels/browse_channels.tsx @@ -131,7 +131,7 @@ export default function BrowseChannels(props: Props) { } setButtons(componentId, buttons); - }, [closeButton, canCreateChannels, intl.locale, theme, componentId]); + }, [closeButton, canCreateChannels, componentId, theme, intl]); const onSelectChannel = useCallback(async (channel: Channel) => { setHeaderButtons(false); @@ -157,7 +157,7 @@ export default function BrowseChannels(props: Props) { switchToChannelById(serverUrl, channel.id, currentTeamId); close(); } - }, [setHeaderButtons, intl.locale]); + }, [setHeaderButtons, serverUrl, currentTeamId, intl]); const onSearch = useCallback(() => { searchChannels(term); @@ -167,7 +167,7 @@ export default function BrowseChannels(props: Props) { const screen = Screens.CREATE_OR_EDIT_CHANNEL; const title = intl.formatMessage({id: 'mobile.create_channel.title', defaultMessage: 'New channel'}); goToScreen(screen, title); - }, [intl.locale]); + }, [intl]); useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]); useNavButtonPressed(CREATE_BUTTON_ID, componentId, handleCreate, [handleCreate]); @@ -176,7 +176,7 @@ export default function BrowseChannels(props: Props) { useEffect(() => { // Update header buttons in case anything related to the header changes setHeaderButtons(!adding); - }, [theme, canCreateChannels, adding]); + }, [adding, setHeaderButtons]); let content; if (adding) { diff --git a/app/screens/browse_channels/channel_list.tsx b/app/screens/browse_channels/channel_list.tsx index 7b5f58f37..aca2fe904 100644 --- a/app/screens/browse_channels/channel_list.tsx +++ b/app/screens/browse_channels/channel_list.tsx @@ -93,7 +93,7 @@ export default function ChannelList({ ); //Style is covered by the theme - }, [loading, theme]); + }, [loading, style, theme.buttonBg]); const renderNoResults = useCallback(() => { if (term) { @@ -119,7 +119,7 @@ export default function ChannelList({ - ), [theme]); + ), [style]); return ( { + const doGetChannels = useCallback((t: string) => { let next: (typeof nextPublic | typeof nextShared | typeof nextArchived); let fetch: (typeof fetchChannels | typeof fetchSharedChannels | typeof fetchArchivedChannels); let page: (typeof publicPage | typeof sharedPage | typeof archivedPage); @@ -187,13 +187,13 @@ export default function SearchHandler(props: Props) { () => dispatch(StopAction), ); } - }; + }, [currentTeamId, serverUrl]); const onEndReached = useCallback(() => { if (!loading && !term) { doGetChannels(typeOfChannels); } - }, [typeOfChannels, loading, term]); + }, [loading, term, doGetChannels, typeOfChannels]); let activeChannels: Channel[]; switch (typeOfChannels) { @@ -210,7 +210,7 @@ export default function SearchHandler(props: Props) { const stopSearch = useCallback(() => { setSearchResults(defaultSearchResults); setTerm(''); - }, [activeChannels]); + }, []); const doSearchChannels = useCallback((text: string) => { if (text) { @@ -231,7 +231,7 @@ export default function SearchHandler(props: Props) { } else { stopSearch(); } - }, [activeChannels, visibleChannels, joinedChannels, stopSearch]); + }, [searchResults, serverUrl, currentTeamId, stopSearch]); const changeChannelType = useCallback((channelType: string) => { setTypeOfChannels(channelType); @@ -276,12 +276,20 @@ export default function SearchHandler(props: Props) { return () => { loadedChannels.current = async () => {/* Do nothing */}; }; + + // We don't care about `doGetChannels` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [joinedChannels]); useEffect(() => { if (!isSearch) { doGetChannels(typeOfChannels); } + + // We don't care about `doGetChannels` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [typeOfChannels, isSearch]); useDidUpdate(() => { diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx index f131787ba..ce0222de2 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.tsx +++ b/app/screens/channel/channel_post_list/channel_post_list.tsx @@ -12,6 +12,7 @@ import PostList from '@components/post_list'; import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useAppState, useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import useDidUpdate from '@hooks/did_update'; import {useDebounce} from '@hooks/utils'; import EphemeralStore from '@store/ephemeral_store'; @@ -106,14 +107,11 @@ const ChannelPostList = ({ } }, [appState === 'active']); - useEffect(() => { + useDidMount(() => { return () => { unsetActiveChannelOnServer(serverUrl); }; - - // We only want to run this on unmount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const intro = (); diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx index bf31d989e..64aed9925 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {defineMessages} from 'react-intl'; import {Text, View, type TextStyle} from 'react-native'; @@ -10,6 +10,7 @@ import FormattedText from '@components/formatted_text'; import {BotTag} from '@components/tag'; import {General, NotificationLevel} from '@constants'; import {useServerUrl} from '@context/server'; +import useDidMount from '@hooks/did_mount'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {getUserIdFromChannelName} from '@utils/user'; @@ -124,12 +125,12 @@ const DirectChannel = ({ const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); - useEffect(() => { + useDidMount(() => { const channelMembers = members?.filter((m) => m.userId !== currentUserId); if (!channelMembers?.length) { fetchProfilesInChannel(serverUrl, channel.id, currentUserId, undefined, false); } - }, []); + }); const message = useMemo(() => { if (channel.type === General.DM_CHANNEL) { diff --git a/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx index 3b751f229..f9d543e26 100644 --- a/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {Text, View} from 'react-native'; @@ -9,6 +9,7 @@ import {fetchChannelCreator} from '@actions/remote/channel'; import CompassIcon from '@components/compass_icon'; import {General, Permissions} from '@constants'; import {useServerUrl} from '@context/server'; +import useDidMount from '@hooks/did_mount'; import {hasPermission} from '@utils/role'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -93,11 +94,11 @@ const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => { return ; }, [channel.type, theme]); - useEffect(() => { + useDidMount(() => { if (!creator && channel.creatorId) { fetchChannelCreator(serverUrl, channel.id); } - }, []); + }); const canManagePeople = useMemo(() => { if (channel.deleteAt !== 0) { diff --git a/app/screens/channel/use_gm_as_dm_notice.tsx b/app/screens/channel/use_gm_as_dm_notice.tsx index 5b32ec861..9e87d01d4 100644 --- a/app/screens/channel/use_gm_as_dm_notice.tsx +++ b/app/screens/channel/use_gm_as_dm_notice.tsx @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect} from 'react'; import {useIntl} from 'react-intl'; import {Alert} from 'react-native'; @@ -9,6 +8,7 @@ import {savePreference} from '@actions/remote/preference'; import {Preferences} from '@constants'; import {useServerUrl} from '@context/server'; import {getPreferenceAsBool} from '@helpers/api/preference'; +import useDidMount from '@hooks/did_mount'; import EphemeralStore from '@store/ephemeral_store'; import type PreferenceModel from '@typings/database/models/servers/preference'; @@ -17,7 +17,7 @@ const useGMasDMNotice = (userId: string, channelType: ChannelType, dismissedGMas const intl = useIntl(); const serverUrl = useServerUrl(); - useEffect(() => { + useDidMount(() => { if (!hasGMasDMFeature) { return; } @@ -59,7 +59,7 @@ const useGMasDMNotice = (userId: string, channelType: ChannelType, dismissedGMas }, ], ); - }, []); + }); }; export default useGMasDMNotice; diff --git a/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx b/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx index cf5d670bc..992b6cd46 100644 --- a/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx +++ b/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx @@ -16,6 +16,7 @@ import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {fileSizeWarning, getExtensionFromMime, getFormattedFileSize} from '@utils/file'; import PickerUtil from '@utils/file/file_picker'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -130,7 +131,7 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set const [uploading, setUploading] = useState(false); const [failed, setFailed] = useState(false); const styles = getStyleSheet(theme); - const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet]); + const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]); const cancelUpload = useRef<() => void | undefined>(); const onProgress = useCallback((p: number, bytes: number) => { @@ -143,7 +144,18 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set setProgress(p); setFile(f); - }, []); + }, [file]); + + const setUploadError = useCallback(() => { + setProgress(0); + setUploading(false); + setFailed(true); + + setError(intl.formatMessage({ + id: 'channel_bookmark.add.file_upload_error', + defaultMessage: 'Error uploading file. Please try again.', + })); + }, [intl]); const onComplete = useCallback((response: ClientResponse) => { cancelUpload.current = undefined; @@ -165,23 +177,12 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set setProgress(1); setFailed(false); setError(''); - }, []); + }, [setBookmark, setUploadError]); const onError = useCallback(() => { cancelUpload.current = undefined; setUploadError(); - }, []); - - const setUploadError = useCallback(() => { - setProgress(0); - setUploading(false); - setFailed(true); - - setError(intl.formatMessage({ - id: 'channel_bookmark.add.file_upload_error', - defaultMessage: 'Error uploading file. Please try again.', - })); - }, [file, intl]); + }, [setUploadError]); const startUpload = useCallback((fileInfo: FileInfo | ExtractedFileInfo) => { setUploading(true); @@ -206,7 +207,7 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set setUploadError(); cancelUpload.current?.(); } - }, [channelId, onProgress, onComplete, onError, serverUrl]); + }, [serverUrl, channelId, onProgress, onComplete, onError, setUploadError]); const browseFile = useCallback(async () => { const picker = new PickerUtil(intl, (files) => { @@ -223,12 +224,12 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set if (res.error) { close(); } - }, [close, startUpload]); + }, [close, intl, startUpload]); const removeAndUpload = useCallback(() => { cancelUpload.current?.(); browseFile(); - }, [file, browseFile]); + }, [browseFile]); const retry = useCallback(() => { cancelUpload.current?.(); @@ -237,7 +238,7 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set } }, [file, startUpload]); - useEffect(() => { + useDidMount(() => { if (!initialFile) { browseFile(); } @@ -245,7 +246,7 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set return () => { cancelUpload.current?.(); }; - }, []); + }); useEffect(() => { if (uploading) { @@ -260,6 +261,10 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set if (!file?.id && file?.name) { setBookmark(file); } + + // We don't care about `setBookmark` changes as long as + // it is up to date when the effect runs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [file, intl, maxFileSize, uploading]); let info; diff --git a/app/screens/channel_bookmark/index.tsx b/app/screens/channel_bookmark/index.tsx index 2e160d1e7..77bfc2c88 100644 --- a/app/screens/channel_bookmark/index.tsx +++ b/app/screens/channel_bookmark/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {useIntl} from 'react-intl'; import {Alert, View, type AlertButton} from 'react-native'; import {SafeAreaView, type Edge} from 'react-native-safe-area-context'; @@ -12,6 +12,7 @@ import Button from '@components/button'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import SecurityManager from '@managers/security_manager'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; @@ -257,9 +258,9 @@ const ChannelBookmarkAddOrEdit = ({ } }, [bookmark, formatMessage, handleDelete]); - useEffect(() => { + useDidMount(() => { enableSaveButton(false); - }, []); + }); useNavButtonPressed(RIGHT_BUTTON.id, componentId, onSaveBookmark, [bookmark]); useNavButtonPressed(closeButtonId, componentId, close, [close]); diff --git a/app/screens/channel_info/title/group_message/group_message.tsx b/app/screens/channel_info/title/group_message/group_message.tsx index f50d87127..db8e0bbfb 100644 --- a/app/screens/channel_info/title/group_message/group_message.tsx +++ b/app/screens/channel_info/title/group_message/group_message.tsx @@ -33,7 +33,7 @@ const GroupMessage = ({currentUserId, displayName, members}: Props) => { const theme = useTheme(); const styles = getStyleSheet(theme); const userIds = useMemo(() => members.map((cm) => cm.userId).filter((id) => id !== currentUserId), - [members.length, currentUserId]); + [members, currentUserId]); return ( <> diff --git a/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx index 536837dbe..7b317f4e5 100644 --- a/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx +++ b/app/screens/convert_gm_to_channel/convert_gm_to_channel.tsx @@ -10,6 +10,7 @@ import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -91,7 +92,7 @@ const ConvertGMToChannel = ({ const loadingAnimationTimeoutRef = useRef(); - useEffect(() => { + useDidMount(() => { loadingAnimationTimeoutRef.current = setTimeout(() => setLoadingAnimationTimeout(true), loadingIndicatorTimeout); async function work() { const {teams} = await fetchGroupMessageMembersCommonTeams(serverUrl, channelId); @@ -107,15 +108,15 @@ const ConvertGMToChannel = ({ return () => { clearTimeout(loadingAnimationTimeoutRef.current); }; - }, []); + }); - useEffect(() => { + useDidMount(() => { mounted.current = true; return () => { mounted.current = false; }; - }, []); + }); useEffect(() => { if (!currentUserId) { diff --git a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx index 87cfccf71..b7283e91f 100644 --- a/app/screens/create_or_edit_channel/create_or_edit_channel.tsx +++ b/app/screens/create_or_edit_channel/create_or_edit_channel.tsx @@ -133,7 +133,7 @@ const CreateOrEditChannel = ({ base.showAsAction = 'always'; base.color = theme.sidebarHeaderTextColor; return base; - }, [editing, theme.sidebarHeaderTextColor, intl, canSave]); + }, [editing, formatMessage, canSave, theme.sidebarHeaderTextColor]); useEffect(() => { setButtons(componentId, { @@ -148,7 +148,7 @@ const CreateOrEditChannel = ({ leftButtons: [makeCloseButton(icon)], }); } - }, [theme, isModal]); + }, [theme, isModal, componentId]); useEffect(() => { setCanSave( @@ -159,7 +159,7 @@ const CreateOrEditChannel = ({ type !== channel.type ), ); - }, [channel, displayName, purpose, header, type]); + }, [channel, displayName, purpose, header, type, channelInfo]); const isValidDisplayName = useCallback((): boolean => { if (isDirect(channel)) { @@ -175,7 +175,7 @@ const CreateOrEditChannel = ({ return false; } return true; - }, [channel, displayName]); + }, [channel, displayName, intl]); const onCreateChannel = useCallback(async () => { dispatch({type: RequestActions.START}); @@ -197,7 +197,7 @@ const CreateOrEditChannel = ({ dispatch({type: RequestActions.COMPLETE}); close(componentId, isModal); switchToChannelById(serverUrl, createdChannel.channel!.id, createdChannel.channel!.team_id); - }, [serverUrl, type, displayName, header, isModal, purpose, isValidDisplayName]); + }, [isValidDisplayName, serverUrl, displayName, purpose, header, type, componentId, isModal]); const onUpdateChannel = useCallback(async () => { if (!channel) { @@ -228,11 +228,11 @@ const CreateOrEditChannel = ({ } dispatch({type: RequestActions.COMPLETE}); close(componentId, isModal); - }, [channel?.id, channel?.type, displayName, header, isModal, purpose, isValidDisplayName]); + }, [channel, isValidDisplayName, header, displayName, purpose, serverUrl, componentId, isModal]); const handleClose = useCallback(() => { close(componentId, isModal); - }, [isModal]); + }, [componentId, isModal]); useNavButtonPressed(CLOSE_BUTTON_ID, componentId, handleClose, [handleClose]); useNavButtonPressed(CREATE_BUTTON_ID, componentId, onCreateChannel, [onCreateChannel]); diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx index 3fed6c49a..530ac5b7e 100644 --- a/app/screens/edit_post/edit_post.tsx +++ b/app/screens/edit_post/edit_post.tsx @@ -18,6 +18,7 @@ import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete'; import {useKeyboardOverlap} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import useDidUpdate from '@hooks/did_update'; import {useInputPropagation} from '@hooks/input'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; @@ -122,12 +123,9 @@ const EditPost = ({ return !hasUploadingFiles && !tooLong && (messageChanged || filesChanged); }, [postMessage, postFiles, editingMessage, maxPostSize, files]); - useEffect(() => { + useDidMount(() => { toggleSaveButton(false); - - // No dependencies to avoid unnecessary re-renders since this is a one-time effect - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useEffect(() => { const t = setTimeout(() => { diff --git a/app/screens/edit_post/edit_post_input/edit_post_input.tsx b/app/screens/edit_post/edit_post_input/edit_post_input.tsx index a03f215c6..ae2a7b8a4 100644 --- a/app/screens/edit_post/edit_post_input/edit_post_input.tsx +++ b/app/screens/edit_post/edit_post_input/edit_post_input.tsx @@ -67,8 +67,7 @@ const EditPostInput = ({ const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true'; const focus = useCallback(() => { inputRef.current?.focus(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [inputRef]); const updateValue = useCallback((valueOrUpdater: string | ((prevValue: string) => string)) => { if (typeof valueOrUpdater === 'function') { diff --git a/app/screens/edit_profile/components/edit_profile_picture.tsx b/app/screens/edit_profile/components/edit_profile_picture.tsx index a7b064231..65dc52268 100644 --- a/app/screens/edit_profile/components/edit_profile_picture.tsx +++ b/app/screens/edit_profile/components/edit_profile_picture.tsx @@ -91,7 +91,7 @@ const EditProfilePicture = ({user, onUpdateProfilePicture}: ChangeProfilePicture }; } return undefined; - }, [pictureUrl]); + }, [pictureUrl, serverUrl]); return ( { onEmojiPress(emoji); DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET); - }, []); + }, [onEmojiPress]); const renderContent = useCallback(() => { return ( @@ -44,7 +44,7 @@ const EmojiPickerScreen = ({closeButtonId, componentId, file, imageUrl, onEmojiP testID='emoji_picker' /> ); - }, []); + }, [file, handleEmojiPress, imageUrl]); return ( { waitForSheetExtended(animatedSheetState, () => { selectEmojiCategoryBarSection(index); }); - - // animatedSheetState and expand are stable and don't need to be in dependencies to avoid unnecessary callback recreation - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [animatedSheetState, expand]); const animatedStyle = useAnimatedStyle(() => { const paddingBottom = withTiming( diff --git a/app/screens/emoji_picker/picker/header/header.tsx b/app/screens/emoji_picker/picker/header/header.tsx index 3ff56cbb2..3180b2220 100644 --- a/app/screens/emoji_picker/picker/header/header.tsx +++ b/app/screens/emoji_picker/picker/header/header.tsx @@ -36,15 +36,15 @@ const PickerHeader = ({skinTone, ...props}: Props) => { const onBlur = useCallback(() => { isSearching.value = false; - }, []); + }, [isSearching]); const onFocus = useCallback(() => { isSearching.value = true; - }, []); + }, [isSearching]); const onLayout = useCallback((e: LayoutChangeEvent) => { containerWidth.value = e.nativeEvent.layout.width; - }, []); + }, [containerWidth]); let search; if (isTablet) { diff --git a/app/screens/emoji_picker/picker/header/skintone_selector/skin_selector.tsx b/app/screens/emoji_picker/picker/header/skintone_selector/skin_selector.tsx index 2ef00eb9f..c59d4d71d 100644 --- a/app/screens/emoji_picker/picker/header/skintone_selector/skin_selector.tsx +++ b/app/screens/emoji_picker/picker/header/skintone_selector/skin_selector.tsx @@ -56,7 +56,7 @@ const SkinSelector = ({onSelectSkin, selected, skins}: Props) => { const code = Object.keys(skinCodes).find((key) => skinCodes[key] === skin) || 'default'; await savePreferredSkinTone(serverUrl, code); onSelectSkin(); - }, [serverUrl]); + }, [onSelectSkin, serverUrl]); return ( <> diff --git a/app/screens/emoji_picker/picker/header/skintone_selector/skintone_selector.tsx b/app/screens/emoji_picker/picker/header/skintone_selector/skintone_selector.tsx index c16d868df..5a3b4d064 100644 --- a/app/screens/emoji_picker/picker/header/skintone_selector/skintone_selector.tsx +++ b/app/screens/emoji_picker/picker/header/skintone_selector/skintone_selector.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {InteractionManager, Platform, StyleSheet} from 'react-native'; import Animated, { type EntryAnimationsValues, type ExitAnimationsValues, FadeIn, FadeOut, @@ -12,6 +12,7 @@ import Tooltip from 'react-native-walkthrough-tooltip'; import {storeSkinEmojiSelectorTutorial} from '@actions/app/global'; import TouchableEmoji from '@components/touchable_emoji'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {skinCodes} from '@utils/emoji'; import CloseButton from './close_button'; @@ -123,16 +124,13 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu }; }, []); - useEffect(() => { + useDidMount(() => { InteractionManager.runAfterInteractions(() => { if (!tutorialWatched) { setTooltipVisible(true); } }); - - // tutorialWatched is not a dependency because it is not used in the effect - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); return ( <> diff --git a/app/screens/find_channels/index.tsx b/app/screens/find_channels/index.tsx index d2adcba59..c89f3eecd 100644 --- a/app/screens/find_channels/index.tsx +++ b/app/screens/find_channels/index.tsx @@ -68,11 +68,11 @@ const FindChannels = ({closeButtonId, componentId}: Props) => { const close = useCallback(() => { Keyboard.dismiss(); return dismissModal({componentId}); - }, []); + }, [componentId]); const onCancel = useCallback(() => { dismissModal({componentId}); - }, []); + }, [componentId]); const onChangeText = useCallback((text: string) => { setTerm(text); diff --git a/app/screens/find_channels/quick_options/quick_options.tsx b/app/screens/find_channels/quick_options/quick_options.tsx index 47d0cac49..d324f9439 100644 --- a/app/screens/find_channels/quick_options/quick_options.tsx +++ b/app/screens/find_channels/quick_options/quick_options.tsx @@ -44,14 +44,14 @@ const QuickOptions = ({canCreateChannels, canJoinChannels, close}: Props) => { showModal(Screens.BROWSE_CHANNELS, title, { closeButton, }); - }, [intl, theme]); + }, [close, intl, theme.sidebarHeaderTextColor]); const createNewChannel = useCallback(async () => { const title = intl.formatMessage({id: 'mobile.create_channel.title', defaultMessage: 'New channel'}); await close(); showModal(Screens.CREATE_OR_EDIT_CHANNEL, title); - }, [intl]); + }, [close, intl]); const openDirectMessage = useCallback(async () => { const title = intl.formatMessage({id: 'create_direct_message.title', defaultMessage: 'Create Direct Message'}); @@ -61,7 +61,7 @@ const QuickOptions = ({canCreateChannels, canJoinChannels, close}: Props) => { showModal(Screens.CREATE_DIRECT_MESSAGE, title, { closeButton, }); - }, [intl, theme]); + }, [close, intl, theme.sidebarHeaderTextColor]); return ( { } }; - useEffect(() => { + useDidMount(() => { mounted.current = true; copyLink(); return () => { mounted.current = false; }; - }, []); + }); useEffect(() => { if (showToast === false) { @@ -84,6 +85,10 @@ const CopyPublicLink = ({item, galleryView = true, setAction}: Props) => { } }, 350); } + + // This effect controls the timeout of the toast, so + // it should only run when `showToast` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [showToast]); return ( diff --git a/app/screens/gallery/footer/download_with_action/index.tsx b/app/screens/gallery/footer/download_with_action/index.tsx index 7b51b1f68..af02f85d3 100644 --- a/app/screens/gallery/footer/download_with_action/index.tsx +++ b/app/screens/gallery/footer/download_with_action/index.tsx @@ -22,6 +22,7 @@ import Toast from '@components/toast'; import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import {alertFailedToOpenDocument, alertOnlyPDFSupported} from '@utils/document'; import {getFullErrorMessage} from '@utils/errors'; import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission, isPdf, pathWithPrefix} from '@utils/file'; @@ -304,7 +305,7 @@ const DownloadWithAction = ({action, enableSecureFilePreview, item, onDownloadSu } }; - useEffect(() => { + useDidMount(() => { mounted.current = true; setShowToast(true); startDownload(); @@ -312,7 +313,7 @@ const DownloadWithAction = ({action, enableSecureFilePreview, item, onDownloadSu return () => { mounted.current = false; }; - }, []); + }); useEffect(() => { let t: NodeJS.Timeout; @@ -336,6 +337,10 @@ const DownloadWithAction = ({action, enableSecureFilePreview, item, onDownloadSu } return () => clearTimeout(t); + + // This effect controls the timeout of the toast, so + // it should only run when `showToast` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [showToast]); return ( diff --git a/app/screens/gallery/gallery.tsx b/app/screens/gallery/gallery.tsx index bb9f82eb3..9971b74b0 100644 --- a/app/screens/gallery/gallery.tsx +++ b/app/screens/gallery/gallery.tsx @@ -107,10 +107,7 @@ const Gallery = forwardRef(({ runOnJS(onLocalIndex)(nextIndex); sharedValues.activeIndex.value = nextIndex; - - // sharedValues do not trigger re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onLocalIndex]); + }, [onLocalIndex, sharedValues]); const renderBackdropComponent = useCallback( ({animatedStyles, translateY}: BackdropProps) => { @@ -150,10 +147,7 @@ const Gallery = forwardRef(({ sharedValues.y.value = 0; runOnJS(onHide)(); - - // sharedValues do not trigger re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [onHide, sharedValues]); const onRenderItem = useCallback((info: RenderItemInfo) => { const currentItem = items[localIndex]; diff --git a/app/screens/gallery/lightbox_swipeout/index.tsx b/app/screens/gallery/lightbox_swipeout/index.tsx index 2ac20ba1b..091943850 100644 --- a/app/screens/gallery/lightbox_swipeout/index.tsx +++ b/app/screens/gallery/lightbox_swipeout/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {type ImageSource} from 'expo-image'; -import React, {forwardRef, useImperativeHandle, useMemo} from 'react'; +import React, {forwardRef, useCallback, useImperativeHandle, useMemo} from 'react'; import {type ImageSize, type ImageStyle, type StyleProp} from 'react-native'; import { cancelAnimation, @@ -58,11 +58,11 @@ const LightboxSwipeout = forwardRef( const lightboxImageOpacity = useSharedValue(1); const childrenOpacity = useSharedValue(0); - const shouldHandleEvent = () => { + const shouldHandleEvent = useCallback(() => { 'worklet'; return childTranslateY.value === 0; - }; + }, [childTranslateY]); const closeLightbox = () => { 'worklet'; @@ -94,7 +94,7 @@ const LightboxSwipeout = forwardRef( closeLightbox, })); - const isVisibleImage = () => { + const isVisibleImage = useCallback(() => { 'worklet'; return ( @@ -103,7 +103,7 @@ const LightboxSwipeout = forwardRef( x.value >= 0 && y.value >= 0 ); - }; + }, [targetDimensions, x, y]); const lightboxSharedValues: LightboxSharedValues = useMemo(() => ({ headerAndFooterHidden, @@ -122,11 +122,24 @@ const LightboxSwipeout = forwardRef( onAnimationFinished, onSwipeActive, onSwipeFailure, - - // The remaining dependencies does not need to be added as they - // are already included in the sharedValues object - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [target, targetDimensions, shouldHandleEvent, isVisibleImage, onAnimationFinished, onSwipeActive, onSwipeFailure]); + }), [ + headerAndFooterHidden, + animationProgress, + childrenOpacity, + childTranslateY, + lightboxImageOpacity, + opacity, + scale, + translateX, + translateY, + target, + targetDimensions, + shouldHandleEvent, + isVisibleImage, + onAnimationFinished, + onSwipeActive, + onSwipeFailure, + ]); return ( diff --git a/app/screens/gallery/lightbox_swipeout/lightbox.tsx b/app/screens/gallery/lightbox_swipeout/lightbox.tsx index d0ab87304..805bd92e3 100644 --- a/app/screens/gallery/lightbox_swipeout/lightbox.tsx +++ b/app/screens/gallery/lightbox_swipeout/lightbox.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {type ImageSource} from 'expo-image'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {type ImageStyle, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'; import Animated, { interpolate, runOnJS, runOnUI, @@ -11,6 +11,7 @@ import Animated, { } from 'react-native-reanimated'; import {ExpoImageAnimated} from '@components/expo_image'; +import useDidMount from '@hooks/did_mount'; import {calculateDimensions} from '@utils/images'; import {pagerTimingConfig} from '../animation_config/timing'; @@ -77,7 +78,7 @@ export default function Lightbox({ }); }; - useEffect(() => { + useDidMount(() => { runOnUI(animateOnMount)(); return () => { @@ -86,10 +87,7 @@ export default function Lightbox({ childLayoutTimeoutRef.current = undefined; } }; - - // We only want to run this on mount and unmount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const {width: tw, height: th} = useMemo(() => calculateDimensions( target.height, diff --git a/app/screens/gallery/pager/index.tsx b/app/screens/gallery/pager/index.tsx index eb4b6cb68..e578ec1c4 100644 --- a/app/screens/gallery/pager/index.tsx +++ b/app/screens/gallery/pager/index.tsx @@ -32,13 +32,13 @@ const Pager = ({ const pagerX = useSharedValue(0); const skipAnimation = useSharedValue(false); - const getPageTranslate = (i: number, w?: number) => { + const getPageTranslate = useCallback((i: number, w?: number) => { 'worklet'; const t = i * (w || sharedWidth.value); const g = gutterWidthToUse * i; return -(t + g); - }; + }, [gutterWidthToUse, sharedWidth]); const toValueAnimation = useSharedValue(getPageTranslate(initialIndex, width)); @@ -79,7 +79,7 @@ const Pager = ({ } runOnJS(updateIndex)(nextIndex); - }, []); + }, [onIndexChange]); const sharedValues: PagerSharedValues = useMemo(() => ({ sharedWidth, @@ -96,11 +96,22 @@ const Pager = ({ onIndexChange: onIndexChangeCb, getPageTranslate, isPagerInProgress, - - // the rest of the values are shared values, - // so they don't need to be included in the deps - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [gutterWidthToUse, activeIndex, onIndexChangeCb]); + }), [ + sharedWidth, + gutterWidthToUse, + isActive, + velocity, + index, + length, + offsetX, + pagerX, + toValueAnimation, + totalWidth, + activeIndex, + onIndexChangeCb, + getPageTranslate, + isPagerInProgress, + ]); useEffect(() => { skipAnimation.value = true; diff --git a/app/screens/gallery/pager/pager.tsx b/app/screens/gallery/pager/pager.tsx index cca0edab3..3a1c20317 100644 --- a/app/screens/gallery/pager/pager.tsx +++ b/app/screens/gallery/pager/pager.tsx @@ -116,13 +116,23 @@ export function PagerContent({ } return temp; - - // The missing dependencies are intentional as they are SharedValues or DerivedValues - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - totalCount, pages, activeIndex, diffValue, onPageStateChange, - gutterWidthToUse, renderPage, getPageTranslate, - width, height, shouldRenderGutter, panGesture, tapGesture, lightboxPanGesture, + totalCount, + pages, + activeIndex, + diffValue, + index, + onPageStateChange, + gutterWidthToUse, + renderPage, + getPageTranslate, + width, + height, + isPagerInProgress, + shouldRenderGutter, + panGesture, + tapGesture, + lightboxPanGesture, ]); return ( diff --git a/app/screens/gallery/renderers/image/index.tsx b/app/screens/gallery/renderers/image/index.tsx index ec1390659..a394f11e2 100644 --- a/app/screens/gallery/renderers/image/index.tsx +++ b/app/screens/gallery/renderers/image/index.tsx @@ -56,11 +56,20 @@ function ImageRenderer({ image, targetDimensions, targetHeight, - - // the rest of the values are shared values, - // so they don't need to be included in the deps - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [targetDimensions, targetHeight, canvas]); + }), [ + interactionsEnabled, + isPagerInProgress, + scale, + scaleOffset, + translation, + panVelocity, + offset, + scaleTranslation, + canvas, + image, + targetDimensions, + targetHeight, + ]); return ( diff --git a/app/screens/gallery/renderers/image/transformer.tsx b/app/screens/gallery/renderers/image/transformer.tsx index 95c5eaa7f..2e0cf9fae 100644 --- a/app/screens/gallery/renderers/image/transformer.tsx +++ b/app/screens/gallery/renderers/image/transformer.tsx @@ -60,10 +60,7 @@ const ImageTransformer = ( const setInteractionsEnabled = useCallback((value: boolean) => { interactionsEnabled.value = value; - - // SharedValue does not trigger re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [interactionsEnabled]); const onLoadImageSuccess = useCallback(() => { setInteractionsEnabled(true); diff --git a/app/screens/gallery/renderers/video/error.tsx b/app/screens/gallery/renderers/video/error.tsx index b4377fd8a..a7aee5d15 100644 --- a/app/screens/gallery/renderers/video/error.tsx +++ b/app/screens/gallery/renderers/video/error.tsx @@ -79,10 +79,7 @@ const VideoError = ({cacheKey, canDownloadFiles, enableSecureFilePreview, filena const onPress = useCallback(() => { hideHeaderAndFooter(!headerAndFooterHidden.value); - - // No need to add shared values to the dependency array here, - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hideHeaderAndFooter]); + }, [headerAndFooterHidden, hideHeaderAndFooter]); let poster; if (posterUri && !loadPosterError) { diff --git a/app/screens/gallery/renderers/video/video_controls/progress_bar.tsx b/app/screens/gallery/renderers/video/video_controls/progress_bar.tsx index c072c9d3e..c446cb397 100644 --- a/app/screens/gallery/renderers/video/video_controls/progress_bar.tsx +++ b/app/screens/gallery/renderers/video/video_controls/progress_bar.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Animated, { @@ -70,7 +70,7 @@ const ProgressBar: React.FC = ({ const localProgress = useSharedValue(progress); const seekTimestamp = useSharedValue(0); - React.useEffect(() => { + useEffect(() => { runOnUI(() => { 'worklet'; if (!isDraggingShared.value) { diff --git a/app/screens/gallery/renderers/video/video_controls/top_controls.tsx b/app/screens/gallery/renderers/video/video_controls/top_controls.tsx index 88e666ff1..6cd32b9d0 100644 --- a/app/screens/gallery/renderers/video/video_controls/top_controls.tsx +++ b/app/screens/gallery/renderers/video/video_controls/top_controls.tsx @@ -1,13 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import {StyleSheet, View, Pressable} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {SafeAreaView, useSafeAreaInsets, type Edge} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; import {useWindowDimensions} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {translateYConfig} from '@hooks/gallery'; import {useDefaultHeaderHeight} from '@hooks/header'; import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context'; @@ -95,7 +96,7 @@ const TopControls: React.FC = ({ marginTop: withTiming(headerAndFooterHidden.value ? insets.top : headerHeight, translateYConfig), })); - useEffect(() => { + useDidMount(() => { const measure = async () => { const result = await measureViewInWindow(speedButtonRef); setViewPosition({ @@ -108,7 +109,7 @@ const TopControls: React.FC = ({ }; measure(); - }, []); + }); let fullscreen; if (isFullscreen) { diff --git a/app/screens/gallery/renderers/video/video_renderer.tsx b/app/screens/gallery/renderers/video/video_renderer.tsx index 30f80d09e..71d57d45b 100644 --- a/app/screens/gallery/renderers/video/video_renderer.tsx +++ b/app/screens/gallery/renderers/video/video_renderer.tsx @@ -147,10 +147,7 @@ const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index videoRef.current?.seek(0.0); } setPaused(false); - - // No need for shared values - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [duration]); + }, [currentTime, duration]); const onPause = useCallback(() => { setPaused(true); @@ -163,18 +160,12 @@ const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index const onRewind = useCallback(() => { const newTime = Math.max(0, currentTime.value - seekSeconds); videoRef.current?.seek(newTime); - - // No need for shared values - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [seekSeconds]); + }, [currentTime, seekSeconds]); const onForward = useCallback(() => { const newTime = Math.min(duration, currentTime.value + seekSeconds); videoRef.current?.seek(newTime); - - // No need for shared values - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [duration, seekSeconds]); + }, [currentTime, duration, seekSeconds]); const onRateChange = useCallback((rate: number) => { setPlaybackRate(rate); diff --git a/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx b/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx index 3f1087755..1c968c658 100644 --- a/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx +++ b/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {FlatList, InteractionManager, StyleSheet, View, type LayoutChangeEvent, type ListRenderItemInfo} from 'react-native'; import Tooltip from 'react-native-walkthrough-tooltip'; @@ -10,6 +10,7 @@ import {Screens} from '@constants'; import {DRAFT_SCHEDULED_POST_LAYOUT_PADDING, DRAFT_TYPE_DRAFT} from '@constants/draft'; import {staticStyles} from '@constants/tooltip'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import DraftTooltip from '@screens/global_drafts/draft_scheduled_post_tooltip'; import {popTopScreen} from '@screens/navigation'; @@ -65,17 +66,14 @@ const GlobalDraftsList: React.FC = ({ const firstDraftId = allDrafts.length ? allDrafts[0].id : ''; - useEffect(() => { + useDidMount(() => { if (tutorialWatched) { return; } InteractionManager.runAfterInteractions(() => { setTooltipVisible(true); }); - - // This effect is intended to run only on the first mount, so dependencies are omitted intentionally. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const collapse = useCallback(() => { popTopScreen(Screens.GLOBAL_DRAFTS); diff --git a/app/screens/global_drafts/components/global_scheduled_post_list/global_scheduled_post_list.tsx b/app/screens/global_drafts/components/global_scheduled_post_list/global_scheduled_post_list.tsx index 267fbec52..096831502 100644 --- a/app/screens/global_drafts/components/global_scheduled_post_list/global_scheduled_post_list.tsx +++ b/app/screens/global_drafts/components/global_scheduled_post_list/global_scheduled_post_list.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View, type LayoutChangeEvent, InteractionManager, type ListRenderItemInfo, Text, FlatList} from 'react-native'; import Tooltip from 'react-native-walkthrough-tooltip'; @@ -12,6 +12,7 @@ import {DRAFT_SCHEDULED_POST_LAYOUT_PADDING, DRAFT_TYPE_SCHEDULED} from '@consta import {staticStyles} from '@constants/tooltip'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import DraftTooltip from '@screens/global_drafts/draft_scheduled_post_tooltip'; import {popTopScreen} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -81,17 +82,14 @@ const GlobalScheduledPostList: React.FC = ({ setLayoutWidth(e.nativeEvent.layout.width - DRAFT_SCHEDULED_POST_LAYOUT_PADDING); }, []); - useEffect(() => { + useDidMount(() => { if (tutorialWatched) { return; } InteractionManager.runAfterInteractions(() => { setTooltipVisible(true); }); - - // This effect is intended to run only on the first mount, so dependencies are omitted intentionally. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const firstScheduledPostId = allScheduledPosts[0]?.id || ''; diff --git a/app/screens/home/channel_list/categories_list/categories/error.tsx b/app/screens/home/channel_list/categories_list/categories/error.tsx index 23d7e9485..6c479e75c 100644 --- a/app/screens/home/channel_list/categories_list/categories/error.tsx +++ b/app/screens/home/channel_list/categories_list/categories/error.tsx @@ -25,7 +25,7 @@ const LoadCategoriesError = () => { if (error) { setLoading(false); } - }, []); + }, [serverUrl]); return ( { useEffect(() => { collapsed.value = category.collapsed; + + // We only want to update the shared value when `category.collapsed` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [category.collapsed]); // Hide favs if empty diff --git a/app/screens/home/channel_list/categories_list/header/header.tsx b/app/screens/home/channel_list/categories_list/header/header.tsx index 3b9cb42a5..4ea6e995c 100644 --- a/app/screens/home/channel_list/categories_list/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.tsx @@ -121,6 +121,9 @@ const ChannelListHeader = ({ const serverUrl = useServerUrl(); useEffect(() => { marginLeft.value = iconPad ? 50 : 0; + + // We only want to update the shared value when `iconPad` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [iconPad]); const onPress = usePreventDoubleTap(useCallback(() => { diff --git a/app/screens/home/channel_list/categories_list/header/loading_unreads.tsx b/app/screens/home/channel_list/categories_list/header/loading_unreads.tsx index ae1fd0ae1..ad5d7fe49 100644 --- a/app/screens/home/channel_list/categories_list/header/loading_unreads.tsx +++ b/app/screens/home/channel_list/categories_list/header/loading_unreads.tsx @@ -51,6 +51,9 @@ const LoadingUnreads = () => { cancelAnimation(opacity); cancelAnimation(rotation); }; + + // We only want to update the animations when the loading state changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading]); if (!loading) { diff --git a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx index 61b8a78aa..b0e843c19 100644 --- a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx +++ b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx @@ -57,7 +57,7 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro Screens.INVITE, intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'}), ); - }, [intl, theme]); + }, [intl]); return ( <> diff --git a/app/screens/home/channel_list/categories_list/load_channels_error/load_channel_error.tsx b/app/screens/home/channel_list/categories_list/load_channels_error/load_channel_error.tsx index 50cac10f7..686b1005f 100644 --- a/app/screens/home/channel_list/categories_list/load_channels_error/load_channel_error.tsx +++ b/app/screens/home/channel_list/categories_list/load_channels_error/load_channel_error.tsx @@ -31,7 +31,7 @@ const LoadChannelsError = ({teamDisplayName, teamId}: Props) => { if (error) { setLoading(false); } - }, [teamId]); + }, [serverUrl, teamId]); if (!teamId) { ; diff --git a/app/screens/home/channel_list/categories_list/load_teams_error/index.tsx b/app/screens/home/channel_list/categories_list/load_teams_error/index.tsx index 82f6a2123..cb5a4fa1c 100644 --- a/app/screens/home/channel_list/categories_list/load_teams_error/index.tsx +++ b/app/screens/home/channel_list/categories_list/load_teams_error/index.tsx @@ -25,7 +25,7 @@ const LoadTeamsError = () => { if (error) { setLoading(false); } - }, []); + }, [serverUrl]); return ( { useEffect(() => { showFilter.value = !unreadsOnTop; + + // We only want to update the shared value when `unreadsOnTop` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [unreadsOnTop]); return ( diff --git a/app/screens/home/channel_list/servers/index.tsx b/app/screens/home/channel_list/servers/index.tsx index eb30c5f78..969897081 100644 --- a/app/screens/home/channel_list/servers/index.tsx +++ b/app/screens/home/channel_list/servers/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Dimensions, StyleSheet} from 'react-native'; @@ -11,6 +11,7 @@ import {useTheme} from '@context/theme'; import {subscribeAllServers} from '@database/subscription/servers'; import {subscribeUnreadAndMentionsByServer, type UnreadObserverArgs} from '@database/subscription/unreads'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {BUTTON_HEIGHT, TITLE_HEIGHT} from '@screens/bottom_sheet'; import {bottomSheet} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; @@ -145,7 +146,7 @@ const Servers = React.forwardRef((_, ref) => { openServers: onPress, }), [onPress]); - useEffect(() => { + useDidMount(() => { const subscription = subscribeAllServers(serversObserver); return () => { @@ -155,7 +156,7 @@ const Servers = React.forwardRef((_, ref) => { }); subscriptions.clear(); }; - }, []); + }); return ( { const onAddServer = useCallback(async () => { addNewServer(theme); - }, []); + }, [theme]); return ( diff --git a/app/screens/home/channel_list/servers/servers_list/index.tsx b/app/screens/home/channel_list/servers/servers_list/index.tsx index 73b4b98cc..f59a3c220 100644 --- a/app/screens/home/channel_list/servers/servers_list/index.tsx +++ b/app/screens/home/channel_list/servers/servers_list/index.tsx @@ -47,7 +47,7 @@ const ServerList = ({servers}: Props) => { const onAddServer = useCallback(async () => { addNewServer(theme); - }, [servers]); + }, [theme]); const renderServer = useCallback(({item: t, index}: ListRenderItemInfo) => { return ( @@ -57,7 +57,7 @@ const ServerList = ({servers}: Props) => { server={t} /> ); - }, []); + }, [serverUrl]); const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]); diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx index 541dbff94..e5853ad20 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx @@ -43,8 +43,8 @@ const ServerOption = ({color, icon, onPress, positionX, progress, style, testID, const styles = getStyleSheet(theme); const containerStyle = useMemo(() => { return [styles.container, {backgroundColor: color}, style]; - }, [color, style]); - const centeredStyle = useMemo(() => [styles.container, styles.centered], []); + }, [color, style, styles]); + const centeredStyle = useMemo(() => [styles.container, styles.centered], [styles]); const trans = progress.interpolate({ inputRange: [0, 1], diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index 215643791..6429ace66 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -302,7 +302,7 @@ const ServerItem = ({ server={server} /> ); - }, [isActive, server, theme, intl]); + }, [handleEdit, handleLogin, handleLogout, handleRemove, server]); useEffect(() => { const listener = DeviceEventEmitter.addListener(Events.SWIPEABLE, (url: string) => { @@ -329,6 +329,9 @@ const ServerItem = ({ subscription.current?.unsubscribe(); subscription.current = undefined; }; + + // We only want to reset the subscription when the server changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.lastActiveAt, isActive]); const serverItem = `server_list.server_item.${server.displayName.replace(/ /g, '_').toLocaleLowerCase()}`; diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 4fefa3285..60db9ba44 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -15,6 +15,7 @@ import ServerVersion from '@components/server_version'; import {Events, Launch, Screens} from '@constants'; import {useTheme} from '@context/theme'; import {useAppState} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import {getAllServers} from '@queries/app/servers'; import {findChannels, popToRoot} from '@screens/navigation'; @@ -134,7 +135,7 @@ export function HomeScreen(props: HomeProps) { } }, [appState]); - useEffect(() => { + useDidMount(() => { if (props.launchType === Launch.DeepLink) { if (props.launchError) { alertInvalidDeepLink(intl); @@ -150,10 +151,7 @@ export function HomeScreen(props: HomeProps) { }); } } - - // only run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useEffect(() => { const listener = DeviceEventEmitter.addListener(Events.EMOJI_PICKER_SEARCH_FOCUSED, (focused: boolean) => { diff --git a/app/screens/home/recent_mentions/recent_mentions.tsx b/app/screens/home/recent_mentions/recent_mentions.tsx index 195d5f5fd..67bbc8acf 100644 --- a/app/screens/home/recent_mentions/recent_mentions.tsx +++ b/app/screens/home/recent_mentions/recent_mentions.tsx @@ -71,6 +71,9 @@ const RecentMentionsScreen = ({appsEnabled, customEmojiNames, mentions, currentT useEffect(() => { opacity.value = isFocused ? 1 : 0; translateX.value = isFocused ? 0 : translateSide; + + // We only want to update the shared values when `isFocused` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFocused]); useEffect(() => { @@ -84,7 +87,7 @@ const RecentMentionsScreen = ({appsEnabled, customEmojiNames, mentions, currentT const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight} = useCollapsibleHeader>(true, onSnap); const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]); - const posts = useMemo(() => selectOrderedPosts(mentions, 0, false, '', '', false, currentTimezone, false).reverse(), [mentions]); + const posts = useMemo(() => selectOrderedPosts(mentions, 0, false, '', '', false, currentTimezone, false).reverse(), [currentTimezone, mentions]); const animated = useAnimatedStyle(() => { return { @@ -131,7 +134,7 @@ const RecentMentionsScreen = ({appsEnabled, customEmojiNames, mentions, currentT )} - ), [loading, theme, paddingTop]); + ), [loading, theme]); const renderItem = useCallback(({item}: ListRenderItemInfo) => { switch (item.type) { @@ -157,7 +160,7 @@ const RecentMentionsScreen = ({appsEnabled, customEmojiNames, mentions, currentT default: return null; } - }, [appsEnabled, customEmojiNames]); + }, [appsEnabled, currentTimezone, customEmojiNames]); return ( diff --git a/app/screens/home/saved_messages/saved_messages.tsx b/app/screens/home/saved_messages/saved_messages.tsx index 5137ed9a2..b96d85342 100644 --- a/app/screens/home/saved_messages/saved_messages.tsx +++ b/app/screens/home/saved_messages/saved_messages.tsx @@ -72,6 +72,9 @@ function SavedMessages({appsEnabled, posts, currentTimezone, customEmojiNames}: useEffect(() => { opacity.value = isFocused ? 1 : 0; translateX.value = isFocused ? 0 : translateSide; + + // We only want to update the shared values when `isFocused` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFocused]); useEffect(() => { @@ -85,7 +88,7 @@ function SavedMessages({appsEnabled, posts, currentTimezone, customEmojiNames}: const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight} = useCollapsibleHeader>(true, onSnap); const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]); - const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, currentTimezone, false).reverse(), [posts]); + const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, currentTimezone, false).reverse(), [currentTimezone, posts]); const animated = useAnimatedStyle(() => { return { @@ -159,7 +162,7 @@ function SavedMessages({appsEnabled, posts, currentTimezone, customEmojiNames}: default: return null; } - }, [appsEnabled, currentTimezone, customEmojiNames, theme]); + }, [appsEnabled, currentTimezone, customEmojiNames]); return ( diff --git a/app/screens/home/search/initial/modifiers/index.tsx b/app/screens/home/search/initial/modifiers/index.tsx index 4845bccbf..81a148471 100644 --- a/app/screens/home/search/initial/modifiers/index.tsx +++ b/app/screens/home/search/initial/modifiers/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {type Dispatch, type RefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {type Dispatch, type RefObject, type SetStateAction, useCallback, useMemo, useRef, useState} from 'react'; import {type IntlShape, useIntl} from 'react-intl'; import {View} from 'react-native'; import Animated, {type SharedValue, useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated'; @@ -9,6 +9,7 @@ import Animated, {type SharedValue, useSharedValue, useAnimatedStyle, withTiming import FormattedText from '@components/formatted_text'; import {ALL_TEAMS_ID} from '@constants/team'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import TeamPicker from '@screens/home/search/team_picker'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -114,14 +115,14 @@ const Modifiers = ({scrollEnabled, searchValue, setSearchValue, searchRef, setTe }, 350); }, [data.length, height, scrollEnabled, showMore]); - useEffect(() => { + useDidMount(() => { return () => { if (timeoutRef.current) { scrollEnabled.value = true; clearTimeout(timeoutRef.current); } }; - }, []); + }); const renderModifier = (item: ModifierItem) => { return ( diff --git a/app/screens/home/search/results/post_results.tsx b/app/screens/home/search/results/post_results.tsx index 04dd00d45..e8faa702e 100644 --- a/app/screens/home/search/results/post_results.tsx +++ b/app/screens/home/search/results/post_results.tsx @@ -50,7 +50,7 @@ const PostResults = ({ }: Props) => { const theme = useTheme(); const styles = getStyles(theme); - const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, currentTimezone, false).reverse(), [posts]); + const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, currentTimezone, false).reverse(), [currentTimezone, posts]); const containerStyle = useMemo(() => ([paddingTop, {flexGrow: 1}]), [paddingTop]); const renderItem = useCallback(({item}: ListRenderItemInfo) => { diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index 0db55e6e7..bf89b9a34 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -138,7 +138,11 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => { const onSnap = useCallback((offset: number, animated = true) => { scrollRef.current?.scrollToOffset({offset, animated}); - // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollRef is a ref object, so its reference should not change between renders + + // scrollRef is a ref object, so its reference should not change between renders + // Also, adding it to the dependency creates a use before define error, circular + // with the useCollapsibleHeader hook call later. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onSnapWithTimeout = useCallback((offset: number, animated = true) => { diff --git a/app/screens/home/tab_bar/index.tsx b/app/screens/home/tab_bar/index.tsx index 03c56f0a0..be381a0a4 100644 --- a/app/screens/home/tab_bar/index.tsx +++ b/app/screens/home/tab_bar/index.tsx @@ -114,6 +114,10 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th }); return () => listner.remove(); + + // We only care about the state changes. + // navigation should stay stable for the lifetime of the component. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [state]); const transform = useAnimatedStyle(() => { diff --git a/app/screens/in_app_notification/index.tsx b/app/screens/in_app_notification/index.tsx index 923b30b1d..cd22fbcb1 100644 --- a/app/screens/in_app_notification/index.tsx +++ b/app/screens/in_app_notification/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {DeviceEventEmitter, Platform, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; import {GestureDetector, Gesture, GestureHandlerRootView} from 'react-native-gesture-handler'; import {Navigation} from 'react-native-navigation'; @@ -12,6 +12,7 @@ import {openNotification} from '@actions/remote/notifications'; import {Navigation as NavigationTypes} from '@constants'; import DatabaseManager from '@database/manager'; import {useIsTablet} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {usePreventDoubleTap} from '@hooks/utils'; import SecurityManager from '@managers/security_manager'; import {dismissOverlay} from '@screens/navigation'; @@ -77,7 +78,7 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I let insets = {top: 0}; if (Platform.OS === 'ios') { // on Android we disable the safe area provider as it conflicts with the gesture system - // eslint-disable-next-line react-hooks/rules-of-hooks + insets = useSafeAreaInsets(); } @@ -105,7 +106,7 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I dismiss(); }, [dismiss])); - useEffect(() => { + useDidMount(() => { initial.value = 0; dismissTimerRef.current = setTimeout(() => { @@ -115,9 +116,9 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I }, AUTO_DISMISS_TIME_MILLIS); return cancelDismissTimer; - }, []); + }); - useEffect(() => { + useDidMount(() => { const didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId: screen}) => { if (componentId === screen && tapped.current && serverUrl) { const {channel_id} = notification.payload || {}; @@ -128,13 +129,13 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I }); return () => didDismissListener.remove(); - }, []); + }); - useEffect(() => { + useDidMount(() => { const listener = DeviceEventEmitter.addListener(NavigationTypes.NAVIGATION_SHOW_OVERLAY, dismiss); return () => listener.remove(); - }, []); + }); const animatedStyle = useAnimatedStyle(() => { const translateY = animate ? withTiming(-130, {duration: 300}) : withTiming(initial.value, {duration: 300}); diff --git a/app/screens/integration_selector/integration_selector.tsx b/app/screens/integration_selector/integration_selector.tsx index 02a193a10..b4d6876ae 100644 --- a/app/screens/integration_selector/integration_selector.tsx +++ b/app/screens/integration_selector/integration_selector.tsx @@ -15,6 +15,7 @@ import {General, Screens, View as ViewConstants} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {useDebounce} from '@hooks/utils'; import SecurityManager from '@managers/security_manager'; @@ -388,17 +389,14 @@ function IntegrationSelector( }; }, []); - useEffect(() => { + useDidMount(() => { if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { getChannels(); } else { // Static and dynamic option search searchDynamicOptions(''); } - - // We only want to get the channels on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useEffect(() => { let listData: DataTypeList = integrationData; diff --git a/app/screens/join_team/join_team.tsx b/app/screens/join_team/join_team.tsx index 0b5327d56..c74eefc00 100644 --- a/app/screens/join_team/join_team.tsx +++ b/app/screens/join_team/join_team.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {View} from 'react-native'; @@ -13,6 +13,7 @@ import TeamList from '@components/team_list'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import SecurityManager from '@managers/security_manager'; import {dismissModal} from '@screens/navigation'; @@ -105,12 +106,12 @@ export default function JoinTeam({ } }, [serverUrl, componentId, intl]); - useEffect(() => { + useDidMount(() => { loadTeams(); return () => { mounted.current = false; }; - }, []); + }); const onClosePressed = useCallback(() => { return dismissModal({componentId}); diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx index 3a0f0326e..a6e43cb39 100644 --- a/app/screens/manage_channel_members/manage_channel_members.tsx +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -17,6 +17,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useAccessControlAttributes} from '@hooks/access_control_attributes'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import SecurityManager from '@managers/security_manager'; import {openAsBottomSheet, popTopScreen, setButtons} from '@screens/navigation'; @@ -274,18 +275,14 @@ export default function ManageChannelMembers({ } }, [loading, searchedTerm, getFetchChannelMembers]); - useEffect(() => { + useDidMount(() => { mounted.current = true; getFetchChannelMembers(); return () => { mounted.current = false; }; - - // This effect is used only to track the mounted state and the initial fetch - // so it should only run once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useEffect(() => { if (canManageAndRemoveMembers) { diff --git a/app/screens/onboarding/index.tsx b/app/screens/onboarding/index.tsx index 82e17cee6..f89d9af17 100644 --- a/app/screens/onboarding/index.tsx +++ b/app/screens/onboarding/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useRef} from 'react'; import { View, useWindowDimensions, @@ -16,6 +16,7 @@ import Animated, {useDerivedValue, useSharedValue} from 'react-native-reanimated import {storeOnboardingViewedValue} from '@actions/app/global'; import {Screens} from '@constants'; +import useDidMount from '@hooks/did_mount'; import {useScreenTransitionAnimation} from '@hooks/screen_transition_animation'; import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; @@ -76,7 +77,7 @@ const Onboarding = ({ storeOnboardingViewedValue(); goToScreen(Screens.SERVER, '', {animated: true, theme, ...props}, loginAnimationOptions()); - }, []); + }, [props, theme]); const nextSlide = useCallback(() => { const nextSlideIndex = currentIndex.value + 1; @@ -85,15 +86,15 @@ const Onboarding = ({ } else if (slidesRef.current && currentIndex.value === LAST_SLIDE_INDEX) { signInHandler(); } - }, [currentIndex, moveToSlide, signInHandler]); + }, [LAST_SLIDE_INDEX, currentIndex, moveToSlide, signInHandler]); const scrollHandler = useCallback((event: NativeSyntheticEvent) => { scrollX.value = event.nativeEvent.contentOffset.x; - }, []); + }, [scrollX]); const animatedStyles = useScreenTransitionAnimation(Screens.ONBOARDING); - useEffect(() => { + useDidMount(() => { const listener = BackHandler.addEventListener('hardwareBackPress', () => { if (!currentIndex.value) { return false; @@ -104,7 +105,7 @@ const Onboarding = ({ }); return () => listener.remove(); - }, []); + }); return ( { const initialElementsOpacity = useSharedValue(reducedMotion ? 1 : 0); - useEffect(() => { + useDidMount(() => { if (index === FIRST_SLIDE) { initialImagePosition.value = withTiming(0, {duration: 600}); initialTitlePosition.value = withTiming(0, {duration: 800}); @@ -89,7 +90,7 @@ const SlideItem = ({theme, item, scrollX, index, lastSlideIndex}: Props) => { initialElementsOpacity.value = withTiming(1, {duration: 1000}); setFirstLoad(false); } - }, []); + }); const translateFirstImageOnLoad = useAnimatedStyle(() => { return { diff --git a/app/screens/pinned_messages/pinned_messages.tsx b/app/screens/pinned_messages/pinned_messages.tsx index 4662fc8fd..639231aa0 100644 --- a/app/screens/pinned_messages/pinned_messages.tsx +++ b/app/screens/pinned_messages/pinned_messages.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {DeviceEventEmitter, FlatList, type ListRenderItemInfo, StyleSheet, View} from 'react-native'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; @@ -14,6 +14,7 @@ import {ExtraKeyboardProvider} from '@context/extra_keyboard'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import {popTopScreen} from '@screens/navigation'; import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; @@ -74,14 +75,11 @@ function SavedMessages({ } }, [componentId]); - useEffect(() => { + useDidMount(() => { fetchPinnedPosts(serverUrl, channelId).finally(() => { setLoading(false); }); - - // Only fetch pinned posts on initial load - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useAndroidHardwareBackHandler(componentId, close); diff --git a/app/screens/post_options/post_options.tsx b/app/screens/post_options/post_options.tsx index 9ac4a3ab9..d5a2c85f3 100644 --- a/app/screens/post_options/post_options.tsx +++ b/app/screens/post_options/post_options.tsx @@ -109,10 +109,19 @@ const PostOptions = ({ return items; }, [ - canAddReaction, canCopyPermalink, canCopyText, - canDelete, canEdit, shouldRenderFollow, shouldShowBindings, - canMarkAsUnread, canPin, canReply, canSavePost, canViewTranslation, shouldShowBORReadReceipts, - + canAddReaction, + canCopyPermalink, + canCopyText, + canDelete, + canEdit, + shouldRenderFollow, + shouldShowBindings, + canMarkAsUnread, + canPin, + canReply, + canSavePost, + canViewTranslation, + shouldShowBORReadReceipts, ]); const renderContent = () => { diff --git a/app/screens/post_options/reaction_bar/pick_reaction/index.tsx b/app/screens/post_options/reaction_bar/pick_reaction/index.tsx index a525dc487..b3199716f 100644 --- a/app/screens/post_options/reaction_bar/pick_reaction/index.tsx +++ b/app/screens/post_options/reaction_bar/pick_reaction/index.tsx @@ -44,7 +44,7 @@ const PickReaction = ({openEmojiPicker, width, height}: PickReactionProps) => { width, height, }, - ], [width, height]); + ], [styles, width, height]); return ( { const index = sortedReactions.indexOf(emoji); setIndex(index); - }, [sortedReactions]); + }, [setIndex, sortedReactions]); const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { const index = Math.min(info.highestMeasuredFrameIndex, info.index); @@ -65,7 +66,7 @@ const EmojiBar = ({emojiSelected, reactionsByName, setIndex, sortedReactions}: P ); }, [onPress, emojiSelected, reactionsByName]); - useEffect(() => { + useDidMount(() => { const t = setTimeout(() => { listRef.current?.scrollToItem({ item: emojiSelected, @@ -75,7 +76,7 @@ const EmojiBar = ({emojiSelected, reactionsByName, setIndex, sortedReactions}: P }, 100); return () => clearTimeout(t); - }, []); + }); return ( getEmojiFirstAlias(r.emojiName)); + const sorted = new Set([...prevSortedReactions]); + const added = rs?.filter((r) => !sorted.has(r)); + added?.forEach(sorted.add, sorted); + const removed = [...sorted].filter((s) => !rs?.includes(s)); + removed.forEach(sorted.delete, sorted); + return Array.from(sorted); +} + const Reactions = ({initialEmoji, location, reactions}: Props) => { const isTablet = useIsTablet(); const [sortedReactions, setSortedReactions] = useState(Array.from(new Set(reactions?.map((r) => getEmojiFirstAlias(r.emojiName))))); @@ -67,17 +77,13 @@ const Reactions = ({initialEmoji, location, reactions}: Props) => { /> ); - }, [index, location, reactions, sortedReactions]); + }, [index, isTablet, location, reactionsByName, sortedReactions]); useEffect(() => { // This helps keep the reactions in the same position at all times until unmounted - const rs = reactions?.map((r) => getEmojiFirstAlias(r.emojiName)); - const sorted = new Set([...sortedReactions]); - const added = rs?.filter((r) => !sorted.has(r)); - added?.forEach(sorted.add, sorted); - const removed = [...sorted].filter((s) => !rs?.includes(s)); - removed.forEach(sorted.delete, sorted); - setSortedReactions(Array.from(sorted)); + setSortedReactions((prevSortedReactions) => { + return getSortedReactions(reactions, prevSortedReactions); + }); }, [reactions]); return ( diff --git a/app/screens/reactions/reactors_list/index.tsx b/app/screens/reactions/reactors_list/index.tsx index cd45ac884..389b5ca68 100644 --- a/app/screens/reactions/reactors_list/index.tsx +++ b/app/screens/reactions/reactors_list/index.tsx @@ -2,13 +2,14 @@ // See LICENSE.txt for license information. import {BottomSheetFlatList} from '@gorhom/bottom-sheet'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useRef} from 'react'; import {type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent} from 'react-native'; import {FlatList} from 'react-native-gesture-handler'; import {fetchUsersByIds} from '@actions/remote/user'; import {useServerUrl} from '@context/server'; import {useBottomSheetListsFix} from '@hooks/bottom_sheet_lists_fix'; +import useDidMount from '@hooks/did_mount'; import Reactor from './reactor'; @@ -30,21 +31,21 @@ const ReactorsList = ({location, reactions, type = 'FlatList'}: Props) => { location={location} reaction={item} /> - ), [reactions]); + ), [location]); const onScroll = useCallback((e: NativeSyntheticEvent) => { if (e.nativeEvent.contentOffset.y <= 0 && enabled && direction === 'down') { setEnabled(false); listRef.current?.scrollToOffset({animated: true, offset: 0}); } - }, [enabled, direction]); + }, [enabled, direction, setEnabled]); - useEffect(() => { + useDidMount(() => { const userIds = reactions.map((r) => r.userId); // Fetch any missing user fetchUsersByIds(serverUrl, userIds); - }, []); + }); if (type === 'BottomSheetFlatList') { return ( diff --git a/app/screens/reactions/reactors_list/reactor/reactor.tsx b/app/screens/reactions/reactors_list/reactor/reactor.tsx index 1fe8e7f2f..23ab0ad07 100644 --- a/app/screens/reactions/reactors_list/reactor/reactor.tsx +++ b/app/screens/reactions/reactors_list/reactor/reactor.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect} from 'react'; +import React from 'react'; import {useIntl} from 'react-intl'; import {fetchUsersByIds} from '@actions/remote/user'; @@ -9,6 +9,7 @@ import UserItem from '@components/user_item'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import {openUserProfileModal} from '@screens/navigation'; import type ReactionModel from '@typings/database/models/servers/reaction'; @@ -36,11 +37,11 @@ const Reactor = ({channelId, location, reaction, user}: Props) => { } }; - useEffect(() => { + useDidMount(() => { if (!user) { fetchUsersByIds(serverUrl, [reaction.userId]); } - }, []); + }); return ( { await dismissOverlay(componentId); }); - }, [close, intl, componentId]); + }, [close, componentId]); const onPressClose = useCallback(() => { close(async () => { @@ -166,7 +166,7 @@ const ReviewApp = ({ if (finished) { runOnJS(doAfterAnimation)(); } - }), []); + }), [doAfterAnimation]); return ( { onSelectOption(selectedTime.valueOf().toString()); diff --git a/app/screens/select_team/header.tsx b/app/screens/select_team/header.tsx index 28ba8c75b..8c1dd59d2 100644 --- a/app/screens/select_team/header.tsx +++ b/app/screens/select_team/header.tsx @@ -53,7 +53,7 @@ function Header() { const canAddOtherServers = managedConfig?.allowOtherServers !== 'false'; const serverButtonRef = useRef(null); - const headerStyle = useMemo(() => ({...styles.header, marginLeft: canAddOtherServers ? MARGIN_WITH_SERVER_ICON : undefined}), [canAddOtherServers]); + const headerStyle = useMemo(() => ({...styles.header, marginLeft: canAddOtherServers ? MARGIN_WITH_SERVER_ICON : undefined}), [canAddOtherServers, styles]); const onLogoutPress = useCallback(() => { alertServerLogout(serverDisplayName, () => logout(serverUrl, intl), intl); }, [serverDisplayName, intl, serverUrl]); diff --git a/app/screens/select_team/select_team.tsx b/app/screens/select_team/select_team.tsx index cb1a543c5..fba65eae7 100644 --- a/app/screens/select_team/select_team.tsx +++ b/app/screens/select_team/select_team.tsx @@ -11,6 +11,7 @@ import {addCurrentUserToTeam, fetchTeamsForComponent, handleTeamChange} from '@a import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import {logDebug} from '@utils/log'; import {alertTeamAddError} from '@utils/navigation'; @@ -69,6 +70,8 @@ const SelectTeam = ({ const [otherTeams, setOtherTeams] = useState([]); + const shouldRedirectToHome = (nTeams > 0) && firstTeamId; + const loadTeams = useCallback(async () => { setLoading(true); const resp = await fetchTeamsForComponent(serverUrl, page.current); @@ -112,17 +115,20 @@ const SelectTeam = ({ return; } - if ((nTeams > 0) && firstTeamId) { + if (shouldRedirectToHome) { resettingToHome.current = true; handleTeamChange(serverUrl, firstTeamId).then(() => { resetToHome(); }); } - }, [(nTeams > 0) && firstTeamId]); - useEffect(() => { + // We only want to run this effect when the shouldRedirectToHome value changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldRedirectToHome]); + + useDidMount(() => { loadTeams(); - }, []); + }); let body; if (joining || (loading && !otherTeams.length)) { diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 12f215aeb..b5317ecdf 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -15,6 +15,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import LocalConfig from '@assets/config.json'; import AppVersion from '@components/app_version'; import {Screens, Launch, DeepLink} from '@constants'; +import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {useScreenTransitionAnimation} from '@hooks/screen_transition_animation'; import {getServerCredentials} from '@init/credentials'; @@ -171,7 +172,7 @@ const Server = ({ return () => unsubscribe.remove(); }, [componentId, url]); - useEffect(() => { + useDidMount(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { if (LocalConfig.ShowOnboarding && animated) { popTopScreen(Screens.SERVER); @@ -188,10 +189,7 @@ const Server = ({ PushNotifications.registerIfNeeded(); return () => backHandler.remove(); - - // We register the back handler and the push notifications only on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useNavButtonPressed(closeButtonId || '', componentId, dismiss, []); diff --git a/app/screens/settings/advanced/advanced.tsx b/app/screens/settings/advanced/advanced.tsx index 79a40324f..a27f74ab9 100644 --- a/app/screens/settings/advanced/advanced.tsx +++ b/app/screens/settings/advanced/advanced.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {useIntl} from 'react-intl'; import {Alert, TouchableOpacity} from 'react-native'; @@ -11,6 +11,7 @@ import SettingSeparator from '@components/settings/separator'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import {usePreventDoubleTap} from '@hooks/utils'; import {goToScreen, popTopScreen} from '@screens/navigation'; import {deleteFileCache, getAllFilesInCachesDirectory, getFormattedFileSize} from '@utils/file'; @@ -76,9 +77,9 @@ const AdvancedSettings = ({ goToScreen(screen, title); }, [intl]); - useEffect(() => { + useDidMount(() => { getAllCachedFiles(); - }, []); + }); const close = useCallback(() => { popTopScreen(componentId); diff --git a/app/screens/settings/display/display.tsx b/app/screens/settings/display/display.tsx index ddbba1735..09e01e8e5 100644 --- a/app/screens/settings/display/display.tsx +++ b/app/screens/settings/display/display.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback} from 'react'; import {defineMessage, useIntl} from 'react-intl'; import SettingContainer from '@components/settings/container'; @@ -9,10 +9,10 @@ import SettingItem from '@components/settings/item'; import {Screens} from '@constants'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useUserTimezoneProps from '@hooks/user_timezone'; import {usePreventDoubleTap} from '@hooks/utils'; import {goToScreen, popTopScreen} from '@screens/navigation'; import {gotoSettingsScreen} from '@screens/settings/config'; -import {getUserTimezoneProps} from '@utils/user'; import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -62,7 +62,8 @@ type DisplayProps = { const Display = ({componentId, currentUser, hasMilitaryTimeFormat, isCRTEnabled, isCRTSwitchEnabled, isThemeSwitchingEnabled}: DisplayProps) => { const intl = useIntl(); const theme = useTheme(); - const timezone = useMemo(() => getUserTimezoneProps(currentUser), [currentUser?.timezone]); + + const timezone = useUserTimezoneProps(currentUser); const goToThemeSettings = usePreventDoubleTap(useCallback(() => { const screen = Screens.SETTINGS_DISPLAY_THEME; diff --git a/app/screens/settings/display_timezone/display_timezone.tsx b/app/screens/settings/display_timezone/display_timezone.tsx index 7add3b852..75344cf85 100644 --- a/app/screens/settings/display_timezone/display_timezone.tsx +++ b/app/screens/settings/display_timezone/display_timezone.tsx @@ -11,6 +11,7 @@ import SettingSeparator from '@components/settings/separator'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useInitialValue from '@hooks/initial_value'; import useBackNavigation from '@hooks/navigate_back'; import {usePreventDoubleTap} from '@hooks/utils'; import {goToScreen, popTopScreen} from '@screens/navigation'; @@ -27,7 +28,8 @@ type DisplayTimezoneProps = { const DisplayTimezone = ({currentUser, componentId}: DisplayTimezoneProps) => { const intl = useIntl(); const serverUrl = useServerUrl(); - const initialTimezone = useMemo(() => getUserTimezoneProps(currentUser), [/* dependency array should remain empty */]); + + const initialTimezone = useInitialValue(() => getUserTimezoneProps(currentUser)); const [userTimezone, setUserTimezone] = useState(initialTimezone); const updateAutomaticTimezone = (useAutomaticTimezone: boolean) => { diff --git a/app/screens/settings/display_timezone_select/index.tsx b/app/screens/settings/display_timezone_select/index.tsx index d69be9bbf..a2271f830 100644 --- a/app/screens/settings/display_timezone_select/index.tsx +++ b/app/screens/settings/display_timezone_select/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {useIntl} from 'react-intl'; import {FlatList} from 'react-native'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; @@ -11,6 +11,7 @@ import Search from '@components/search'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import {popTopScreen} from '@screens/navigation'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -104,11 +105,11 @@ const SelectTimezones = ({componentId, onBack, currentTimezone}: SelectTimezones const close = useCallback((newTimezone?: string) => { onBack(newTimezone || currentTimezone); popTopScreen(componentId); - }, [currentTimezone, componentId]); + }, [onBack, currentTimezone, componentId]); const onPressTimezone = useCallback((selectedTimezone: string) => { close(selectedTimezone); - }, []); + }, [close]); const renderItem = useCallback(({item: timezone}: {item: string}) => { return ( @@ -120,7 +121,7 @@ const SelectTimezones = ({componentId, onBack, currentTimezone}: SelectTimezones ); }, [currentTimezone, onPressTimezone]); - useEffect(() => { + useDidMount(() => { // let's get all supported timezones const getSupportedTimezones = async () => { const allTzs = await getAllSupportedTimezones(serverUrl); @@ -133,7 +134,7 @@ const SelectTimezones = ({componentId, onBack, currentTimezone}: SelectTimezones } }; getSupportedTimezones(); - }, []); + }); useAndroidHardwareBackHandler(componentId, close); diff --git a/app/screens/settings/notification_call/notification_call.tsx b/app/screens/settings/notification_call/notification_call.tsx index 0c6b1f28b..f35a6636e 100644 --- a/app/screens/settings/notification_call/notification_call.tsx +++ b/app/screens/settings/notification_call/notification_call.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import InCallManager from 'react-native-incall-manager'; @@ -16,9 +16,9 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useBackNavigation from '@hooks/navigate_back'; +import useNotificationProps from '@hooks/notification_props'; import {popTopScreen} from '@screens/navigation'; import {changeOpacity} from '@utils/theme'; -import {getNotificationProps} from '@utils/user'; import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -40,7 +40,7 @@ const NotificationCall = ({componentId, currentUser}: Props) => { const intl = useIntl(); const theme = useTheme(); - const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); + const notifyProps = useNotificationProps(currentUser); const [callsMobileSound, setCallsMobileSound] = useState(() => Boolean(notifyProps?.calls_mobile_sound ? notifyProps.calls_mobile_sound === 'true' : notifyProps?.calls_desktop_sound === 'true')); const [callsMobileNotificationSound, setCallsMobileNotificationSound] = useState(() => { diff --git a/app/screens/settings/notification_email/notification_email.tsx b/app/screens/settings/notification_email/notification_email.tsx index a47a9380a..e23bf0143 100644 --- a/app/screens/settings/notification_email/notification_email.tsx +++ b/app/screens/settings/notification_email/notification_email.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {Text} from 'react-native'; @@ -15,11 +15,13 @@ import {Preferences} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useInitialValue from '@hooks/initial_value'; import useBackNavigation from '@hooks/navigate_back'; +import useNotificationProps from '@hooks/notification_props'; import {popTopScreen} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; -import {getEmailInterval, getNotificationProps} from '@utils/user'; +import {getEmailInterval} from '@utils/user'; import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -63,12 +65,13 @@ type NotificationEmailProps = { } const NotificationEmail = ({componentId, currentUser, emailInterval, enableEmailBatching, isCRTEnabled, sendEmailNotifications}: NotificationEmailProps) => { - const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); - const initialInterval = useMemo( + const notifyProps = useNotificationProps(currentUser); + const initialInterval = useInitialValue( () => getEmailInterval(sendEmailNotifications && notifyProps?.email === 'true', enableEmailBatching, parseInt(emailInterval, 10)).toString(), - [/* dependency array should remain empty */], ); - const initialEmailThreads = useMemo(() => Boolean(notifyProps?.email_threads === 'all'), [/* dependency array should remain empty */]); + const initialEmailThreads = useInitialValue( + () => Boolean(notifyProps?.email_threads === 'all'), + ); const [notifyInterval, setNotifyInterval] = useState(initialInterval); const [emailThreads, setEmailThreads] = useState(initialEmailThreads); diff --git a/app/screens/settings/notification_push/notification_push.tsx b/app/screens/settings/notification_push/notification_push.tsx index b196a6096..befb3be92 100644 --- a/app/screens/settings/notification_push/notification_push.tsx +++ b/app/screens/settings/notification_push/notification_push.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {Platform} from 'react-native'; import {updateMe} from '@actions/remote/user'; @@ -10,8 +10,8 @@ import SettingSeparator from '@components/settings/separator'; import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useBackNavigation from '@hooks/navigate_back'; +import useNotificationProps from '@hooks/notification_props'; import {popTopScreen} from '@screens/navigation'; -import {getNotificationProps} from '@utils/user'; import MobileSendPush from './push_send'; import MobilePushStatus from './push_status'; @@ -28,8 +28,7 @@ type NotificationMobileProps = { }; const NotificationPush = ({componentId, currentUser, isCRTEnabled, sendPushNotifications}: NotificationMobileProps) => { const serverUrl = useServerUrl(); - - const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); + const notifyProps = useNotificationProps(currentUser); const [pushSend, setPushSend] = useState(notifyProps.push); const [pushStatus, setPushStatus] = useState(notifyProps.push_status); @@ -39,7 +38,7 @@ const NotificationPush = ({componentId, currentUser, isCRTEnabled, sendPushNotif setPushThreadPref(pushThread === 'all' ? 'mention' : 'all'); }, [pushThread]); - const close = () => popTopScreen(componentId); + const close = useCallback(() => popTopScreen(componentId), [componentId]); const canSaveSettings = useCallback(() => { const p = pushSend !== notifyProps.push; diff --git a/app/screens/settings/notifications/notifications.tsx b/app/screens/settings/notifications/notifications.tsx index 9ef4ec519..347e46215 100644 --- a/app/screens/settings/notifications/notifications.tsx +++ b/app/screens/settings/notifications/notifications.tsx @@ -12,10 +12,11 @@ import {General, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useAppState} from '@hooks/device'; +import useNotificationProps from '@hooks/notification_props'; import {popTopScreen} from '@screens/navigation'; import {gotoSettingsScreen} from '@screens/settings/config'; import {logError} from '@utils/log'; -import {getEmailInterval, getEmailIntervalTexts, getNotificationProps} from '@utils/user'; +import {getEmailInterval, getEmailIntervalTexts} from '@utils/user'; import NotificationsDisabledNotice from './notifications_disabled_notice'; import SendTestNotificationNotice from './send_test_notification_notice'; @@ -65,11 +66,7 @@ const Notifications = ({ const intl = useIntl(); const serverUrl = useServerUrl(); - // We need to depend on the notifyProps object directly, - // since changes in it may not be reflected in the currentUser object - // (it is still the same object reference). - // eslint-disable-next-line react-hooks/exhaustive-deps - const notifyProps = useMemo(() => getNotificationProps(currentUser), [currentUser?.notifyProps]); + const notifyProps = useNotificationProps(currentUser); const callsRingingEnabled = useMemo(() => getCallsConfig(serverUrl).EnableRinging, [serverUrl]); const [isRegistered, setIsRegistered] = useState(true); diff --git a/app/screens/settings/settings.tsx b/app/screens/settings/settings.tsx index 4709ede67..424e80dd7 100644 --- a/app/screens/settings/settings.tsx +++ b/app/screens/settings/settings.tsx @@ -55,7 +55,7 @@ const Settings = ({componentId, helpLink, showHelp, siteName}: SettingsProps) => setButtons(componentId, { leftButtons: [closeButton], }); - }, []); + }, [closeButton, componentId]); useAndroidHardwareBackHandler(componentId, close); useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, []); diff --git a/app/screens/share_feedback/index.tsx b/app/screens/share_feedback/index.tsx index 51692c4bb..90ada5e6c 100644 --- a/app/screens/share_feedback/index.tsx +++ b/app/screens/share_feedback/index.tsx @@ -114,7 +114,7 @@ const ShareFeedback = ({ await goToNPSChannel(serverUrl); giveFeedbackAction(serverUrl); }); - }, [close, intl, serverUrl]); + }, [close, componentId, serverUrl]); const onPressNo = useCallback(() => { close(() => dismissOverlay(componentId)); @@ -132,7 +132,7 @@ const ShareFeedback = ({ if (finished) { runOnJS(doAfterAnimation)(); } - }), []); + }), [doAfterAnimation]); return ( { + useDidMount(() => { mounted.current = true; baseTimer.current = setTimeout(() => { if (!isPanned.value) { @@ -246,10 +247,7 @@ const SnackBar = ({ stopTimers(); mounted.current = false; }; - - // Only run on initial load - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); // This effect dismisses the Navigation Overlay after we have hidden the snack bar useEffect(() => { diff --git a/app/screens/terms_of_service/terms_of_service.tsx b/app/screens/terms_of_service/terms_of_service.tsx index d6726067e..53ba3c16c 100644 --- a/app/screens/terms_of_service/terms_of_service.tsx +++ b/app/screens/terms_of_service/terms_of_service.tsx @@ -20,6 +20,7 @@ import {Screens} from '@constants/index'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useDidMount from '@hooks/did_mount'; import SecurityManager from '@managers/security_manager'; import {dismissOverlay} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; @@ -190,12 +191,9 @@ const TermsOfService = ({ } }, [declineTerms, closeTermsAndLogout, getTermsError]); - useEffect(() => { + useDidMount(() => { getTerms(); - - // Only get the terms on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useEffect(() => { return () => { diff --git a/app/screens/thread/thread_post_list/thread_post_list.tsx b/app/screens/thread/thread_post_list/thread_post_list.tsx index e000435ba..45467a65c 100644 --- a/app/screens/thread/thread_post_list/thread_post_list.tsx +++ b/app/screens/thread/thread_post_list/thread_post_list.tsx @@ -12,6 +12,7 @@ import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useAppState} from '@hooks/device'; +import useDidMount from '@hooks/did_mount'; import {useFetchingThreadState} from '@hooks/fetching_thread'; import {useDebounce} from '@hooks/utils'; import {isMinimumServerVersion} from '@utils/helpers'; @@ -73,14 +74,11 @@ const ThreadPostList = ({ }, [posts, rootPost]); // If CRT is enabled, mark the thread as read on mount. - useEffect(() => { + useDidMount(() => { if (isCRTEnabled && thread?.isFollowing) { markThreadAsRead(serverUrl, teamId, rootPost.id); } - - // We only want to mark the thread as read on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); // If CRT is enabled, When new post arrives and thread modal is open, mark thread as read. const oldPostsCount = useRef(posts.length); diff --git a/app/screens/user_profile/user_profile.tsx b/app/screens/user_profile/user_profile.tsx index 44adac7f2..7b472a638 100644 --- a/app/screens/user_profile/user_profile.tsx +++ b/app/screens/user_profile/user_profile.tsx @@ -3,13 +3,14 @@ import moment from 'moment'; import mtz from 'moment-timezone'; -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {fetchTeamAndChannelMembership} from '@actions/remote/user'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; +import useDidMount from '@hooks/did_mount'; import {getLocaleFromLanguage} from '@i18n'; import BottomSheet from '@screens/bottom_sheet'; import {bottomSheetSnapPoint} from '@utils/helpers'; @@ -192,11 +193,11 @@ const UserProfile = ({ canManageAndRemoveMembers, ]); - useEffect(() => { + useDidMount(() => { if (currentUserId !== user.id) { fetchTeamAndChannelMembership(serverUrl, user.id, teamId, channelId); } - }, [channelId, currentUserId, serverUrl, teamId, user.id]); + }); const renderContent = () => { return ( diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 289821f20..30dc385be 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -658,7 +658,6 @@ "mobile.calls_not_connected": "You're not connected to a call in the current channel.", "mobile.calls_ok": "OK", "mobile.calls_okay": "Okay", - "mobile.calls_open_channel": "Open Channel", "mobile.calls_participant_limit_title_GA": "This call is at capacity", "mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.", "mobile.calls_participant_rec_title": "Recording is in progress", diff --git a/eslint.config.mjs b/eslint.config.mjs index f296a7066..04e76a16d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -76,7 +76,7 @@ export default defineConfig([ ], "react/display-name": [2, { "ignoreTranspilerName": false }], "react/jsx-filename-extension": "off", - "react-hooks/exhaustive-deps": "warn", + "react-hooks/exhaustive-deps": "error", "camelcase": ["off", { "properties": "never" }], "@typescript-eslint/ban-types": "off", "@typescript-eslint/no-non-null-assertion": "off", diff --git a/eslint.precommit.config.mjs b/eslint.precommit.config.mjs deleted file mode 100644 index c5910fa4a..000000000 --- a/eslint.precommit.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -// Pre-commit ESLint config with stricter rules -import baseConfig from './eslint.config.mjs'; - -export default [ - ...baseConfig, - { - rules: { - // Override rules to be stricter for pre-commit - 'react-hooks/exhaustive-deps': 'error', - // Add other strict rules here if needed - // 'no-console': 'error', - // '@typescript-eslint/no-unused-vars': 'error', - }, - }, -]; diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 80365c200..8697774db 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -129,7 +129,7 @@ if [ -n "$jsfiles" ]; then echo "Checking lint for:" for js in $jsfiles; do echo "$js" - e=$(node_modules/.bin/eslint --quiet --fix --config eslint.precommit.config.mjs $js) + e=$(node_modules/.bin/eslint --quiet --fix $js) if [ -n "$e" ]; then echo "ERROR: Check eslint hints." echo "$e" diff --git a/share_extension/components/channel_item/channel_item.tsx b/share_extension/components/channel_item/channel_item.tsx index 6c163914c..7c11678b9 100644 --- a/share_extension/components/channel_item/channel_item.tsx +++ b/share_extension/components/channel_item/channel_item.tsx @@ -74,7 +74,7 @@ const ChannelListItem = ({ const handleOnPress = useCallback(() => { onPress(channel.id); - }, [channel.id]); + }, [channel.id, onPress]); const textStyles = useMemo(() => [ textStyle.regular, diff --git a/share_extension/components/content_view/attachments/info.tsx b/share_extension/components/content_view/attachments/info.tsx index c0ba23d5d..9f3dd8b5b 100644 --- a/share_extension/components/content_view/attachments/info.tsx +++ b/share_extension/components/content_view/attachments/info.tsx @@ -67,14 +67,14 @@ const Info = ({contentMode, file, hasError, theme}: Props) => { styles.container, contentMode === 'small' && styles.small, hasError && styles.error, - ], [contentMode, hasError]); + ], [contentMode, hasError, styles]); - const textContainerStyle = useMemo(() => (contentMode === 'small' && styles.smallWrapper), [contentMode]); + const textContainerStyle = useMemo(() => (contentMode === 'small' && styles.smallWrapper), [contentMode, styles]); const nameStyle = useMemo(() => [ styles.name, contentMode === 'small' && styles.smallName, - ], [contentMode]); + ], [contentMode, styles]); const size = useMemo(() => { return `${file.extension} ${getFormattedFileSize(file.size)}`; diff --git a/share_extension/components/content_view/options/options.tsx b/share_extension/components/content_view/options/options.tsx index 61fda59ae..36df15e78 100644 --- a/share_extension/components/content_view/options/options.tsx +++ b/share_extension/components/content_view/options/options.tsx @@ -32,29 +32,29 @@ const Options = ({channelDisplayName, hasChannels, serverDisplayName, theme}: Pr id: 'share_extension.server_label', defaultMessage: 'Server', }); - }, [intl.locale]); + }, [intl]); const channelLabel = useMemo(() => { return intl.formatMessage({ id: 'share_extension.channel_label', defaultMessage: 'Channel', }); - }, [intl.locale]); + }, [intl]); const errorLabel = useMemo(() => { return intl.formatMessage({ id: 'share_extension.channel_error', defaultMessage: 'You are not a member of a team on the selected server. Select another server or open Mattermost to join a team.', }); - }, [intl.locale]); + }, [intl]); const onServerPress = useCallback(() => { navigator.navigate('Servers'); - }, []); + }, [navigator]); const onChannelPress = useCallback(() => { navigator.navigate('Channels'); - }, []); + }, [navigator]); let channel; if (hasChannels) { diff --git a/share_extension/components/header/post_button.tsx b/share_extension/components/header/post_button.tsx index 6fac7aa08..60a57ed3c 100644 --- a/share_extension/components/header/post_button.tsx +++ b/share_extension/components/header/post_button.tsx @@ -56,7 +56,7 @@ const PostButton = ({theme}: Props) => { preauthSecret: credentials.preauthSecret, }); } - }, [serverUrl, channelId, message, files, linkPreviewUrl, userId]); + }, [serverUrl, channelId, userId, message, linkPreviewUrl, closeExtension, files]); return ( { const onPress = useCallback((channelId: string) => { setShareExtensionChannelId(channelId); navigation.goBack(); - }, []); + }, [navigation]); const renderSectionHeader = useCallback(() => ( @@ -47,7 +47,7 @@ const RecentList = ({recentChannels, showTeamName, theme}: Props) => { theme={theme} /> ); - }, [onPress, showTeamName]); + }, [onPress, showTeamName, theme]); useEffect(() => { setSections(buildSections(recentChannels)); diff --git a/share_extension/components/search_channels/search_channels.tsx b/share_extension/components/search_channels/search_channels.tsx index 24b0f3baa..4931408aa 100644 --- a/share_extension/components/search_channels/search_channels.tsx +++ b/share_extension/components/search_channels/search_channels.tsx @@ -48,7 +48,7 @@ const SearchChannels = ({ const onPress = useCallback((channelId: string) => { setShareExtensionChannelId(channelId); navigation.goBack(); - }, []); + }, [navigation]); const renderEmpty = useCallback(() => { if (term) { @@ -60,7 +60,7 @@ const SearchChannels = ({ } return null; - }, [term, theme]); + }, [term]); const renderItem = useCallback(({item}: ListRenderItemInfo) => { return ( @@ -71,7 +71,7 @@ const SearchChannels = ({ theme={theme} /> ); - }, [showTeamName]); + }, [onPress, showTeamName, theme]); const data = useMemo(() => { const items: Array = [...channelsMatchStart]; diff --git a/share_extension/components/servers_list/server_item.tsx b/share_extension/components/servers_list/server_item.tsx index c600ad3e8..1415cf34b 100644 --- a/share_extension/components/servers_list/server_item.tsx +++ b/share_extension/components/servers_list/server_item.tsx @@ -59,7 +59,7 @@ const ServerItem = ({server, theme}: Props) => { const onServerPressed = useCallback(() => { setShareExtensionServerUrl(server.url); navigation.goBack(); - }, [server]); + }, [navigation, server.url]); return ( item.url; const ServersList = ({servers, theme}: Props) => { const intl = useIntl(); - const data = useMemo(() => sortServersByDisplayName(servers, intl), [intl.locale, servers]); + const data = useMemo(() => sortServersByDisplayName(servers, intl), [intl, servers]); const renderServer = useCallback(({item}: ListRenderItemInfo) => { return ( diff --git a/share_extension/screens/channels.tsx b/share_extension/screens/channels.tsx index 5d361d938..7ef549fd7 100644 --- a/share_extension/screens/channels.tsx +++ b/share_extension/screens/channels.tsx @@ -48,6 +48,10 @@ const Channels = ({theme}: Props) => { navigator.setOptions({ title: intl.formatMessage({id: 'share_extension.channels_screen.title', defaultMessage: 'Select channel'}), }); + + // We only care about changes in the locale + // navigator should not change in the lifetime of this component. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [intl.locale]); const cancelButtonProps = useMemo(() => ({ diff --git a/share_extension/screens/servers.tsx b/share_extension/screens/servers.tsx index 0324cf411..0036082ad 100644 --- a/share_extension/screens/servers.tsx +++ b/share_extension/screens/servers.tsx @@ -26,6 +26,10 @@ const Servers = ({theme}: Props) => { navigator.setOptions({ title: intl.formatMessage({id: 'share_extension.servers_screen.title', defaultMessage: 'Select server'}), }); + + // We only care about changes in the locale + // navigator should not change in the lifetime of this component. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [intl.locale]); return ( diff --git a/share_extension/screens/share.tsx b/share_extension/screens/share.tsx index 918f8d2e6..9aa36ef94 100644 --- a/share_extension/screens/share.tsx +++ b/share_extension/screens/share.tsx @@ -10,6 +10,7 @@ import {StyleSheet, View} from 'react-native'; import {from as from$} from 'rxjs'; import DatabaseManager from '@database/manager'; +import useDidMount from '@hooks/did_mount'; import {getActiveServerUrl} from '@queries/app/servers'; import ContentView from '@share/components/content_view'; import NoMemberships from '@share/components/error/no_memberships'; @@ -78,9 +79,13 @@ const ShareScreen = ({hasChannelMemberships, initialServerUrl, files, linkPrevie {applicationName}, ), }); + + // We only care about changes in the locale + // navigator should not change in the lifetime of this component. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [intl.locale]); - useEffect(() => { + useDidMount(() => { setShareExtensionState({ files, linkPreviewUrl, @@ -92,7 +97,7 @@ const ShareScreen = ({hasChannelMemberships, initialServerUrl, files, linkPrevie headerLeft: () => (), headerRight: () => (), }); - }, []); + }); return (