Skip to content

Commit 52ec734

Browse files
committed
Merge branch 'main' of github.com:callstack-internal/Expensify-App into perf-send-message-phase-12
2 parents 3a32e42 + c4ba24a commit 52ec734

11 files changed

Lines changed: 503 additions & 72 deletions

File tree

contributingGuides/NAVIGATION.md

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,7 @@ In Expensify, we use an extended implementation of this function because:
11411141
- In case of opening the RHP, appropriate screens should be pushed to the navigation to be displayed below the overlay. A guide on how to set up a good screen for RHP can be found [here](#how-to-set-a-correct-screen-below-the-rhp).
11421142
- When opening the settings of a specific workspace, the workspace list needs to be pushed to the state.
11431143
- When the `backTo` parameter is in the URL, we need to build a state also for the screen we want to return to. (`backTo` parameter is deprecated, more information can be found [here](#how-to-remove-backto-from-url))
1144-
- For dynamic routes, state is built from the current path and [entryScreens](#entry-screens-access-control) access control.
1144+
- For dynamic routes, when the page is refreshed the navigation state only contains the deepest screen. To make back navigation work, all intermediate dynamic screens must be inserted into the state in the correct order so the app knows which screen to return to.
11451145

11461146
Here are examples how the state is generated based on route:
11471147

@@ -1255,6 +1255,77 @@ As you can see after opening the workspace settings of the specific workspace, w
12551255

12561256
In the above example, we can see that when building a state from a link leading to a screen in RHP, screens that appear below the overlay are also built.
12571257

1258+
- `settings/profile/address/country?country=US`
1259+
1260+
```json
1261+
{
1262+
"stale": false,
1263+
"type": "stack",
1264+
"key": "stack-key-7",
1265+
"index": 1,
1266+
"routes": [
1267+
{
1268+
"name": "SettingsSplitNavigator",
1269+
"state": {
1270+
"stale": false,
1271+
"type": "stack",
1272+
"key": "stack-key-8",
1273+
"index": 1,
1274+
"routes": [
1275+
{
1276+
"name": "Settings_Root",
1277+
"key": "Settings_Root-key"
1278+
},
1279+
{
1280+
"name": "Settings_Profile",
1281+
"key": "Settings_Profile-key"
1282+
}
1283+
]
1284+
},
1285+
"key": "SettingsSplitNavigator-key"
1286+
},
1287+
{
1288+
"name": "RightModalNavigator",
1289+
"state": {
1290+
"stale": false,
1291+
"type": "stack",
1292+
"key": "stack-key-9",
1293+
"index": 0,
1294+
"routes": [
1295+
{
1296+
"name": "Settings",
1297+
"state": {
1298+
"stale": false,
1299+
"type": "stack",
1300+
"key": "stack-key-10",
1301+
"index": 1,
1302+
"routes": [
1303+
{
1304+
"name": "Settings_Address",
1305+
"path": "/settings/profile/address",
1306+
"key": "Settings_Address-key"
1307+
},
1308+
{
1309+
"name": "Dynamic_Address_Country",
1310+
"path": "/settings/profile/address/country?country=US",
1311+
"params": {
1312+
"country": "US"
1313+
},
1314+
"key": "Dynamic_Address_Country-key"
1315+
}
1316+
]
1317+
},
1318+
"key": "Settings-key"
1319+
}
1320+
]
1321+
},
1322+
"key": "RightModalNavigator-key"
1323+
}
1324+
]
1325+
}
1326+
```
1327+
Since `country` is a dynamic suffix, the `Dynamic_Address_Country` screen is layered on top of the static `Settings_Address` screen in the initial state, ensuring correct back navigation after a refresh.
1328+
12581329
## Setting the correct screen underneath RHP
12591330

12601331
RHP screens can usually be opened from a specific central screen. Of course there are cases where one RHP screen can be used in different tabs. However, most often one RHP screen has a specific central screen assigned underneath.

src/libs/Navigation/Navigation.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {CommonActions, StackActions} from '@react-navigation/native';
44
import {Str} from 'expensify-common';
55
// eslint-disable-next-line you-dont-need-lodash-underscore/omit
66
import omit from 'lodash/omit';
7-
import {nanoid} from 'nanoid/non-secure';
87
import {DeviceEventEmitter, Dimensions} from 'react-native';
98
import type {OnyxEntry} from 'react-native-onyx';
109
import Onyx from 'react-native-onyx';
@@ -27,7 +26,6 @@ import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS';
2726
import type {SidePanel} from '@src/types/onyx';
2827
import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState';
2928
import originalCloseRHPFlow from './helpers/closeRHPFlow';
30-
import findMatchingDynamicSuffix from './helpers/dynamicRoutesUtils/findMatchingDynamicSuffix';
3129
import getPathFromState from './helpers/getPathFromState';
3230
import getStateFromPath from './helpers/getStateFromPath';
3331
import getTopmostReportParams from './helpers/getTopmostReportParams';
@@ -446,22 +444,6 @@ function goUp(backToRoute: Route, options?: GoBackOptions) {
446444

447445
// If we need to pop more than one route from rootState, we replace the current route to not lose visited routes from the navigation state
448446
if (indexOfBackToRoute === -1 || (isRootNavigatorState(targetState) && distanceToPop > 1)) {
449-
const actionPayload = minimalAction.payload as NavigationRoute;
450-
451-
// StackRouter's REPLACE drops `path`, use a targeted RESET for dynamic routes to preserve it.
452-
if (actionPayload?.path && findMatchingDynamicSuffix(backToRoute)) {
453-
const routes = targetState.routes.with(targetState.index ?? targetState.routes.length - 1, {
454-
key: `${actionPayload.name}-${nanoid()}`,
455-
name: actionPayload.name,
456-
params: actionPayload.params,
457-
path: actionPayload.path,
458-
});
459-
460-
const resetAction = {type: CONST.NAVIGATION_ACTIONS.RESET, payload: {index: targetState.index, routes}, target: targetState.key} as NavigationAction;
461-
navigationRef.current.dispatch(resetAction);
462-
return;
463-
}
464-
465447
const replaceAction = {...minimalAction, type: CONST.NAVIGATION.ACTION_TYPE.REPLACE} as NavigationAction;
466448
navigationRef.current.dispatch(replaceAction);
467449
return;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type {NavigationState, PartialState} from '@react-navigation/native';
2+
import findFocusedRouteWithOnyxTabGuard from '@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard';
3+
import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath';
4+
import type {Route} from '@src/ROUTES';
5+
import type {Screen} from '@src/SCREENS';
6+
import findMatchingDynamicSuffix from './findMatchingDynamicSuffix';
7+
import getPathWithoutDynamicSuffix from './getPathWithoutDynamicSuffix';
8+
import isDynamicRouteScreen from './isDynamicRouteScreen';
9+
10+
/**
11+
* Recursively inserts `stateToInsert` below `accumulatedState` in the
12+
* navigation tree. At each level, if the first route names match we recurse
13+
* deeper; otherwise the route from `stateToInsert` is prepended and the index
14+
* is shifted so the previously focused route stays focused.
15+
*
16+
* @param accumulatedState - The accumulated state to insert the new state below.
17+
* @param stateToInsert - The state to insert below the accumulated state.
18+
* @returns The new state with the new state inserted below the accumulated state.
19+
*
20+
* @private - Internal helper. Do not export or use outside this file.
21+
*/
22+
function insertStateBelow(accumulatedState: PartialState<NavigationState>, stateToInsert: PartialState<NavigationState>): PartialState<NavigationState> {
23+
const routeToInsert = stateToInsert.routes.at(0);
24+
if (!routeToInsert) {
25+
return accumulatedState;
26+
}
27+
28+
const existingRoute = accumulatedState.routes.at(0);
29+
if (!existingRoute) {
30+
return stateToInsert;
31+
}
32+
33+
// Same navigator at this level - recurse deeper to find the divergence point.
34+
if (routeToInsert.name === existingRoute.name) {
35+
const existingNestedState = existingRoute.state as PartialState<NavigationState> | undefined;
36+
const nestedStateToInsert = routeToInsert.state as PartialState<NavigationState> | undefined;
37+
38+
if (existingNestedState && nestedStateToInsert) {
39+
const mergedNestedState = insertStateBelow(existingNestedState, nestedStateToInsert);
40+
const updatedRoute = {...existingRoute, state: mergedNestedState};
41+
const updatedRoutes = [updatedRoute, ...accumulatedState.routes.slice(1)];
42+
return {
43+
...accumulatedState,
44+
routes: updatedRoutes,
45+
index: accumulatedState.index ?? accumulatedState.routes.length - 1,
46+
} as PartialState<NavigationState>;
47+
}
48+
49+
return accumulatedState;
50+
}
51+
52+
// Different navigator - this is the divergence point.
53+
// Prepend the new route and shift the index so the previously focused route stays focused.
54+
const updatedRoutes = [routeToInsert, ...accumulatedState.routes];
55+
return {
56+
...accumulatedState,
57+
routes: updatedRoutes,
58+
index: (accumulatedState.index ?? accumulatedState.routes.length - 1) + 1,
59+
} as PartialState<NavigationState>;
60+
}
61+
62+
/**
63+
* Iteratively strips dynamic suffixes from a URL and inserts each intermediate
64+
* navigation state below the accumulated state. This restores the full
65+
* screen chain so that back-navigation works correctly after a page refresh
66+
* when the URL contains stacked dynamic suffixes.
67+
*
68+
* @param state - Initial navigation state (from getStateFromPath for the full URL)
69+
* @param focusedRoutePath - The full URL path of the currently focused route
70+
* @returns Navigation state with all intermediate screens present
71+
*/
72+
function getDynamicRouteAdaptedState(state: PartialState<NavigationState>, focusedRoutePath: string): PartialState<NavigationState> {
73+
let accumulatedState = state;
74+
let currentPath = focusedRoutePath;
75+
76+
let newFocused = findFocusedRouteWithOnyxTabGuard(accumulatedState);
77+
do {
78+
const suffixMatch = findMatchingDynamicSuffix(currentPath);
79+
if (!suffixMatch) {
80+
break;
81+
}
82+
83+
const basePath = getPathWithoutDynamicSuffix(currentPath, suffixMatch.actualSuffix, suffixMatch.pattern);
84+
if (!basePath || basePath === currentPath) {
85+
break;
86+
}
87+
88+
const baseState = getStateFromPath(basePath as Route);
89+
if (!baseState) {
90+
break;
91+
}
92+
93+
accumulatedState = insertStateBelow(accumulatedState, baseState);
94+
currentPath = basePath;
95+
96+
newFocused = findFocusedRouteWithOnyxTabGuard(baseState);
97+
} while (!!newFocused && isDynamicRouteScreen(newFocused.name as Screen));
98+
99+
return accumulatedState;
100+
}
101+
102+
export default getDynamicRouteAdaptedState;

src/libs/Navigation/helpers/getAdaptedStateFromPath.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ROUTES from '@src/ROUTES';
1212
import SCREENS from '@src/SCREENS';
1313
import type {Screen} from '@src/SCREENS';
1414
import findMatchingDynamicSuffix from './dynamicRoutesUtils/findMatchingDynamicSuffix';
15+
import getDynamicRouteAdaptedState from './dynamicRoutesUtils/getDynamicRouteAdaptedState';
1516
import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix';
1617
import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen';
1718
import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard';
@@ -294,16 +295,28 @@ function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamL
294295
if (!fullScreenRoute) {
295296
const focusedRoute = findFocusedRouteWithOnyxTabGuard(state);
296297

298+
let currentState = state;
299+
if (focusedRoute?.path && isDynamicRouteScreen(focusedRoute.name as Screen)) {
300+
currentState = getDynamicRouteAdaptedState(state, focusedRoute.path) as PartialState<NavigationState<RootNavigatorParamList>>;
301+
302+
// getDynamicRouteAdaptedState may have already resolved the full screen route.
303+
// In that case, skip the default full screen route injection below - the state is already complete.
304+
const hasFullScreenRoute = currentState.routes.some((route) => isFullScreenName(route.name));
305+
if (hasFullScreenRoute) {
306+
return currentState;
307+
}
308+
}
309+
297310
if (focusedRoute) {
298311
const matchingRootRoute = getMatchingFullScreenRoute(focusedRoute);
299312

300313
// If there is a matching root route, add it to the state.
301314
if (matchingRootRoute) {
302-
return getRoutesWithIndex([matchingRootRoute, ...state.routes]);
315+
return getRoutesWithIndex([matchingRootRoute, ...currentState.routes]);
303316
}
304317
}
305318

306-
const onboardingNavigator = state.routes.find((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR);
319+
const onboardingNavigator = currentState.routes.find((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR);
307320

308321
// The onboarding flow consists of several screens. If we open any of the screens, the previous screens from that flow should be in the state.
309322
if (onboardingNavigator?.state) {
@@ -315,16 +328,16 @@ function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamL
315328
return getRoutesWithIndex([{name: SCREENS.HOME}, adaptedOnboardingNavigator]);
316329
}
317330

318-
const isRightModalNavigator = state.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
331+
const isRightModalNavigator = currentState.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
319332

320333
if (isRightModalNavigator) {
321-
return getRoutesWithIndex([{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, ...state.routes]);
334+
return getRoutesWithIndex([{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, ...currentState.routes]);
322335
}
323336

324337
const defaultFullScreenRoute = getDefaultFullScreenRoute(focusedRoute);
325338

326339
// If not, add the default full screen route.
327-
return getRoutesWithIndex([defaultFullScreenRoute, ...state.routes]);
340+
return getRoutesWithIndex([defaultFullScreenRoute, ...currentState.routes]);
328341
}
329342

330343
return state;

src/libs/Navigation/helpers/getPathFromState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ function getPathFromState(state: State): string {
149149
const screenName = focusedRoute?.name ?? '';
150150

151151
if (isDynamicRouteScreen(screenName as Screen)) {
152-
return focusedRoute?.path ?? getPathFromStateWithDynamicRoute(state);
152+
return getPathFromStateWithDynamicRoute(state);
153153
}
154154

155155
return RNGetPathFromState(state, config);

src/pages/inbox/report/PureReportActionItem.tsx

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import TextLink from '@components/TextLink';
4141
import UnreadActionIndicator from '@components/UnreadActionIndicator';
4242
import useConfirmModal from '@hooks/useConfirmModal';
4343
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
44-
import useEnvironment from '@hooks/useEnvironment';
4544
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
4645
import useLocalize from '@hooks/useLocalize';
4746
import useOnyx from '@hooks/useOnyx';
@@ -52,7 +51,6 @@ import useStyleUtils from '@hooks/useStyleUtils';
5251
import useTheme from '@hooks/useTheme';
5352
import useThemeStyles from '@hooks/useThemeStyles';
5453
import {cleanUpMoneyRequest} from '@libs/actions/IOU/DeleteMoneyRequest';
55-
import {isPersonalCardBrokenConnection} from '@libs/CardUtils';
5654
import {isChronosOOOListAction} from '@libs/ChronosUtils';
5755
import ControlSelection from '@libs/ControlSelection';
5856
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
@@ -69,7 +67,6 @@ import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
6967
import {isPolicyAdmin} from '@libs/PolicyUtils';
7068
import {
7169
extractLinksFromMessageHtml,
72-
getCardConnectionBrokenMessage,
7370
getChangedApproverActionMessage,
7471
getCompanyCardConnectionBrokenMessage,
7572
getIntegrationSyncFailedMessage,
@@ -138,6 +135,7 @@ import type * as OnyxTypes from '@src/types/onyx';
138135
import type {Errors} from '@src/types/onyx/OnyxCommon';
139136
import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject';
140137
import ApprovalFlowContent, {isApprovalFlowAction} from './actionContents/ApprovalFlowContent';
138+
import CardBrokenConnectionContent from './actionContents/CardBrokenConnectionContent';
141139
import ChatMessageContent from './actionContents/ChatMessageContent';
142140
import ConfirmWhisperContent from './actionContents/ConfirmWhisperContent';
143141
import FraudAlertContent from './actionContents/FraudAlertContent';
@@ -266,9 +264,6 @@ type PureReportActionItemProps = {
266264
/** Whether the room is a chronos report */
267265
isChronosReport?: boolean;
268266

269-
/** All cards */
270-
cardList?: OnyxTypes.CardList;
271-
272267
/** Function to resolve actionable report mention whisper */
273268
resolveActionableReportMentionWhisper?: (
274269
report: OnyxEntry<OnyxTypes.Report>,
@@ -366,7 +361,6 @@ function PureReportActionItem({
366361
linkedReport,
367362
iouReportOfLinkedReport,
368363
linkedTransactionRouteError,
369-
cardList,
370364
isUserValidated,
371365
parentReport,
372366
personalDetails,
@@ -424,7 +418,6 @@ function PureReportActionItem({
424418

425419
const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID);
426420
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye']);
427-
const {environmentURL} = useEnvironment();
428421

429422
const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`);
430423
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`);
@@ -1035,16 +1028,7 @@ function PureReportActionItem({
10351028
/>
10361029
);
10371030
} else if (isCardBrokenConnectionAction(action)) {
1038-
const message = getOriginalMessage(action);
1039-
const cardID = message?.cardID;
1040-
const cardName = message?.cardName;
1041-
const card = cardID ? cardList?.[cardID] : undefined;
1042-
const connectionLink = cardID && isPersonalCardBrokenConnection(card) ? `${environmentURL}/${ROUTES.SETTINGS_WALLET_PERSONAL_CARD_DETAILS.getRoute(String(cardID))}` : undefined;
1043-
children = (
1044-
<ReportActionItemBasicMessage message="">
1045-
<RenderHTML html={`<comment>${getCardConnectionBrokenMessage(card, cardName, translate, connectionLink)}</comment>`} />
1046-
</ReportActionItemBasicMessage>
1047-
);
1031+
children = <CardBrokenConnectionContent action={action} />;
10481032
} else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION)) {
10491033
children = <ExportIntegration action={action} />;
10501034
} else if (isRenamedAction(action)) {
@@ -1464,7 +1448,6 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => {
14641448
deepEqual(prevProps.taskReport, nextProps.taskReport) &&
14651449
prevProps.shouldHighlight === nextProps.shouldHighlight &&
14661450
deepEqual(prevProps.bankAccountList, nextProps.bankAccountList) &&
1467-
deepEqual(prevProps.cardList, nextProps.cardList) &&
14681451
prevProps.reportNameValuePairsOrigin === nextProps.reportNameValuePairsOrigin &&
14691452
prevProps.reportNameValuePairsOriginalID === nextProps.reportNameValuePairsOriginalID &&
14701453
prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction

0 commit comments

Comments
 (0)