Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,8 @@ function handleReplaceFullscreenUnderRHP(
}
const staleTabState = existingTabState ? markFocusedTabRouteForRemount(updatedTabState, existingTabState) : updatedTabState;

const updatedTabRoute = {...existingTabRoute, state: staleTabState} as StackNavigationState<ParamListBase>['routes'][number];
// Drop consumed deep-link hints before remounting, or React Navigation can replay the old target over the new state.
const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: staleTabState} as StackNavigationState<ParamListBase>['routes'][number];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove state-only deep-link hints before remounting

When the existing TAB_NAVIGATOR was created by a nested push, getRehydratedTabNavigatorStateAfterPush() stores the consumed target as route.params.state without a screen key (lines 176–182). This call does not remove that hint because withSanitizedDeepLinkParams() only sanitizes params containing a string screen, so creating a workspace afterward can still let React Navigation replay the previous nested workspace state over staleTabState. Sanitize the state-only form here as well before remounting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The state-only hint comes from the separate nested-push flow, while this PR addresses stale params.screen hints from the reported distance-settings flow. Expanding the shared sanitizer would increase this regression fix’s blast radius, so I propose handling the state-only form in a focused follow-up with dedicated coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Save original route so handleRemoveFullscreenUnderRHP can fully restore it on cancel.
// In the cold-start fallback the tab navigator has no nested state yet, so saving the raw
// route would leave it stateless and the dismiss-restore path (removePreInsertedFullscreenIfNeeded)
Expand Down
21 changes: 16 additions & 5 deletions src/libs/Navigation/helpers/linkTo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,21 @@ function shouldChangeToMatchingFullScreen(
return newFocusedRoute?.name === SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD && lastActiveScreen !== SCREENS.SETTINGS.SUBSCRIPTION.ROOT;
}

export {isSwitchingTabsWithinTabNavigator, getActiveScreenInRoute, shouldChangeToMatchingFullScreen, isNavigatingToReportActionWithinSameReport};
function getMatchingFullScreenRouteParams(
matchingFullScreenRoute: NavigationPartialRoute,
): NavigationPartialRoute['params'] | {screen: string; params: NavigationPartialRoute['params'] | undefined} {
const lastRoute = matchingFullScreenRoute.state?.routes?.at(-1);
if (!lastRoute) {
return matchingFullScreenRoute.params;
}

return {
screen: lastRoute.name,
params: lastRoute.state ? {...lastRoute.params, state: lastRoute.state} : lastRoute.params,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now preserves nested state for all matching-fullscreen navigators, not just Workspace.

getMatchingFullScreenRouteParams runs for every matching-fullscreen tab switch, so Reports / Settings / Search split navigators are also affected. Before this PR their split was reset to its initial route on tab switch; now the full nested stack is restored. That's very likely the more-correct behavior and aligns this branch with the state-preserving branch just above (~L249), but the added tests only cover WORKSPACE_SPLIT_NAVIGATOR.

  • Could you confirm a non-workspace matching-fullscreen RHP open (e.g. Reports/Settings) still lands on the expected background screen and doesn't jump to an unexpected deep sub-screen?
  • If it checks out, consider a one-line test for a non-workspace navigator to lock the generalization in.

};
}

export {isSwitchingTabsWithinTabNavigator, getActiveScreenInRoute, getMatchingFullScreenRouteParams, shouldChangeToMatchingFullScreen, isNavigatingToReportActionWithinSameReport};

