From ab1b86269673144927944ffcd68a8c37c107ac96 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Sun, 2 Aug 2026 16:36:32 +0300 Subject: [PATCH 1/7] Fix infinite loading page after unlinking secondary login from email UNLINK_LOGIN is a standalone public screen with no route beneath it, so a tab opened directly from the unlink email has an empty nav stack and UnlinkLoginPage's bare Navigation.goBack() no-ops forever, leaving the spinner stuck. Reset the stack to TAB_NAVIGATOR when there's nothing to pop, matching the pattern ValidateLoginPage already uses for this. Two more gaps kept the result invisible even once navigation worked: BaseLoginForm's mount effect cleared account.message before the success text could render, and unlinkLogin's failureData never wrote an error at all, so a failed unlink showed nothing on UnlinkLoginForm either. --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/actions/Session/index.ts | 1 + src/pages/UnlinkLoginPage.tsx | 27 ++++++++++++++++++-- src/pages/signin/LoginForm/BaseLoginForm.tsx | 11 ++++++-- 13 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 7985462fac08..1fe9795bc2ad 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3587,6 +3587,7 @@ ${amount} für ${merchant} – ${date}`, unlink: 'Verknüpfung aufheben', linkSent: 'Link gesendet!', successfullyUnlinkedLogin: 'Sekundäres Login wurde erfolgreich getrennt!', + unlinkError: 'Sekundäres Login konnte nicht getrennt werden. Bitte versuchen Sie es erneut.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/en.ts b/src/languages/en.ts index 076f253dd641..3ceade82bf07 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3683,6 +3683,7 @@ const translations = { unlink: 'Unlink', linkSent: 'Link sent!', successfullyUnlinkedLogin: 'Secondary login successfully unlinked!', + unlinkError: "Couldn't unlink your secondary login. Please try again.", }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => `Our email provider has temporarily suspended emails to ${login} due to delivery issues. To unblock your login, please follow these steps:`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 8aafd38e5a39..b4e7a2deaf45 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3463,6 +3463,7 @@ ${amount} para ${merchant} - ${date}`, unlink: 'Desvincular', linkSent: '¡Enlace enviado!', successfullyUnlinkedLogin: '¡Nombre de usuario secundario desvinculado correctamente!', + unlinkError: 'No se pudo desvincular tu nombre de usuario secundario. Por favor, inténtalo de nuevo.', }, emailDeliveryFailurePage: { ourEmailProvider: (login) => diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 03fc902ca84c..d86998301637 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3593,6 +3593,7 @@ ${amount} pour ${merchant} - ${date}`, unlink: 'Dissocier', linkSent: 'Lien envoyé !', successfullyUnlinkedLogin: 'Connexion secondaire dissociée avec succès !', + unlinkError: 'Impossible de dissocier votre connexion secondaire. Veuillez réessayer.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/it.ts b/src/languages/it.ts index d5d143f1be5f..93e23e460879 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3573,6 +3573,7 @@ ${amount} per ${merchant} - ${date}`, unlink: 'Scollega', linkSent: 'Link inviato!', successfullyUnlinkedLogin: 'Accesso secondario scollegato correttamente!', + unlinkError: 'Impossibile scollegare il tuo accesso secondario. Riprova.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 22bdedf0bf98..3ac6152c06d1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3544,6 +3544,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' unlink: 'リンク解除', linkSent: 'リンクを送信しました!', successfullyUnlinkedLogin: 'セカンダリログインを正常に連携解除しました!', + unlinkError: 'セカンダリログインの連携解除に失敗しました。もう一度お試しください。', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2b7e77d15664..8ace87ffc24f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3574,6 +3574,7 @@ ${amount} voor ${merchant} - ${date}`, unlink: 'Koppeling verbreken', linkSent: 'Link verzonden!', successfullyUnlinkedLogin: 'Secundaire login is succesvol ontkoppeld!', + unlinkError: 'Je secundaire login kon niet worden ontkoppeld. Probeer het opnieuw.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bb4db669b8d7..48a240c5be0a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3558,6 +3558,7 @@ ${amount} dla ${merchant} - ${date}`, unlink: 'Odłącz', linkSent: 'Link wysłany!', successfullyUnlinkedLogin: 'Dodatkowe logowanie zostało pomyślnie odłączone!', + unlinkError: 'Nie udało się odłączyć dodatkowego logowania. Spróbuj ponownie.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 550ac8da144d..320ac9d365cd 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3562,6 +3562,7 @@ ${amount} para ${merchant} - ${date}`, unlink: 'Desvincular', linkSent: 'Link enviado!', successfullyUnlinkedLogin: 'Login secundário desvinculado com sucesso!', + unlinkError: 'Não foi possível desvincular seu login secundário. Tente novamente.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index bd140d2ad548..6507547ec840 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3461,6 +3461,7 @@ ${amount},商户:${merchant} - 日期:${date}`, unlink: '取消关联', linkSent: '链接已发送!', successfullyUnlinkedLogin: '次要登录已成功取消关联!', + unlinkError: '无法取消关联次要登录,请重试。', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => `由于投递问题,我们的邮件服务提供商已暂时停止向 ${login} 发送邮件。要解除对您登录的限制,请按以下步骤操作:`, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index f60dfe53054f..cf796c3915e5 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1280,6 +1280,7 @@ function unlinkLogin(accountID: number, validateCode: string) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('unlinkLoginForm.unlinkError'), }, }, ]; 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..91d93765c232 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) { @@ -287,7 +292,9 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor style={[styles.mv2]} type="success" // eslint-disable-next-line @typescript-eslint/naming-convention - messages={{0: closeAccount?.success ? closeAccount.success : accountMessage}} + messages={{ + 0: closeAccount?.success ? closeAccount.success : accountMessage, + }} /> )} { From e2350a5fc6aebed3625109ccdff85aa3d6408af6 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Sun, 2 Aug 2026 18:07:10 +0300 Subject: [PATCH 2/7] Add test coverage for the unlink-login navigation and message fixes Covers UnlinkLoginPage's stack reset on a fresh tab (and the stale- callback guard), BaseLoginForm's scoped clearAccountMessages skip for the unlink success message (with a control case for ordinary stale messages), and unlinkLogin's failureData now setting account.errors without touching credentials.login. --- tests/actions/SessionTest.ts | 38 ++++++- tests/ui/BaseLoginFormTest.tsx | 37 ++++++- tests/ui/UnlinkLoginPageTest.tsx | 177 +++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 tests/ui/UnlinkLoginPageTest.tsx diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index f3b0649a48b5..adca564e49c3 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -25,7 +25,7 @@ import '@libs/Notification/PushNotification/subscribeToPushNotifications'; import {KEYS_TO_PRESERVE_SUPPORTAL, signOutAndRedirectToSignIn} from '@src/libs/actions/Session'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Credentials, Session} from '@src/types/onyx'; +import type {Account, Credentials, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -823,4 +823,40 @@ describe('Session', () => { expect(session?.signedInWithSAML).toBe(false); }); }); + + describe('unlinkLogin', () => { + test('sets account.errors when the request fails, without clearing credentials.login', async () => { + let account: OnyxEntry; + Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (val) => (account = val), + }); + + await Onyx.merge(ONYXKEYS.CREDENTIALS, {login: 'secondary@example.com'}); + await waitForBatchedUpdates(); + + (HttpUtils.xhr as jest.MockedFunction).mockImplementationOnce(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.EXP_ERROR, + }), + ); + + SessionUtil.unlinkLogin(1, 'ABCDEF'); + await waitForBatchedUpdates(); + + expect(account?.isLoading).toBe(false); + expect(Object.values(account?.errors ?? {})).toContain("Couldn't unlink your secondary login. Please try again."); + + // UnlinkLoginForm (the correct destination for a failed unlink) already renders account.errors + // and has no mount-time clear — clearing the credential here would force the plain login form + // instead, dropping the resend/unlink affordance right after a failed unlink. + const credentials = await new Promise>((resolve) => { + Onyx.connect({ + key: ONYXKEYS.CREDENTIALS, + callback: resolve, + }); + }); + expect(credentials?.login).toBe('secondary@example.com'); + }); + }); }); 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..9fd9ee52cc8d --- /dev/null +++ b/tests/ui/UnlinkLoginPageTest.tsx @@ -0,0 +1,177 @@ +import {act, render, waitFor} from '@testing-library/react-native'; + +import Navigation, {navigationRef} 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: (...args: unknown[]) => mockCanGoBack(...args), + }, + 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(); + }); +}); From 7cca88fc1940d68aac70bc7018fdf231138b2e46 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Sun, 2 Aug 2026 18:59:54 +0300 Subject: [PATCH 3/7] Load IntlStore in the unlinkLogin test so the error assertion checks translated text Without it translateLocal falls back to the raw key, since this action test doesn't render a LocaleContextProvider to load translations. --- tests/actions/SessionTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index adca564e49c3..267f82fba42e 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -18,6 +18,7 @@ import reauthenticate from '@libs/Reauthentication'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import * as SessionUtil from '@src/libs/actions/Session'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import '@libs/Notification/PushNotification/subscribeToPushNotifications'; @@ -826,6 +827,8 @@ describe('Session', () => { describe('unlinkLogin', () => { test('sets account.errors when the request fails, without clearing credentials.login', async () => { + await IntlStore.load(CONST.LOCALES.EN); + let account: OnyxEntry; Onyx.connect({ key: ONYXKEYS.ACCOUNT, From 6268342b1e9c27163b7a5f375fb31fd20b9991d3 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Tue, 4 Aug 2026 18:55:19 +0300 Subject: [PATCH 4/7] Fix CI failures surfaced after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reposition the naming-convention eslint-disable in BaseLoginForm.tsx: the merge's formatter reflowed the object literal and left the directive covering the wrong line, making it both unused and ineffective at once. - Replace an unsafe HttpUtils.xhr cast in the new unlinkLogin test with jest.mocked(), which pushed the file's grandfathered no-unsafe-type-assertion count from 12 to 13. - Drop an unused navigationRef import in UnlinkLoginPageTest.tsx. - Add unlinkLoginForm.unlinkError to el.ts, a new locale file main added after this branch's translations were written. - Simplify the canGoBack mock to a zero-arg call — spreading unknown[] into a function TypeScript infers as zero-arg doesn't type-check. --- src/languages/el.ts | 1 + src/pages/signin/LoginForm/BaseLoginForm.tsx | 2 +- tests/actions/SessionTest.ts | 2 +- tests/ui/UnlinkLoginPageTest.tsx | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/languages/el.ts b/src/languages/el.ts index 4391e63ad2cd..7a26ade35ed8 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3641,6 +3641,7 @@ ${amount} για ${merchant} - ${date}`, unlink: 'Αποσύνδεση', linkSent: 'Ο σύνδεσμος στάλθηκε!', successfullyUnlinkedLogin: 'Δευτερεύουσα σύνδεση αποσυνδέθηκε με επιτυχία!', + unlinkError: 'Δεν ήταν δυνατή η αποσύνδεση της δευτερεύουσας σύνδεσής σας. Παρακαλούμε δοκιμάστε ξανά.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index 91d93765c232..cc363ffed0d6 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -291,8 +291,8 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index d184fce96a64..24591b5a4a83 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -839,7 +839,7 @@ describe('Session', () => { await Onyx.merge(ONYXKEYS.CREDENTIALS, {login: 'secondary@example.com'}); await waitForBatchedUpdates(); - (HttpUtils.xhr as jest.MockedFunction).mockImplementationOnce(() => + jest.mocked(HttpUtils.xhr).mockImplementationOnce(() => Promise.resolve({ jsonCode: CONST.JSON_CODE.EXP_ERROR, }), diff --git a/tests/ui/UnlinkLoginPageTest.tsx b/tests/ui/UnlinkLoginPageTest.tsx index 9fd9ee52cc8d..b4599269e5ff 100644 --- a/tests/ui/UnlinkLoginPageTest.tsx +++ b/tests/ui/UnlinkLoginPageTest.tsx @@ -1,6 +1,6 @@ import {act, render, waitFor} from '@testing-library/react-native'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {PublicScreensParamList} from '@libs/Navigation/types'; @@ -44,7 +44,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ // above are initialized, so capturing them directly would freeze `undefined`. navigationRef: { current: { - canGoBack: (...args: unknown[]) => mockCanGoBack(...args), + canGoBack: () => mockCanGoBack(), }, reset: (...args: unknown[]) => { mockNavigationReset(...args); From dfca08833d8d2367ada633ddd32f7f739eb362e3 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Tue, 4 Aug 2026 21:26:37 +0300 Subject: [PATCH 5/7] Preserve unlink errors on the login form and dedupe the success-message check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an expired/invalid unlink link is opened in a browser that never cached credentials.login for the flow (e.g. a different browser or profile than the one that requested the unlink), shouldShowLoginForm selects BaseLoginForm instead of UnlinkLoginForm. Its mount effect only exempted the success message from clearAccountMessages(), so the freshly-written unlink error was wiped before it could render, leaving a blank sign-in form. Extend the guard to also preserve that specific error by value, the same way the success message is matched — not by testing account.errors generally, which would strand unrelated stale errors on the page. Also extract the repeated 'unlinkLoginForm.successfullyUnlinkedLogin' literal (BaseLoginForm's accountMessage memo, its mount guard, and UnlinkLoginForm's message check) into a single isUnlinkLoginSuccessMessage predicate in SessionUtils so a future rename of the key can't silently break one of the sites. --- src/libs/SessionUtils.ts | 12 +++++++ src/pages/signin/LoginForm/BaseLoginForm.tsx | 15 ++++++--- src/pages/signin/UnlinkLoginForm.tsx | 9 ++++-- tests/ui/BaseLoginFormTest.tsx | 34 ++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index 8f362ab48aed..c95fe93d9898 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -133,6 +133,17 @@ function useIsAgentAccount(): boolean { return isAgentEmail(sessionEmail); } +const UNLINK_LOGIN_SUCCESS_MESSAGE = 'unlinkLoginForm.successfullyUnlinkedLogin'; + +/** + * `account.message` is compared against this raw translation key from multiple sign-in surfaces + * (BaseLoginForm's success text and its mount-clear guard, UnlinkLoginForm). Centralizing the + * literal here means a rename of the key can't silently break one of those sites. + */ +function isUnlinkLoginSuccessMessage(message?: string | null): boolean { + return message === UNLINK_LOGIN_SUCCESS_MESSAGE; +} + export { isLoggingInAsNewUser, didUserLogInDuringSession, @@ -142,4 +153,5 @@ export { isLoggingInAsDelegate, isAgentEmail, useIsAgentAccount, + isUnlinkLoginSuccessMessage, }; diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index cc363ffed0d6..bbcd250a6b91 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -22,7 +22,7 @@ import {getLatestErrorMessage} from '@libs/ErrorUtils'; import isInputAutoFilled from '@libs/isInputAutoFilled'; import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from '@libs/LoginUtils'; import {parsePhoneNumber} from '@libs/PhoneNumber'; -import {isAgentEmail} from '@libs/SessionUtils'; +import {isAgentEmail, isUnlinkLoginSuccessMessage} from '@libs/SessionUtils'; import StringUtils from '@libs/StringUtils'; import {isNumericWithSpecialChars, isValidEmailWithTLD} from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; @@ -63,7 +63,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor const isFocused = useIsFocused(); const isLoading = useRef(false); const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); - const accountMessage = account?.message === 'unlinkLoginForm.successfullyUnlinkedLogin' ? translate(account.message) : (account?.message ?? ''); + const accountMessage = isUnlinkLoginSuccessMessage(account?.message) ? translate('unlinkLoginForm.successfullyUnlinkedLogin') : (account?.message ?? ''); /** * Validate the input value and set the error for formError @@ -168,9 +168,16 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor // 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'; + const hasJustUnlinkedLogin = isUnlinkLoginSuccessMessage(account?.message); - if (isFocused && isVisible && !hasJustUnlinkedLogin) { + // A failed/expired unlink can also land here: when credentials.login isn't already cached in this + // browser (e.g. the link was opened somewhere other than the tab that requested the unlink), + // shouldShowLoginForm selects this form instead of UnlinkLoginForm. Preserve that specific error by + // value, not by testing account.errors generally — a broader check would also strand a stale error + // from an earlier failed sign-in attempt on the page. + const hasUnlinkLoginError = Object.values(account?.errors ?? {}).includes(translate('unlinkLoginForm.unlinkError')); + + if (isFocused && isVisible && !hasJustUnlinkedLogin && !hasUnlinkLoginError) { clearAccountMessages(); } if (!canFocusInputOnScreenFocus() || !input.current || !isVisible || !isFocused) { diff --git a/src/pages/signin/UnlinkLoginForm.tsx b/src/pages/signin/UnlinkLoginForm.tsx index 468f19a31efd..4b7681590248 100644 --- a/src/pages/signin/UnlinkLoginForm.tsx +++ b/src/pages/signin/UnlinkLoginForm.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getErrorsWithTranslationData} from '@libs/ErrorUtils'; import {normalizeLogin} from '@libs/LoginUtils'; +import {isUnlinkLoginSuccessMessage} from '@libs/SessionUtils'; import {requestUnlinkValidationLink} from '@userActions/Session'; import redirectToSignIn from '@userActions/SignInRedirect'; @@ -28,8 +29,12 @@ function UnlinkLoginForm() { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); - const unlinkMessage = - account?.message === 'unlinkLoginForm.linkSent' || account?.message === 'unlinkLoginForm.successfullyUnlinkedLogin' ? translate(account?.message) : account?.message; + let unlinkMessage = account?.message; + if (account?.message === 'unlinkLoginForm.linkSent') { + unlinkMessage = translate(account.message); + } else if (isUnlinkLoginSuccessMessage(account?.message)) { + unlinkMessage = translate('unlinkLoginForm.successfullyUnlinkedLogin'); + } const primaryLogin = normalizeLogin(account?.primaryLogin); const secondaryLogin = normalizeLogin(credentials?.login); diff --git a/tests/ui/BaseLoginFormTest.tsx b/tests/ui/BaseLoginFormTest.tsx index a656a9cb711e..c00c0b3d21f5 100644 --- a/tests/ui/BaseLoginFormTest.tsx +++ b/tests/ui/BaseLoginFormTest.tsx @@ -185,4 +185,38 @@ describe('BaseLoginForm', () => { expect(clearAccountMessages).toHaveBeenCalled(); }); }); + + it('does not clear a freshly-set unlink error on mount', async () => { + // A failed/expired unlink can also land on this form: when credentials.login isn't already + // cached in this browser, shouldShowLoginForm selects this form instead of UnlinkLoginForm. + // The error must survive the mount the same way the success message does. + await Onyx.set(ONYXKEYS.ACCOUNT, { + isLoading: false, + errors: {1: 'unlinkLoginForm.unlinkError'}, + }); + await waitForBatchedUpdates(); + + renderForm(); + await waitFor(() => { + expect(screen.getByText('unlinkLoginForm.unlinkError')).toBeTruthy(); + }); + + expect(clearAccountMessages).not.toHaveBeenCalled(); + }); + + it('still clears an unrelated stale error on an ordinary mount (control case)', async () => { + // Regression guard: the skip must be scoped to the specific unlink error value only — any + // other leftover error (e.g. from an earlier failed sign-in attempt) must still be cleared. + await Onyx.set(ONYXKEYS.ACCOUNT, { + isLoading: false, + errors: {1: 'loginForm.error.invalidFormatEmailLogin'}, + }); + await waitForBatchedUpdates(); + + renderForm(); + + await waitFor(() => { + expect(clearAccountMessages).toHaveBeenCalled(); + }); + }); }); From fb6af14ca65c940a73e9c521f7bd1c45ea14ae95 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Tue, 4 Aug 2026 23:23:32 +0300 Subject: [PATCH 6/7] Fix eslint naming-convention violation in new error-preservation tests Literal numeric object keys ({1: ...}) trip @typescript-eslint/naming- convention. Switch to a computed key (Date.now()), matching how production's ErrorUtils actually builds these error objects. --- tests/ui/BaseLoginFormTest.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui/BaseLoginFormTest.tsx b/tests/ui/BaseLoginFormTest.tsx index c00c0b3d21f5..b9953f101448 100644 --- a/tests/ui/BaseLoginFormTest.tsx +++ b/tests/ui/BaseLoginFormTest.tsx @@ -192,7 +192,7 @@ describe('BaseLoginForm', () => { // The error must survive the mount the same way the success message does. await Onyx.set(ONYXKEYS.ACCOUNT, { isLoading: false, - errors: {1: 'unlinkLoginForm.unlinkError'}, + errors: {[Date.now()]: 'unlinkLoginForm.unlinkError'}, }); await waitForBatchedUpdates(); @@ -209,7 +209,7 @@ describe('BaseLoginForm', () => { // other leftover error (e.g. from an earlier failed sign-in attempt) must still be cleared. await Onyx.set(ONYXKEYS.ACCOUNT, { isLoading: false, - errors: {1: 'loginForm.error.invalidFormatEmailLogin'}, + errors: {[Date.now()]: 'loginForm.error.invalidFormatEmailLogin'}, }); await waitForBatchedUpdates(); From e6a738eaf2b73bbf618f54234fb348c54b9ebe4e Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Wed, 5 Aug 2026 17:47:38 +0300 Subject: [PATCH 7/7] Revert the failureData error write and the error-preservation guard Per @Pujan92's review: the backend already returns onyxData with a proper error message on unlink failure, so a client-side generic fallback (unlinkLoginForm.unlinkError) was redundant and looked like a workaround rather than a fix. Reverting to the approved proposal's scope: - unlinkLogin's failureData goes back to only flipping isLoading. - BaseLoginForm's mount-clear guard goes back to exempting only the success message, not a specific unlink error value. - UnlinkLoginForm and the accountMessage memo go back to their inline literal comparisons (drops the isUnlinkLoginSuccessMessage extraction along with it, since it existed only to serve the guard above). - Removes the now-unused unlinkLoginForm.unlinkError translation key from all locale files, and the tests that covered the reverted behavior. --- src/languages/de.ts | 1 - src/languages/el.ts | 1 - src/languages/en.ts | 1 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - src/libs/SessionUtils.ts | 12 ------ src/libs/actions/Session/index.ts | 1 - src/pages/signin/LoginForm/BaseLoginForm.tsx | 15 ++----- src/pages/signin/UnlinkLoginForm.tsx | 9 +---- tests/actions/SessionTest.ts | 41 +------------------- tests/ui/BaseLoginFormTest.tsx | 34 ---------------- 17 files changed, 7 insertions(+), 116 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 87b09d8f45df..d0991c549c73 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3588,7 +3588,6 @@ ${amount} für ${merchant} – ${date}`, unlink: 'Verknüpfung aufheben', linkSent: 'Link gesendet!', successfullyUnlinkedLogin: 'Sekundäres Login wurde erfolgreich getrennt!', - unlinkError: 'Sekundäres Login konnte nicht getrennt werden. Bitte versuchen Sie es erneut.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/el.ts b/src/languages/el.ts index 7a26ade35ed8..4391e63ad2cd 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3641,7 +3641,6 @@ ${amount} για ${merchant} - ${date}`, unlink: 'Αποσύνδεση', linkSent: 'Ο σύνδεσμος στάλθηκε!', successfullyUnlinkedLogin: 'Δευτερεύουσα σύνδεση αποσυνδέθηκε με επιτυχία!', - unlinkError: 'Δεν ήταν δυνατή η αποσύνδεση της δευτερεύουσας σύνδεσής σας. Παρακαλούμε δοκιμάστε ξανά.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/en.ts b/src/languages/en.ts index 0b0257eb46e1..91a6c4fc24e8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3684,7 +3684,6 @@ const translations = { unlink: 'Unlink', linkSent: 'Link sent!', successfullyUnlinkedLogin: 'Secondary login successfully unlinked!', - unlinkError: "Couldn't unlink your secondary login. Please try again.", }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => `Our email provider has temporarily suspended emails to ${login} due to delivery issues. To unblock your login, please follow these steps:`, diff --git a/src/languages/es.ts b/src/languages/es.ts index a4a0ffa5ecd1..10a973e7a9bc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3464,7 +3464,6 @@ ${amount} para ${merchant} - ${date}`, unlink: 'Desvincular', linkSent: '¡Enlace enviado!', successfullyUnlinkedLogin: '¡Nombre de usuario secundario desvinculado correctamente!', - unlinkError: 'No se pudo desvincular tu nombre de usuario secundario. Por favor, inténtalo de nuevo.', }, emailDeliveryFailurePage: { ourEmailProvider: (login) => diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b16ea550c382..e27e999b89df 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3594,7 +3594,6 @@ ${amount} pour ${merchant} - ${date}`, unlink: 'Dissocier', linkSent: 'Lien envoyé !', successfullyUnlinkedLogin: 'Connexion secondaire dissociée avec succès !', - unlinkError: 'Impossible de dissocier votre connexion secondaire. Veuillez réessayer.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/it.ts b/src/languages/it.ts index 848789b0ef20..cf6c4b48a409 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3574,7 +3574,6 @@ ${amount} per ${merchant} - ${date}`, unlink: 'Scollega', linkSent: 'Link inviato!', successfullyUnlinkedLogin: 'Accesso secondario scollegato correttamente!', - unlinkError: 'Impossibile scollegare il tuo accesso secondario. Riprova.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4297664f8d45..b5cb7e254633 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3545,7 +3545,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' unlink: 'リンク解除', linkSent: 'リンクを送信しました!', successfullyUnlinkedLogin: 'セカンダリログインを正常に連携解除しました!', - unlinkError: 'セカンダリログインの連携解除に失敗しました。もう一度お試しください。', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/nl.ts b/src/languages/nl.ts index cc4bb3bb8e33..0ef89e0a32c9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3575,7 +3575,6 @@ ${amount} voor ${merchant} - ${date}`, unlink: 'Koppeling verbreken', linkSent: 'Link verzonden!', successfullyUnlinkedLogin: 'Secundaire login is succesvol ontkoppeld!', - unlinkError: 'Je secundaire login kon niet worden ontkoppeld. Probeer het opnieuw.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c2dfd008fb2c..cbe6f9a63725 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3559,7 +3559,6 @@ ${amount} dla ${merchant} - ${date}`, unlink: 'Odłącz', linkSent: 'Link wysłany!', successfullyUnlinkedLogin: 'Dodatkowe logowanie zostało pomyślnie odłączone!', - unlinkError: 'Nie udało się odłączyć dodatkowego logowania. Spróbuj ponownie.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 16d91c344b4f..d755a20e994d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3563,7 +3563,6 @@ ${amount} para ${merchant} - ${date}`, unlink: 'Desvincular', linkSent: 'Link enviado!', successfullyUnlinkedLogin: 'Login secundário desvinculado com sucesso!', - unlinkError: 'Não foi possível desvincular seu login secundário. Tente novamente.', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c8bad988b0fa..ee4798c12823 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3462,7 +3462,6 @@ ${amount},商户:${merchant} - 日期:${date}`, unlink: '取消关联', linkSent: '链接已发送!', successfullyUnlinkedLogin: '次要登录已成功取消关联!', - unlinkError: '无法取消关联次要登录,请重试。', }, emailDeliveryFailurePage: { ourEmailProvider: (login: string) => `由于投递问题,我们的邮件服务提供商已暂时停止向 ${login} 发送邮件。要解除对您登录的限制,请按以下步骤操作:`, diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index c95fe93d9898..8f362ab48aed 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -133,17 +133,6 @@ function useIsAgentAccount(): boolean { return isAgentEmail(sessionEmail); } -const UNLINK_LOGIN_SUCCESS_MESSAGE = 'unlinkLoginForm.successfullyUnlinkedLogin'; - -/** - * `account.message` is compared against this raw translation key from multiple sign-in surfaces - * (BaseLoginForm's success text and its mount-clear guard, UnlinkLoginForm). Centralizing the - * literal here means a rename of the key can't silently break one of those sites. - */ -function isUnlinkLoginSuccessMessage(message?: string | null): boolean { - return message === UNLINK_LOGIN_SUCCESS_MESSAGE; -} - export { isLoggingInAsNewUser, didUserLogInDuringSession, @@ -153,5 +142,4 @@ export { isLoggingInAsDelegate, isAgentEmail, useIsAgentAccount, - isUnlinkLoginSuccessMessage, }; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 6281689a6d8b..a6ae491b1baa 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1273,7 +1273,6 @@ function unlinkLogin(accountID: number, validateCode: string) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('unlinkLoginForm.unlinkError'), }, }, ]; diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index bbcd250a6b91..cc363ffed0d6 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -22,7 +22,7 @@ import {getLatestErrorMessage} from '@libs/ErrorUtils'; import isInputAutoFilled from '@libs/isInputAutoFilled'; import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from '@libs/LoginUtils'; import {parsePhoneNumber} from '@libs/PhoneNumber'; -import {isAgentEmail, isUnlinkLoginSuccessMessage} from '@libs/SessionUtils'; +import {isAgentEmail} from '@libs/SessionUtils'; import StringUtils from '@libs/StringUtils'; import {isNumericWithSpecialChars, isValidEmailWithTLD} from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; @@ -63,7 +63,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor const isFocused = useIsFocused(); const isLoading = useRef(false); const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); - const accountMessage = isUnlinkLoginSuccessMessage(account?.message) ? translate('unlinkLoginForm.successfullyUnlinkedLogin') : (account?.message ?? ''); + const accountMessage = account?.message === 'unlinkLoginForm.successfullyUnlinkedLogin' ? translate(account.message) : (account?.message ?? ''); /** * Validate the input value and set the error for formError @@ -168,16 +168,9 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor // 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 = isUnlinkLoginSuccessMessage(account?.message); + const hasJustUnlinkedLogin = account?.message === 'unlinkLoginForm.successfullyUnlinkedLogin'; - // A failed/expired unlink can also land here: when credentials.login isn't already cached in this - // browser (e.g. the link was opened somewhere other than the tab that requested the unlink), - // shouldShowLoginForm selects this form instead of UnlinkLoginForm. Preserve that specific error by - // value, not by testing account.errors generally — a broader check would also strand a stale error - // from an earlier failed sign-in attempt on the page. - const hasUnlinkLoginError = Object.values(account?.errors ?? {}).includes(translate('unlinkLoginForm.unlinkError')); - - if (isFocused && isVisible && !hasJustUnlinkedLogin && !hasUnlinkLoginError) { + if (isFocused && isVisible && !hasJustUnlinkedLogin) { clearAccountMessages(); } if (!canFocusInputOnScreenFocus() || !input.current || !isVisible || !isFocused) { diff --git a/src/pages/signin/UnlinkLoginForm.tsx b/src/pages/signin/UnlinkLoginForm.tsx index 4b7681590248..468f19a31efd 100644 --- a/src/pages/signin/UnlinkLoginForm.tsx +++ b/src/pages/signin/UnlinkLoginForm.tsx @@ -10,7 +10,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getErrorsWithTranslationData} from '@libs/ErrorUtils'; import {normalizeLogin} from '@libs/LoginUtils'; -import {isUnlinkLoginSuccessMessage} from '@libs/SessionUtils'; import {requestUnlinkValidationLink} from '@userActions/Session'; import redirectToSignIn from '@userActions/SignInRedirect'; @@ -29,12 +28,8 @@ function UnlinkLoginForm() { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); - let unlinkMessage = account?.message; - if (account?.message === 'unlinkLoginForm.linkSent') { - unlinkMessage = translate(account.message); - } else if (isUnlinkLoginSuccessMessage(account?.message)) { - unlinkMessage = translate('unlinkLoginForm.successfullyUnlinkedLogin'); - } + const unlinkMessage = + account?.message === 'unlinkLoginForm.linkSent' || account?.message === 'unlinkLoginForm.successfullyUnlinkedLogin' ? translate(account?.message) : account?.message; const primaryLogin = normalizeLogin(account?.primaryLogin); const secondaryLogin = normalizeLogin(credentials?.login); diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 24591b5a4a83..3c2b942bec42 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -18,7 +18,6 @@ import reauthenticate from '@libs/Reauthentication'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; import * as SessionUtil from '@src/libs/actions/Session'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import '@libs/Notification/PushNotification/subscribeToPushNotifications'; @@ -26,7 +25,7 @@ import '@libs/Notification/PushNotification/subscribeToPushNotifications'; import {KEYS_TO_PRESERVE_SUPPORTAL, signOutAndRedirectToSignIn} from '@src/libs/actions/Session'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account, Credentials, Session} from '@src/types/onyx'; +import type {Credentials, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -826,44 +825,6 @@ describe('Session', () => { }); }); - describe('unlinkLogin', () => { - test('sets account.errors when the request fails, without clearing credentials.login', async () => { - await IntlStore.load(CONST.LOCALES.EN); - - let account: OnyxEntry; - Onyx.connect({ - key: ONYXKEYS.ACCOUNT, - callback: (val) => (account = val), - }); - - await Onyx.merge(ONYXKEYS.CREDENTIALS, {login: 'secondary@example.com'}); - await waitForBatchedUpdates(); - - jest.mocked(HttpUtils.xhr).mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.EXP_ERROR, - }), - ); - - SessionUtil.unlinkLogin(1, 'ABCDEF'); - await waitForBatchedUpdates(); - - expect(account?.isLoading).toBe(false); - expect(Object.values(account?.errors ?? {})).toContain("Couldn't unlink your secondary login. Please try again."); - - // UnlinkLoginForm (the correct destination for a failed unlink) already renders account.errors - // and has no mount-time clear — clearing the credential here would force the plain login form - // instead, dropping the resend/unlink affordance right after a failed unlink. - const credentials = await new Promise>((resolve) => { - Onyx.connect({ - key: ONYXKEYS.CREDENTIALS, - callback: resolve, - }); - }); - expect(credentials?.login).toBe('secondary@example.com'); - }); - }); - describe('resendValidateCode', () => { test('sends the login argument as the email param, independent of the CREDENTIALS Onyx cache', async () => { const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); diff --git a/tests/ui/BaseLoginFormTest.tsx b/tests/ui/BaseLoginFormTest.tsx index b9953f101448..a656a9cb711e 100644 --- a/tests/ui/BaseLoginFormTest.tsx +++ b/tests/ui/BaseLoginFormTest.tsx @@ -185,38 +185,4 @@ describe('BaseLoginForm', () => { expect(clearAccountMessages).toHaveBeenCalled(); }); }); - - it('does not clear a freshly-set unlink error on mount', async () => { - // A failed/expired unlink can also land on this form: when credentials.login isn't already - // cached in this browser, shouldShowLoginForm selects this form instead of UnlinkLoginForm. - // The error must survive the mount the same way the success message does. - await Onyx.set(ONYXKEYS.ACCOUNT, { - isLoading: false, - errors: {[Date.now()]: 'unlinkLoginForm.unlinkError'}, - }); - await waitForBatchedUpdates(); - - renderForm(); - await waitFor(() => { - expect(screen.getByText('unlinkLoginForm.unlinkError')).toBeTruthy(); - }); - - expect(clearAccountMessages).not.toHaveBeenCalled(); - }); - - it('still clears an unrelated stale error on an ordinary mount (control case)', async () => { - // Regression guard: the skip must be scoped to the specific unlink error value only — any - // other leftover error (e.g. from an earlier failed sign-in attempt) must still be cleared. - await Onyx.set(ONYXKEYS.ACCOUNT, { - isLoading: false, - errors: {[Date.now()]: 'loginForm.error.invalidFormatEmailLogin'}, - }); - await waitForBatchedUpdates(); - - renderForm(); - - await waitFor(() => { - expect(clearAccountMessages).toHaveBeenCalled(); - }); - }); });