diff --git a/package-lock.json b/package-lock.json
index 1e8aeaf24ffe..9f8caf136d98 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -223,7 +223,7 @@
"babel-jest": "29.7.0",
"babel-loader": "^10.0.0",
"babel-plugin-module-resolver": "^5.0.0",
- "babel-plugin-react-compiler": "1.0.0",
+ "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260422",
"babel-plugin-react-native-web": "^0.18.7",
"babel-plugin-transform-remove-console": "^6.9.4",
"clean-webpack-plugin": "^4.0.0",
@@ -18638,9 +18638,9 @@
}
},
"node_modules/babel-plugin-react-compiler": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
- "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
+ "version": "0.0.0-experimental-a1856f3-20260422",
+ "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-0.0.0-experimental-a1856f3-20260422.tgz",
+ "integrity": "sha512-rYwQFX52TaZk2lJZNLp2V9jun1sSJZEBAW5S4Rjt8mnOjEtz5Cab/7yjG4ZLBWuofgWiqSb6laPVkhBpYd0n4w==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
diff --git a/package.json b/package.json
index 9f3fc61b869e..7ab0b97ade7b 100644
--- a/package.json
+++ b/package.json
@@ -286,7 +286,7 @@
"babel-jest": "29.7.0",
"babel-loader": "^10.0.0",
"babel-plugin-module-resolver": "^5.0.0",
- "babel-plugin-react-compiler": "1.0.0",
+ "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260422",
"babel-plugin-react-native-web": "^0.18.7",
"babel-plugin-transform-remove-console": "^6.9.4",
"clean-webpack-plugin": "^4.0.0",
@@ -360,6 +360,7 @@
"webpack-merge": "^5.8.0"
},
"overrides": {
+ "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260422",
"braces": "3.0.3",
"yargs": "17.7.2",
"yargs-parser": "21.1.1",
diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx
index c4b6239145ca..0685bebf0715 100644
--- a/src/components/AvatarWithImagePicker.tsx
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -1,5 +1,5 @@
import {useIsFocused} from '@react-navigation/native';
-import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
+import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -141,7 +141,7 @@ function AvatarWithImagePicker({
/**
* Validates an image and opens avatar crop modal if valid
*/
- const showAvatarCropModal = useCallback((image: FileObject) => {
+ const showAvatarCropModal = (image: FileObject) => {
validateAvatarImage(image)
.then((validationResult) => {
if (!validationResult.isValid) {
@@ -161,7 +161,7 @@ function AvatarWithImagePicker({
.catch(() => {
setError('attachmentPicker.errorWhileSelectingCorruptedAttachment', {});
});
- }, []);
+ };
const hideAvatarCropModal = () => {
setIsAvatarCropModalOpen(false);
@@ -201,23 +201,20 @@ function AvatarWithImagePicker({
return menuItems;
};
- const onPressAvatar = useCallback(
- (openPicker: OpenPicker) => {
- anchorRef.current?.blur();
- if (disabled && enablePreview && onViewPhotoPress) {
- onViewPhotoPress();
- return;
- }
- if (isUsingDefaultAvatar) {
- openPicker({
- onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
- });
- return;
- }
- setIsMenuVisible((prev) => !prev);
- },
- [disabled, enablePreview, isUsingDefaultAvatar, onViewPhotoPress, showAvatarCropModal],
- );
+ const onPressAvatar = (openPicker: OpenPicker) => {
+ anchorRef.current?.blur();
+ if (disabled && enablePreview && onViewPhotoPress) {
+ onViewPhotoPress();
+ return;
+ }
+ if (isUsingDefaultAvatar) {
+ openPicker({
+ onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
+ });
+ return;
+ }
+ setIsMenuVisible((prev) => !prev);
+ };
useLayoutEffect(() => {
if (!anchorRef.current || !isMenuVisible) {
diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx
index 4ec12eb75787..4d1d574263c5 100644
--- a/src/components/ConfirmedRoute.tsx
+++ b/src/components/ConfirmedRoute.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect} from 'react';
+import React, {useEffect} from 'react';
import type {ReactNode} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -12,7 +12,6 @@ import {init as initMapboxToken, stop as stopMapboxToken} from '@userActions/Map
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Transaction} from '@src/types/onyx';
-import type {WaypointCollection} from '@src/types/onyx/Transaction';
import type IconAsset from '@src/types/utils/IconAsset';
import DistanceMapView from './DistanceMapView';
import ImageSVG from './ImageSVG';
@@ -49,51 +48,38 @@ function ConfirmedRoute({transaction, isSmallerIcon, shouldHaveBorderRadius = tr
const [mapboxAccessToken] = useOnyx(ONYXKEYS.MAPBOX_ACCESS_TOKEN);
- const getMarkerComponent = useCallback(
- (icon: IconAsset): ReactNode => (
-
- ),
- [theme],
- );
-
- const getWaypointMarkers = useCallback(
- (waypointsData: WaypointCollection): WayPoint[] => {
- const numberOfWaypoints = Object.keys(waypointsData).length;
- const lastWaypointIndex = numberOfWaypoints - 1;
-
- return Object.entries(waypointsData)
- .map(([key, waypoint]) => {
- if (!waypoint?.lat || !waypoint?.lng) {
- return;
- }
-
- const index = getWaypointIndex(key);
- let MarkerComponent: IconAsset;
- if (index === 0) {
- MarkerComponent = expensifyIcons.DotIndicatorUnfilled;
- } else if (index === lastWaypointIndex) {
- MarkerComponent = expensifyIcons.Location;
- } else {
- MarkerComponent = expensifyIcons.DotIndicator;
- }
-
- return {
- id: `${waypoint.lng},${waypoint.lat},${index}`,
- coordinate: [waypoint.lng, waypoint.lat] as const,
- markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent),
- };
- })
- .filter((waypoint): waypoint is WayPoint => !!waypoint);
- },
- [getMarkerComponent, expensifyIcons.DotIndicator, expensifyIcons.DotIndicatorUnfilled, expensifyIcons.Location],
+ const getMarkerComponent = (icon: IconAsset): ReactNode => (
+
);
- const waypointMarkers = getWaypointMarkers(waypoints);
+ const lastWaypointIndex = Object.keys(waypoints).length - 1;
+ const waypointMarkers: WayPoint[] = [];
+ for (const [key, waypoint] of Object.entries(waypoints)) {
+ if (!waypoint?.lat || !waypoint?.lng) {
+ continue;
+ }
+
+ const index = getWaypointIndex(key);
+ let MarkerComponent: IconAsset;
+ if (index === 0) {
+ MarkerComponent = expensifyIcons.DotIndicatorUnfilled;
+ } else if (index === lastWaypointIndex) {
+ MarkerComponent = expensifyIcons.Location;
+ } else {
+ MarkerComponent = expensifyIcons.DotIndicator;
+ }
+
+ waypointMarkers.push({
+ id: `${waypoint.lng},${waypoint.lat},${index}`,
+ coordinate: [waypoint.lng, waypoint.lat] as const,
+ markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent),
+ });
+ }
useEffect(() => {
initMapboxToken();
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx
index 0e12867877a0..5f57737a8026 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.tsx
+++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo} from 'react';
+import React from 'react';
import type {ReactNode} from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
@@ -60,45 +60,37 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa
const mileageRate = isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction) : defaultMileageRate;
const {unit} = mileageRate ?? {};
- const getMarkerComponent = useCallback(
- (icon: IconAsset): ReactNode => (
-
- ),
- [theme],
+ const getMarkerComponent = (icon: IconAsset): ReactNode => (
+
);
- const waypointMarkers = useMemo(
- () =>
- Object.entries(waypoints ?? {})
- .map(([key, waypoint]) => {
- if (!waypoint?.lat || !waypoint?.lng) {
- return;
- }
+ const waypointMarkers: WayPoint[] = [];
+ for (const [key, waypoint] of Object.entries(waypoints ?? {})) {
+ if (!waypoint?.lat || !waypoint?.lng) {
+ continue;
+ }
- const index = getWaypointIndex(key);
- let MarkerComponent: IconAsset;
- if (index === 0) {
- MarkerComponent = expensifyIcons.DotIndicatorUnfilled;
- } else if (index === lastWaypointIndex) {
- MarkerComponent = expensifyIcons.Location;
- } else {
- MarkerComponent = expensifyIcons.DotIndicator;
- }
+ const index = getWaypointIndex(key);
+ let MarkerComponent: IconAsset;
+ if (index === 0) {
+ MarkerComponent = expensifyIcons.DotIndicatorUnfilled;
+ } else if (index === lastWaypointIndex) {
+ MarkerComponent = expensifyIcons.Location;
+ } else {
+ MarkerComponent = expensifyIcons.DotIndicator;
+ }
- return {
- id: `${waypoint.lng},${waypoint.lat},${index}`,
- coordinate: [waypoint.lng, waypoint.lat] as const,
- markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent),
- };
- })
- .filter((waypoint): waypoint is WayPoint => !!waypoint),
- [waypoints, lastWaypointIndex, getMarkerComponent, expensifyIcons.DotIndicator, expensifyIcons.DotIndicatorUnfilled, expensifyIcons.Location],
- );
+ waypointMarkers.push({
+ id: `${waypoint.lng},${waypoint.lat},${index}`,
+ coordinate: [waypoint.lng, waypoint.lat] as const,
+ markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent),
+ });
+ }
return (
<>
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx
index 6eb4672cf35f..3c3fea6df87e 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal.tsx
@@ -1,6 +1,6 @@
import type {ImageContentFit} from 'expo-image';
import type {SourceLoadEventPayload} from 'expo-video';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
// eslint-disable-next-line no-restricted-imports
import {Image, InteractionManager, View} from 'react-native';
// eslint-disable-next-line no-restricted-imports
@@ -267,7 +267,7 @@ function FeatureTrainingModal({
setIllustrationAspectRatio(track.size.width / track.size.height);
};
- const renderIllustration = useCallback(() => {
+ const renderIllustration = () => {
const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
return (
@@ -333,34 +333,11 @@ function FeatureTrainingModal({
)}
);
- }, [
- illustrationAspectRatio,
- styles.w100,
- styles.featureTrainingModalImage,
- styles.onboardingVideoPlayer,
- styles.flex1,
- styles.alignItemsCenter,
- styles.justifyContentCenter,
- styles.h100,
- illustrationInnerContainerStyle,
- videoURL,
- image,
- shouldRenderSVG,
- contentFitImage,
- imageWidth,
- imageHeight,
- videoStatus,
- animationStyle,
- animation,
- shouldUseNarrowLayout,
- isInLandscapeMode,
- isReduceMotionEnabled,
- illustrations.Hands,
- ]);
-
- const toggleWillShowAgain = useCallback(() => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain), []);
-
- const closeModal = useCallback(() => {
+ };
+
+ const toggleWillShowAgain = () => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain);
+
+ const closeModal = () => {
Log.hmmm(`[FeatureTrainingModal] closeModal called - willShowAgain: ${willShowAgain}, shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`);
if (!willShowAgain) {
@@ -387,9 +364,9 @@ function FeatureTrainingModal({
Log.hmmm('[FeatureTrainingModal] No onClose callback provided');
}
});
- }, [onClose, shouldGoBack, willShowAgain]);
+ };
- const closeAndConfirmModal = useCallback(() => {
+ const closeAndConfirmModal = () => {
Log.hmmm(`[FeatureTrainingModal] Button pressed - shouldCloseOnConfirm: ${shouldCloseOnConfirm}, hasOnConfirm: ${!!onConfirm}, willShowAgain: ${willShowAgain}`);
if (shouldCloseOnConfirm) {
@@ -403,7 +380,7 @@ function FeatureTrainingModal({
} else {
Log.hmmm('[FeatureTrainingModal] No onConfirm callback provided');
}
- }, [shouldCloseOnConfirm, onConfirm, closeModal, willShowAgain]);
+ };
// Scrolls modal to the bottom when keyboard appears so the action buttons are visible.
useEffect(() => {
@@ -415,10 +392,7 @@ function FeatureTrainingModal({
const Wrapper = shouldUseScrollView ? ScrollView : View;
- const wrapperStyles = useMemo(
- () => (shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}),
- [shouldUseScrollView, StyleUtils, insets, isKeyboardActive],
- );
+ const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {};
return (
void;
};
-type PopoverReactionListAnchors = Record;
-
type FormattedReaction = {
/** The emoji codes to display in the bubble */
emojiCodes: string[];
@@ -61,24 +54,17 @@ type FormattedReaction = {
/** Callback to fire on press */
onPress: () => void;
- /** Callback to fire on reaction list open */
- onReactionListOpen: (event: ReactionListEvent) => void;
-
/** The name of the emoji */
reactionEmojiName: string;
- /** The type of action that's pending */
+ /** The type of action that's pending */
pendingAction?: PendingAction;
-
- setIsEmojiPickerActive?: (state: boolean) => void;
};
function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReactions = false, setIsEmojiPickerActive}: ReportActionItemEmojiReactionsProps) {
const styles = useThemeStyles();
const {preferredLocale} = useLocalize();
const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
- const reactionListRef = useContext(ReactionListContext);
- const popoverReactionListAnchors = useRef({});
const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE);
const reportActionID = reportAction.reportActionID;
@@ -116,10 +102,6 @@ function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReac
toggleReaction(emoji, preferredSkinTone, true);
};
- const onReactionListOpen = (event: ReactionListEvent) => {
- reactionListRef?.current?.showReactionList(event, popoverReactionListAnchors.current[emojiName], emojiName, reportActionID);
- };
-
return {
emojiCodes,
userAccountIDs,
@@ -127,7 +109,6 @@ function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReac
hasUserReacted,
oldestTimestamp,
onPress,
- onReactionListOpen,
reactionEmojiName: emojiName,
pendingAction: emojiReaction.pendingAction,
};
@@ -146,37 +127,20 @@ function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReac
}
return (
- (
-
- )}
- renderTooltipContentKey={[...reaction.userAccountIDs.map(String), ...reaction.emojiCodes]}
+
-
-
- {
- popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref ?? null;
- }}
- count={reaction.reactionCount}
- emojiCodes={reaction.emojiCodes}
- onPress={reaction.onPress}
- hasUserReacted={reaction.hasUserReacted}
- onReactionListOpen={reaction.onReactionListOpen}
- shouldBlockReactions={shouldBlockReactions}
- />
-
-
-
+ emojiCodes={reaction.emojiCodes}
+ reactionCount={reaction.reactionCount}
+ hasUserReacted={reaction.hasUserReacted}
+ userAccountIDs={reaction.userAccountIDs}
+ reactionEmojiName={reaction.reactionEmojiName}
+ onPress={reaction.onPress}
+ reportActionID={reportActionID}
+ reportActionPendingAction={reportAction.pendingAction}
+ pendingAction={reaction.pendingAction}
+ currentUserAccountID={currentUserAccountID}
+ shouldBlockReactions={shouldBlockReactions}
+ />
);
})}
{!shouldBlockReactions && (
diff --git a/src/components/Reactions/ReportActionReactionBubble.tsx b/src/components/Reactions/ReportActionReactionBubble.tsx
new file mode 100644
index 000000000000..f7685579e751
--- /dev/null
+++ b/src/components/Reactions/ReportActionReactionBubble.tsx
@@ -0,0 +1,98 @@
+import React, {useContext, useRef} from 'react';
+import {View} from 'react-native';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
+import {ReactionListContext} from '@pages/inbox/ReportScreenContext';
+import type {ReactionListAnchor, ReactionListEvent} from '@pages/inbox/ReportScreenContext';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+import EmojiReactionBubble from './EmojiReactionBubble';
+import ReactionTooltipContent from './ReactionTooltipContent';
+
+type ReportActionReactionBubbleProps = {
+ /** The emoji codes to display in the bubble */
+ emojiCodes: string[];
+
+ /** Number of reactions for this emoji */
+ reactionCount: number;
+
+ /** Whether the current user has reacted with this emoji */
+ hasUserReacted: boolean;
+
+ /** IDs of users who reacted with this emoji */
+ userAccountIDs: number[];
+
+ /** Name of the reaction emoji */
+ reactionEmojiName: string;
+
+ /** Called when the bubble is pressed (toggles reaction) */
+ onPress: () => void;
+
+ /** ID of the report action this reaction belongs to */
+ reportActionID: string;
+
+ /** Pending action of the report action (drives offline opacity) */
+ reportActionPendingAction: PendingAction | undefined;
+
+ /** Pending action for this individual reaction */
+ pendingAction: PendingAction | undefined;
+
+ /** Current user's account ID, used for the tooltip header */
+ currentUserAccountID: number;
+
+ /** Disables reactions when the report action has errors */
+ shouldBlockReactions: boolean;
+};
+
+function ReportActionReactionBubble({
+ emojiCodes,
+ reactionCount,
+ hasUserReacted,
+ userAccountIDs,
+ reactionEmojiName,
+ onPress,
+ reportActionID,
+ reportActionPendingAction,
+ pendingAction,
+ currentUserAccountID,
+ shouldBlockReactions,
+}: ReportActionReactionBubbleProps) {
+ const anchorRef = useRef(null);
+ const {showReactionList} = useContext(ReactionListContext);
+
+ return (
+ (
+
+ )}
+ renderTooltipContentKey={[...userAccountIDs.map(String), ...emojiCodes]}
+ >
+
+
+ {
+ anchorRef.current = node ?? null;
+ }}
+ count={reactionCount}
+ emojiCodes={emojiCodes}
+ onPress={onPress}
+ hasUserReacted={hasUserReacted}
+ onReactionListOpen={(event: ReactionListEvent) => showReactionList(event, anchorRef.current, reactionEmojiName, reportActionID)}
+ shouldBlockReactions={shouldBlockReactions}
+ />
+
+
+
+ );
+}
+
+ReportActionReactionBubble.displayName = 'ReportActionReactionBubble';
+
+export default ReportActionReactionBubble;
diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx
index a5763fc11184..7c711a5d3a29 100644
--- a/src/components/Search/SearchChartView.tsx
+++ b/src/components/Search/SearchChartView.tsx
@@ -15,7 +15,7 @@ import type {ChartView, GroupedItem, SearchChartProps, SearchGroupBy, SearchQuer
type SearchChartViewProps = {
/** The current search query JSON */
- queryJSON: SearchQueryJSON;
+ queryJSON: Readonly;
/** The view type (bar, etc.) */
view: ChartView;
@@ -51,16 +51,19 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar
const handleItemPress = (filterQuery: string) => {
const currentQueryString = buildSearchQueryString(queryJSON);
- const newQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`);
+ const parsedQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`);
- if (!newQueryJSON) {
+ if (!parsedQueryJSON) {
Log.alert('[SearchChartView] Failed to build search query JSON from filter query');
return;
}
- newQueryJSON.groupBy = undefined;
- newQueryJSON.view = CONST.SEARCH.VIEW.TABLE;
- newQueryJSON.sortBy = CONST.SEARCH.TABLE_COLUMNS.DATE;
- newQueryJSON.sortOrder = CONST.SEARCH.SORT_ORDER.DESC;
+ const newQueryJSON: SearchQueryJSON = {
+ ...parsedQueryJSON,
+ groupBy: undefined,
+ view: CONST.SEARCH.VIEW.TABLE,
+ sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
+ sortOrder: CONST.SEARCH.SORT_ORDER.DESC,
+ };
const newQueryString = buildSearchQueryString(newQueryJSON);
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString}));
diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx
index 654381149e72..e0904e5441c4 100644
--- a/src/components/Search/SearchContext.tsx
+++ b/src/components/Search/SearchContext.tsx
@@ -1,6 +1,6 @@
import {useNavigation} from '@react-navigation/core';
import type {NavigationState} from '@react-navigation/routers';
-import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
+import React, {useContext, useEffect, useRef, useState} from 'react';
// We need direct access to useOnyx from react-native-onyx to avoid circular dependencies in SearchContext
// eslint-disable-next-line no-restricted-imports
import {useOnyx} from 'react-native-onyx';
@@ -98,7 +98,7 @@ function SearchContextProvider({children}: SearchContextProps) {
const queryParam = useRootNavigationState((state) => selectSearchQueryParam(state ?? navigation.getState()));
const rawQueryParam = useRootNavigationState((state) => selectSearchRawQueryParam(state ?? navigation.getState()));
const definedQueryParam = usePreviousDefined(queryParam) ?? buildSearchQueryString();
- const currentSearchQueryJSON = useMemo(() => buildSearchQueryJSON(definedQueryParam, rawQueryParam), [definedQueryParam, rawQueryParam]);
+ const currentSearchQueryJSON = buildSearchQueryJSON(definedQueryParam, rawQueryParam);
const areTransactionsEmpty = useRef(true);
const [lastSearchType, setLastSearchType] = useState();
@@ -117,38 +117,35 @@ function SearchContextProvider({children}: SearchContextProps) {
const {defaultCardFeed} = useCardFeedsForDisplay();
const {accountID} = useCurrentUserPersonalDetails();
const defaultCardFeedID = defaultCardFeed?.id;
- const suggestedSearches = useMemo(() => getSuggestedSearches(accountID, defaultCardFeedID), [accountID, defaultCardFeedID]);
+ const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeedID);
- const currentSearchKey = useMemo(() => {
- return Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key;
- }, [currentSimilarSearchHash, suggestedSearches]);
+ const currentSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key;
const shouldUseLiveData = !!currentSearchKey && isTodoSearch(currentRecentSearchHash, suggestedSearches);
// If viewing a to-do search, use live data from useTodos, otherwise return the snapshot data
// We do this so that we can show the counters for the to-do search results without visiting the specific to-do page, e.g. show `Approve [3]` while viewing the `Submit` to-do search.
- const currentSearchResults = useMemo(() => {
- if (shouldUseLiveData) {
- const liveData = todoSearchResultsData[currentSearchKey as keyof typeof todoSearchResultsData];
- const searchInfo: SearchResultsInfo = {
- ...(snapshotSearchResults?.search ?? defaultSearchInfo),
- count: liveData.metadata.count,
- total: liveData.metadata.total,
- currency: liveData.metadata.currency,
- };
- const hasResults = Object.keys(liveData.data).length > 0;
- // For to-do searches, always return a valid SearchResults object (even with empty data)
- // This ensures we show the empty state instead of loading/blocking views
- return {
- search: {...searchInfo, isLoading: false, hasResults},
- data: liveData.data,
- };
- }
-
- return snapshotSearchResults ?? undefined;
- }, [currentSearchKey, shouldUseLiveData, snapshotSearchResults, todoSearchResultsData]);
-
- const setSelectedTransactions: SearchActionsContextValue['setSelectedTransactions'] = useCallback((transactionIDs, data = []) => {
+ let currentSearchResults;
+ if (shouldUseLiveData) {
+ const liveData = todoSearchResultsData[currentSearchKey as keyof typeof todoSearchResultsData];
+ const searchInfo: SearchResultsInfo = {
+ ...(snapshotSearchResults?.search ?? defaultSearchInfo),
+ count: liveData.metadata.count,
+ total: liveData.metadata.total,
+ currency: liveData.metadata.currency,
+ };
+ const hasResults = Object.keys(liveData.data).length > 0;
+ // For to-do searches, always return a valid SearchResults object (even with empty data)
+ // This ensures we show the empty state instead of loading/blocking views
+ currentSearchResults = {
+ search: {...searchInfo, isLoading: false, hasResults},
+ data: liveData.data,
+ };
+ } else {
+ currentSearchResults = snapshotSearchResults ?? undefined;
+ }
+
+ const setSelectedTransactions: SearchActionsContextValue['setSelectedTransactions'] = (transactionIDs, data = []) => {
if (transactionIDs instanceof Array) {
if (!transactionIDs.length && areTransactionsEmpty.current) {
areTransactionsEmpty.current = true;
@@ -234,7 +231,7 @@ function SearchContextProvider({children}: SearchContextProps) {
selectedTransactions: transactionIDs,
shouldTurnOffSelectionMode: false,
}));
- }, []);
+ };
const currentSearchHashRef = useRef(currentSearchHash);
useEffect(() => {
@@ -254,38 +251,33 @@ function SearchContextProvider({children}: SearchContextProps) {
});
};
- const clearSelectedTransactions: SearchActionsContextValue['clearSelectedTransactions'] = useCallback(
- (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => {
- if (typeof searchHashOrClearIDsFlag === 'boolean') {
- setSelectedTransactions([]);
- return;
- }
+ const clearSelectedTransactions: SearchActionsContextValue['clearSelectedTransactions'] = (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => {
+ if (typeof searchHashOrClearIDsFlag === 'boolean') {
+ setSelectedTransactions([]);
+ return;
+ }
- if (searchHashOrClearIDsFlag === currentSearchHashRef.current) {
- return;
+ if (searchHashOrClearIDsFlag === currentSearchHashRef.current) {
+ return;
+ }
+
+ setSearchContextData((prevState) => {
+ if (prevState.selectedReports.length === 0 && isEmptyObject(prevState.selectedTransactions) && !prevState.shouldTurnOffSelectionMode) {
+ return prevState;
}
+ return {
+ ...prevState,
+ shouldTurnOffSelectionMode,
+ selectedTransactions: {},
+ selectedReports: [],
+ };
+ });
- setSearchContextData((prevState) => {
- if (prevState.selectedReports.length === 0 && isEmptyObject(prevState.selectedTransactions) && !prevState.shouldTurnOffSelectionMode) {
- return prevState;
- }
- return {
- ...prevState,
- shouldTurnOffSelectionMode,
- selectedTransactions: {},
- selectedReports: [],
- };
- });
-
- setShouldShowSelectAllMatchingItems(false);
- selectAllMatchingItems(false);
- },
- // currentSearchHash is read via currentSearchHashRef to keep this callback stable.
- // setShouldShowSelectAllMatchingItems and selectAllMatchingItems are stable useState setters.
- [setSelectedTransactions],
- );
+ setShouldShowSelectAllMatchingItems(false);
+ selectAllMatchingItems(false);
+ };
- const removeTransaction: SearchActionsContextValue['removeTransaction'] = useCallback((transactionID) => {
+ const removeTransaction: SearchActionsContextValue['removeTransaction'] = (transactionID) => {
if (!transactionID) {
return;
}
@@ -314,63 +306,41 @@ function SearchContextProvider({children}: SearchContextProps) {
}
return newState;
});
- }, []);
+ };
- const setShouldResetSearchQuery = useCallback((shouldReset: boolean) => {
+ const setShouldResetSearchQuery = (shouldReset: boolean) => {
setSearchContextData((prevState) => ({
...prevState,
shouldResetSearchQuery: shouldReset,
}));
- }, []);
-
- const searchStateContextValue: SearchStateContextValue = useMemo(
- () => ({
- ...searchContextData,
- suggestedSearches,
- currentSearchKey,
- currentSearchHash,
- currentSimilarSearchHash,
- currentSearchResults,
- shouldUseLiveData,
- shouldShowFiltersBarLoading,
- lastSearchType,
- shouldShowSelectAllMatchingItems,
- areAllMatchingItemsSelected,
- currentSearchQueryJSON,
- }),
- [
- searchContextData,
- suggestedSearches,
- currentSearchKey,
- currentSearchHash,
- currentSimilarSearchHash,
- currentSearchResults,
- shouldUseLiveData,
- shouldShowFiltersBarLoading,
- lastSearchType,
- shouldShowSelectAllMatchingItems,
- areAllMatchingItemsSelected,
- currentSearchQueryJSON,
- ],
- );
+ };
- const searchActionsContextValue: SearchActionsContextValue = useMemo(
- () => ({
- removeTransaction,
- setSelectedTransactions,
- setCurrentSelectedTransactionReportID,
- clearSelectedTransactions,
- setShouldShowFiltersBarLoading,
- setLastSearchType,
- setShouldShowSelectAllMatchingItems,
- selectAllMatchingItems,
- setShouldResetSearchQuery,
- }),
- // shouldShowFiltersBarLoading, setLastSearchType, setShouldShowSelectAllMatchingItems,
- // and selectAllMatchingItems are stable useState setters — excluded from deps intentionally.
- // setCurrentSelectedTransactionReportID only uses setSearchContextData (stable setter).
- [removeTransaction, setSelectedTransactions, clearSelectedTransactions, setShouldResetSearchQuery],
- );
+ const searchStateContextValue: SearchStateContextValue = {
+ ...searchContextData,
+ suggestedSearches,
+ currentSearchKey,
+ currentSearchHash,
+ currentSimilarSearchHash,
+ currentSearchResults,
+ shouldUseLiveData,
+ shouldShowFiltersBarLoading,
+ lastSearchType,
+ shouldShowSelectAllMatchingItems,
+ areAllMatchingItemsSelected,
+ currentSearchQueryJSON,
+ };
+
+ const searchActionsContextValue: SearchActionsContextValue = {
+ removeTransaction,
+ setSelectedTransactions,
+ setCurrentSelectedTransactionReportID,
+ clearSelectedTransactions,
+ setShouldShowFiltersBarLoading,
+ setLastSearchType,
+ setShouldShowSelectAllMatchingItems,
+ selectAllMatchingItems,
+ setShouldResetSearchQuery,
+ };
return (
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 69e4b2dced4f..92bc0c505a31 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -169,7 +169,7 @@ type SearchContextData = {
currentSearchHash: number;
currentSimilarSearchHash: number;
currentSearchKey: SearchKey | undefined;
- currentSearchQueryJSON: SearchQueryJSON | undefined;
+ currentSearchQueryJSON: Readonly | undefined;
currentSearchResults: SearchResults | undefined;
currentSelectedTransactionReportID: string | undefined;
selectedTransactions: SelectedTransactions;
@@ -326,7 +326,7 @@ type SearchAutocompleteQueryRange = {
};
type SearchParams = {
- queryJSON: SearchQueryJSON;
+ queryJSON: Readonly;
searchKey: SearchKey | undefined;
offset: number;
prevReportsLength?: number;
diff --git a/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts b/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts
index 7893e0c62a0d..92be05a99362 100644
--- a/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts
+++ b/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts
@@ -1,61 +1,55 @@
-import {useCallback, useMemo, useRef} from 'react';
+import {useRef} from 'react';
import type {PlaybackContextVideoRefs, StopVideo} from './types';
function usePlaybackContextVideoRefs(resetCallback: () => void) {
const currentVideoPlayerRef: PlaybackContextVideoRefs['playerRef'] = useRef(null);
const currentVideoViewRef: PlaybackContextVideoRefs['viewRef'] = useRef(null);
- const playVideo: PlaybackContextVideoRefs['play'] = useCallback(() => {
+ const playVideo: PlaybackContextVideoRefs['play'] = () => {
currentVideoPlayerRef.current?.play();
- }, []);
+ };
- const pauseVideo: PlaybackContextVideoRefs['pause'] = useCallback(() => {
+ const pauseVideo: PlaybackContextVideoRefs['pause'] = () => {
currentVideoPlayerRef.current?.pause();
- }, []);
+ };
- const replayVideo: PlaybackContextVideoRefs['replay'] = useCallback(() => {
+ const replayVideo: PlaybackContextVideoRefs['replay'] = () => {
currentVideoPlayerRef.current?.replay();
- }, []);
+ };
- const stopVideo: StopVideo = useCallback(() => {
+ const stopVideo: StopVideo = () => {
if (!currentVideoPlayerRef.current) {
return;
}
currentVideoPlayerRef.current.pause();
currentVideoPlayerRef.current.currentTime = 0;
- }, [currentVideoPlayerRef]);
+ };
- const checkIfVideoIsPlaying: PlaybackContextVideoRefs['isPlaying'] = useCallback(
- (statusCallback) => statusCallback(currentVideoPlayerRef.current?.playing ?? false),
- [currentVideoPlayerRef],
- );
+ const checkIfVideoIsPlaying: PlaybackContextVideoRefs['isPlaying'] = (statusCallback) => statusCallback(currentVideoPlayerRef.current?.playing ?? false);
- const resetVideoPlayerData: PlaybackContextVideoRefs['resetPlayerData'] = useCallback(() => {
+ const resetVideoPlayerData: PlaybackContextVideoRefs['resetPlayerData'] = () => {
stopVideo();
currentVideoPlayerRef.current = null;
currentVideoViewRef.current = null;
resetCallback();
- }, [resetCallback, stopVideo]);
+ };
const updateCurrentVideoPlayerRefs: PlaybackContextVideoRefs['updateRefs'] = (playerRef, viewRef) => {
currentVideoPlayerRef.current = playerRef;
currentVideoViewRef.current = viewRef;
};
- return useMemo(
- (): PlaybackContextVideoRefs => ({
- playerRef: currentVideoPlayerRef,
- viewRef: currentVideoViewRef,
- play: playVideo,
- pause: pauseVideo,
- replay: replayVideo,
- stop: stopVideo,
- isPlaying: checkIfVideoIsPlaying,
- resetPlayerData: resetVideoPlayerData,
- updateRefs: updateCurrentVideoPlayerRefs,
- }),
- [checkIfVideoIsPlaying, pauseVideo, playVideo, replayVideo, resetVideoPlayerData, stopVideo],
- );
+ return {
+ playerRef: currentVideoPlayerRef,
+ viewRef: currentVideoViewRef,
+ play: playVideo,
+ pause: pauseVideo,
+ replay: replayVideo,
+ stop: stopVideo,
+ isPlaying: checkIfVideoIsPlaying,
+ resetPlayerData: resetVideoPlayerData,
+ updateRefs: updateCurrentVideoPlayerRefs,
+ };
}
export default usePlaybackContextVideoRefs;
diff --git a/src/hooks/useBasePopoverReactionList/index.ts b/src/hooks/useBasePopoverReactionList/index.ts
deleted file mode 100644
index 22fa8413f67d..000000000000
--- a/src/hooks/useBasePopoverReactionList/index.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import {useEffect, useRef, useState} from 'react';
-import type {SyntheticEvent} from 'react';
-import {Dimensions} from 'react-native';
-import * as EmojiUtils from '@libs/EmojiUtils';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
-import type {BasePopoverReactionListHookProps, ReactionListAnchor, ShowReactionList} from './types';
-
-export default function useBasePopoverReactionList({emojiName, emojiReactions, accountID, reportActionID, preferredLocale}: BasePopoverReactionListHookProps) {
- const [isPopoverVisible, setIsPopoverVisible] = useState(false);
- const [cursorRelativePosition, setCursorRelativePosition] = useState({horizontal: 0, vertical: 0});
- const [popoverAnchorPosition, setPopoverAnchorPosition] = useState({horizontal: 0, vertical: 0});
- const reactionListRef = useRef(null);
-
- function getReactionInformation() {
- const selectedReaction = emojiReactions?.[emojiName];
-
- if (!selectedReaction) {
- // If there is no reaction, we return default values
- return {
- emojiName: '',
- reactionCount: 0,
- emojiCodes: [],
- hasUserReacted: false,
- users: [],
- isReady: false,
- };
- }
-
- const {emojiCodes, reactionCount, hasUserReacted, userAccountIDs} = EmojiUtils.getEmojiReactionDetails(emojiName, selectedReaction, accountID);
-
- const users = PersonalDetailsUtils.getPersonalDetailsByIDs({accountIDs: userAccountIDs, currentUserAccountID: accountID, shouldChangeUserDisplayName: true});
- return {
- emojiName,
- emojiCodes,
- reactionCount,
- hasUserReacted,
- users,
- isReady: true,
- };
- }
-
- /**
- * Get the BasePopoverReactionList anchor position
- * We calculate the anchor coordinates from measureInWindow async method
- */
- function getReactionListMeasuredLocation(): Promise<{x: number; y: number}> {
- return new Promise((resolve) => {
- const reactionListAnchor = reactionListRef.current;
- if (reactionListAnchor && 'measureInWindow' in reactionListAnchor) {
- reactionListAnchor.measureInWindow((x, y) => resolve({x, y}));
- } else {
- resolve({x: 0, y: 0});
- }
- });
- }
-
- /**
- * Show the ReactionList modal popover.
- *
- * @param event - Object - A press event.
- * @param reactionListAnchor - Element - reactionListAnchor
- */
- const showReactionList: ShowReactionList = (event, reactionListAnchor) => {
- // We get the cursor coordinates and the reactionListAnchor coordinates to calculate the popover position
- const nativeEvent = (event as SyntheticEvent)?.nativeEvent || {};
- reactionListRef.current = reactionListAnchor;
- getReactionListMeasuredLocation().then(({x, y}) => {
- setCursorRelativePosition({horizontal: nativeEvent.pageX - x, vertical: nativeEvent.pageY - y});
- setPopoverAnchorPosition({
- horizontal: nativeEvent.pageX,
- vertical: nativeEvent.pageY,
- });
- setIsPopoverVisible(true);
- });
- };
-
- /**
- * Hide the ReactionList modal popover.
- */
- function hideReactionList() {
- setIsPopoverVisible(false);
- }
-
- useEffect(() => {
- const dimensionsEventListener = Dimensions.addEventListener('change', () => {
- if (!isPopoverVisible) {
- // If the popover is not visible, we don't need to update the component
- return;
- }
- getReactionListMeasuredLocation().then(({x, y}) => {
- if (!x || !y) {
- return;
- }
- setPopoverAnchorPosition({
- horizontal: cursorRelativePosition.horizontal + x,
- vertical: cursorRelativePosition.vertical + y,
- });
- });
- });
-
- return () => {
- dimensionsEventListener.remove();
- };
- }, [
- isPopoverVisible,
- reportActionID,
- preferredLocale,
- cursorRelativePosition.horizontal,
- cursorRelativePosition.vertical,
- popoverAnchorPosition.horizontal,
- popoverAnchorPosition.vertical,
- ]);
-
- useEffect(() => {
- if (!isPopoverVisible) {
- // If the popover is not visible, we don't need to update the component
- return;
- }
-
- // Hide the list when all reactions are removed
- const users = emojiReactions?.[emojiName]?.users;
-
- if (!users || Object.keys(users).length > 0) {
- return;
- }
-
- hideReactionList();
- }, [emojiReactions, emojiName, isPopoverVisible, reportActionID, preferredLocale]);
-
- return {isPopoverVisible, cursorRelativePosition, popoverAnchorPosition, getReactionInformation, hideReactionList, reactionListRef, showReactionList};
-}
diff --git a/src/hooks/useBasePopoverReactionList/types.ts b/src/hooks/useBasePopoverReactionList/types.ts
deleted file mode 100644
index 1b944f8cd233..000000000000
--- a/src/hooks/useBasePopoverReactionList/types.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type {ForwardedRef} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import type {LocaleContextProps} from '@components/LocaleContextProvider';
-import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
-import type {ReactionListAnchor, ReactionListEvent, ReactionListRef} from '@pages/inbox/ReportScreenContext';
-import type {ReportActionReactions} from '@src/types/onyx';
-
-type BasePopoverReactionListProps = {
- /** The ID of the report action */
- reportActionID: string;
-
- /** The emoji name */
- emojiName: string;
-
- /** Reference to the outer element */
- ref: ForwardedRef>;
-};
-
-type BasePopoverReactionListHookProps = Omit & {
- /** The reactions for the report action */
- emojiReactions: OnyxEntry;
-
- /** The current user's account ID */
- accountID: WithCurrentUserPersonalDetailsProps['currentUserPersonalDetails']['accountID'];
-
- preferredLocale: LocaleContextProps['preferredLocale'];
-};
-
-type ShowReactionList = (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor) => void;
-
-type InnerReactionListRef = {
- showReactionList: ShowReactionList;
- hideReactionList: () => void;
- isActiveReportAction: (actionID: number | string) => boolean;
-};
-
-export type {BasePopoverReactionListHookProps, BasePopoverReactionListProps, ShowReactionList, ReactionListAnchor, InnerReactionListRef};
diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts
index 53935137a3ca..8cf706060819 100644
--- a/src/hooks/useSearchPageSetup.ts
+++ b/src/hooks/useSearchPageSetup.ts
@@ -17,7 +17,7 @@ import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals';
* - Fires openSearch() to load bank account data
* - Re-fires openSearch() when coming back online
*/
-function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) {
+function useSearchPageSetup(queryJSON: Readonly | undefined) {
const {isOffline} = useNetwork();
const prevIsOffline = usePrevious(isOffline);
const {clearSelectedTransactions} = useSearchActionsContext();
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index dc5a9c3a183b..ac558c6966b1 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -450,7 +450,7 @@ function getDefaultSearchQueryJSON() {
return defaultSearchQueryJSON;
}
-function wasViewExplicitlySet(queryJSON?: SearchQueryJSON) {
+function wasViewExplicitlySet(queryJSON?: SearchQueryJSON | Readonly) {
if (!queryJSON?.view) {
return false;
}
@@ -578,17 +578,21 @@ function getRawFilterListFromQuery(rawQuery: SearchQueryString) {
}
// Cache for buildSearchQueryJSON to avoid re-running the PEG parser for identical queries.
+// Cached values are shallow-frozen; callers needing to change fields must spread into a new object first.
// This is a pure function called from 64+ sites — many fire during the same render cycle
// with identical query strings, each running the full parser from scratch.
-const buildSearchQueryJSONCache = new Map();
+const buildSearchQueryJSONCache = new Map | undefined>();
const BUILD_SEARCH_QUERY_JSON_CACHE_MAX_SIZE = 50;
const BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR = '\x00'; // Null byte prevents collisions if query/rawQuery contain arbitrary strings
-function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString) {
- const cacheKey = rawQuery ? `${query}${BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR}${rawQuery}` : query;
+function getBuildSearchQueryJSONCacheKey(query: SearchQueryString, rawQuery?: SearchQueryString) {
+ return rawQuery ? `${query}${BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR}${rawQuery}` : query;
+}
+
+function getCachedSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString): Readonly | undefined {
+ const cacheKey = getBuildSearchQueryJSONCacheKey(query, rawQuery);
if (buildSearchQueryJSONCache.has(cacheKey)) {
- const cached = buildSearchQueryJSONCache.get(cacheKey);
- return cached ? {...cached} : cached;
+ return buildSearchQueryJSONCache.get(cacheKey);
}
try {
@@ -628,21 +632,27 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt
buildSearchQueryJSONCache.delete(firstKey);
}
}
- buildSearchQueryJSONCache.set(cacheKey, result);
+ const frozen = Object.freeze(result);
+ buildSearchQueryJSONCache.set(cacheKey, frozen);
- return {...result};
+ return frozen;
} catch (e) {
console.error(`Error when parsing SearchQuery: "${query}"`, e);
}
}
+/** Parses {@link query} (and optionally {@link rawQuery}) into JSON. Repeated calls share the same cached object identity for referential stability. */
+function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString): Readonly | undefined {
+ return getCachedSearchQueryJSON(query, rawQuery);
+}
+
/**
* Formats a given `SearchQueryJSON` object into the string version of query.
* This format of query is the most basic string format and is used as the query param `q` in search URLs.
*
* In a way this is the reverse of buildSearchQueryJSON()
*/
-function buildSearchQueryString(queryJSON?: SearchQueryJSON) {
+function buildSearchQueryString(queryJSON?: SearchQueryJSON | Readonly) {
const queryParts: string[] = [];
const defaultQueryJSON = buildSearchQueryJSON('');
const isViewExplicitlySet = wasViewExplicitlySet(queryJSON);
@@ -1784,11 +1794,11 @@ function buildCannedSearchQuery({
return buildSearchQueryString(normalizedQueryJSON);
}
-function isDefaultExpensesQuery(queryJSON: SearchQueryJSON) {
+function isDefaultExpensesQuery(queryJSON: SearchQueryJSON | Readonly) {
return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && !queryJSON.status && !queryJSON.filters && !queryJSON.groupBy && !queryJSON.policyID;
}
-function isDefaultExpenseReportsQuery(queryJSON: SearchQueryJSON) {
+function isDefaultExpenseReportsQuery(queryJSON: SearchQueryJSON | Readonly) {
return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !queryJSON.status && !queryJSON.filters && !queryJSON.groupBy && !queryJSON.policyID;
}
@@ -1808,8 +1818,11 @@ const sortOptionsWithEmptyValue = (a: string, b: string, localeCompare: LocaleCo
/**
* Given a search query, this function will standardize the query by replacing display values with their corresponding IDs.
*/
-function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) {
- const standardQuery = cloneDeep(queryJSON);
+function traverseAndUpdatedQuery(
+ queryJSON: SearchQueryJSON | Readonly,
+ computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[],
+) {
+ const standardQuery = cloneDeep(queryJSON) as SearchQueryJSON;
const filters = standardQuery.filters;
const traverse = (node: ASTNode) => {
if (!node.operator) {
diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts
index a954196f0df9..0ed173b6ce97 100644
--- a/src/libs/SearchUIUtils.ts
+++ b/src/libs/SearchUIUtils.ts
@@ -2803,7 +2803,7 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFi
return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON));
}
-function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: SearchQueryJSON | undefined): number[] {
+function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: Readonly | undefined): number[] {
if (!data || !queryJSON?.groupBy) {
return [];
}
@@ -4395,7 +4395,7 @@ function shouldShowEmptyState(isDataLoaded: boolean, dataLength: number, type: S
return !isDataLoaded || dataLength === 0 || !type || !Object.values(CONST.SEARCH.DATA_TYPES).includes(type);
}
-function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON: SearchQueryJSON | undefined) {
+function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON: Readonly | undefined) {
const {status} = queryJSON ?? {};
const sortedSearchResultStatus = !Array.isArray(searchResults?.search?.status)
diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts
index 390ded37d3e8..3fb1e3a4942e 100644
--- a/src/libs/actions/IOU/index.ts
+++ b/src/libs/actions/IOU/index.ts
@@ -2530,7 +2530,7 @@ const expenseReportStatusFilterMapping: Record,
iouReport: OnyxEntry,
isInvoice: boolean | undefined,
transaction?: OnyxEntry,
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index 62f746d0c9d7..b10a6c9b5699 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -295,7 +295,7 @@ function getPayActionCallback(
function getOnyxLoadingData(
hash: number,
- queryJSON?: SearchQueryJSON,
+ queryJSON?: Readonly,
offset?: number,
isOffline?: boolean,
isSearchAPI = false,
@@ -354,7 +354,7 @@ function getOnyxLoadingData(
return {optimisticData, finallyData, failureData};
}
-function saveSearch({queryJSON, newName}: {queryJSON: SearchQueryJSON; newName?: string}) {
+function saveSearch({queryJSON, newName}: {queryJSON: Readonly; newName?: string}) {
const saveSearchName = newName ?? queryJSON?.inputQuery ?? '';
const jsonQuery = JSON.stringify(queryJSON);
@@ -521,7 +521,7 @@ function search({
shouldUpdateLastSearchParams = true,
skipWaitForWrites = false,
}: {
- queryJSON: SearchQueryJSON;
+ queryJSON: Readonly;
searchKey: SearchKey | undefined;
offset?: number;
shouldCalculateTotals?: boolean;
diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx
index 4943fef205d1..3bd150ac7293 100644
--- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react';
+import React from 'react';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
@@ -18,18 +18,14 @@ function JobTitle({onNext, onMove, isEditing}: JobTitleProps) {
const {translate} = useLocalize();
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
- const inputID = SIGNER_JOB_TITLE;
- const defaultValue = String(reimbursementAccountDraft?.[inputID] ?? '');
-
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- return getFieldRequiredErrors(values, [inputID], translate);
- },
- [inputID, translate],
- );
+ const defaultValue = String(reimbursementAccountDraft?.[SIGNER_JOB_TITLE] ?? '');
+
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ return getFieldRequiredErrors(values, [SIGNER_JOB_TITLE], translate);
+ };
const handleSubmit = useReimbursementAccountStepFormSubmit({
- fieldIds: [inputID],
+ fieldIds: [SIGNER_JOB_TITLE],
onNext,
shouldSaveDraft: isEditing,
});
@@ -43,7 +39,7 @@ function JobTitle({onNext, onMove, isEditing}: JobTitleProps) {
formTitle={translate('signerInfoStep.whatsYourJobTitle')}
validate={validate}
onSubmit={handleSubmit}
- inputId={inputID}
+ inputId={SIGNER_JOB_TITLE}
inputLabel={translate('signerInfoStep.jobTitle')}
inputMode={CONST.INPUT_MODE.TEXT}
defaultValue={defaultValue}
diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx
index 6b35a67c4c78..6cc9c8eaa730 100644
--- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react';
+import React from 'react';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
@@ -18,24 +18,20 @@ function Name({onNext, onMove, isEditing}: NameProps) {
const {translate} = useLocalize();
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
- const inputID = SIGNER_FULL_NAME;
- const defaultValue = String(reimbursementAccountDraft?.[inputID] ?? '');
+ const defaultValue = String(reimbursementAccountDraft?.[SIGNER_FULL_NAME] ?? '');
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = getFieldRequiredErrors(values, [inputID], translate);
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ const errors = getFieldRequiredErrors(values, [SIGNER_FULL_NAME], translate);
- if (values[inputID] && !isValidLegalName(String(values[inputID]))) {
- errors[inputID] = translate('bankAccount.error.fullName');
- }
+ if (values[SIGNER_FULL_NAME] && !isValidLegalName(String(values[SIGNER_FULL_NAME]))) {
+ errors[SIGNER_FULL_NAME] = translate('bankAccount.error.fullName');
+ }
- return errors;
- },
- [inputID, translate],
- );
+ return errors;
+ };
const handleSubmit = useReimbursementAccountStepFormSubmit({
- fieldIds: [inputID],
+ fieldIds: [SIGNER_FULL_NAME],
onNext,
shouldSaveDraft: isEditing,
});
@@ -49,7 +45,7 @@ function Name({onNext, onMove, isEditing}: NameProps) {
formTitle={translate('signerInfoStep.whatsYourName')}
validate={validate}
onSubmit={handleSubmit}
- inputId={inputID}
+ inputId={SIGNER_FULL_NAME}
inputLabel={translate('signerInfoStep.fullName')}
inputMode={CONST.INPUT_MODE.TEXT}
defaultValue={defaultValue}
diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx
index b8ca7467e107..6e8279bab6f0 100644
--- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
import Button from '@components/Button';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
@@ -43,39 +43,28 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) {
const isPDSandFSGDownloaded = reimbursementAccount?.achData?.corpay?.downloadedPDSandFSG ?? reimbursementAccountDraft?.[signerInfoKeys.DOWNLOADED_PDS_AND_FSG] ?? false;
const [isPDSandFSGDownloadedTouched, setIsPDSandFSGDownloadedTouched] = useState(false);
- const copyOfIDInputID = COPY_OF_ID;
- const addressProofInputID = ADDRESS_PROOF;
- const directorsProofInputID = PROOF_OF_DIRECTORS;
- const codiceFiscaleInputID = CODICE_FISCALE;
-
const defaultValues: Record = {
- [copyOfIDInputID]: Array.isArray(reimbursementAccountDraft?.[copyOfIDInputID]) ? (reimbursementAccountDraft?.[copyOfIDInputID] ?? []) : [],
- [addressProofInputID]: Array.isArray(reimbursementAccountDraft?.[addressProofInputID]) ? (reimbursementAccountDraft?.[addressProofInputID] ?? []) : [],
- [directorsProofInputID]: Array.isArray(reimbursementAccountDraft?.[directorsProofInputID]) ? (reimbursementAccountDraft?.[directorsProofInputID] ?? []) : [],
- [codiceFiscaleInputID]: Array.isArray(reimbursementAccountDraft?.[codiceFiscaleInputID]) ? (reimbursementAccountDraft?.[codiceFiscaleInputID] ?? []) : [],
+ [COPY_OF_ID]: Array.isArray(reimbursementAccountDraft?.[COPY_OF_ID]) ? (reimbursementAccountDraft?.[COPY_OF_ID] ?? []) : [],
+ [ADDRESS_PROOF]: Array.isArray(reimbursementAccountDraft?.[ADDRESS_PROOF]) ? (reimbursementAccountDraft?.[ADDRESS_PROOF] ?? []) : [],
+ [PROOF_OF_DIRECTORS]: Array.isArray(reimbursementAccountDraft?.[PROOF_OF_DIRECTORS]) ? (reimbursementAccountDraft?.[PROOF_OF_DIRECTORS] ?? []) : [],
+ [CODICE_FISCALE]: Array.isArray(reimbursementAccountDraft?.[CODICE_FISCALE]) ? (reimbursementAccountDraft?.[CODICE_FISCALE] ?? []) : [],
};
- const [uploadedIDs, setUploadedID] = useState(defaultValues[copyOfIDInputID]);
- const [uploadedProofsOfAddress, setUploadedProofOfAddress] = useState(defaultValues[addressProofInputID]);
- const [uploadedProofsOfDirectors, setUploadedProofsOfDirectors] = useState(defaultValues[directorsProofInputID]);
- const [uploadedCodiceFiscale, setUploadedCodiceFiscale] = useState(defaultValues[codiceFiscaleInputID]);
+ const [uploadedIDs, setUploadedID] = useState(defaultValues[COPY_OF_ID]);
+ const [uploadedProofsOfAddress, setUploadedProofOfAddress] = useState(defaultValues[ADDRESS_PROOF]);
+ const [uploadedProofsOfDirectors, setUploadedProofsOfDirectors] = useState(defaultValues[PROOF_OF_DIRECTORS]);
+ const [uploadedCodiceFiscale, setUploadedCodiceFiscale] = useState(defaultValues[CODICE_FISCALE]);
useEffect(() => {
getEnvironmentURL().then(setEnvironmentUrl);
}, []);
- const STEP_FIELDS = useMemo(
- (): Array> => [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID],
- [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID],
- );
+ const STEP_FIELDS: Array> = [COPY_OF_ID, ADDRESS_PROOF, PROOF_OF_DIRECTORS, CODICE_FISCALE];
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- setIsPDSandFSGDownloadedTouched(true);
- return getFieldRequiredErrors(values, STEP_FIELDS, translate);
- },
- [STEP_FIELDS, translate],
- );
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ setIsPDSandFSGDownloadedTouched(true);
+ return getFieldRequiredErrors(values, STEP_FIELDS, translate);
+ };
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: STEP_FIELDS,
@@ -137,16 +126,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) {
buttonText={translate('signerInfoStep.chooseFile')}
uploadedFiles={uploadedIDs}
onUpload={(files) => {
- handleSelectFile(files, uploadedIDs, copyOfIDInputID, setUploadedID);
+ handleSelectFile(files, uploadedIDs, COPY_OF_ID, setUploadedID);
}}
onRemove={(fileName) => {
- handleRemoveFile(fileName, uploadedIDs, copyOfIDInputID, setUploadedID);
+ handleRemoveFile(fileName, uploadedIDs, COPY_OF_ID, setUploadedID);
}}
acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]}
value={uploadedIDs}
- inputID={copyOfIDInputID}
+ inputID={COPY_OF_ID}
setError={(error) => {
- setUploadError(error, copyOfIDInputID);
+ setUploadError(error, COPY_OF_ID);
}}
fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT}
/>
@@ -165,16 +154,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) {
buttonText={translate('signerInfoStep.chooseFile')}
uploadedFiles={uploadedProofsOfAddress}
onUpload={(files) => {
- handleSelectFile(files, uploadedProofsOfAddress, addressProofInputID, setUploadedProofOfAddress);
+ handleSelectFile(files, uploadedProofsOfAddress, ADDRESS_PROOF, setUploadedProofOfAddress);
}}
onRemove={(fileName) => {
- handleRemoveFile(fileName, uploadedProofsOfAddress, addressProofInputID, setUploadedProofOfAddress);
+ handleRemoveFile(fileName, uploadedProofsOfAddress, ADDRESS_PROOF, setUploadedProofOfAddress);
}}
acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]}
value={uploadedProofsOfAddress}
- inputID={addressProofInputID}
+ inputID={ADDRESS_PROOF}
setError={(error) => {
- setUploadError(error, addressProofInputID);
+ setUploadError(error, ADDRESS_PROOF);
}}
fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT}
/>
@@ -192,16 +181,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) {
buttonText={translate('signerInfoStep.chooseFile')}
uploadedFiles={uploadedProofsOfDirectors}
onUpload={(files) => {
- handleSelectFile(files, uploadedProofsOfDirectors, directorsProofInputID, setUploadedProofsOfDirectors);
+ handleSelectFile(files, uploadedProofsOfDirectors, PROOF_OF_DIRECTORS, setUploadedProofsOfDirectors);
}}
onRemove={(fileName) => {
- handleRemoveFile(fileName, uploadedProofsOfDirectors, directorsProofInputID, setUploadedProofsOfDirectors);
+ handleRemoveFile(fileName, uploadedProofsOfDirectors, PROOF_OF_DIRECTORS, setUploadedProofsOfDirectors);
}}
acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]}
value={uploadedProofsOfDirectors}
- inputID={directorsProofInputID}
+ inputID={PROOF_OF_DIRECTORS}
setError={(error) => {
- setUploadError(error, directorsProofInputID);
+ setUploadError(error, PROOF_OF_DIRECTORS);
}}
fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT}
/>
@@ -217,16 +206,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) {
buttonText={translate('signerInfoStep.chooseFile')}
uploadedFiles={uploadedCodiceFiscale}
onUpload={(files) => {
- handleSelectFile(files, uploadedCodiceFiscale, codiceFiscaleInputID, setUploadedCodiceFiscale);
+ handleSelectFile(files, uploadedCodiceFiscale, CODICE_FISCALE, setUploadedCodiceFiscale);
}}
onRemove={(fileName) => {
- handleRemoveFile(fileName, uploadedCodiceFiscale, codiceFiscaleInputID, setUploadedCodiceFiscale);
+ handleRemoveFile(fileName, uploadedCodiceFiscale, CODICE_FISCALE, setUploadedCodiceFiscale);
}}
acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]}
value={uploadedCodiceFiscale}
- inputID={codiceFiscaleInputID}
+ inputID={CODICE_FISCALE}
setError={(error) => {
- setUploadError(error, codiceFiscaleInputID);
+ setUploadError(error, CODICE_FISCALE);
}}
fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT}
/>
diff --git a/src/pages/inbox/ReactionListWrapper.tsx b/src/pages/inbox/ReactionListWrapper.tsx
index 8d0372e232b9..17bbf9573e86 100644
--- a/src/pages/inbox/ReactionListWrapper.tsx
+++ b/src/pages/inbox/ReactionListWrapper.tsx
@@ -1,14 +1,105 @@
-import React, {useRef} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
+import type {SyntheticEvent} from 'react';
+import {Dimensions} from 'react-native';
import PopoverReactionList from './report/ReactionList/PopoverReactionList';
import {ReactionListContext} from './ReportScreenContext';
-import type {ReactionListRef} from './ReportScreenContext';
+import type {ReactionListAnchor, ReactionListContextType, ReactionListEvent} from './ReportScreenContext';
+
+type AnchorPosition = {horizontal: number; vertical: number};
+
+type ActiveReactionList = {
+ reportActionID: string;
+ emojiName: string;
+ cursorRelativePosition: AnchorPosition;
+ anchorPosition: AnchorPosition;
+};
+
+function getAnchorOrigin(anchor: ReactionListAnchor | null): {x: number; y: number} {
+ if (!anchor) {
+ return {x: 0, y: 0};
+ }
+ const rect = anchor.getBoundingClientRect();
+ return {x: rect.left, y: rect.top};
+}
+
+function getNativeMouseEvent(event: ReactionListEvent | undefined): {pageX: number; pageY: number} {
+ if (!event) {
+ return {pageX: 0, pageY: 0};
+ }
+ const synthetic = event as SyntheticEvent;
+ const native = synthetic.nativeEvent as {pageX?: number; pageY?: number} | undefined;
+ return {pageX: native?.pageX ?? 0, pageY: native?.pageY ?? 0};
+}
function ReactionListWrapper({children}: {children: React.ReactNode}) {
- const reactionListRef = useRef(null);
+ const [activeReactionList, setActiveReactionList] = useState(null);
+ const anchorRef = useRef(null);
+
+ const showReactionList = (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor, emojiName: string, reportActionID: string): void => {
+ const {pageX, pageY} = getNativeMouseEvent(event);
+ const {x, y} = getAnchorOrigin(reactionListAnchor);
+ anchorRef.current = reactionListAnchor;
+ setActiveReactionList({
+ reportActionID,
+ emojiName,
+ cursorRelativePosition: {horizontal: pageX - x, vertical: pageY - y},
+ anchorPosition: {horizontal: pageX, vertical: pageY},
+ });
+ };
+
+ const hideReactionList = (): void => {
+ anchorRef.current = null;
+ setActiveReactionList(null);
+ };
+
+ const isActiveReportAction = (reportActionID: number | string): boolean => !!reportActionID && !!activeReactionList && activeReactionList.reportActionID === reportActionID;
+
+ const isVisible = activeReactionList !== null;
+ const cursorHorizontal = activeReactionList?.cursorRelativePosition.horizontal ?? 0;
+ const cursorVertical = activeReactionList?.cursorRelativePosition.vertical ?? 0;
+
+ // Recompute the popover anchor position when window dimensions change so the popover
+ // stays anchored to the reaction bubble after rotation/resize.
+ useEffect(() => {
+ if (!isVisible) {
+ return;
+ }
+ const dimensionsEventListener = Dimensions.addEventListener('change', () => {
+ const {x, y} = getAnchorOrigin(anchorRef.current);
+ if (!x || !y) {
+ return;
+ }
+ setActiveReactionList((prev) => {
+ if (!prev) {
+ return prev;
+ }
+ return {
+ ...prev,
+ anchorPosition: {
+ horizontal: prev.cursorRelativePosition.horizontal + x,
+ vertical: prev.cursorRelativePosition.vertical + y,
+ },
+ };
+ });
+ });
+ return () => {
+ dimensionsEventListener.remove();
+ };
+ }, [isVisible, cursorHorizontal, cursorVertical]);
+
+ const contextValue: ReactionListContextType = {showReactionList, hideReactionList, isActiveReportAction};
+
return (
-
+
{children}
-
+
);
}
diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx
index 74e038890c2a..6790e427478a 100644
--- a/src/pages/inbox/ReportScreen.tsx
+++ b/src/pages/inbox/ReportScreen.tsx
@@ -72,10 +72,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
if (!shouldDeferNonEssentials) {
return;
}
- let animationFrameId: number;
+ const animationFrameRef = {current: 0};
const handle = TransitionTracker.runAfterTransitions({
callback: () => {
- animationFrameId = requestAnimationFrame(() => setShouldDeferNonEssentials(false));
+ animationFrameRef.current = requestAnimationFrame(() => setShouldDeferNonEssentials(false));
},
waitForUpcomingTransition: true,
});
@@ -84,7 +84,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
const safetyTimeout = setTimeout(() => setShouldDeferNonEssentials(false), CONST.MAX_TRANSITION_DURATION_MS * 3);
return () => {
handle.cancel();
- cancelAnimationFrame(animationFrameId);
+ cancelAnimationFrame(animationFrameRef.current);
clearTimeout(safetyTimeout);
};
}, [shouldDeferNonEssentials]),
diff --git a/src/pages/inbox/ReportScreenContext.ts b/src/pages/inbox/ReportScreenContext.ts
index 50b1e3e707f9..9102cdba56d3 100644
--- a/src/pages/inbox/ReportScreenContext.ts
+++ b/src/pages/inbox/ReportScreenContext.ts
@@ -7,10 +7,10 @@ type ReactionListAnchor = View | Text | HTMLDivElement | null;
type ReactionListEvent = GestureResponderEvent | MouseEvent | SyntheticEvent;
-type ReactionListRef = {
+type ReactionListContextType = {
showReactionList: (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor, emojiName: string, reportActionID: string) => void;
hideReactionList: () => void;
- isActiveReportAction: (actionID: number | string) => boolean;
+ isActiveReportAction: (reportActionID: number | string) => boolean;
};
type FlatListRefType = RefObject | null> | null;
@@ -22,10 +22,13 @@ type ActionListContextType = {
scrollPositionRef: RefObject;
scrollOffsetRef: RefObject;
};
-type ReactionListContextType = RefObject | null;
const ActionListContext = createContext({flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}});
-const ReactionListContext = createContext(null);
+const ReactionListContext = createContext({
+ showReactionList: () => {},
+ hideReactionList: () => {},
+ isActiveReportAction: () => false,
+});
export {ActionListContext, ReactionListContext};
-export type {ReactionListRef, ActionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition};
+export type {ReactionListContextType, ActionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition};
diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx
index 9b693721dc19..c00debdda5c1 100644
--- a/src/pages/inbox/report/PureReportActionItem.tsx
+++ b/src/pages/inbox/report/PureReportActionItem.tsx
@@ -363,7 +363,7 @@ function PureReportActionItem({
const shouldRenderViewBasedOnAction = useTableReportViewActionRenderConditionals(action);
const [isHidden, setIsHidden] = useState(false);
const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED);
- const reactionListRef = useContext(ReactionListContext);
+ const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext);
const {updateHiddenAttachments} = useContext(AttachmentModalContext);
const composerTextInputRef = useRef(null);
const popoverAnchorRef = useRef>(null);
@@ -464,11 +464,11 @@ function PureReportActionItem({
if (isActive(action.reportActionID)) {
hideEmojiPicker(true);
}
- if (reactionListRef?.current?.isActiveReportAction(action.reportActionID)) {
- reactionListRef?.current?.hideReactionList();
+ if (isActiveReactionListReportAction(action.reportActionID)) {
+ hideReactionList();
}
},
- [action.reportActionID, reactionListRef],
+ [action.reportActionID, isActiveReactionListReportAction, hideReactionList],
);
useEffect(() => {
diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx
new file mode 100644
index 000000000000..ff364f697142
--- /dev/null
+++ b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx
@@ -0,0 +1,68 @@
+import React, {useEffect} from 'react';
+import type {RefObject} from 'react';
+import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useOnyx from '@hooks/useOnyx';
+import {getEmojiReactionDetails} from '@libs/EmojiUtils';
+import {getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils';
+import type {ReactionListAnchor} from '@pages/inbox/ReportScreenContext';
+import ONYXKEYS from '@src/ONYXKEYS';
+import BaseReactionList from './BaseReactionList';
+
+type PopoverReactionListProps = {
+ isVisible: boolean;
+ emojiName: string;
+ reportActionID: string | undefined;
+ anchorPosition: {horizontal: number; vertical: number};
+ anchorRef: RefObject;
+ onClose: () => void;
+};
+
+function PopoverReactionList({isVisible, emojiName, reportActionID, anchorPosition, anchorRef, onClose}: PopoverReactionListProps) {
+ const {accountID} = useCurrentUserPersonalDetails();
+
+ const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`);
+
+ const selectedReaction = emojiReactions?.[emojiName];
+ const isReady = !!selectedReaction;
+ const {emojiCodes = [], reactionCount = 0, hasUserReacted = false, userAccountIDs = []} = selectedReaction ? getEmojiReactionDetails(emojiName, selectedReaction, accountID) : {};
+ const users = isReady ? getPersonalDetailsByIDs({accountIDs: userAccountIDs, currentUserAccountID: accountID, shouldChangeUserDisplayName: true}) : [];
+
+ // Hide the list when all reactions are removed
+ useEffect(() => {
+ if (!isVisible) {
+ return;
+ }
+ const reactionUsers = emojiReactions?.[emojiName]?.users;
+ if (!reactionUsers || Object.keys(reactionUsers).length > 0) {
+ return;
+ }
+ onClose();
+ }, [emojiReactions, emojiName, isVisible, onClose]);
+
+ return (
+
+
+
+ );
+}
+
+export default PopoverReactionList;
diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx
deleted file mode 100644
index 6f65e72888e6..000000000000
--- a/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, {useImperativeHandle} from 'react';
-import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
-import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
-import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import useBasePopoverReactionList from '@hooks/useBasePopoverReactionList';
-import type {BasePopoverReactionListProps} from '@hooks/useBasePopoverReactionList/types';
-import useLocalize from '@hooks/useLocalize';
-import useOnyx from '@hooks/useOnyx';
-import BaseReactionList from '@pages/inbox/report/ReactionList/BaseReactionList';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-
-type PopoverReactionListProps = WithCurrentUserPersonalDetailsProps & BasePopoverReactionListProps;
-
-function BasePopoverReactionList({emojiName, reportActionID, currentUserPersonalDetails, ref}: PopoverReactionListProps) {
- const {preferredLocale} = useLocalize();
-
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const reactionReportActionID = reportActionID || CONST.DEFAULT_NUMBER_ID;
- const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reactionReportActionID}`);
- const {isPopoverVisible, hideReactionList, showReactionList, popoverAnchorPosition, reactionListRef, getReactionInformation} = useBasePopoverReactionList({
- emojiName,
- emojiReactions,
- accountID: currentUserPersonalDetails.accountID,
- reportActionID,
- preferredLocale,
- });
-
- // Get the reaction information
- const {emojiCodes, reactionCount, hasUserReacted, users, isReady} = getReactionInformation();
- useImperativeHandle(ref, () => ({hideReactionList, showReactionList}));
-
- return (
-
-
-
- );
-}
-
-export default withCurrentUserPersonalDetails(BasePopoverReactionList);
diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList/index.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList/index.tsx
deleted file mode 100644
index c6a29e2c1af9..000000000000
--- a/src/pages/inbox/report/ReactionList/PopoverReactionList/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import React, {useImperativeHandle, useRef, useState} from 'react';
-import type {ForwardedRef} from 'react';
-import type {InnerReactionListRef} from '@hooks/useBasePopoverReactionList/types';
-import type {ReactionListRef} from '@pages/inbox/ReportScreenContext';
-import BasePopoverReactionList from './BasePopoverReactionList';
-
-type PopoverReactionListProps = {
- /** Reference to the outer element */
- ref?: ForwardedRef;
-};
-
-function PopoverReactionList({ref}: PopoverReactionListProps) {
- const innerReactionListRef = useRef(null);
- const [reactionListReportActionID, setReactionListReportActionID] = useState('');
- const [reactionListEmojiName, setReactionListEmojiName] = useState('');
-
- const showReactionList: ReactionListRef['showReactionList'] = (event, reactionListAnchor, emojiName, reportActionID) => {
- setReactionListReportActionID(reportActionID);
- setReactionListEmojiName(emojiName);
- innerReactionListRef.current?.showReactionList(event, reactionListAnchor);
- };
-
- const hideReactionList = () => {
- innerReactionListRef.current?.hideReactionList();
- };
-
- const isActiveReportAction = (actionID: number | string) => !!actionID && reactionListReportActionID === actionID;
-
- useImperativeHandle(ref, () => ({showReactionList, hideReactionList, isActiveReportAction}));
-
- return (
-
- );
-}
-
-PopoverReactionList.displayName = 'PopoverReactionList';
-
-export default React.memo(PopoverReactionList);
diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx
index f233f3a63152..1a1bb949be5e 100644
--- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx
+++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useEffect, useRef} from 'react';
import type {View} from 'react-native';
import type {Attachment} from '@components/Attachments/types';
import useNetwork from '@hooks/useNetwork';
@@ -55,48 +55,37 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen
const submitRef = useRef(null);
- const shouldFetchReport = useMemo(() => {
- return isEmptyObject(reportActions?.[reportActionID ?? CONST.DEFAULT_NUMBER_ID]);
- }, [reportActions, reportActionID]);
+ const shouldFetchReport = isEmptyObject(reportActions?.[reportActionID ?? CONST.DEFAULT_NUMBER_ID]);
- const isLoading = useMemo(() => {
- if (isOffline || isReportNotFound(report) || !reportActionReportID) {
- return false;
- }
+ let isLoading = false;
+ if (!(isOffline || isReportNotFound(report) || !reportActionReportID)) {
const isEmptyReport = isEmptyObject(report);
- return !!isLoadingApp || isEmptyReport || (reportLoadingState?.isLoadingInitialReportActions !== false && shouldFetchReport);
- }, [isOffline, reportActionReportID, isLoadingApp, report, reportLoadingState?.isLoadingInitialReportActions, shouldFetchReport]);
-
- const fetchReport = useCallback(() => {
- openReport({reportID: reportActionReportID, introSelected, reportActionID, betas});
- }, [reportActionReportID, introSelected, reportActionID, betas]);
+ isLoading = !!isLoadingApp || isEmptyReport || (reportLoadingState?.isLoadingInitialReportActions !== false && shouldFetchReport);
+ }
useEffect(() => {
if (!reportActionReportID || !shouldFetchReport) {
return;
}
- fetchReport();
- }, [reportActionReportID, fetchReport, shouldFetchReport]);
-
- const onCarouselAttachmentChange = useCallback(
- (attachment: Attachment) => {
- const routeToNavigate = ROUTES.REPORT_ATTACHMENTS.getRoute({
- reportID,
- reportActionID: attachment.reportActionID,
- attachmentID: attachment.attachmentID,
- type,
- source: SafeString(attachment.source),
- accountID,
- isAuthTokenRequired: attachment?.isAuthTokenRequired,
- originalFileName: attachment?.file?.name,
- attachmentLink: attachment?.attachmentLink,
- hashKey,
- });
- Navigation.navigate(routeToNavigate);
- },
- [reportID, reportActionID, type, accountID, hashKey],
- );
+ openReport({reportID: reportActionReportID, introSelected, reportActionID, betas});
+ }, [reportActionReportID, shouldFetchReport, introSelected, reportActionID, betas]);
+
+ const onCarouselAttachmentChange = (attachment: Attachment) => {
+ const routeToNavigate = ROUTES.REPORT_ATTACHMENTS.getRoute({
+ reportID,
+ reportActionID: attachment.reportActionID,
+ attachmentID: attachment.attachmentID,
+ type,
+ source: SafeString(attachment.source),
+ accountID,
+ isAuthTokenRequired: attachment?.isAuthTokenRequired,
+ originalFileName: attachment?.file?.name,
+ attachmentLink: attachment?.attachmentLink,
+ hashKey,
+ });
+ Navigation.navigate(routeToNavigate);
+ };
const onDownloadAttachment = useDownloadAttachment({
isAuthTokenRequired,
@@ -105,46 +94,29 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen
// Skip API root normalization for search attachments because this route is only opened from preview,
// which already passes a resolved source. Keep normalization for other types to support email entry points.
- const source = useMemo(() => getValidatedImageSource(sourceParam, type !== CONST.ATTACHMENT_TYPE.SEARCH), [sourceParam, type]);
+ const source = getValidatedImageSource(sourceParam, type !== CONST.ATTACHMENT_TYPE.SEARCH);
const modalType = useReportAttachmentModalType(source);
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundPage = !isLoading && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID;
- const contentProps = useMemo(
- () => ({
- // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource
- type,
- report,
- shouldShowNotFoundPage,
- isAuthTokenRequired: !!isAuthTokenRequired,
- attachmentLink: attachmentLink ?? '',
- originalFileName: originalFileName ?? '',
- isLoading,
- source,
- attachmentID,
- accountID,
- headerTitle,
- submitRef,
- onDownloadAttachment,
- onCarouselAttachmentChange,
- }),
- [
- accountID,
- attachmentID,
- attachmentLink,
- headerTitle,
- isAuthTokenRequired,
- isLoading,
- onCarouselAttachmentChange,
- onDownloadAttachment,
- originalFileName,
- report,
- shouldShowNotFoundPage,
- source,
- type,
- ],
- );
+ const contentProps: AttachmentModalBaseContentProps = {
+ // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource
+ type,
+ report,
+ shouldShowNotFoundPage,
+ isAuthTokenRequired: !!isAuthTokenRequired,
+ attachmentLink: attachmentLink ?? '',
+ originalFileName: originalFileName ?? '',
+ isLoading,
+ source,
+ attachmentID,
+ accountID,
+ headerTitle,
+ submitRef,
+ onDownloadAttachment,
+ onCarouselAttachmentChange,
+ };
return (
diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index 4cc3ebef27d1..c29caceee28f 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -3,13 +3,12 @@ import {differenceInDays} from 'date-fns';
import {stopLocationUpdatesAsync} from 'expo-location';
import React, {useContext, useEffect, useLayoutEffect, useRef} from 'react';
// eslint-disable-next-line no-restricted-imports
-import type {GestureResponderEvent, ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native';
+import type {ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
import AccountSwitcher from '@components/AccountSwitcher';
import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView';
import Icon from '@components/Icon';
-import MenuItem from '@components/MenuItem';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS';
import TabBarBottomContent from '@components/Navigation/TabBarBottomContent';
@@ -49,7 +48,6 @@ import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan
import {shouldHideOldAppRedirect} from '@libs/TryNewDotUtils';
import {getProfilePageBrickRoadIndicator} from '@libs/UserUtils';
import type SETTINGS_TO_RHP from '@navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP';
-import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';
import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const';
import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications';
import variables from '@styles/variables';
@@ -71,6 +69,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import type WithSentryLabel from '@src/types/utils/SentryLabel';
+import SettingsMenuItem from './SettingsMenuItem';
type InitialSettingsPageProps = WithCurrentUserPersonalDetailsProps;
@@ -101,6 +100,8 @@ type MenuData = WithSentryLabel & {
type Menu = {sectionStyle: StyleProp; sectionTranslationKey: TranslationPaths; items: MenuData[]};
+export type {MenuData};
+
function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPageProps) {
const {convertToDisplayString} = useCurrencyListActions();
const icons = useMemoizedLazyExpensifyIcons([
@@ -144,7 +145,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
const theme = useTheme();
const styles = useThemeStyles();
const {isExecuting, singleExecution} = useSingleExecution();
- const popoverAnchor = useRef(null);
const {translate} = useLocalize();
const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name);
const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? '';
@@ -416,30 +416,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
* @returns the menu items for passed data
*/
const getMenuItemsSection = (menuItemsData: Menu) => {
- const openPopover = (link: string | (() => Promise) | undefined, event: GestureResponderEvent | MouseEvent) => {
- if (!isScreenFocused) {
- return;
- }
-
- if (typeof link === 'function') {
- link?.()?.then((url) =>
- showContextMenu({
- type: CONST.CONTEXT_MENU_TYPES.LINK,
- event,
- selection: url,
- contextMenuAnchor: popoverAnchor.current,
- }),
- );
- } else if (link) {
- showContextMenu({
- type: CONST.CONTEXT_MENU_TYPES.LINK,
- event,
- selection: link,
- contextMenuAnchor: popoverAnchor.current,
- });
- }
- };
-
return (
openPopover(item.link, event) : undefined}
- shouldShowContextMenuHint={!!item.link}
- focused={isFocused}
- role={CONST.ROLE.TAB}
- isPaneMenu
- sentryLabel={item.sentryLabel}
- iconRight={item.iconRight}
- shouldShowRightIcon={item.shouldShowRightIcon}
- shouldIconUseAutoWidthStyle
+ wrapperStyle={styles.sectionMenuItem(shouldUseNarrowLayout)}
/>
);
})}
diff --git a/src/pages/settings/SettingsMenuItem.tsx b/src/pages/settings/SettingsMenuItem.tsx
new file mode 100644
index 000000000000..d0ad3258518f
--- /dev/null
+++ b/src/pages/settings/SettingsMenuItem.tsx
@@ -0,0 +1,81 @@
+import React, {useRef} from 'react';
+import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
+import MenuItem from '@components/MenuItem';
+import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';
+import CONST from '@src/CONST';
+import type {MenuData} from './InitialSettingsPage';
+
+type SettingsMenuItemProps = {
+ item: MenuData;
+ isFocused: boolean;
+ keyTitle: string | undefined;
+ isExecuting: boolean;
+ isScreenFocused: boolean;
+ onPress: () => void;
+ wrapperStyle: StyleProp;
+};
+
+function SettingsMenuItem({item, isFocused, keyTitle, isExecuting, isScreenFocused, onPress, wrapperStyle}: SettingsMenuItemProps) {
+ const popoverAnchor = useRef(null);
+
+ const onSecondaryInteraction = item.link
+ ? (event: GestureResponderEvent | MouseEvent) => {
+ if (!isScreenFocused) {
+ return;
+ }
+ const {link} = item;
+ if (typeof link === 'function') {
+ link()?.then((url) =>
+ showContextMenu({
+ type: CONST.CONTEXT_MENU_TYPES.LINK,
+ event,
+ selection: url,
+ contextMenuAnchor: popoverAnchor.current,
+ }),
+ );
+ } else if (link) {
+ showContextMenu({
+ type: CONST.CONTEXT_MENU_TYPES.LINK,
+ event,
+ selection: link,
+ contextMenuAnchor: popoverAnchor.current,
+ });
+ }
+ }
+ : undefined;
+
+ return (
+
+ );
+}
+
+SettingsMenuItem.displayName = 'SettingsMenuItem';
+
+export default SettingsMenuItem;
diff --git a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx
index 6481a3c7f72d..d2461603a43e 100644
--- a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx
+++ b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx
@@ -1,5 +1,4 @@
-import type {ReactNode} from 'react';
-import React, {useMemo} from 'react';
+import React from 'react';
import {View} from 'react-native';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import Button from '@components/Button';
@@ -20,7 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {cancelBillingSubscription} from '@libs/actions/Subscription';
import Navigation from '@libs/Navigation/Navigation';
import {canCancelSubscription} from '@libs/SubscriptionUtils';
-import type {CancellationType, FeedbackSurveyOptionID} from '@src/CONST';
+import type {FeedbackSurveyOptionID} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -47,98 +46,25 @@ function CancelSubscriptionPage() {
// Falls back to reading cancellation details directly on remount (hook only detects array growth).
// Skipped for eligible users so they see the survey, not a historical success screen.
- const resolvedCancellationType = useMemo(() => {
- if (cancellationTypeFromHook) {
- return cancellationTypeFromHook;
- }
- if (isEligibleToCancel || !cancellationDetails?.length) {
- return undefined;
- }
+ let resolvedCancellationType = cancellationTypeFromHook;
+ if (!resolvedCancellationType && !isEligibleToCancel && cancellationDetails?.length) {
const pendingManual = cancellationDetails.find((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.MANUAL && !detail.cancellationDate);
- if (pendingManual) {
- return CONST.CANCELLATION_TYPE.MANUAL;
- }
const noneEntry = cancellationDetails.find((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.NONE);
- if (noneEntry) {
- return CONST.CANCELLATION_TYPE.NONE;
+ if (pendingManual) {
+ resolvedCancellationType = CONST.CANCELLATION_TYPE.MANUAL;
+ } else if (noneEntry) {
+ resolvedCancellationType = CONST.CANCELLATION_TYPE.NONE;
+ } else {
+ resolvedCancellationType = CONST.CANCELLATION_TYPE.AUTOMATIC;
}
- return CONST.CANCELLATION_TYPE.AUTOMATIC;
- }, [cancellationTypeFromHook, cancellationDetails, isEligibleToCancel]);
+ }
const handleSubmit = (cancellationReason: FeedbackSurveyOptionID, cancellationNote = '') => {
cancelBillingSubscription(cancellationReason, cancellationNote);
};
- const acknowledgementText = useMemo(() => , [translate]);
-
- const manualCancellationContent = useMemo(
- () => (
-
-
- {translate('subscription.cancelSubscription.requestSubmitted.title')}
-
-
-
-
-
-
-
- ),
- [styles, translate],
- );
-
- const automaticCancellationContent = useMemo(
- () => (
-
-
- {translate('subscription.cancelSubscription.subscriptionCanceled.title')}
- {translate('subscription.cancelSubscription.subscriptionCanceled.subtitle')}
- {translate('subscription.cancelSubscription.subscriptionCanceled.info')}
-
-
-
-
-
-
- ),
- [styles, translate, workspacesListRoute],
- );
- const surveyContent = useMemo(
- () => (
- {acknowledgementText}}
- isNoteRequired
- isLoading={!!formState?.isLoading}
- enabledWhenOffline={false}
- />
- ),
- [acknowledgementText, formState?.isLoading, styles.flex1, styles.mb2, styles.mt4, translate],
- );
-
- const contentMap: Partial> = {
- [CONST.CANCELLATION_TYPE.MANUAL]: manualCancellationContent,
- [CONST.CANCELLATION_TYPE.AUTOMATIC]: automaticCancellationContent,
- [CONST.CANCELLATION_TYPE.NONE]: manualCancellationContent,
- };
-
- const screenContent = resolvedCancellationType ? contentMap[resolvedCancellationType] : surveyContent;
+ const isManualCancellation = resolvedCancellationType === CONST.CANCELLATION_TYPE.MANUAL || resolvedCancellationType === CONST.CANCELLATION_TYPE.NONE;
+ const isAutomaticCancellation = resolvedCancellationType === CONST.CANCELLATION_TYPE.AUTOMATIC;
if (isLoadingGuardData) {
return ;
@@ -158,7 +84,62 @@ function CancelSubscriptionPage() {
title={translate('subscription.cancelSubscription.title')}
onBackButtonPress={Navigation.goBack}
/>
- {screenContent}
+
+ {isManualCancellation && (
+
+
+ {translate('subscription.cancelSubscription.requestSubmitted.title')}
+
+
+
+
+
+
+
+ )}
+ {isAutomaticCancellation && (
+
+
+ {translate('subscription.cancelSubscription.subscriptionCanceled.title')}
+ {translate('subscription.cancelSubscription.subscriptionCanceled.subtitle')}
+ {translate('subscription.cancelSubscription.subscriptionCanceled.info')}
+
+
+
+
+
+
+ )}
+ {!resolvedCancellationType && (
+
+
+
+ }
+ isNoteRequired
+ isLoading={!!formState?.isLoading}
+ enabledWhenOffline={false}
+ />
+ )}
+
diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx
index da787c019817..02c7f55bb848 100644
--- a/src/pages/settings/Wallet/PaymentMethodList.tsx
+++ b/src/pages/settings/Wallet/PaymentMethodList.tsx
@@ -3,7 +3,7 @@ import {createPoliciesForDomainCardsSelector} from '@selectors/Policy';
import {FlashList} from '@shopify/flash-list';
import lodashSortBy from 'lodash/sortBy';
import type {ReactElement} from 'react';
-import React, {useCallback, useMemo} from 'react';
+import React from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {OnyxCollection} from 'react-native-onyx';
@@ -192,19 +192,14 @@ function PaymentMethodList({
const isLoadingBankAccountList = isLoadingOnyxValue(bankAccountListResult);
const [cardList = getEmptyObject(), cardListResult] = useOnyx(ONYXKEYS.CARD_LIST);
const isLoadingCardList = isLoadingOnyxValue(cardListResult);
- const cardDomains = useMemo(
- () =>
- shouldShowAssignedCards
- ? Object.values(isLoadingCardList ? {} : (cardList ?? {}))
- .filter((card) => !!card.domainName)
- .map((card) => card.domainName)
- : [],
- [shouldShowAssignedCards, isLoadingCardList, cardList],
- );
- const policiesForDomainCardsSelectorFactory = useMemo(() => createPoliciesForDomainCardsSelector(cardDomains), [cardDomains]);
- const policiesForDomainCardsSelector = useCallback((policies: OnyxCollection) => policiesForDomainCardsSelectorFactory(policies), [policiesForDomainCardsSelectorFactory]);
+ const cardDomains = shouldShowAssignedCards
+ ? Object.values(isLoadingCardList ? {} : (cardList ?? {}))
+ .filter((card) => !!card.domainName)
+ .map((card) => card.domainName)
+ : [];
+ const policiesForDomainCardsSelectorFactory = createPoliciesForDomainCardsSelector(cardDomains);
const [policiesForAssignedCards] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
- selector: policiesForDomainCardsSelector,
+ selector: (policies: OnyxCollection) => policiesForDomainCardsSelectorFactory(policies),
});
// Temporarily disabled because P2P debit cards are disabled.
// const [fundList = getEmptyObject()] = useOnyx(ONYXKEYS.FUND_LIST);
@@ -212,7 +207,7 @@ function PaymentMethodList({
const {shouldShowRbrForFeedNameWithDomainID} = useCardFeedErrors();
const shouldShowListFooterComponent = shouldShowAddBankAccount;
- const filteredPaymentMethods = useMemo(() => {
+ const computeFilteredPaymentMethods = (): Array => {
if (shouldShowAssignedCards) {
const assignedCards = Object.values(isLoadingCardList ? {} : (cardList ?? {}))
// Include active Expensify cards, company cards (domain), and personal cards
@@ -481,36 +476,11 @@ function PaymentMethodList({
};
});
return combinedPaymentMethods;
- }, [
- shouldShowAssignedCards,
- isLoadingBankAccountList,
- bankAccountList,
- customCardNames,
- styles,
- translate,
- isOffline,
- filterType,
- filterCurrency,
- excludeStates,
- isLoadingCardList,
- cardList,
- isBetaEnabled,
- onPress,
- policiesForAssignedCards,
- illustrations,
- companyCardFeedIcons,
- shouldShowRbrForFeedNameWithDomainID,
- privatePersonalDetails,
- shouldShowRightIcon,
- activePaymentMethodID,
- expensifyIcons.LuggageWithLines,
- expensifyIcons.ThreeDots,
- actionPaymentMethodType,
- onThreeDotsMenuPress,
- itemIconRight,
- ]);
-
- const onPressItem = useCallback(() => {
+ };
+
+ const filteredPaymentMethods = computeFilteredPaymentMethods();
+
+ const onPressItem = () => {
if (!isUserValidated && !shouldSkipDefaultAccountValidation) {
const path = Navigation.getActiveRoute();
if (path.includes(ROUTES.WORKSPACES_LIST.route) && policyID) {
@@ -521,81 +491,53 @@ function PaymentMethodList({
return;
}
onAddBankAccountPress();
- }, [isUserValidated, onAddBankAccountPress, policyID, shouldSkipDefaultAccountValidation]);
-
- const renderListFooterComponent = useCallback(
- () => (
-
- ),
-
- [onPressItem, translate, expensifyIcons.Plus, styles.paymentMethod, listItemStyle],
+ };
+
+ const renderListFooterComponent = () => (
+
);
- const itemsToRender = useMemo(() => {
- if (!shouldShowBankAccountSections) {
- return filteredPaymentMethods;
- }
- if (
- filteredPaymentMethods.find((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL) &&
- filteredPaymentMethods.find((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS)
- ) {
- return [
- translate('walletPage.personalBankAccounts'),
- ...filteredPaymentMethods.filter((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL),
- translate('walletPage.businessBankAccounts'),
- ...filteredPaymentMethods.filter((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS),
- ];
- }
- return filteredPaymentMethods;
- }, [filteredPaymentMethods, shouldShowBankAccountSections, translate]);
-
- const filteredPaymentMethodsWithoutStrings = useMemo(() => filteredPaymentMethods.filter((method) => typeof method !== 'string'), [filteredPaymentMethods]);
-
- /**
- * Create a menuItem for each passed paymentMethod
- */
- const renderItem = useCallback(
- ({item, index}: RenderSuggestionMenuItemProps) => {
- if (typeof item === 'string') {
- return (
-
- {item}
-
- );
- }
+ const hasPersonalBank = filteredPaymentMethods.find((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL);
+ const hasBusinessBank = filteredPaymentMethods.find((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS);
+ const itemsToRender =
+ shouldShowBankAccountSections && hasPersonalBank && hasBusinessBank
+ ? [
+ translate('walletPage.personalBankAccounts'),
+ ...filteredPaymentMethods.filter((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL),
+ translate('walletPage.businessBankAccounts'),
+ ...filteredPaymentMethods.filter((method) => (method as BankAccount).accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS),
+ ]
+ : filteredPaymentMethods;
+
+ const filteredPaymentMethodsWithoutStrings = filteredPaymentMethods.filter((method) => typeof method !== 'string');
+
+ const renderItem = ({item, index}: RenderSuggestionMenuItemProps) => {
+ if (typeof item === 'string') {
return (
-
+
+ {item}
+
);
- },
- [
- filteredPaymentMethodsWithoutStrings,
- invoiceTransferBankAccountID,
- userWallet?.walletLinkedAccountID,
- shouldHideDefaultBadge,
- listItemStyle,
- threeDotsMenuItems,
- styles.mt4,
- styles.mt6,
- styles.mb1,
- styles.textLabel,
- styles.colorMuted,
- ],
- );
+ }
+ return (
+
+ );
+ };
return (
diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx
index c3736bc6e020..b859721a626a 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitTypePage.tsx
@@ -1,6 +1,6 @@
import {useFocusEffect} from '@react-navigation/native';
import {format, toZonedTime} from 'date-fns-tz';
-import React, {useMemo, useRef, useState} from 'react';
+import React, {useRef, useState} from 'react';
import {View} from 'react-native';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import ConfirmModal from '@components/ConfirmModal';
@@ -18,7 +18,6 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useDefaultFundID from '@hooks/useDefaultFundID';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
-import usePermissions from '@hooks/usePermissions';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';
import {updateExpensifyCardLimitType} from '@libs/actions/Card';
@@ -51,7 +50,6 @@ function WorkspaceEditCardLimitTypePage({route}: WorkspaceEditCardLimitTypePageP
const styles = useThemeStyles();
const formRef = useRef(null);
- const {isBetaEnabled} = usePermissions();
const policy = usePolicy(policyID);
const defaultFundID = useDefaultFundID(policyID);
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards});
@@ -77,28 +75,13 @@ function WorkspaceEditCardLimitTypePage({route}: WorkspaceEditCardLimitTypePageP
const assigneePersonalDetails = personalDetails?.[card?.accountID ?? CONST.DEFAULT_NUMBER_ID];
const assigneeTimeZone = assigneePersonalDetails?.timezone?.selected;
- const minDate = useMemo(() => {
- if (!assigneeTimeZone) {
- return new Date();
- }
- return toZonedTime(new Date(), assigneeTimeZone);
- }, [assigneeTimeZone]);
+ const minDate = assigneeTimeZone ? toZonedTime(new Date(), assigneeTimeZone) : new Date();
- const validFromDefaultValue = useMemo(() => {
- const validFrom = card?.nameValuePairs?.validFrom;
- if (!validFrom) {
- return format(minDate, CONST.DATE.FNS_FORMAT_STRING);
- }
- return DateUtils.formatUTCDateTimeToDateInTimezone(validFrom, assigneeTimeZone);
- }, [card?.nameValuePairs?.validFrom, assigneeTimeZone, minDate]);
+ const validFrom = card?.nameValuePairs?.validFrom;
+ const validFromDefaultValue = validFrom ? DateUtils.formatUTCDateTimeToDateInTimezone(validFrom, assigneeTimeZone) : format(minDate, CONST.DATE.FNS_FORMAT_STRING);
- const validThruDefaultValue = useMemo(() => {
- const validThru = card?.nameValuePairs?.validThru;
- if (!validThru) {
- return undefined;
- }
- return DateUtils.formatUTCDateTimeToDateInTimezone(validThru, assigneeTimeZone);
- }, [card?.nameValuePairs?.validThru, assigneeTimeZone]);
+ const validThru = card?.nameValuePairs?.validThru;
+ const validThruDefaultValue = validThru ? DateUtils.formatUTCDateTimeToDateInTimezone(validThru, assigneeTimeZone) : undefined;
const goBack = () => {
if (backTo) {
@@ -169,48 +152,45 @@ function WorkspaceEditCardLimitTypePage({route}: WorkspaceEditCardLimitTypePageP
}
}
- const data = useMemo(() => {
- const options = [];
-
- if (areApprovalsConfigured) {
- options.push({
- value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
- label: translate('workspace.card.issueNewCard.smartLimit'),
- description: translate('workspace.card.issueNewCard.smartLimitDescription'),
- keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
- isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
- });
- }
+ const data = [];
- options.push({
- value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
- label: translate('workspace.card.issueNewCard.monthly'),
- description: translate('workspace.card.issueNewCard.monthlyDescription'),
- keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
- isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ if (areApprovalsConfigured) {
+ data.push({
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
+ label: translate('workspace.card.issueNewCard.smartLimit'),
+ description: translate('workspace.card.issueNewCard.smartLimitDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
});
+ }
- if (shouldShowFixedOption) {
- options.push({
- value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
- label: translate('workspace.card.issueNewCard.fixedAmount'),
- description: translate('workspace.card.issueNewCard.fixedAmountDescription'),
- keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
- isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
- });
- }
+ data.push({
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ label: translate('workspace.card.issueNewCard.monthly'),
+ description: translate('workspace.card.issueNewCard.monthlyDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ });
+
+ if (shouldShowFixedOption) {
+ data.push({
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
+ label: translate('workspace.card.issueNewCard.fixedAmount'),
+ description: translate('workspace.card.issueNewCard.fixedAmountDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
+ });
+ }
- if (card?.nameValuePairs?.isVirtual) {
- options.push({
- value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE,
- label: translate('workspace.card.issueNewCard.singleUse'),
- description: translate('workspace.card.issueNewCard.singleUseDescription'),
- keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE,
- isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE,
- });
- }
- return options;
- }, [areApprovalsConfigured, translate, typeSelected, shouldShowFixedOption, card?.nameValuePairs?.isVirtual, isBetaEnabled]);
+ if (card?.nameValuePairs?.isVirtual) {
+ data.push({
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE,
+ label: translate('workspace.card.issueNewCard.singleUse'),
+ description: translate('workspace.card.issueNewCard.singleUseDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE,
+ });
+ }
const validate = (values: FormOnyxValues) => {
if (!expirationToggle) {
diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingExportPage.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingExportPage.tsx
index b0fc9390b03a..9f3f32ca1720 100644
--- a/src/pages/workspace/travel/WorkspaceTravelInvoicingExportPage.tsx
+++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingExportPage.tsx
@@ -1,5 +1,5 @@
import {endOfMonth, format, startOfMonth} from 'date-fns';
-import React, {useCallback, useEffect, useRef, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import Button from '@components/Button';
import FormHelpMessage from '@components/FormHelpMessage';
@@ -127,7 +127,7 @@ function WorkspaceTravelInvoicingExportPage({route}: WorkspaceTravelInvoicingExp
* Callers must validate via hasDateSelected() before calling — this function
* assumes the selection is complete (ON is set, or both AFTER and BEFORE are set).
*/
- const getDateRange = useCallback((): {startDate: string; endDate: string} => {
+ const getDateRange = (): {startDate: string; endDate: string} => {
const values = dateFilterBaseRef.current?.getDateValues();
const dateOn = values?.[CONST.SEARCH.DATE_MODIFIERS.ON];
const dateAfter = values?.[CONST.SEARCH.DATE_MODIFIERS.AFTER];
@@ -160,13 +160,13 @@ function WorkspaceTravelInvoicingExportPage({route}: WorkspaceTravelInvoicingExp
startDate: format(startOfMonth(now), 'yyyy-MM-dd'),
endDate: format(endOfMonth(now), 'yyyy-MM-dd'),
};
- }, []);
+ };
/**
* Handles PDF export — always requests fresh generation from the backend.
* The useEffect below auto-downloads the file once generation completes.
*/
- const processDownload = useCallback(() => {
+ const processDownload = () => {
if (isGenerating) {
return;
}
@@ -185,7 +185,7 @@ function WorkspaceTravelInvoicingExportPage({route}: WorkspaceTravelInvoicingExp
setIsDownloading(true);
getTravelInvoiceStatementPDF(policyID, startDate, endDate);
- }, [getDateRange, hasDateSelected, isDateRangeInvalid, isGenerating, policyID, translate]);
+ };
useEffect(() => {
if (!prevIsGenerating || isGenerating) {
diff --git a/src/types/onyx/ReportNavigation.ts b/src/types/onyx/ReportNavigation.ts
index a7af1edf38ba..f2d4f8156563 100644
--- a/src/types/onyx/ReportNavigation.ts
+++ b/src/types/onyx/ReportNavigation.ts
@@ -23,7 +23,7 @@ type LastSearchParams = {
/**
* The full query JSON object that was used in the last search.
*/
- queryJSON?: SearchQueryJSON;
+ queryJSON?: Readonly;
/**
* The current offset used in pagination for fetching the previous set of results.
*/
diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx
index 60a375dde90d..276b0c3446b3 100644
--- a/tests/perf-test/ReportActionsList.perf-test.tsx
+++ b/tests/perf-test/ReportActionsList.perf-test.tsx
@@ -74,6 +74,11 @@ const mockOnLayout = jest.fn();
const mockOnScroll = jest.fn();
const mockLoadChats = jest.fn();
const mockRef = {current: null, flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}};
+const mockReactionListContextValue = {
+ showReactionList: () => {},
+ hideReactionList: () => {},
+ isActiveReportAction: () => false,
+};
const TEST_USER_ACCOUNT_ID = 1;
const TEST_USER_LOGIN = 'test@test.com';
@@ -101,7 +106,7 @@ function ReportActionsListWrapper() {
return (
-
+
{
});
it('should not include view in query string when groupBy is removed after chart drill-down', () => {
- const queryJSON = buildSearchQueryJSON('type:expense date:this-month groupBy:category view:bar');
+ const parsed = buildSearchQueryJSON('type:expense date:this-month groupBy:category view:bar');
- if (!queryJSON) {
+ if (!parsed) {
throw new Error('Failed to parse query string');
}
- queryJSON.groupBy = undefined;
- queryJSON.view = CONST.SEARCH.VIEW.TABLE;
+ const queryJSON: SearchQueryJSON = {
+ ...parsed,
+ groupBy: undefined,
+ view: CONST.SEARCH.VIEW.TABLE,
+ };
const result = buildSearchQueryString(queryJSON);
@@ -1999,28 +2002,31 @@ describe('SearchQueryUtils', () => {
});
describe('buildSearchQueryJSON cache', () => {
- test('mutating the returned object does not affect subsequent calls for the same query', () => {
+ test('mutating a spread copy does not affect the cached frozen object or later calls', () => {
const query = `type:expense groupBy:category view:bar date:last-month merchant:test${Date.now()}`;
- const first = buildSearchQueryJSON(query);
- if (first) {
- first.groupBy = undefined;
- first.view = CONST.SEARCH.VIEW.TABLE;
- }
+ const parsed = buildSearchQueryJSON(query);
+ expect(parsed).toBeDefined();
+ expect(Object.isFrozen(parsed)).toBe(true);
- const second = buildSearchQueryJSON(query);
+ const mutableCopy = {...parsed};
+ mutableCopy.groupBy = undefined;
+ mutableCopy.view = CONST.SEARCH.VIEW.TABLE;
- expect(second?.groupBy).toBe('category');
- expect(second?.view).toBe('bar');
+ expect(buildSearchQueryJSON(query)?.groupBy).toBe('category');
+ expect(buildSearchQueryJSON(query)?.view).toBe('bar');
+ expect(mutableCopy.groupBy).toBeUndefined();
});
- test('returns equal result on repeated calls with the same query', () => {
+ test('returns the same reference on repeated calls with the same query', () => {
const query = 'type:expense status:outstanding';
const first = buildSearchQueryJSON(query);
const second = buildSearchQueryJSON(query);
expect(first).toEqual(second);
+ expect(first).toBe(second);
+ expect(Object.isFrozen(first)).toBe(true);
});
test('returns independent results for the same query with different rawQuery values', () => {