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
5 changes: 3 additions & 2 deletions src/libs/Navigation/AppNavigator/PublicScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ function PublicScreens() {
const StyleUtils = useStyleUtils();
return (
<RootStack.Navigator screenOptions={defaultScreenOptions}>
{/* The structure for the HOME route has to be the same in public and auth screens. That's why the name for SignInPage is SCREENS.HOME. */}
{/* SignInPage is registered under TAB_NAVIGATOR (the authenticated top-level navigator name) so the public and auth */}
{/* root structures match. TAB_NAVIGATOR has no path of its own, so the logged-out URL stays at the root "/". */}
<RootStack.Screen
name={SCREENS.HOME}
name={NAVIGATORS.TAB_NAVIGATOR}
options={{
...defaultScreenOptions,
// If you want to change this, make sure there aren't any animation bugs when signing out.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const clearPreservedSearchNavigatorStates = () => {
}
};

// Preserved states belong to the authenticated session. They must be dropped on logout so the next
// session can't restore them — e.g. restoreTabNavigatorRoutes reattaching a previous user's tab
// subtree to the public sign-in route (which shares the TAB_NAVIGATOR name).
const clearPreservedNavigatorStates = () => {
for (const key of Object.keys(preservedNavigatorStates)) {
delete preservedNavigatorStates[key];
}
};

const getPreservedNavigatorState = <T extends NavigationState = StackNavigationState<ParamListBase>>(key: string): T | undefined => preservedNavigatorStates[key] as T | undefined;

const setPreservedNavigatorState = (key: string, state: NavigationState) => {
Expand All @@ -52,4 +61,4 @@ function usePreserveNavigatorState(state: StackNavigationState<ParamListBase>, r

export default usePreserveNavigatorState;

export {getPreservedNavigatorState, setPreservedNavigatorState, cleanPreservedNavigatorStates, clearPreservedSearchNavigatorStates};
export {getPreservedNavigatorState, setPreservedNavigatorState, cleanPreservedNavigatorStates, clearPreservedSearchNavigatorStates, clearPreservedNavigatorStates};
11 changes: 7 additions & 4 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import AppNavigator from './AppNavigator';
import {cleanPreservedNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import {cleanPreservedNavigatorStates, clearPreservedNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import getNavigationBaseTheme from './getNavigationBaseTheme';
import createDynamicRoute from './helpers/dynamicRoutesUtils/createDynamicRoute';
import getActiveTabName from './helpers/getActiveTabName';
Expand Down Expand Up @@ -225,6 +224,10 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
return;
}

// Drop the previous session's preserved navigator states. Otherwise restoreTabNavigatorRoutes
// reattaches the prior TAB_NAVIGATOR subtree to the public sign-in route (which shares that name).
clearPreservedNavigatorStates();

const stateToReset = getStateToResetAfterLogout(navigationRef.getRootState());
if (!stateToReset) {
return;
Expand All @@ -234,9 +237,9 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
navigationRef.reset(stateToReset);
} catch (error) {
// A synthesized reset state can be rejected by RN; fall back to a known-valid
// SCREENS.HOME (PublicScreens maps "/" → SignInPage) instead of leaving a blank screen.
// TAB_NAVIGATOR (PublicScreens maps "/" → SignInPage) instead of leaving a blank screen.
Log.alert('[NavigationRoot] Post-logout navigation reset failed', {error: String(error)});
navigationRef.reset({index: 0, routes: [{name: SCREENS.HOME}]});
navigationRef.reset({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
}
}, [authenticated, previousAuthenticated]);

Expand Down
13 changes: 6 additions & 7 deletions src/libs/Navigation/helpers/getAdaptedStateFromPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,11 @@ type GetAdaptedStateFromPath = (...args: [...Parameters<typeof RNGetStateFromPat
const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState<NavigationState> => ({routes, index: routes.length - 1});

/**
* Screens that are registered in PublicScreens (unauthenticated navigator) and should not
* have TabNavigator prepended, because when the user is unauthenticated TabNavigator does
* not exist in the navigator tree and the RESET action would fail.
* Standalone full-screen public pages registered in PublicScreens (unauthenticated navigator) that
* should NOT have TabNavigator prepended — they render on their own, with no tab navigator underneath.
*
* Keep in sync with the screens registered in PublicScreens.tsx (excluding SCREENS.HOME,
* which doubles as the authenticated home tab, and navigator entries).
* Keep in sync with the screens registered in PublicScreens.tsx (excluding TAB_NAVIGATOR, which hosts
* the SignInPage at the root, and the other navigator entries).
*/
const PUBLIC_SCREENS = new Set<string>([
SCREENS.VALIDATE_LOGIN,
Expand Down Expand Up @@ -414,8 +413,8 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR
normalizedPath = '/';
}

// PublicScreens registers SCREENS.HOME ('Home') without a path mapping, so React Navigation derives `/Home` as the URL.
// The authenticated config maps SCREENS.HOME to lowercase 'home', and the case-sensitive mismatch falls to NOT_FOUND.
// `/Home` (capital H) has no route mapping — the config maps SCREENS.HOME to lowercase 'home' — so it would
// fall through to NOT_FOUND. Redirect legacy/cached `/Home` paths to the root instead.
if (normalizedPath === `/${SCREENS.HOME}`) {
normalizedPath = '/';
}
Expand Down
12 changes: 8 additions & 4 deletions src/libs/Navigation/helpers/getStateToResetAfterLogout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@ function getStateToResetAfterLogout(rootState: NavigationState | undefined): Nav
}

// ValidateLogin's /v/ code is spent by logout; keeping it strands the user. Reset to
// SCREENS.HOME — this runs only post-logout (NavigationRoot gates on !authenticated), when
// PublicScreens is mounted and HOME (SignInPage) is its top-level route.
// TAB_NAVIGATOR — this runs only post-logout (NavigationRoot gates on !authenticated), when
// PublicScreens is mounted and TAB_NAVIGATOR (SignInPage) is its top-level route.
if (lastRoute.name === SCREENS.VALIDATE_LOGIN) {
return {index: 0, routes: [{name: SCREENS.HOME}]};
return {index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]};
}

// ReportsSplit is shared between logged-in & logged-out; its params can carry stale auth.
const shouldClearParams = lastRoute.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR;

// TAB_NAVIGATOR hosts the public SignInPage at the root. Drop nested tab focus carried over from the
// authenticated session so the post-logout URL lands on "/" instead of a tab path like "/home".
const shouldClearNestedState = lastRoute.name === NAVIGATORS.TAB_NAVIGATOR;
return {
...rootState,
index: 0,
routes: [{...lastRoute, params: shouldClearParams ? undefined : lastRoute.params}],
routes: [{...lastRoute, params: shouldClearParams ? undefined : lastRoute.params, state: shouldClearNestedState ? undefined : lastRoute.state}],

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 Clear TAB_NAVIGATOR params during logout reset

Please also clear params when lastRoute.name === NAVIGATORS.TAB_NAVIGATOR. Some TAB_NAVIGATOR routes carry their child tab in params rather than only in state (for example, getRehydratedTabNavigatorStateAfterPush() stores the full tab subtree in params.state), but this reset now drops state while preserving those params. Since PublicScreens and AuthScreens share the TAB_NAVIGATOR route name, logging out from such a route can leave the previous session's tab subtree attached to the public sign-in route and let the next login hydrate back to a stale tab/path instead of starting cleanly at /.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this comment is valid. We can get a dirty URL from leftover params after logout

Steps to reproduce:

  1. Log in to the Expensify App
  2. Navigate around in-app a few times (jump across tabs into a nested page)
  3. Log out and observe the URL
MacOS-Chrome.mov

What do you think? @sumo-slonik

};
}

Expand Down
2 changes: 2 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2993,6 +2993,8 @@ type ShareNavigatorParamList = {
};

type PublicScreensParamList = SharedScreensParamList & {
// Hosts the public SignInPage (see PublicScreens), mirroring the authenticated top-level navigator name.
[NAVIGATORS.TAB_NAVIGATOR]: NavigatorScreenParams<TabNavigatorParamList>;
[SCREENS.UNLINK_LOGIN]: {
accountID?: string;
validateCode?: string;
Expand Down
12 changes: 5 additions & 7 deletions src/pages/ValidateLoginPage/index.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import {isValidValidateCode} from '@libs/ValidationUtils';
import {handleExitToNavigation, initAutoAuthState, signInWithValidateCode} from '@userActions/Session';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type {Session as SessionType} from '@src/types/onyx';
import type ValidateLoginPageProps from './types';

Expand Down Expand Up @@ -110,18 +110,16 @@ function ValidateLoginPage({
useEffect(() => {
let ignore = false;
if (canCompleteTwoFactorOnSignIn) {
// Show the sign-in page so its ValidateCodeForm renders the authenticator-code stage.
// ROUTES.HOME ('home') is nested under the authenticated TAB_NAVIGATOR, so navigate/goBack
// to it no-op from the public /v/ route; reset the stack to SCREENS.HOME instead — the same
// mechanism logout uses to surface the public SignInPage. The "2FA required" modal stays
// rendered as the fallback so a failed hand-off shows it, not a blank or an endless loader.
// Surface the sign-in page so its ValidateCodeForm renders the authenticator-code stage. navigate/goBack
// no-op from the public /v/ route, so reset the stack to TAB_NAVIGATOR (which hosts the public SignInPage).
// The "2FA required" modal stays rendered as the fallback so a failed hand-off shows it, not a blank screen.
Navigation.isNavigationReady().then(() => {
// Bail if the effect re-ran (e.g. `isSignedIn` flipped true) before this resolved, so a
// stale callback can't reset the stack out from under the new state.
if (ignore) {
return;
}
navigationRef.reset({index: 0, routes: [{name: SCREENS.HOME}]});
navigationRef.reset({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
});
}

Expand Down
48 changes: 48 additions & 0 deletions tests/navigation/PublicSignInPageRootUrlTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {NavigationContainer} from '@react-navigation/native';
import {render, screen} from '@testing-library/react-native';
import React from 'react';
import {View} from 'react-native';
import PublicScreens from '@libs/Navigation/AppNavigator/PublicScreens';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
import navigationRef from '@libs/Navigation/navigationRef';
import NAVIGATORS from '@src/NAVIGATORS';

// SignInPage is the initial (and only rendered) public route, so it gets a findable testID. The `mock`
// prefix is required for the hoisted jest.mock factory to reference it; a function declaration hoists too,
// so there's no temporal-dead-zone risk when the factory runs.
function mockSignInPage() {
return <View testID="sign-in-page" />;
}

// Stub the public pages so the test can mount the real PublicScreens navigator without their provider trees.
jest.mock('@pages/signin/SignInPage', () => mockSignInPage);
jest.mock('@pages/ConnectionCompletePage', () => () => null);
jest.mock('@pages/LogInWithShortLivedAuthTokenPage', () => () => null);
jest.mock('@pages/signin/SAMLSignInPage', () => () => null);
jest.mock('@pages/UnlinkLoginPage', () => () => null);
jest.mock('@pages/ValidateLoginPage', () => () => null);
jest.mock('@libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator', () => () => null);
jest.mock('@libs/Navigation/AppNavigator/useRootNavigatorScreenOptions', () => () => ({basicModalNavigator: {}}));
jest.mock('@hooks/useTheme', () => () => ({overlay: '#000000'}));
jest.mock('@hooks/useStyleUtils', () => () => ({getBackgroundColorWithOpacityStyle: () => ({})}));

describe('Logged-out sign-in page URL', () => {
it('renders the SignInPage at the root "/" (registered under TAB_NAVIGATOR, which has no path)', () => {
render(
<NavigationContainer ref={navigationRef}>
<PublicScreens />
</NavigationContainer>,
);

expect(screen.getByTestId('sign-in-page')).toBeOnTheScreen();

const rootState = navigationRef.getRootState();
expect(rootState.routes.at(0)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR);
expect(getPathFromState(rootState)).toBe('/');
});

it('redirects a legacy "/Home" URL to the root "/"', () => {
expect(getPathFromState(getAdaptedStateFromPath('/Home', undefined))).toBe('/');
});
});
5 changes: 3 additions & 2 deletions tests/ui/ValidateLoginPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {PublicScreensParamList} from '@libs/Navigation/types';
import ValidateLoginPage from '@pages/ValidateLoginPage/index.web';
import {handleExitToNavigation} from '@userActions/Session';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
Expand Down Expand Up @@ -238,7 +239,7 @@ describe('ValidateLoginPage', () => {
});
await waitForBatchedUpdatesWithAct();

expect(mockNavigationReset).toHaveBeenCalledWith({index: 0, routes: [{name: SCREENS.HOME}]});
expect(mockNavigationReset).toHaveBeenCalledWith({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
});

it('Should route an exitTo 2FA magic link to the sign-in page AND keep the deferred exitTo navigation', async () => {
Expand Down Expand Up @@ -266,7 +267,7 @@ describe('ValidateLoginPage', () => {
});
await waitForBatchedUpdatesWithAct();

expect(mockNavigationReset).toHaveBeenCalledWith({index: 0, routes: [{name: SCREENS.HOME}]});
expect(mockNavigationReset).toHaveBeenCalledWith({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
expect(handleExitToNavigation).toHaveBeenCalledWith('concierge');
});

Expand Down
30 changes: 20 additions & 10 deletions tests/unit/getStateToResetAfterLogoutTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import getStateToResetAfterLogout from '@libs/Navigation/helpers/getStateToReset
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';

/** Build a minimal root NavigationState from a list of `{name, params?}` routes. */
const buildRootState = (routes: Array<{name: string; params?: Record<string, unknown>}>): NavigationState => ({
type RouteState = NavigationState['routes'][number]['state'];

/** Build a minimal root NavigationState from a list of `{name, params?, state?}` routes. */
const buildRootState = (routes: Array<{name: string; params?: Record<string, unknown>; state?: RouteState}>): NavigationState => ({
key: 'stack-root',
index: routes.length - 1,
routeNames: routes.map((route) => route.name),
routes: routes.map((route, i) => ({key: `${route.name}-${i}`, name: route.name, params: route.params})),
routes: routes.map((route, i) => ({key: `${route.name}-${i}`, name: route.name, params: route.params, state: route.state})),
type: 'stack',
stale: false,
});
Expand All @@ -22,25 +24,25 @@ describe('getStateToResetAfterLogout', () => {
expect(getStateToResetAfterLogout(buildRootState([]))).toBeUndefined();
});

it('resets to SCREENS.HOME when a consumed magic-link is the last route, even if a host is mounted (post-logout reset always lands on the public HOME/SignInPage route)', () => {
it('resets to TAB_NAVIGATOR when a consumed magic-link is the last route, even if a host is mounted (post-logout reset always lands on the public TAB_NAVIGATOR/SignInPage route)', () => {
const result = getStateToResetAfterLogout(buildRootState([{name: NAVIGATORS.TAB_NAVIGATOR, params: {deep: 'link'}}, {name: SCREENS.VALIDATE_LOGIN}]));

expect(result).toEqual({index: 0, routes: [{name: SCREENS.HOME}]});
expect(result).toEqual({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
});

it('resets to SCREENS.HOME for a consumed magic-link with a ReportsSplit host too', () => {
it('resets to TAB_NAVIGATOR for a consumed magic-link with a ReportsSplit host too', () => {
const result = getStateToResetAfterLogout(buildRootState([{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, {name: SCREENS.VALIDATE_LOGIN}]));

expect(result).toEqual({index: 0, routes: [{name: SCREENS.HOME}]});
expect(result).toEqual({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
});

it('resets to SCREENS.HOME when a consumed magic-link is the only route (fresh session, no host mounted)', () => {
it('resets to TAB_NAVIGATOR when a consumed magic-link is the only route (fresh session, no host mounted)', () => {
const result = getStateToResetAfterLogout(buildRootState([{name: SCREENS.VALIDATE_LOGIN, params: {accountID: '1'}}]));

expect(result).toEqual({index: 0, routes: [{name: SCREENS.HOME}]});
expect(result).toEqual({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
});

it('PRESERVES params and does NOT redirect to HOME for a non-magic-link login/logout route (TransitionBetweenApps — a29d2e24c6a)', () => {
it('PRESERVES params and does NOT redirect to TAB_NAVIGATOR for a non-magic-link login/logout route (TransitionBetweenApps — a29d2e24c6a)', () => {
const transitionParams = {shortLivedAuthToken: 'tok', exitTo: 'settings'};
const result = getStateToResetAfterLogout(buildRootState([{name: SCREENS.TRANSITION_BETWEEN_APPS, params: transitionParams}]));

Expand All @@ -63,4 +65,12 @@ describe('getStateToResetAfterLogout', () => {
expect(result?.routes[0].name).toBe(NAVIGATORS.TAB_NAVIGATOR);
expect(result?.routes[0].params).toEqual(params);
});

it('drops the nested tab state when logging out from a TAB_NAVIGATOR tab, so the SignInPage lands on the root "/" (not "/home")', () => {
// Simulate logging out while the Home tab is focused inside TAB_NAVIGATOR.
const result = getStateToResetAfterLogout(buildRootState([{name: NAVIGATORS.TAB_NAVIGATOR, state: {index: 0, routes: [{key: 'home', name: SCREENS.HOME}]}}]));

expect(result?.routes[0].name).toBe(NAVIGATORS.TAB_NAVIGATOR);
expect(result?.routes[0].state).toBeUndefined();
});
});
Loading