export default function linkTo(navigation: NavigationContainerRef<RootNavigatorParamList> | null, path: Route, options?: LinkToOptions) {
if (!navigation) {
Expand Down Expand Up @@ -254,14 +268,11 @@ export default function linkTo(navigation: NavigationContainerRef<RootNavigatorP
navigation.dispatch(additionalAction);
} else {
// Navigate within the existing TAB_NAVIGATOR (tab switch) rather than pushing a new one.
const lastRouteInMatchingFullScreen = matchingFullScreenRoute.state?.routes?.at(-1);
const additionalAction = CommonActions.navigate({
name: NAVIGATORS.TAB_NAVIGATOR,
params: {
screen: matchingFullScreenRoute.name,
params: lastRouteInMatchingFullScreen
? {screen: lastRouteInMatchingFullScreen.name, params: lastRouteInMatchingFullScreen.params}
: matchingFullScreenRoute.params,
params: getMatchingFullScreenRouteParams(matchingFullScreenRoute),
},
});
navigation.dispatch(additionalAction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {findFocusedRoute} from '@react-navigation/native';

import {getMatchingFullScreenRoute} from './getAdaptedStateFromPath';
import getStateFromPath from './getStateFromPath';
import {shouldChangeToMatchingFullScreen} from './linkTo';
import {getMatchingFullScreenRouteParams, shouldChangeToMatchingFullScreen} from './linkTo';
import {getTabState} from './tabNavigatorUtils';

type CrossTabContext = {
Expand Down Expand Up @@ -95,12 +95,11 @@ function swapBackgroundTabForRHPTarget(currentState: NavigationState | undefined
navigationRef.dispatch(additionalAction);
} else {
// Plain tab switch within the existing TAB_NAVIGATOR.
const lastRouteInMatchingFullScreen = matchingFullScreenRoute.state?.routes?.at(-1);
const additionalAction: StackNavigationAction = {
type: CONST.NAVIGATION.ACTION_TYPE.NAVIGATE,
payload: {
name: matchingFullScreenRoute.name,
params: lastRouteInMatchingFullScreen ? {screen: lastRouteInMatchingFullScreen.name, params: lastRouteInMatchingFullScreen.params} : matchingFullScreenRoute.params,
params: getMatchingFullScreenRouteParams(matchingFullScreenRoute),
Comment thread
fedirjh marked this conversation as resolved.
},
target: tabNavigatorStateKey,
};
Expand Down
24 changes: 14 additions & 10 deletions src/pages/workspace/WorkspaceInitialPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name);
const waitForNavigate = useWaitForNavigation();
const {singleExecution, isExecuting} = useSingleExecution();
const wasRendered = useRef(false);
const policyIDWithClosedRHPRef = useRef<string | undefined>(undefined);

const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector});
const policy = policyDraft?.id ? policyDraft : policyProp;
const policyID = policy?.id;
const routePolicyID = route.params?.policyID;

const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params?.policyID}`);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${routePolicyID}`);
const workspaceAccountID = useWorkspaceAccountID(policyID);
const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData(policyID);
const {shouldShowRbrForWorkspaceAccountID} = useCardFeedErrors();
Expand Down Expand Up @@ -232,17 +234,18 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy);
// While the policy is being fetched (e.g., right after joinAccessiblePolicy), the role is not yet populated,
// so checkIfShouldShowPolicy returns false. Suppress NotFound during this loading window.
const computedShouldShowNotFoundPage = isWorkspacesTabFocused && !shouldShowPolicy && !policy?.isLoading && (!isPendingDelete || prevIsPendingDelete);
const computedShouldShowNotFoundPage = !!routePolicyID && isWorkspacesTabFocused && !shouldShowPolicy && !policy?.isLoading && (!isPendingDelete || prevIsPendingDelete);
// Latch to true: once the not-found state is detected, keep showing it so the normal
// workspace content doesn't flash during the exit animation when navigation state
// changes (e.g., isWorkspacesTabFocused becomes false after StackActions.pop()).
const prevShouldShowNotFoundPage = usePrevious(computedShouldShowNotFoundPage);
const shouldShowNotFoundPage = computedShouldShowNotFoundPage || !!prevShouldShowNotFoundPage;
const prevRoutePolicyID = usePrevious(routePolicyID);
const shouldShowNotFoundPage = computedShouldShowNotFoundPage || (prevRoutePolicyID === routePolicyID && !!prevShouldShowNotFoundPage);
Comment thread
fedirjh marked this conversation as resolved.
const fetchPolicyData = () => {
if (policyDraft?.id || !isFocused) {
if (policyDraft?.id || !isFocused || !routePolicyID) {
return;
}
openPolicyInitialPage(route.params.policyID);
openPolicyInitialPage(routePolicyID);
};
useNetwork({onReconnect: fetchPolicyData});
useFocusEffect(
Expand Down Expand Up @@ -502,17 +505,17 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
// Close RHP if we land on a route that no longer exists in the menu
const canAccessRoute = activeRoute && (workspaceMenuItems.some((item) => item.screenName === activeRoute) || activeRoute === SCREENS.WORKSPACE.INITIAL);
useEffect(() => {
if (!shouldShowNotFoundPage && canAccessRoute) {
if (!routePolicyID || (!shouldShowNotFoundPage && canAccessRoute)) {
return;
}
if (wasRendered.current) {
if (policyIDWithClosedRHPRef.current === routePolicyID) {
return;
}
wasRendered.current = true;
policyIDWithClosedRHPRef.current = routePolicyID;
Navigation.isNavigationReady().then(() => {
Navigation.closeRHPFlow();
});
}, [canAccessRoute, shouldShowNotFoundPage]);
}, [canAccessRoute, routePolicyID, shouldShowNotFoundPage]);

// When this page is revealed from under the RHP during workspace creation (#90985), the RHP
// slide-out is held until the page has painted. Signal readiness from the first non-empty layout
Expand Down Expand Up @@ -597,3 +600,4 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
}

export default withPolicyAndFullscreenLoading(WorkspaceInitialPage);
export {WorkspaceInitialPage};
47 changes: 46 additions & 1 deletion tests/navigation/linkToHelpersTests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import {getActiveScreenInRoute, isNavigatingToReportActionWithinSameReport, isSwitchingTabsWithinTabNavigator, shouldChangeToMatchingFullScreen} from '@libs/Navigation/helpers/linkTo';
import {
getActiveScreenInRoute,
getMatchingFullScreenRouteParams,
isNavigatingToReportActionWithinSameReport,
isSwitchingTabsWithinTabNavigator,
shouldChangeToMatchingFullScreen,
} from '@libs/Navigation/helpers/linkTo';
import type {NavigationPartialRoute, RootNavigatorParamList} from '@libs/Navigation/types';

import NAVIGATORS from '@src/NAVIGATORS';
Expand Down Expand Up @@ -114,6 +120,45 @@ describe('getActiveScreenInRoute', () => {
});
});

