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