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