describe('getMatchingFullScreenRouteParams', () => {
Comment thread
fedirjh marked this conversation as resolved.
it('returns the route params when there is no nested route', () => {
const route: NavigationPartialRoute = {name: NAVIGATORS.WORKSPACE_NAVIGATOR, params: {policyID: '1'}};

expect(getMatchingFullScreenRouteParams(route)).toEqual({policyID: '1'});
});

it('omits state when the last route has none', () => {
const route: NavigationPartialRoute = {
name: NAVIGATORS.WORKSPACE_NAVIGATOR,
state: {index: 0, routes: [{name: SCREENS.WORKSPACE.INITIAL, params: {policyID: '1'}}]},
};

expect(getMatchingFullScreenRouteParams(route)).toEqual({screen: SCREENS.WORKSPACE.INITIAL, params: {policyID: '1'}});
});

it('preserves the nested split state when building an initialized workspace background', () => {
const splitState = {
routes: [
{name: SCREENS.WORKSPACE.INITIAL, params: {policyID: '1'}},
{name: SCREENS.WORKSPACE.DISTANCE_RATES, params: {policyID: '1'}},
],
index: 1,
};
const route: NavigationPartialRoute = {
name: NAVIGATORS.WORKSPACE_NAVIGATOR,
state: {
routes: [{name: SCREENS.WORKSPACES_LIST}, {name: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, state: splitState}],
index: 1,
},
};

expect(getMatchingFullScreenRouteParams(route)).toEqual({
screen: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR,
params: {state: splitState},
});
});
});

describe('shouldChangeToMatchingFullScreen', () => {
it('returns true when names differ', () => {
const result = shouldChangeToMatchingFullScreen({name: 'SomeRHPScreen', key: 'k1'}, {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR});
Expand Down
128 changes: 128 additions & 0 deletions tests/ui/WorkspaceInitialPageTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {render, waitFor} from '@testing-library/react-native';

import {openPolicyInitialPage} from '@libs/actions/Policy/Policy';
import Navigation from '@libs/Navigation/Navigation';

import {WorkspaceInitialPage} from '@pages/workspace/WorkspaceInitialPage';

import type * as ReactNavigation from '@react-navigation/native';

import React from 'react';

import createMock from '../utils/createMock';

const mockFullPageNotFoundView = jest.fn(({children}: {children: React.ReactNode}) => children);
let mockActiveRoute: string | undefined;
let mockIsWorkspacesTabFocused = true;

jest.mock('@libs/actions/Policy/Policy', () => ({
clearErrors: jest.fn(),
openPolicyInitialPage: jest.fn(),
removeWorkspace: jest.fn(),
}));

jest.mock('@libs/Navigation/Navigation', () => ({
closeRHPFlow: jest.fn(),
isNavigationReady: () => Promise.resolve(),
}));

jest.mock('@react-navigation/native', () => {
const navigation = jest.requireActual<typeof ReactNavigation>('@react-navigation/native');
return {
...navigation,
useFocusEffect: (callback: () => void) => callback(),
useIsFocused: () => true,
useNavigationState: () => mockActiveRoute,
};
});

jest.mock('@hooks/useCardFeedErrors', () => () => ({shouldShowRbrForWorkspaceAccountID: {}}));
jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: () => ({convertToDisplayString: jest.fn()})}));
jest.mock('@hooks/useGetReceiptPartnersIntegrationData', () => () => ({shouldShowEnterCredentialsError: false}));
jest.mock('@hooks/useIsWorkspacesTabFocused', () => () => mockIsWorkspacesTabFocused);
jest.mock('@hooks/useLazyAsset', () => ({
useMemoizedLazyExpensifyIcons: () => new Proxy({}, {get: () => 'icon'}),
}));
jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key}));
jest.mock('@hooks/useNetwork', () => jest.fn());
jest.mock('@hooks/useOnyx', () => () => [undefined]);
jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false}));
jest.mock('@hooks/usePolicyConnectionsPrefetch', () => jest.fn());
jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: true}));
jest.mock('@hooks/useSingleExecution', () => () => ({singleExecution: (callback: () => void) => callback, isExecuting: false}));
jest.mock('@hooks/useThemeStyles', () => () => ({
flexColumn: {},
mh3: {},
mt3: {},
overflowVisible: {},
pb4: {},
pb14: {},
ph5: {},
pv2: {},
sectionMenuItem: () => ({}),
}));
jest.mock('@hooks/useWaitForNavigation', () => () => (callback: () => void) => callback);
jest.mock('@hooks/useWorkspaceAccountID', () => jest.fn());

