Enable exhaustive deps in the repo (#9508)

* Enable exhaustive deps in the repo

* Add tests for the new hooks

* Update claude.md

* Remove pre-commit overwrite
This commit is contained in:
Daniel Espino García 2026-02-18 11:56:16 +01:00 committed by GitHub
parent 36439bd5f4
commit cff12f7182
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
182 changed files with 1154 additions and 602 deletions

View file

@ -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

View file

@ -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 = (
<FormattedText

View file

@ -20,7 +20,7 @@ const AtMentionItem = ({
}: AtMentionItemProps) => {
const completeMention = useCallback((u: UserModel | UserProfile) => {
onPress?.(u.username);
}, []);
}, [onPress]);
return (
<UserItem

View file

@ -40,7 +40,7 @@ const AddMembersBox = ({
await dismissBottomSheet();
showModal(Screens.CHANNEL_ADD_MEMBERS, title, {channelId, inModal}, options);
}, [intl, channelId, inModal, testID, displayName]);
}, [intl, theme, displayName, inModal, channelId]);
return (
<OptionBox

View file

@ -26,7 +26,7 @@ const MutedBox = ({channelId, containerStyle, isMuted, showSnackBar = false, tes
const handleOnPress = useCallback(async () => {
await dismissBottomSheet();
toggleMuteChannel(serverUrl, channelId, showSnackBar);
}, [channelId, isMuted, serverUrl, showSnackBar]);
}, [channelId, serverUrl, showSnackBar]);
const muteActionTestId = isMuted ? `${testID}.unmute.action` : `${testID}.mute.action`;

View file

@ -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) {

View file

@ -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) {

View file

@ -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 (
<CompassIcon

View file

@ -149,7 +149,7 @@ const ChannelItem = ({
const handleOnPress = useCallback(() => {
onPress(channel);
}, [channel.id]);
}, [channel, onPress]);
const textStyles = useMemo(() => [
isBolded && !isMuted ? textStyle.bold : textStyle.regular,

View file

@ -38,7 +38,7 @@ const ImageFileOverlay = ({value}: ImageFileOverlayProps) => {
style.moreImagesText,
{fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale))},
];
}, [isTablet]);
}, [dimensions.scale, isTablet, style]);
return (
<View style={style.moreImagesWrapper}>

View file

@ -58,7 +58,7 @@ const TabletOptions = ({
const toggleOverlay = useCallback(() => {
setShowOptions(false);
}, []);
}, [setShowOptions]);
const overlayStyle = useMemo(() => ({
marginTop: openUp ? 0 : openDownMargin,

View file

@ -64,7 +64,7 @@ const FileResult = ({
setShowOptions(true);
onOptionsPress(fInfo);
});
}, []);
}, [height, onOptionsPress]);
const handleSetAction = useCallback((action: GalleryAction) => {
setAction(action);

View file

@ -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<ViewStyle> = [style.input];

View file

@ -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 (

View file

@ -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 (

View file

@ -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,
]);

View file

@ -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) {

View file

@ -17,7 +17,7 @@ type Props = {
const MathView = (props: Props) => {
const id = useId();
const config = useMemo(() => ({id}), []);
const config = useMemo(() => ({id}), [id]);
return (
<RNMathView

View file

@ -50,7 +50,7 @@ const NavigationSearch = forwardRef<SearchRef, Props>(({
const onFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
hideHeader?.();
searchProps.onFocus?.(e);
}, [hideHeader, searchProps.onFocus]);
}, [hideHeader, searchProps]);
const showEmitter = useCallback(() => {
if (Platform.OS === 'android') {

View file

@ -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) {

View file

@ -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 (
<View style={styles.container}>

View file

@ -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 (

View file

@ -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(() => {

View file

@ -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);

View file

@ -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<Props> = ({
const [scheduledPostTooltipVisible, setScheduledPostTooltipVisible] = useState(false);
useEffect(() => {
useDidMount(() => {
if (scheduledPostFeatureTooltipWatched || !scheduledPostEnabled) {
return;
}
@ -73,10 +74,7 @@ const SendButton: React.FC<Props> = ({
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);

View file

@ -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) {

View file

@ -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;

View file

@ -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) {

View file

@ -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 (

View file

@ -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]);

View file

@ -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();

View file

@ -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);

View file

@ -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(() => {

View file

@ -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],

View file

@ -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 (

View file

@ -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 (
<GestureDetector gesture={composedGestures}>

View file

@ -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;

View file

@ -84,21 +84,24 @@ const Search = forwardRef<SearchRef, SearchProps>((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: {

View file

@ -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) {

View file

@ -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;

View file

@ -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;

View file

@ -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) => (
<View

View file

@ -127,7 +127,7 @@ const CodeHighlightRenderer = ({defaultColor, digits, fontFamily, fontSize, rows
selectable,
digits,
});
}, [defaultColor, digits, fontFamily, fontSize, stylesheet]);
}, [defaultColor, digits, fontFamily, fontSize, selectable, stylesheet]);
return (
<ScrollView

View file

@ -70,7 +70,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
PerformanceMetricsManager.startMetric('mobile_team_switch');
handleTeamChange(serverUrl, team.id);
}, [selected, team?.id, serverUrl]);
}, [team, selected, serverUrl]);
if (!team) {
return null;

View file

@ -55,10 +55,16 @@ export default function TeamSidebar({iconPad, canJoinOtherTeams, hasMoreThanOneT
useEffect(() => {
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 (

View file

@ -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 (
<Animated.View

View file

@ -21,7 +21,7 @@ const TutorialHighlight = ({children, itemBounds, itemBorderRadius, onDismiss, o
if (onShow) {
setTimeout(onShow, 1000);
}
}, [itemBounds]);
}, [onShow]);
return (
<Modal

View file

@ -162,6 +162,9 @@ export const useHideExtraKeyboardIfNeeded = (callback: (...args: any) => void, d
}
callback(...args);
// The dependencies should be passed by the caller.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyboardContext, ...dependencies]));
};

View file

@ -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;
}

View file

@ -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,

View file

@ -108,6 +108,9 @@ export function useViewPosition(viewRef: RefObject<View>, 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;

View file

@ -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);
});
});

12
app/hooks/did_mount.ts Normal file
View file

@ -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;

View file

@ -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);
}

View file

@ -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;
};

View file

@ -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';

View file

@ -153,12 +153,12 @@ export const useCollapsibleHeader = <T>(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,

View file

@ -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');
});
});

View file

@ -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<T>(factory: () => T) {
// We only want the initial value, no updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo<T>(factory, []);
}
export default useInitialValue;

View file

@ -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);
};

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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;
};

View file

@ -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'});
});
});

View file

@ -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;

View file

@ -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 (

View file

@ -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) {

View file

@ -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);

View file

@ -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;

View file

@ -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) => {

View file

@ -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<BehaviorSubject<CallsConfigState>> = {};
@ -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;
};

