From 735fdb36595cc299932241209533c151cd6f1f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:11:33 +0000 Subject: [PATCH 1/6] fix(mobile): rebuild the sign-in screen when the app language changes The sign-in route renders its screen under the active language so the whole accessibility tree is rebuilt with the new catalog after a language change, instead of relying on a re-render of an existing native scene. --- apps/mobile/src/app/(auth)/login.tsx | 4 +- .../src/components/login-screen.test.ts | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/app/(auth)/login.tsx b/apps/mobile/src/app/(auth)/login.tsx index 1e6c611c89b..9ed21a11db3 100644 --- a/apps/mobile/src/app/(auth)/login.tsx +++ b/apps/mobile/src/app/(auth)/login.tsx @@ -1,5 +1,7 @@ +import { useTranslation } from 'react-i18next'; import { LoginScreen } from '@/components/login-screen'; export default function LoginRoute() { - return ; + const { i18n } = useTranslation(); + return ; } diff --git a/apps/mobile/src/components/login-screen.test.ts b/apps/mobile/src/components/login-screen.test.ts index 37dd1bb5d7c..e92dc571e8e 100644 --- a/apps/mobile/src/components/login-screen.test.ts +++ b/apps/mobile/src/components/login-screen.test.ts @@ -16,6 +16,7 @@ import { persistLoginDrafts, restoreLoginDrafts, } from '@/lib/login-draft'; +import LoginRoute from '@/app/(auth)/login'; import { LoginScreen } from './login-screen'; import { errorMessage, resolveKeyboardBottomPadding } from './login-screen-state'; @@ -149,6 +150,19 @@ async function mountLoginScreen(): Promise { return renderer; } +async function mountLoginRoute(): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + ref.current = TestRenderer.create(createElement(LoginRoute)); + await Promise.resolve(); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + describe('login-screen refresh boundary', () => { it('passes refreshToken and expiresIn through the approved token response', () => { const result = parseDeviceAuthTokenResponse({ @@ -398,6 +412,53 @@ describe('login-screen language globe', () => { }); }); +describe('login route language refresh', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + deviceAuth.status = 'idle'; + deviceAuth.token = undefined; + deviceAuth.code = undefined; + deviceAuth.refreshToken = undefined; + deviceAuth.expiresIn = undefined; + deviceAuth.error = undefined; + deviceAuth.verificationUrl = undefined; + deviceAuth.resumed = false; + }); + + it('remounts the sign-in screen and re-labels it in the applied language', async () => { + vi.mocked(restoreLoginDrafts).mockClear(); + const renderer = await mountLoginRoute(); + const mountsBeforeSwitch = vi.mocked(restoreLoginDrafts).mock.calls.length; + expect(mountsBeforeSwitch).toBeGreaterThan(0); + + await act(async () => { + await i18n.changeLanguage('ja'); + }); + try { + // A fresh mount re-runs the draft restore, so the count is how a remount + // is observable: the route's `useTranslation` re-render alone would keep + // the same screen instance. + expect(vi.mocked(restoreLoginDrafts).mock.calls.length).toBeGreaterThan(mountsBeforeSwitch); + // The fresh mount restores its drafts asynchronously, so flush that + // before reading the revealed form. + await act(async () => { + await Promise.resolve(); + }); + const pressableLabels = renderer.root + .findAll(node => typeof node.type === 'string' && (node.type as string) === 'Pressable') + .map(node => node.props.accessibilityLabel); + expect(pressableLabels).toContain(i18n.t('common.language')); + expect(pressableLabels).not.toContain('Language'); + } finally { + await act(async () => { + await i18n.changeLanguage('en'); + }); + } + + renderer.unmount(); + }); +}); + describe('login-screen device-code actions', () => { beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; From 3bb3fdc9aae16790df892e9db7801aaf282cd054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:18:57 +0000 Subject: [PATCH 2/6] fix(mobile): keep the sign-in drafts across a screen remount A language change remounts the sign-in screen inside the same app process, so the drafts the form holds in memory now win over the stored copies. --- apps/mobile/src/lib/login-draft.test.ts | 50 +++++++++++++++++++++++++ apps/mobile/src/lib/login-draft.ts | 11 ++++-- 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/lib/login-draft.test.ts diff --git a/apps/mobile/src/lib/login-draft.test.ts b/apps/mobile/src/lib/login-draft.test.ts new file mode 100644 index 00000000000..fe08d871fbc --- /dev/null +++ b/apps/mobile/src/lib/login-draft.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearLoginDrafts, + restoreLoginDrafts, + setLoginEmailDraft, + setSsoRecoveryDraft, +} from './login-draft'; + +const stored = vi.hoisted(() => ({ + email: null as string | null, + sso: null as string | null, +})); + +vi.mock('@/lib/auth/secure-store', () => ({ + getItemAsync: vi.fn((key: string) => + Promise.resolve(key.includes('sso') ? stored.sso : stored.email) + ), + setItemAsync: vi.fn(() => Promise.resolve()), + deleteItemAsync: vi.fn(() => Promise.resolve()), +})); + +describe('login drafts across a remount', () => { + beforeEach(() => { + stored.email = null; + stored.sso = null; + clearLoginDrafts(); + }); + + it('keeps the drafts the form is holding in memory', async () => { + setLoginEmailDraft('typed@example.com'); + setSsoRecoveryDraft({ email: 'typed@example.com', ssoOrganizationId: 'org-1' }); + stored.email = 'older@example.com'; + + await expect(restoreLoginDrafts()).resolves.toEqual({ + email: 'typed@example.com', + ssoRecovery: { email: 'typed@example.com', ssoOrganizationId: 'org-1' }, + }); + }); + + it('falls back to the stored drafts when the form holds none', async () => { + stored.email = 'stored@example.com'; + stored.sso = JSON.stringify({ email: 'stored@example.com', ssoOrganizationId: 'org-2' }); + + await expect(restoreLoginDrafts()).resolves.toEqual({ + email: 'stored@example.com', + ssoRecovery: { email: 'stored@example.com', ssoOrganizationId: 'org-2' }, + }); + }); +}); diff --git a/apps/mobile/src/lib/login-draft.ts b/apps/mobile/src/lib/login-draft.ts index 86b56fa7d94..15b48a58012 100644 --- a/apps/mobile/src/lib/login-draft.ts +++ b/apps/mobile/src/lib/login-draft.ts @@ -33,9 +33,11 @@ export async function persistLoginDrafts(): Promise { } } -/** Read the persisted drafts on login mount. The caller deletes only after it - * applies the restored values, because a cancelled mount must not lose the - * drafts. */ +/** Read the persisted drafts on login mount. A draft the form is holding in + * memory wins over the stored copy: a remount inside the same app process, such + * as the one a language change causes, must not drop what the person already + * typed. The caller deletes the stored copies only after it applies the + * restored values, because a cancelled mount must not lose them. */ export async function restoreLoginDrafts(): Promise<{ email: string; ssoRecovery: SsoRecoveryDraft | null; @@ -59,7 +61,8 @@ export async function restoreLoginDrafts(): Promise<{ // Corrupt draft: ignore it. } } - return { email: email ?? '', ssoRecovery }; + const restoredEmail = emailDraft.length > 0 ? emailDraft : (email ?? ''); + return { email: restoredEmail, ssoRecovery: ssoRecoveryDraft ?? ssoRecovery }; } /** Delete the persisted drafts after the caller applies the restored values. */ From c7d7acf54cef62a078bebcc6317a8a0449a000ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:42:09 +0000 Subject: [PATCH 3/6] test(mobile): cover the draft a language change keeps on the sign-in screen --- apps/mobile/src/components/login-screen.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/mobile/src/components/login-screen.test.ts b/apps/mobile/src/components/login-screen.test.ts index e92dc571e8e..1d44e3bdd3d 100644 --- a/apps/mobile/src/components/login-screen.test.ts +++ b/apps/mobile/src/components/login-screen.test.ts @@ -427,6 +427,10 @@ describe('login route language refresh', () => { it('remounts the sign-in screen and re-labels it in the applied language', async () => { vi.mocked(restoreLoginDrafts).mockClear(); + vi.mocked(restoreLoginDrafts).mockResolvedValue({ + email: 'typed@example.com', + ssoRecovery: null, + }); const renderer = await mountLoginRoute(); const mountsBeforeSwitch = vi.mocked(restoreLoginDrafts).mock.calls.length; expect(mountsBeforeSwitch).toBeGreaterThan(0); @@ -449,10 +453,18 @@ describe('login route language refresh', () => { .map(node => node.props.accessibilityLabel); expect(pressableLabels).toContain(i18n.t('common.language')); expect(pressableLabels).not.toContain('Language'); + // The remount keeps the address the person had typed, so a language + // change does not blank the field it re-labels. + const idleAuth = findByType(renderer.root, 'IdleAuth')[0]; + expect(idleAuth?.props.initialEmail).toBe('typed@example.com'); } finally { await act(async () => { await i18n.changeLanguage('en'); }); + vi.mocked(restoreLoginDrafts).mockResolvedValue({ + email: '', + ssoRecovery: null, + }); } renderer.unmount(); From 8adc42168783aa8130b05d348cb725eed595a96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:45:26 +0000 Subject: [PATCH 4/6] test(mobile): cover a corrupt stored login draft --- apps/mobile/src/lib/login-draft.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mobile/src/lib/login-draft.test.ts b/apps/mobile/src/lib/login-draft.test.ts index fe08d871fbc..80c6493b691 100644 --- a/apps/mobile/src/lib/login-draft.test.ts +++ b/apps/mobile/src/lib/login-draft.test.ts @@ -47,4 +47,10 @@ describe('login drafts across a remount', () => { ssoRecovery: { email: 'stored@example.com', ssoOrganizationId: 'org-2' }, }); }); + + it('ignores a corrupt stored draft instead of failing the mount', async () => { + stored.sso = '{not json'; + + await expect(restoreLoginDrafts()).resolves.toEqual({ email: '', ssoRecovery: null }); + }); }); From 9830700d1118dd4561bf36a5abc840cedf0558de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:00:07 +0000 Subject: [PATCH 5/6] fix(mobile): clear the sign-in form drafts on every successful sign-in The drafts the form holds in memory now survive a remount, so they must be dropped whenever any provider finishes a sign-in, not only on the device-auth path. --- apps/mobile/src/components/login-screen.tsx | 2 -- apps/mobile/src/lib/auth/auth-context.tsx | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index 2405882d622..b8c9fc358b6 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -25,7 +25,6 @@ import { useAuth } from '@/lib/auth/auth-context'; import { useDeviceAuth } from '@/lib/auth/use-device-auth'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { - clearLoginDrafts, clearPersistedLoginDrafts, persistLoginDrafts, restoreLoginDrafts, @@ -66,7 +65,6 @@ export function LoginScreen() { setPersistFailed(false); try { await signIn(tokenValue, refreshTokenValue, expiresInValue); - clearLoginDrafts(); } catch { // The message renders from the catalog at draw time, so a language // switch re-renders it in the new language. diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index e9113f6b0d6..d0a9d009a5c 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -71,6 +71,7 @@ import { clearKiloClawOwned, gateKiloClawOwned } from '@/lib/kiloclaw-tab-owners import { clearLastActiveInstance } from '@/lib/last-active-instance'; import { clearLastRoute } from '@/lib/last-route'; import { clearLastOpenedSession } from '@/lib/last-opened-session'; +import { clearLoginDrafts } from '@/lib/login-draft'; import { clearLauncherSurfaces } from '@/lib/native-launcher-surfaces'; import { resetPurchaseErrorToastDedup } from '@/lib/credits/use-store-credit-purchase'; import { @@ -450,6 +451,10 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // A direct account switch must not keep the prior account's session // state: trusted hosts, image confirms, media caches, temp copies. clearSessionScopedState(); + // The sign-in form's drafts belong to the attempt that just succeeded, + // whatever provider finished it; a later signed-out screen must not + // prefill the previous attempt's address or SSO banner. + clearLoginDrafts(); }); }, [] From 452f6109a552172b3014d473e268226a1d9eb3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:50:26 +0000 Subject: [PATCH 6/6] test(mobile): pin the sign-in draft clear to signIn The merged tree clears the sign-in form drafts only inside signIn, so the screen's own tests can no longer see that clear. Cover it where it now lives: a completed sign-in retires the typed address and the SSO banner, and a sign-in whose credential write fails keeps both. --- .../lib/auth/auth-context.lifecycle.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx b/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx index c450fe9c840..357ba2453a4 100644 --- a/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx @@ -465,6 +465,8 @@ vi.mock('@/lib/storage-keys', () => ({ KEEP_SCREEN_ON_KEY: 'keep-session-screen-on', KILOCLAW_OWNED_KEY: 'kiloclaw-owned', LEGACY_EXCHANGE_DONE_KEY: 'legacy-exchange-done', + LOGIN_EMAIL_DRAFT_KEY: 'login-email-draft', + LOGIN_SSO_RECOVERY_DRAFT_KEY: 'login-sso-recovery-draft', NOTIFICATION_PROMPT_SEEN_KEY: 'notification-prompt-seen', ORGANIZATION_STORAGE_KEY: 'organization', ORGANIZATION_PERSONAL_STORAGE_KEY: 'selected-organization-personal', @@ -816,6 +818,46 @@ describe('sign-out teardown ordering', () => { unmount(); }); + it('retires the sign-in form drafts once a sign-in publishes its token', async () => { + const { ctx, unmount } = await mountAndGetContext(); + // The mount loads auth-context from a fresh module registry, so the drafts + // module the provider closes over is the one imported after it. + const drafts = await import('@/lib/login-draft'); + drafts.setLoginEmailDraft('typed@example.com'); + drafts.setSsoRecoveryDraft({ email: 'typed@example.com', ssoOrganizationId: 'org-1' }); + + await act(async () => { + await ctx.signIn('signed-in-token'); + }); + + // Every provider ends in signIn, so a published token is the one place + // that may drop what the form was holding: the next signed-out screen + // must not pre-fill the previous attempt's address or SSO banner. + await expect(drafts.restoreLoginDrafts()).resolves.toEqual({ email: '', ssoRecovery: null }); + + unmount(); + }); + + it('keeps the sign-in form drafts when the credential write fails', async () => { + const { ctx, unmount } = await mountAndGetContext(); + const drafts = await import('@/lib/login-draft'); + drafts.setLoginEmailDraft('typed@example.com'); + drafts.setSsoRecoveryDraft({ email: 'typed@example.com', ssoOrganizationId: 'org-1' }); + hoisted.secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')); + + await act(async () => { + await expect(ctx.signIn('signed-in-token')).rejects.toThrow('keychain unavailable'); + }); + + // A failed attempt published nothing, so the person keeps what they typed. + await expect(drafts.restoreLoginDrafts()).resolves.toEqual({ + email: 'typed@example.com', + ssoRecovery: { email: 'typed@example.com', ssoOrganizationId: 'org-1' }, + }); + + unmount(); + }); + it('unregisters the prior account activity tokens on sign-in (account switch)', async () => { const { ctx, unmount } = await mountAndGetContext();