jest.mock('@components/BlockingViews/FullPageNotFoundView', () => (props: {children: React.ReactNode; shouldShow: boolean}) => mockFullPageNotFoundView(props));
jest.mock('@components/HeaderWithBackButton', () => jest.fn());
jest.mock('@components/HighlightableMenuItem', () => jest.fn());
jest.mock('@components/Navigation/TabBarBottomContent', () => jest.fn());
jest.mock(
'@components/OfflineWithFeedback',
() =>
({children}: {children: React.ReactNode}) =>
children,
);
jest.mock(
'@components/ScreenWrapper',
() =>
({children}: {children: React.ReactNode}) =>
children,
);
jest.mock(
'@components/ScrollView',
() =>
({children}: {children: React.ReactNode}) =>
children,
);

describe('WorkspaceInitialPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockActiveRoute = undefined;
mockIsWorkspacesTabFocused = true;
});

it('waits for route params before fetching policy data, showing Not Found, or closing the RHP', async () => {
const props = createMock<React.ComponentProps<typeof WorkspaceInitialPage>>({route: {params: undefined}});
render(<WorkspaceInitialPage {...props} />);

expect(openPolicyInitialPage).not.toHaveBeenCalled();
expect(mockFullPageNotFoundView).toHaveBeenCalledWith(expect.objectContaining({shouldShow: false}));
await waitFor(() => expect(Navigation.closeRHPFlow).not.toHaveBeenCalled());
});

it('evaluates closing the RHP independently for each policy', async () => {
mockActiveRoute = 'inaccessible-workspace-route';
const firstProps = createMock<React.ComponentProps<typeof WorkspaceInitialPage>>({route: {params: {policyID: 'policy-1'}}});
const {rerender} = render(<WorkspaceInitialPage {...firstProps} />);
await waitFor(() => expect(Navigation.closeRHPFlow).toHaveBeenCalledTimes(1));

const secondProps = createMock<React.ComponentProps<typeof WorkspaceInitialPage>>({route: {params: {policyID: 'policy-2'}}});
rerender(<WorkspaceInitialPage {...secondProps} />);
await waitFor(() => expect(Navigation.closeRHPFlow).toHaveBeenCalledTimes(2));
});

it('does not retain Not Found after the route policy changes', () => {
const firstProps = createMock<React.ComponentProps<typeof WorkspaceInitialPage>>({route: {params: {policyID: 'policy-1'}}});
const {rerender} = render(<WorkspaceInitialPage {...firstProps} />);
expect(mockFullPageNotFoundView).toHaveBeenLastCalledWith(expect.objectContaining({shouldShow: true}));

mockIsWorkspacesTabFocused = false;
const secondProps = createMock<React.ComponentProps<typeof WorkspaceInitialPage>>({route: {params: {policyID: 'policy-2'}}});
rerender(<WorkspaceInitialPage {...secondProps} />);

expect(mockFullPageNotFoundView).toHaveBeenLastCalledWith(expect.objectContaining({shouldShow: false}));
});
});
Loading
Loading