View file

@ -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<BehaviorSubject<CallsState>> = {};
@ -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;
};

View file

@ -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<BehaviorSubject<ChannelsWithCalls>> = {};
@ -31,7 +33,7 @@ export const observeChannelsWithCalls = (serverUrl: string) => {
export const useChannelsWithCalls = (serverUrl: string) => {
const [state, setState] = useState<ChannelsWithCalls>({});
useEffect(() => {
useDidMount(() => {
const subscription = getChannelsWithCallsSubject(serverUrl).subscribe((channelsWithCalls) => {
setState(channelsWithCalls);
});
@ -39,7 +41,7 @@ export const useChannelsWithCalls = (serverUrl: string) => {
return () => {
subscription?.unsubscribe();
};
}, []);
});
return state;
};

View file

@ -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 (

View file

@ -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;

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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({
<View
style={style.separator}
/>
), [theme]);
), [style]);
return (
<FlatList

View file

@ -151,7 +151,7 @@ export default function SearchHandler(props: Props) {
const isSearch = Boolean(term);
const doGetChannels = (t: string) => {
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(() => {

View file

@ -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 = (<Intro channelId={channelId}/>);

View file

@ -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) {

View file

@ -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 <PrivateChannel theme={theme}/>;
}, [channel.type, theme]);
useEffect(() => {
useDidMount(() => {
if (!creator && channel.creatorId) {
fetchChannelCreator(serverUrl, channel.id);
}
}, []);
});
const canManagePeople = useMemo(() => {
if (channel.deleteAt !== 0) {

View file

@ -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;

View file

@ -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;

View file

@ -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]);

View file

@ -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 (
<>

View file

@ -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<NodeJS.Timeout>();
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) {

View file

@ -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]);

View file

@ -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(() => {

View file

@ -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') {

View file

@ -91,7 +91,7 @@ const EditProfilePicture = ({user, onUpdateProfilePicture}: ChangeProfilePicture
};
}
return undefined;
}, [pictureUrl]);
}, [pictureUrl, serverUrl]);
return (
<View

View file

@ -33,7 +33,7 @@ const EmojiPickerScreen = ({closeButtonId, componentId, file, imageUrl, onEmojiP
const handleEmojiPress = useCallback((emoji: string) => {
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 (
<BottomSheet

View file

@ -35,10 +35,7 @@ const PickerFooter = (props: BottomSheetFooterProps) => {
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(

View file

@ -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) {

View file

@ -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 (
<>

View file

@ -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 (
<>

View file

@ -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);

View file

@ -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 (
<Animated.View

View file

@ -12,6 +12,7 @@ import {fetchPublicLink} from '@actions/remote/file';
import Toast from '@components/toast';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {useServerUrl} from '@context/server';
import useDidMount from '@hooks/did_mount';
import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery';
@ -67,14 +68,14 @@ const CopyPublicLink = ({item, galleryView = true, setAction}: Props) => {
}
};
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 (

Some files were not shown because too many files have changed in this diff Show more