diff --git a/src/pages/UnlinkLoginPage.tsx b/src/pages/UnlinkLoginPage.tsx
index 7420dad9ce9f..537a7e8c5b71 100644
--- a/src/pages/UnlinkLoginPage.tsx
+++ b/src/pages/UnlinkLoginPage.tsx
@@ -3,7 +3,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import useOnyx from '@hooks/useOnyx';
import usePrevious from '@hooks/usePrevious';
-import Navigation from '@libs/Navigation/Navigation';
+import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -12,6 +12,7 @@ import type {PublicScreensParamList} from '@navigation/types';
import {unlinkLogin} from '@userActions/Session';
import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -37,7 +38,29 @@ function UnlinkLoginPage({route}: UnlinkLoginPageProps) {
return;
}
- Navigation.goBack();
+ if (navigationRef.current?.canGoBack()) {
+ Navigation.goBack();
+ return;
+ }
+
+ // A tab opened from the unlink email has UNLINK_LOGIN as its only public root route, so bare goBack()
+ // no-ops and this loader never unmounts. Reset to TAB_NAVIGATOR (which hosts the public SignInPage) so
+ // the unlink result renders.
+ let ignore = false;
+ Navigation.isNavigationReady().then(() => {
+ // Bail if the effect re-ran 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: NAVIGATORS.TAB_NAVIGATOR}],
+ });
+ });
+ return () => {
+ ignore = true;
+ };
}, [prevIsLoading, account?.isLoading]);
const reasonAttributes: SkeletonSpanReasonAttributes = {
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx
index dd22ac511798..cc363ffed0d6 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.tsx
+++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx
@@ -165,7 +165,12 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor
// When the user is in the transition route and not yet authenticated, this component will also be mounted,
// resetting account.isLoading will cause the app to briefly display the session expiration page.
- if (isFocused && isVisible) {
+ // UnlinkLoginPage resets the stack to the sign-in page as soon as the unlink settles, so this mount is
+ // the one that has to render the result. unlinkLogin has just written the whole account object, so there
+ // is no stale state here for clearAccountMessages to clean up.
+ const hasJustUnlinkedLogin = account?.message === 'unlinkLoginForm.successfullyUnlinkedLogin';
+
+ if (isFocused && isVisible && !hasJustUnlinkedLogin) {
clearAccountMessages();
}
if (!canFocusInputOnScreenFocus() || !input.current || !isVisible || !isFocused) {
@@ -286,8 +291,10 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor
)}
{
diff --git a/tests/ui/BaseLoginFormTest.tsx b/tests/ui/BaseLoginFormTest.tsx
index 1c2b8340bbf2..a656a9cb711e 100644
--- a/tests/ui/BaseLoginFormTest.tsx
+++ b/tests/ui/BaseLoginFormTest.tsx
@@ -2,7 +2,7 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'
import {LoginProvider} from '@pages/signin/SignInLoginContext';
-import {beginSignIn} from '@userActions/Session';
+import {beginSignIn, clearAccountMessages} from '@userActions/Session';
import ONYXKEYS from '@src/ONYXKEYS';
import SCREENS from '@src/SCREENS';
@@ -150,4 +150,39 @@ describe('BaseLoginForm', () => {
expect(mockBeginSignIn).toHaveBeenCalledWith('user@expensify.com');
});
});
+
+ it('does not clear the account message on mount when it is a freshly-set unlink success message', async () => {
+ // UnlinkLoginPage resets the stack to the sign-in page as soon as the unlink settles, so this
+ // mount is the one that has to render the result — clearing it here would show nothing.
+ await Onyx.set(ONYXKEYS.ACCOUNT, {
+ isLoading: false,
+ errors: null,
+ message: 'unlinkLoginForm.successfullyUnlinkedLogin',
+ });
+ await waitForBatchedUpdates();
+
+ renderForm();
+ await waitFor(() => {
+ expect(screen.getByText('unlinkLoginForm.successfullyUnlinkedLogin')).toBeTruthy();
+ });
+
+ expect(clearAccountMessages).not.toHaveBeenCalled();
+ });
+
+ it('still clears a stale account message on an ordinary mount (control case)', async () => {
+ // Regression guard: the skip above must be scoped to the unlink success value only — any other
+ // leftover message (e.g. from an earlier flow) must still be cleared as before.
+ await Onyx.set(ONYXKEYS.ACCOUNT, {
+ isLoading: false,
+ errors: null,
+ message: 'closeAccountPage.reasonForLeavingPrompt',
+ });
+ await waitForBatchedUpdates();
+
+ renderForm();
+
+ await waitFor(() => {
+ expect(clearAccountMessages).toHaveBeenCalled();
+ });
+ });
});
diff --git a/tests/ui/UnlinkLoginPageTest.tsx b/tests/ui/UnlinkLoginPageTest.tsx
new file mode 100644
index 000000000000..b4599269e5ff
--- /dev/null
+++ b/tests/ui/UnlinkLoginPageTest.tsx
@@ -0,0 +1,177 @@
+import {act, render, waitFor} from '@testing-library/react-native';
+
+import Navigation from '@libs/Navigation/Navigation';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
+import type {PublicScreensParamList} from '@libs/Navigation/types';
+
+import UnlinkLoginPage from '@pages/UnlinkLoginPage';
+
+import {unlinkLogin} from '@userActions/Session';
+
+import NAVIGATORS from '@src/NAVIGATORS';
+import ONYXKEYS from '@src/ONYXKEYS';
+import SCREENS from '@src/SCREENS';
+
+import {NavigationContainer} from '@react-navigation/native';
+import React from 'react';
+import Onyx from 'react-native-onyx';
+
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+// Controllable deferred for isNavigationReady() so tests can resolve it on demand — and, for the
+// stale-callback guard, resolve it *after* the page unmounts to prove the reset is skipped.
+const mockIsNavigationReady = {resolve: () => {}};
+
+// Standalone fn so assertions don't access `navigationRef.reset` unbound (unbound-method lint rule).
+const mockNavigationReset = jest.fn();
+
+// Standalone fn so `navigationRef.current.canGoBack` can be reconfigured per test.
+const mockCanGoBack = jest.fn(() => false);
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ goBack: jest.fn(),
+ navigate: jest.fn(),
+ isNavigationReady: jest.fn(
+ () =>
+ new Promise((resolve) => {
+ mockIsNavigationReady.resolve = resolve;
+ }),
+ ),
+ getActiveRoute: jest.fn(() => ''),
+ getActiveRouteWithoutParams: jest.fn(() => ''),
+ isActiveRoute: jest.fn(() => false),
+ // Dereference inside the closures (not at factory time) — the factory runs before the consts
+ // above are initialized, so capturing them directly would freeze `undefined`.
+ navigationRef: {
+ current: {
+ canGoBack: () => mockCanGoBack(),
+ },
+ reset: (...args: unknown[]) => {
+ mockNavigationReset(...args);
+ },
+ isReady: () => true,
+ },
+}));
+
+jest.mock('@userActions/Session', () => ({
+ unlinkLogin: jest.fn(),
+}));
+
+const RootStack = createPlatformStackNavigator();
+
+const renderPage = (initialParams: PublicScreensParamList[typeof SCREENS.UNLINK_LOGIN]) => {
+ return render(
+
+
+
+
+ ,
+ );
+};
+
+describe('UnlinkLoginPage', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ mockIsNavigationReady.resolve = () => {};
+ mockCanGoBack.mockReturnValue(false);
+ await act(async () => {
+ await Onyx.clear();
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ it('calls unlinkLogin on mount with the route params', async () => {
+ renderPage({accountID: '1', validateCode: 'ABCDEF'});
+ await waitForBatchedUpdatesWithAct();
+
+ expect(unlinkLogin).toHaveBeenCalledWith(1, 'ABCDEF');
+ });
+
+ it('resets the stack to TAB_NAVIGATOR when the request settles on a fresh tab (canGoBack is false)', async () => {
+ // Fresh tab opened from the unlink email: UNLINK_LOGIN is the only route, so canGoBack() is false.
+ renderPage({accountID: '1', validateCode: 'ABCDEF'});
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false, message: 'unlinkLoginForm.successfullyUnlinkedLogin'});
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Navigation.goBack).not.toHaveBeenCalled();
+
+ // Resolve the navigation-ready gate, then the effect resets the public stack to SignInPage.
+ await act(async () => {
+ mockIsNavigationReady.resolve();
+ await Promise.resolve();
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ expect(mockNavigationReset).toHaveBeenCalledWith({index: 0, routes: [{name: NAVIGATORS.TAB_NAVIGATOR}]});
+ });
+
+ it('calls goBack instead of resetting when a pop is possible (native/pushed-stack behaviour)', async () => {
+ // A native deep link pushes UNLINK_LOGIN onto the app's existing stack, so canGoBack() is true.
+ mockCanGoBack.mockReturnValue(true);
+
+ renderPage({accountID: '1', validateCode: 'ABCDEF'});
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false, message: 'unlinkLoginForm.successfullyUnlinkedLogin'});
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ await waitFor(() => {
+ expect(Navigation.goBack).toHaveBeenCalled();
+ });
+ expect(mockNavigationReset).not.toHaveBeenCalled();
+ });
+
+ it('does not reset the stack when the page unmounts before navigation is ready (stale-callback guard)', async () => {
+ const {unmount} = renderPage({accountID: '1', validateCode: 'ABCDEF'});
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ // Transition to settled: this fires the completion effect, which calls isNavigationReady()
+ // and starts the pending promise.
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false});
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ // isNavigationReady() is still pending. Unmounting runs the effect cleanup (sets `ignore = true`).
+ await act(async () => {
+ unmount();
+ });
+
+ // Resolving now fires the stale callback, which must skip the reset.
+ await act(async () => {
+ mockIsNavigationReady.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockNavigationReset).not.toHaveBeenCalled();
+ });
+});