Skip to content
27 changes: 25 additions & 2 deletions src/pages/UnlinkLoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';

Expand All @@ -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 = {
Expand Down
13 changes: 10 additions & 3 deletions src/pages/signin/LoginForm/BaseLoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -286,8 +291,10 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor
<DotIndicatorMessage
style={[styles.mv2]}
type="success"
// eslint-disable-next-line @typescript-eslint/naming-convention
messages={{0: closeAccount?.success ? closeAccount.success : accountMessage}}
messages={{
// eslint-disable-next-line @typescript-eslint/naming-convention
0: closeAccount?.success ? closeAccount.success : accountMessage,
}}
/>
)}
{
Expand Down
37 changes: 36 additions & 1 deletion tests/ui/BaseLoginFormTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
});
177 changes: 177 additions & 0 deletions tests/ui/UnlinkLoginPageTest.tsx
Original file line number Diff line number Diff line change
@@ -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<void>((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<PublicScreensParamList>();

const renderPage = (initialParams: PublicScreensParamList[typeof SCREENS.UNLINK_LOGIN]) => {
return render(
<NavigationContainer>
<RootStack.Navigator>
<RootStack.Screen
name={SCREENS.UNLINK_LOGIN}
component={UnlinkLoginPage}
initialParams={initialParams}
/>
</RootStack.Navigator>
</NavigationContainer>,
);
};

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();
});
});
Loading