From b190d9e2471f8792374e7d1113ffb7e218b7f877 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 20 Apr 2026 13:54:34 -0400 Subject: [PATCH 1/3] recover password to flow --- plans/store-split.md | 2 +- .../recover-password/device-selector.tsx | 19 +- .../login/recover-password/explain-device.tsx | 3 +- .../recover-password/flow.test.tsx} | 86 ++++-- shared/login/recover-password/flow.tsx | 235 ++++++++++++++++ shared/login/recover-password/paper-key.tsx | 8 +- shared/login/recover-password/password.tsx | 5 +- .../recover-password/prompt-reset-shared.tsx | 7 +- shared/login/relogin/container.tsx | 3 +- shared/provision/password.tsx | 14 +- shared/stores/recover-password.tsx | 253 ------------------ 11 files changed, 325 insertions(+), 310 deletions(-) rename shared/{stores/tests/recover-password.test.ts => login/recover-password/flow.test.tsx} (51%) create mode 100644 shared/login/recover-password/flow.tsx delete mode 100644 shared/stores/recover-password.tsx diff --git a/plans/store-split.md b/plans/store-split.md index 701c6038af8f..285fe1b5b0e7 100644 --- a/plans/store-split.md +++ b/plans/store-split.md @@ -13,7 +13,7 @@ Recommended implementation order: - [x] `settings-email` - [x] `settings-phone` - [x] `people` -- [ ] `recover-password` +- [x] `recover-password` - [ ] `settings-password` - [ ] `tracker` - [ ] `team-building` diff --git a/shared/login/recover-password/device-selector.tsx b/shared/login/recover-password/device-selector.tsx index dcb0901e1473..6f0ad7c36000 100644 --- a/shared/login/recover-password/device-selector.tsx +++ b/shared/login/recover-password/device-selector.tsx @@ -1,26 +1,23 @@ import SelectOtherDevice from '@/provision/select-other-device' import type {Device} from '@/stores/provision' -import {useState as useRecoverState} from '@/stores/recover-password' +import { + cancelRecoverPassword, + submitRecoverPasswordDeviceSelect, + submitRecoverPasswordNoDevice, +} from './flow' type Props = {route: {params: {devices: ReadonlyArray}}} const RecoverPasswordDeviceSelector = ({route}: Props) => { const {devices} = route.params - const submitDeviceSelect = useRecoverState(s => s.dispatch.dynamic.submitDeviceSelect) - const submitNoDevice = useRecoverState(s => s.dispatch.dynamic.submitNoDevice) - const cancel = useRecoverState(s => s.dispatch.dynamic.cancel) const onBack = () => { - cancel?.() + cancelRecoverPassword() } const onResetAccount = () => { - submitNoDevice?.() + submitRecoverPasswordNoDevice() } const onSelect = (name: string) => { - if (submitDeviceSelect) { - submitDeviceSelect(devices.find(device => device.name === name)?.id) - } else { - console.log('Missing device select?') - } + submitRecoverPasswordDeviceSelect(devices.find(device => device.name === name)?.id) } const props = { devices, diff --git a/shared/login/recover-password/explain-device.tsx b/shared/login/recover-password/explain-device.tsx index 79c53fe4048f..e918de4a0cee 100644 --- a/shared/login/recover-password/explain-device.tsx +++ b/shared/login/recover-password/explain-device.tsx @@ -3,13 +3,12 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import type {ButtonType} from '@/common-adapters/button' import {SignupScreen} from '@/signup/common' -import {useState as useRecoverState} from '@/stores/recover-password' +import {startRecoverPassword} from './flow' type Props = {route: {params: {deviceName: string; deviceType: T.RPCGen.DeviceType; username: string}}} const ConnectedExplainDevice = ({route}: Props) => { const {deviceName, deviceType, username} = route.params - const startRecoverPassword = useRecoverState(s => s.dispatch.startRecoverPassword) const onBack = () => { startRecoverPassword({ replaceRoute: true, diff --git a/shared/stores/tests/recover-password.test.ts b/shared/login/recover-password/flow.test.tsx similarity index 51% rename from shared/stores/tests/recover-password.test.ts rename to shared/login/recover-password/flow.test.tsx index ba8fc62bdb3b..4d42a02652ca 100644 --- a/shared/stores/tests/recover-password.test.ts +++ b/shared/login/recover-password/flow.test.tsx @@ -6,20 +6,31 @@ jest.mock('@/constants/router', () => { const actual = jest.requireActual('@/constants/router') return { ...actual, + clearModals: jest.fn(), navigateAppend: jest.fn(), navigateUp: jest.fn(), } }) -import {useState as useRecoverPasswordState} from '../recover-password' - -const {navigateAppend: mockNavigateAppend, navigateUp: mockNavigateUp} = require('@/constants/router') as { +import { + startRecoverPassword, + submitRecoverPasswordDeviceSelect, + submitRecoverPasswordReset, +} from './flow' + +const { + clearModals: mockClearModals, + navigateAppend: mockNavigateAppend, + navigateUp: mockNavigateUp, +} = require('@/constants/router') as { + clearModals: jest.Mock navigateAppend: jest.Mock navigateUp: jest.Mock } afterEach(() => { jest.restoreAllMocks() + mockClearModals.mockReset() mockNavigateAppend.mockReset() mockNavigateUp.mockReset() resetAllStores() @@ -56,12 +67,9 @@ test('startRecoverPassword exposes device selection handlers', async () => { }) try { - useRecoverPasswordState.getState().dispatch.startRecoverPassword({username: 'alice'}) + startRecoverPassword({username: 'alice'}) await flush() - const state = useRecoverPasswordState.getState() - expect(state.dispatch.dynamic.submitDeviceSelect).toBeDefined() - expect(state.dispatch.dynamic.cancel).toBeDefined() expect(mockNavigateAppend).toHaveBeenCalledWith( { name: 'recoverPasswordDeviceSelector', @@ -78,28 +86,30 @@ test('startRecoverPassword exposes device selection handlers', async () => { false ) - state.dispatch.dynamic.submitDeviceSelect?.(T.Devices.stringToDeviceID('device-1')) + submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-1')) + submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-1')) + expect(chooserResponse?.result).toHaveBeenCalledTimes(1) expect(chooserResponse?.result).toHaveBeenCalledWith(T.Devices.stringToDeviceID('device-1')) - expect(useRecoverPasswordState.getState().dispatch.dynamic.submitDeviceSelect).toBeUndefined() - expect(useRecoverPasswordState.getState().dispatch.dynamic.cancel).toBeUndefined() } finally { finishListener() await flush() } }) -test('resetState clears recover-password state after it has been populated', async () => { +test('resetAllStores clears pending recover-password handlers', async () => { + let chooserResponse: {error: jest.Mock; result: jest.Mock} | undefined let finishListener = () => {} jest.spyOn(T.RPCGen, 'loginRecoverPassphraseRpcListener').mockImplementation(async listener => { + chooserResponse = {error: jest.fn(), result: jest.fn()} const chooseDevice = listener.customResponseIncomingCallMap?.['keybase.1.loginUi.chooseDeviceToRecoverWith'] if (!chooseDevice) { throw new Error('chooseDeviceToRecoverWith handler missing') } chooseDevice( {devices: [makeRpcDevice('tablet', 'device-2', 'desktop')]} as any, - {error: jest.fn(), result: jest.fn()} as any + chooserResponse as any ) await new Promise(resolve => { finishListener = resolve @@ -108,14 +118,56 @@ test('resetState clears recover-password state after it has been populated', asy }) try { - useRecoverPasswordState.getState().dispatch.startRecoverPassword({username: 'alice'}) + startRecoverPassword({username: 'alice'}) + await flush() + + resetAllStores() + submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-2')) + + expect(chooserResponse?.result).not.toHaveBeenCalled() + } finally { + finishListener() await flush() - expect(useRecoverPasswordState.getState().dispatch.dynamic.submitDeviceSelect).toBeDefined() + } +}) + +test('reset-password prompt resolves callback and local banner handler', async () => { + let promptResponse: {result: jest.Mock} | undefined + let finishListener = () => {} + const onResetEmailSent = jest.fn() + + jest.spyOn(T.RPCGen, 'loginRecoverPassphraseRpcListener').mockImplementation(async listener => { + promptResponse = {result: jest.fn()} + const promptReset = listener.customResponseIncomingCallMap?.['keybase.1.loginUi.promptResetAccount'] + if (!promptReset) { + throw new Error('promptResetAccount handler missing') + } + promptReset( + {prompt: {t: T.RPCGen.ResetPromptType.enterResetPw}} as any, + promptResponse as any + ) + await new Promise(resolve => { + finishListener = resolve + }) + return undefined as any + }) + + try { + startRecoverPassword({onResetEmailSent, username: 'alice'}) + await flush() + + expect(mockNavigateAppend).toHaveBeenCalledWith({ + name: 'recoverPasswordPromptResetPassword', + params: {username: 'alice'}, + }) - useRecoverPasswordState.getState().dispatch.resetState() + submitRecoverPasswordReset(T.RPCGen.ResetPromptResponse.confirmReset) + submitRecoverPasswordReset(T.RPCGen.ResetPromptResponse.confirmReset) - expect(useRecoverPasswordState.getState().resetEmailSent).toBe(false) - expect(useRecoverPasswordState.getState().dispatch.dynamic.submitDeviceSelect).toBeUndefined() + expect(promptResponse?.result).toHaveBeenCalledTimes(1) + expect(promptResponse?.result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.confirmReset) + expect(onResetEmailSent).toHaveBeenCalledTimes(1) + expect(mockNavigateUp).toHaveBeenCalledTimes(1) } finally { finishListener() await flush() diff --git a/shared/login/recover-password/flow.tsx b/shared/login/recover-password/flow.tsx new file mode 100644 index 000000000000..b6f7a1890e32 --- /dev/null +++ b/shared/login/recover-password/flow.tsx @@ -0,0 +1,235 @@ +import * as T from '@/constants/types' +import {clearModals, navigateAppend, navigateUp} from '@/constants/router' +import {waitingKeyRecoverPassword} from '@/constants/strings' +import {ignorePromise, wrapErrors} from '@/constants/utils' +import logger from '@/logger' +import {startAccountReset} from '@/login/reset/account-reset' +import {useConfigState} from '@/stores/config' +import {callNamed, clearNamed, clearOwner, setNamed} from '@/stores/flow-handles' +import {useProvisionState} from '@/stores/provision' +import {rpcDeviceToDevice} from '@/constants/rpc-utils' +import {RPCError} from '@/util/errors' + +type StartRecoverPasswordParams = { + abortProvisioning?: boolean + onResetEmailSent?: () => void + replaceRoute?: boolean + username: string +} + +const owner = 'recoverPassword' + +const slots = { + cancel: 'cancel', + submitDeviceSelect: 'submitDeviceSelect', + submitNoDevice: 'submitNoDevice', + submitPaperKey: 'submitPaperKey', + submitPassword: 'submitPassword', + submitResetPassword: 'submitResetPassword', +} as const + +const clearSlots = (...slotNames: ReadonlyArray<(typeof slots)[keyof typeof slots]>) => { + slotNames.forEach(slot => clearNamed(owner, slot)) +} + +const setHandle = (active: () => boolean, slot: (typeof slots)[keyof typeof slots], handle?: (...args: Array) => void) => { + setNamed( + owner, + slot, + handle + ? (...args: Array) => { + if (active()) { + handle(...args) + } + } + : undefined + ) +} + +export const cancelRecoverPassword = () => callNamed(owner, slots.cancel) +export const submitRecoverPasswordDeviceSelect = (deviceID?: T.Devices.DeviceID) => + callNamed(owner, slots.submitDeviceSelect, deviceID) +export const submitRecoverPasswordNoDevice = () => callNamed(owner, slots.submitNoDevice) +export const submitRecoverPasswordPaperKey = (paperKey: string) => + callNamed(owner, slots.submitPaperKey, paperKey) +export const submitRecoverPasswordPassword = (password: string) => + callNamed(owner, slots.submitPassword, password) +export const submitRecoverPasswordReset = (action: T.RPCGen.ResetPromptResponse) => + callNamed(owner, slots.submitResetPassword, action) + +export const startRecoverPassword = ({ + abortProvisioning, + onResetEmailSent, + replaceRoute, + username, +}: StartRecoverPasswordParams) => { + clearOwner(owner) + const f = async () => { + if (abortProvisioning) { + useProvisionState.getState().dispatch.dynamic.cancel?.() + } + let active = true + let hadError = false + const isActive = () => active + try { + await T.RPCGen.loginRecoverPassphraseRpcListener({ + customResponseIncomingCallMap: { + 'keybase.1.loginUi.chooseDeviceToRecoverWith': (params, response) => { + const devices = (params.devices || []).map(d => rpcDeviceToDevice(d)) + const clear = () => clearSlots(slots.cancel, slots.submitDeviceSelect, slots.submitNoDevice) + const cancel = wrapErrors(() => { + clear() + response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) + navigateUp() + }) + setHandle(isActive, slots.cancel, cancel) + setHandle( + isActive, + slots.submitDeviceSelect, + wrapErrors((deviceID?: T.Devices.DeviceID) => { + clear() + if (deviceID) { + response.result(deviceID) + } else { + cancel() + } + }) + ) + setHandle( + isActive, + slots.submitNoDevice, + wrapErrors(() => { + clear() + response.result('' as T.Devices.DeviceID) + }) + ) + navigateAppend({name: 'recoverPasswordDeviceSelector', params: {devices}}, !!replaceRoute) + }, + 'keybase.1.loginUi.promptPassphraseRecovery': () => {}, + 'keybase.1.loginUi.promptResetAccount': (params, response) => { + if (params.prompt.t === T.RPCGen.ResetPromptType.enterResetPw) { + navigateAppend({name: 'recoverPasswordPromptResetPassword', params: {username}}) + const clear = () => clearSlots(slots.cancel, slots.submitResetPassword) + setHandle( + isActive, + slots.submitResetPassword, + wrapErrors((action: T.RPCGen.ResetPromptResponse) => { + clear() + response.result(action) + onResetEmailSent?.() + navigateUp() + }) + ) + setHandle( + isActive, + slots.cancel, + wrapErrors(() => { + clear() + response.result(T.RPCGen.ResetPromptResponse.nothing) + navigateUp() + }) + ) + } else { + startAccountReset(true, username) + response.result(T.RPCGen.ResetPromptResponse.nothing) + } + }, + 'keybase.1.secretUi.getPassphrase': (params, response) => { + if (params.pinentry.type === T.RPCGen.PassphraseType.paperKey) { + const clear = () => clearSlots(slots.cancel, slots.submitPaperKey) + setHandle( + isActive, + slots.cancel, + wrapErrors(() => { + clear() + response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) + startRecoverPassword({onResetEmailSent, replaceRoute: true, username}) + }) + ) + setHandle( + isActive, + slots.submitPaperKey, + wrapErrors((passphrase: string) => { + clear() + response.result({passphrase, storeSecret: false}) + }) + ) + navigateAppend( + { + name: 'recoverPasswordPaperKey', + params: {error: params.pinentry.retryLabel || undefined}, + }, + true + ) + } else { + const clear = () => clearSlots(slots.cancel, slots.submitPassword) + setHandle( + isActive, + slots.cancel, + wrapErrors(() => { + clear() + response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) + }) + ) + setHandle( + isActive, + slots.submitPassword, + wrapErrors((passphrase: string) => { + clear() + response.result({passphrase, storeSecret: true}) + }) + ) + if (!params.pinentry.retryLabel) { + navigateAppend({name: 'recoverPasswordSetPassword', params: {error: undefined}}) + } else { + navigateAppend( + { + name: 'recoverPasswordSetPassword', + params: {error: params.pinentry.retryLabel}, + }, + true + ) + } + } + }, + }, + incomingCallMap: { + 'keybase.1.loginUi.explainDeviceRecovery': params => { + navigateAppend( + { + name: 'recoverPasswordExplainDevice', + params: {deviceName: params.name, deviceType: params.kind, username}, + }, + true + ) + }, + }, + params: {username}, + waitingKey: waitingKeyRecoverPassword, + }) + console.log('Recovered account') + } catch (error) { + if (!(error instanceof RPCError)) { + return + } + hadError = true + logger.warn('RPC returned error: ' + error.message) + if (!(error.code === T.RPCGen.StatusCode.sccanceled || error.code === T.RPCGen.StatusCode.scinputcanceled)) { + navigateAppend( + { + name: useConfigState.getState().loggedIn ? 'recoverPasswordErrorModal' : 'recoverPasswordError', + params: {error: error.message}, + }, + true + ) + } + } finally { + active = false + } + logger.info(`finished ${hadError ? 'with error' : 'without error'}`) + if (!hadError) { + clearModals() + } + } + ignorePromise(f()) +} diff --git a/shared/login/recover-password/paper-key.tsx b/shared/login/recover-password/paper-key.tsx index f737b21cf1f8..c86d203609e7 100644 --- a/shared/login/recover-password/paper-key.tsx +++ b/shared/login/recover-password/paper-key.tsx @@ -3,21 +3,19 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import type {ButtonType} from '@/common-adapters/button' import {SignupScreen} from '@/signup/common' -import {useState as useRecoverState} from '@/stores/recover-password' +import {cancelRecoverPassword, submitRecoverPasswordPaperKey} from './flow' type Props = {route: {params: {error?: string}}} const PaperKey = ({route}: Props) => { const {error} = route.params - const cancel = useRecoverState(s => s.dispatch.dynamic.cancel) - const submitPaperKey = useRecoverState(s => s.dispatch.dynamic.submitPaperKey) const onBack = () => { - cancel?.() + cancelRecoverPassword() } const [paperKey, setPaperKey] = React.useState('') const onSubmit = () => { if (paperKey) { - submitPaperKey?.(paperKey) + submitRecoverPasswordPaperKey(paperKey) } } diff --git a/shared/login/recover-password/password.tsx b/shared/login/recover-password/password.tsx index 1094ebf3c565..454b35e9a211 100644 --- a/shared/login/recover-password/password.tsx +++ b/shared/login/recover-password/password.tsx @@ -1,15 +1,14 @@ import * as C from '@/constants' import {UpdatePassword} from '@/settings/password' -import {useState as useRecoverState} from '@/stores/recover-password' +import {submitRecoverPasswordPassword} from './flow' type Props = {route: {params: {error?: string}}} const Password = ({route}: Props) => { const {error} = route.params const waiting = C.Waiting.useAnyWaiting(C.waitingKeyRecoverPassword) - const submitPassword = useRecoverState(s => s.dispatch.dynamic.submitPassword) const onSave = (p: string) => { - submitPassword?.(p) + submitRecoverPasswordPassword(p) } return } diff --git a/shared/login/recover-password/prompt-reset-shared.tsx b/shared/login/recover-password/prompt-reset-shared.tsx index ef0f8fed9f45..e2a377c0c245 100644 --- a/shared/login/recover-password/prompt-reset-shared.tsx +++ b/shared/login/recover-password/prompt-reset-shared.tsx @@ -5,8 +5,8 @@ import {useSafeNavigation} from '@/util/safe-navigation' import * as T from '@/constants/types' import {SignupScreen} from '@/signup/common' import type {ButtonType} from '@/common-adapters/button' -import {useState as useRecoverState} from '@/stores/recover-password' import {enterResetPipeline} from '@/login/reset/account-reset' +import {startRecoverPassword, submitRecoverPasswordReset} from './flow' export type Props = { resetPassword?: boolean @@ -19,9 +19,6 @@ const PromptReset = (props: Props) => { const [error, setError] = React.useState('') const {resetPassword, skipPassword, username} = props - const submitResetPassword = useRecoverState(s => s.dispatch.dynamic.submitResetPassword) - const startRecoverPassword = useRecoverState(s => s.dispatch.startRecoverPassword) - const onContinue = () => { // dont do this in preflight if (C.androidIsTestDevice) { @@ -29,7 +26,7 @@ const PromptReset = (props: Props) => { return } if (resetPassword) { - submitResetPassword?.(T.RPCGen.ResetPromptResponse.confirmReset) + submitRecoverPasswordReset(T.RPCGen.ResetPromptResponse.confirmReset) } if (skipPassword) { enterResetPipeline({onError: setError, username}) diff --git a/shared/login/relogin/container.tsx b/shared/login/relogin/container.tsx index 0378c2a44566..724adcb084ca 100644 --- a/shared/login/relogin/container.tsx +++ b/shared/login/relogin/container.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import {useConfigState} from '@/stores/config' import Login from '.' import sortBy from 'lodash/sortBy' -import {useState as useRecoverState} from '@/stores/recover-password' +import {startRecoverPassword} from '@/login/recover-password/flow' import useRequestAutoInvite from '@/signup/use-request-auto-invite' import {useProvisionState} from '@/stores/provision' @@ -13,7 +13,6 @@ const ReloginContainer = () => { const _users = useConfigState(s => s.configuredAccounts) const perror = useConfigState(s => s.loginError) const pselectedUser = useConfigState(s => s.defaultUsername) - const startRecoverPassword = useRecoverState(s => s.dispatch.startRecoverPassword) const onForgotPassword = (username: string) => { startRecoverPassword({username}) } diff --git a/shared/provision/password.tsx b/shared/provision/password.tsx index e19113c467ed..00e51b80f05e 100644 --- a/shared/provision/password.tsx +++ b/shared/provision/password.tsx @@ -3,18 +3,17 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import UserCard from '../login/user-card' import {SignupScreen, errorBanner} from '../signup/common' -import {useState as useRecoverState} from '@/stores/recover-password' +import {startRecoverPassword} from '@/login/recover-password/flow' import {useProvisionState} from '@/stores/provision' const Password = () => { const error = useProvisionState(s => s.error) - const resetEmailSent = useRecoverState(s => s.resetEmailSent) const username = useProvisionState(s => s.username) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) const navigateUp = C.Router2.navigateUp - const startRecoverPassword = useRecoverState(s => s.dispatch.startRecoverPassword) + const [resetEmailSent, setResetEmailSent] = React.useState(false) const _onForgotPassword = () => { - startRecoverPassword({abortProvisioning: true, username}) + startRecoverPassword({abortProvisioning: true, onResetEmailSent: () => setResetEmailSent(true), username}) } const onBack = () => { navigateUp() @@ -23,13 +22,6 @@ const Password = () => { const onSubmit = (password: string) => !waiting && _onSubmit?.(password) const [password, setPassword] = React.useState('') const _onSubmitClick = () => onSubmit(password) - const resetState = useRecoverState(s => s.dispatch.resetState) - React.useEffect( - () => () => { - resetState() - }, - [resetState] - ) return ( - -const initialStore: Store = { - resetEmailSent: false, -} - -export type State = Store & { - dispatch: { - dynamic: { - cancel?: () => void - submitDeviceSelect?: (deviceID?: T.Devices.DeviceID) => void - submitNoDevice?: () => void - submitPaperKey?: (key: string) => void - submitPassword?: (pw: string) => void - submitResetPassword?: (action: T.RPCGen.ResetPromptResponse) => void - } - resetState: () => void - startRecoverPassword: (p: {username: string; abortProvisioning?: boolean; replaceRoute?: boolean}) => void - } -} - -export const useState = Z.createZustand('recover-password', (set, get) => { - const dispatch: State['dispatch'] = { - dynamic: { - cancel: undefined, - submitDeviceSelect: undefined, - submitPaperKey: undefined, - submitPassword: undefined, - submitResetPassword: undefined, - }, - resetState: () => { - // we do not cancel as we'll get logouts etc and don't want to lose our state - set(s => ({ - ...s, - ...initialStore, - dispatch: { - ...s.dispatch, - dynamic: {}, - }, - })) - }, - startRecoverPassword: p => { - const f = async () => { - if (p.abortProvisioning) { - useProvisionState.getState().dispatch.dynamic.cancel?.() - } - let hadError = false - try { - await T.RPCGen.loginRecoverPassphraseRpcListener({ - customResponseIncomingCallMap: { - 'keybase.1.loginUi.chooseDeviceToRecoverWith': (params, response) => { - const replaceRoute = !!p.replaceRoute - const devices = (params.devices || []).map(d => rpcDeviceToDevice(d)) - set(s => { - const clear = () => { - set(s => { - s.dispatch.dynamic.cancel = undefined - s.dispatch.dynamic.submitDeviceSelect = undefined - s.dispatch.dynamic.submitNoDevice = undefined - }) - } - const cancel = wrapErrors(() => { - clear() - response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) - navigateUp() - }) - s.dispatch.dynamic.cancel = cancel - s.dispatch.dynamic.submitDeviceSelect = wrapErrors((deviceID?: T.Devices.DeviceID) => { - clear() - if (deviceID) { - response.result(deviceID) - } else { - cancel() - } - }) - // Empty string tells the service "no device chosen, proceed to account reset" - s.dispatch.dynamic.submitNoDevice = wrapErrors(() => { - clear() - response.result('' as T.Devices.DeviceID) - }) - }) - navigateAppend({name: 'recoverPasswordDeviceSelector', params: {devices}}, replaceRoute) - }, - 'keybase.1.loginUi.promptPassphraseRecovery': () => {}, - // This same RPC is called at the beginning and end of the 7-day wait by the service. - 'keybase.1.loginUi.promptResetAccount': (params, response) => { - if (params.prompt.t === T.RPCGen.ResetPromptType.enterResetPw) { - navigateAppend({name: 'recoverPasswordPromptResetPassword', params: {username: p.username}}) - const clear = () => { - set(s => { - s.dispatch.dynamic.submitResetPassword = undefined - s.dispatch.dynamic.cancel = undefined - }) - } - set(s => { - s.dispatch.dynamic.submitResetPassword = wrapErrors( - (action: T.RPCGen.ResetPromptResponse) => { - clear() - response.result(action) - set(s => { - s.resetEmailSent = true - }) - navigateUp() - } - ) - s.dispatch.dynamic.cancel = wrapErrors(() => { - clear() - response.result(T.RPCGen.ResetPromptResponse.nothing) - navigateUp() - }) - }) - } else { - startAccountReset(true, p.username) - response.result(T.RPCGen.ResetPromptResponse.nothing) - } - }, - 'keybase.1.secretUi.getPassphrase': (params, response) => { - if (params.pinentry.type === T.RPCGen.PassphraseType.paperKey) { - const clear = () => { - set(s => { - s.dispatch.dynamic.submitPaperKey = undefined - s.dispatch.dynamic.cancel = undefined - }) - } - set(s => { - s.dispatch.dynamic.cancel = wrapErrors(() => { - clear() - response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) - get().dispatch.startRecoverPassword({ - replaceRoute: true, - username: p.username, - }) - }) - s.dispatch.dynamic.submitPaperKey = wrapErrors((passphrase: string) => { - clear() - response.result({passphrase, storeSecret: false}) - }) - }) - navigateAppend( - { - name: 'recoverPasswordPaperKey', - params: {error: params.pinentry.retryLabel || undefined}, - }, - true - ) - } else { - const clear = () => { - set(s => { - s.dispatch.dynamic.submitPassword = undefined - s.dispatch.dynamic.cancel = undefined - }) - } - set(s => { - s.dispatch.dynamic.cancel = wrapErrors(() => { - clear() - response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) - }) - s.dispatch.dynamic.submitPassword = wrapErrors((passphrase: string) => { - clear() - response.result({passphrase, storeSecret: true}) - }) - }) - if (!params.pinentry.retryLabel) { - // TODO maybe wait for loggedIn, for now the service promises to send this after login. - navigateAppend({name: 'recoverPasswordSetPassword', params: {error: undefined}}) - } else { - navigateAppend( - { - name: 'recoverPasswordSetPassword', - params: {error: params.pinentry.retryLabel}, - }, - true - ) - } - } - }, - }, - incomingCallMap: { - 'keybase.1.loginUi.explainDeviceRecovery': params => { - navigateAppend( - { - name: 'recoverPasswordExplainDevice', - params: {deviceName: params.name, deviceType: params.kind, username: p.username}, - }, - true - ) - }, - }, - params: {username: p.username}, - waitingKey: waitingKeyRecoverPassword, - }) - console.log('Recovered account') - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - hadError = true - logger.warn('RPC returned error: ' + error.message) - if ( - !( - error instanceof RPCError && - (error.code === T.RPCGen.StatusCode.sccanceled || - error.code === T.RPCGen.StatusCode.scinputcanceled) - ) - ) { - const msg = error.message - navigateAppend( - { - name: useConfigState.getState().loggedIn - ? 'recoverPasswordErrorModal' - : 'recoverPasswordError', - params: {error: msg}, - }, - true - ) - } - } finally { - set(s => { - s.dispatch.dynamic.submitPassword = undefined - s.dispatch.dynamic.cancel = undefined - s.dispatch.dynamic.submitPaperKey = undefined - s.dispatch.dynamic.submitResetPassword = undefined - s.dispatch.dynamic.submitDeviceSelect = undefined - s.dispatch.dynamic.submitNoDevice = undefined - }) - } - logger.info(`finished ${hadError ? 'with error' : 'without error'}`) - if (!hadError) { - clearModals() - } - } - ignorePromise(f()) - }, - } - return { - ...initialStore, - dispatch, - } -}) From c68a5c1f830c80ddf4e94b1588495e8f1ae95ca4 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 20 Apr 2026 14:23:01 -0400 Subject: [PATCH 2/3] WIP --- plans/store-split.md | 3 ++ shared/login/recover-password/flow.tsx | 62 +++++++++++++----------- shared/login/reset/account-reset.test.ts | 34 +++++++++++++ shared/login/reset/account-reset.tsx | 16 ++++-- shared/stores/flow-handles.tsx | 62 ++++++++++++++++++++++-- shared/stores/tests/flow-handles.test.ts | 57 +++++++++++++++++++++- skill/zustand-store-pruning/SKILL.md | 3 ++ 7 files changed, 199 insertions(+), 38 deletions(-) diff --git a/plans/store-split.md b/plans/store-split.md index 285fe1b5b0e7..8fbf4134dab8 100644 --- a/plans/store-split.md +++ b/plans/store-split.md @@ -103,6 +103,9 @@ Planned change: - submit paper key - submit password - submit reset password +- Use scoped `flow-handles` registrations that return disposers and keep those disposers local to the flow owner. +- Clear those named handlers when each step or the listener finishes; do not rely only on an inactive guard, and use generation-safe cleanup so an older flow cannot wipe handlers from a newer restart. +- Dispose keyed handlers too if the owning route or listener exits before the token is consumed. - Move `resetEmailSent` into route params or local screen state. - Move orchestration into a recover-password feature module, not a store. - Update screens to read explicit params/handlers rather than selecting `dispatch.dynamic.*`. diff --git a/shared/login/recover-password/flow.tsx b/shared/login/recover-password/flow.tsx index b6f7a1890e32..f1e72d66aa03 100644 --- a/shared/login/recover-password/flow.tsx +++ b/shared/login/recover-password/flow.tsx @@ -5,7 +5,7 @@ import {ignorePromise, wrapErrors} from '@/constants/utils' import logger from '@/logger' import {startAccountReset} from '@/login/reset/account-reset' import {useConfigState} from '@/stores/config' -import {callNamed, clearNamed, clearOwner, setNamed} from '@/stores/flow-handles' +import {callNamed, clearOwner, setNamedScoped} from '@/stores/flow-handles' import {useProvisionState} from '@/stores/provision' import {rpcDeviceToDevice} from '@/constants/rpc-utils' import {RPCError} from '@/util/errors' @@ -27,24 +27,8 @@ const slots = { submitPassword: 'submitPassword', submitResetPassword: 'submitResetPassword', } as const - -const clearSlots = (...slotNames: ReadonlyArray<(typeof slots)[keyof typeof slots]>) => { - slotNames.forEach(slot => clearNamed(owner, slot)) -} - -const setHandle = (active: () => boolean, slot: (typeof slots)[keyof typeof slots], handle?: (...args: Array) => void) => { - setNamed( - owner, - slot, - handle - ? (...args: Array) => { - if (active()) { - handle(...args) - } - } - : undefined - ) -} +type Slot = (typeof slots)[keyof typeof slots] +type ScopedHandle = ReturnType export const cancelRecoverPassword = () => callNamed(owner, slots.cancel) export const submitRecoverPasswordDeviceSelect = (deviceID?: T.Devices.DeviceID) => @@ -70,7 +54,29 @@ export const startRecoverPassword = ({ } let active = true let hadError = false + const handles = new Map() const isActive = () => active + const clearSlots = (...slotNames: ReadonlyArray) => { + slotNames.forEach(slot => { + const handle = handles.get(slot) + if (handle) { + handle.dispose() + handles.delete(slot) + } + }) + } + const setHandle = (slot: Slot, handle?: (...args: Array) => void) => { + if (!handle) { + clearSlots(slot) + return + } + const scoped = setNamedScoped(owner, slot, (...args: Array) => { + if (isActive()) { + handle(...args) + } + }) + handles.set(slot, scoped) + } try { await T.RPCGen.loginRecoverPassphraseRpcListener({ customResponseIncomingCallMap: { @@ -82,9 +88,8 @@ export const startRecoverPassword = ({ response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) navigateUp() }) - setHandle(isActive, slots.cancel, cancel) + setHandle(slots.cancel, cancel) setHandle( - isActive, slots.submitDeviceSelect, wrapErrors((deviceID?: T.Devices.DeviceID) => { clear() @@ -96,7 +101,6 @@ export const startRecoverPassword = ({ }) ) setHandle( - isActive, slots.submitNoDevice, wrapErrors(() => { clear() @@ -111,7 +115,6 @@ export const startRecoverPassword = ({ navigateAppend({name: 'recoverPasswordPromptResetPassword', params: {username}}) const clear = () => clearSlots(slots.cancel, slots.submitResetPassword) setHandle( - isActive, slots.submitResetPassword, wrapErrors((action: T.RPCGen.ResetPromptResponse) => { clear() @@ -121,7 +124,6 @@ export const startRecoverPassword = ({ }) ) setHandle( - isActive, slots.cancel, wrapErrors(() => { clear() @@ -138,7 +140,6 @@ export const startRecoverPassword = ({ if (params.pinentry.type === T.RPCGen.PassphraseType.paperKey) { const clear = () => clearSlots(slots.cancel, slots.submitPaperKey) setHandle( - isActive, slots.cancel, wrapErrors(() => { clear() @@ -147,7 +148,6 @@ export const startRecoverPassword = ({ }) ) setHandle( - isActive, slots.submitPaperKey, wrapErrors((passphrase: string) => { clear() @@ -164,7 +164,6 @@ export const startRecoverPassword = ({ } else { const clear = () => clearSlots(slots.cancel, slots.submitPassword) setHandle( - isActive, slots.cancel, wrapErrors(() => { clear() @@ -172,7 +171,6 @@ export const startRecoverPassword = ({ }) ) setHandle( - isActive, slots.submitPassword, wrapErrors((passphrase: string) => { clear() @@ -224,6 +222,14 @@ export const startRecoverPassword = ({ ) } } finally { + clearSlots( + slots.cancel, + slots.submitDeviceSelect, + slots.submitNoDevice, + slots.submitPaperKey, + slots.submitPassword, + slots.submitResetPassword + ) active = false } logger.info(`finished ${hadError ? 'with error' : 'without error'}`) diff --git a/shared/login/reset/account-reset.test.ts b/shared/login/reset/account-reset.test.ts index ee8c2f489b7d..fbb93d8ab15b 100644 --- a/shared/login/reset/account-reset.test.ts +++ b/shared/login/reset/account-reset.test.ts @@ -161,3 +161,37 @@ test('submitResetPrompt sends nothing responses back to the login flow', async ( expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing) expect(mockNavUpToScreen).toHaveBeenCalledWith('login') }) + +test('enterResetPipeline disposes an unconsumed reset prompt when the listener exits', async () => { + const result = jest.fn() + let finishListener = () => {} + + jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(async listener => { + listener.customResponseIncomingCallMap?.['keybase.1.loginUi.promptResetAccount']?.( + { + prompt: { + complete: {hasWallet: false}, + t: T.RPCGen.ResetPromptType.complete, + }, + } as any, + {result} as any + ) + await new Promise(resolve => { + finishListener = resolve + }) + return undefined as any + }) + + enterResetPipeline({username: 'alice'}) + await Promise.resolve() + + const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params + ?.resetKey as string + finishListener() + await Promise.resolve() + + submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.confirmReset) + + expect(result).not.toHaveBeenCalled() + expect(mockStartProvision).not.toHaveBeenCalled() +}) diff --git a/shared/login/reset/account-reset.tsx b/shared/login/reset/account-reset.tsx index 64391b2cc4f0..9ad667f26c5d 100644 --- a/shared/login/reset/account-reset.tsx +++ b/shared/login/reset/account-reset.tsx @@ -3,7 +3,7 @@ import * as S from '@/constants/strings' import * as T from '@/constants/types' import {ignorePromise} from '@/constants/utils' import logger from '@/logger' -import {consumeKeyed, registerKeyed} from '@/stores/flow-handles' +import {consumeKeyed, registerKeyedScoped} from '@/stores/flow-handles' import {useProvisionState} from '@/stores/provision' import {RPCError} from '@/util/errors' @@ -17,7 +17,7 @@ const resetOwner = 'reset' const resetPromptSlot = 'submitResetPrompt' const registerResetPrompt = (handle: (action: T.RPCGen.ResetPromptResponse) => void) => - registerKeyed(resetOwner, resetPromptSlot, handle) + registerKeyedScoped(resetOwner, resetPromptSlot, handle) export const startAccountReset = (skipPassword: boolean, username: string) => { navigateAppend({name: 'recoverPasswordPromptResetAccount', params: {skipPassword, username}}, true) @@ -26,6 +26,7 @@ export const startAccountReset = (skipPassword: boolean, username: string) => { export const enterResetPipeline = ({onError, password = '', username}: EnterResetPipelineParams) => { onError?.('') const f = async () => { + const pendingDisposers = new Set<() => void>() const promptReset = ( params: T.RPCGen.MessageTypes['keybase.1.loginUi.promptResetAccount']['inParam'], response: { @@ -35,7 +36,9 @@ export const enterResetPipeline = ({onError, password = '', username}: EnterRese if (params.prompt.t === T.RPCGen.ResetPromptType.complete) { const {hasWallet} = params.prompt.complete logger.info('Showing final reset screen') - const resetKey = registerResetPrompt((action: T.RPCGen.ResetPromptResponse) => { + let disposeRegistration = () => {} + const registration = registerResetPrompt((action: T.RPCGen.ResetPromptResponse) => { + pendingDisposers.delete(disposeRegistration) response.result(action) if (action === T.RPCGen.ResetPromptResponse.confirmReset) { useProvisionState.getState().dispatch.startProvision(username, true) @@ -43,7 +46,9 @@ export const enterResetPipeline = ({onError, password = '', username}: EnterRese navUpToScreen('login') } }) - navigateAppend({name: 'resetConfirm', params: {hasWallet, resetKey}}, true) + disposeRegistration = registration.dispose + pendingDisposers.add(disposeRegistration) + navigateAppend({name: 'resetConfirm', params: {hasWallet, resetKey: registration.key}}, true) } else { logger.info('Starting account reset process') response.result(T.RPCGen.ResetPromptResponse.nothing) @@ -76,6 +81,9 @@ export const enterResetPipeline = ({onError, password = '', username}: EnterRese } logger.warn('Error resetting account:', error) onError?.(error.desc) + } finally { + pendingDisposers.forEach(dispose => dispose()) + pendingDisposers.clear() } } ignorePromise(f()) diff --git a/shared/stores/flow-handles.tsx b/shared/stores/flow-handles.tsx index 62e04d1439b0..e9df1dadf294 100644 --- a/shared/stores/flow-handles.tsx +++ b/shared/stores/flow-handles.tsx @@ -1,11 +1,23 @@ import {registerExternalResetter} from '@/util/zustand' type Handle = (...args: Array) => void +type ScopedKeyedHandle = { + dispose: () => void + key: string +} +type ScopedNamedHandle = { + dispose: () => void + token: number +} type KeyedHandleEntry = { handle: Handle owner: string slot: string } +type NamedHandleEntry = { + handle: Handle + token: number +} const makeNamedKey = (owner: string, slot: string) => `${owner}:${slot}` @@ -16,12 +28,19 @@ const makeNamedKey = (owner: string, slot: string) => `${owner}:${slot}` // to carry an opaque token through navigation and resolve the callback later. // // Keep only live handlers here. Do not store banners, form state, waiting state, or caches. +// +// Prefer the scoped helpers below: +// - `setNamedScoped(...)` for named owner/slot handlers that a flow replaces over time +// - `registerKeyedScoped(...)` for one-shot keyed handlers carried through route params +// +// Both return disposers so cleanup lives next to registration. Named disposers are token-aware so +// stale cleanup from an older flow cannot clear a newer replacement handler. const keyed = new Map() -const named = new Map() +const named = new Map() let nextID = 0 export const callNamed = (owner: string, slot: string, ...args: Array) => { - named.get(makeNamedKey(owner, slot))?.(...args) + named.get(makeNamedKey(owner, slot))?.handle(...args) } export const clearKeyed = (key: string) => { @@ -32,6 +51,13 @@ export const clearNamed = (owner: string, slot: string) => { named.delete(makeNamedKey(owner, slot)) } +export const clearNamedIfToken = (owner: string, slot: string, token: number) => { + const key = makeNamedKey(owner, slot) + if (named.get(key)?.token === token) { + named.delete(key) + } +} + export const clearOwner = (owner: string) => { for (const [key, entry] of keyed.entries()) { if (entry.owner === owner) { @@ -52,19 +78,45 @@ export const consumeKeyed = (key: string, ...args: Array) => { handle?.(...args) } -export const registerKeyed = (owner: string, slot: string, handle: Handle) => { +// Preferred keyed API: keep the disposer next to the registration site and pass only the opaque key +// through navigation. The disposer is safe to call after consume; it becomes a no-op. +export const registerKeyedScoped = (owner: string, slot: string, handle: Handle): ScopedKeyedHandle => { nextID += 1 const key = `${owner}:${slot}:${nextID}` keyed.set(key, {handle, owner, slot}) - return key + return { + dispose: () => { + keyed.delete(key) + }, + key, + } +} + +export const registerKeyed = (owner: string, slot: string, handle: Handle) => { + return registerKeyedScoped(owner, slot, handle).key +} + +// Preferred named API: the disposer is token-aware, so stale cleanup from an older flow cannot +// clear a newer replacement handler for the same owner/slot. +export const setNamedScoped = (owner: string, slot: string, handle: Handle): ScopedNamedHandle => { + nextID += 1 + const token = nextID + named.set(makeNamedKey(owner, slot), {handle, token}) + return { + dispose: () => { + clearNamedIfToken(owner, slot, token) + }, + token, + } } export const setNamed = (owner: string, slot: string, handle?: Handle) => { const key = makeNamedKey(owner, slot) if (handle) { - named.set(key, handle) + return setNamedScoped(owner, slot, handle).token } else { named.delete(key) + return undefined } } diff --git a/shared/stores/tests/flow-handles.test.ts b/shared/stores/tests/flow-handles.test.ts index de7dd4529b9b..96a3e52e2bb0 100644 --- a/shared/stores/tests/flow-handles.test.ts +++ b/shared/stores/tests/flow-handles.test.ts @@ -1,7 +1,16 @@ /// import {resetAllStores} from '@/util/zustand' -import {callNamed, consumeKeyed, registerKeyed, setNamed, clearOwner} from '../flow-handles' +import { + callNamed, + clearNamedIfToken, + consumeKeyed, + registerKeyed, + registerKeyedScoped, + setNamed, + setNamedScoped, + clearOwner, +} from '../flow-handles' afterEach(() => { jest.restoreAllMocks() @@ -20,6 +29,42 @@ test('named handles can be set, called, and cleared by owner', () => { expect(fn).toHaveBeenCalledTimes(1) }) +test('token-based named cleanup does not clear newer replacement handlers', () => { + const stale = jest.fn() + const current = jest.fn() + + const staleToken = setNamed('recoverPassword', 'submitPassword', stale) + const currentToken = setNamed('recoverPassword', 'submitPassword', current) + expect(staleToken).toBeDefined() + expect(currentToken).toBeDefined() + + clearNamedIfToken('recoverPassword', 'submitPassword', staleToken ?? -1) + callNamed('recoverPassword', 'submitPassword', 'hunter2') + expect(stale).not.toHaveBeenCalled() + expect(current).toHaveBeenCalledWith('hunter2') + + clearNamedIfToken('recoverPassword', 'submitPassword', currentToken ?? -1) + callNamed('recoverPassword', 'submitPassword', 'again') + expect(current).toHaveBeenCalledTimes(1) +}) + +test('scoped named disposers do not clear newer replacement handlers', () => { + const stale = jest.fn() + const current = jest.fn() + + const staleHandle = setNamedScoped('recoverPassword', 'submitPassword', stale) + const currentHandle = setNamedScoped('recoverPassword', 'submitPassword', current) + + staleHandle.dispose() + callNamed('recoverPassword', 'submitPassword', 'hunter2') + expect(stale).not.toHaveBeenCalled() + expect(current).toHaveBeenCalledWith('hunter2') + + currentHandle.dispose() + callNamed('recoverPassword', 'submitPassword', 'again') + expect(current).toHaveBeenCalledTimes(1) +}) + test('keyed handles are one-shot when consumed', () => { const fn = jest.fn() @@ -30,3 +75,13 @@ test('keyed handles are one-shot when consumed', () => { expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledWith('confirm') }) + +test('scoped keyed disposers clear unconsumed handlers', () => { + const fn = jest.fn() + + const handle = registerKeyedScoped('reset', 'submitResetPrompt', fn) + handle.dispose() + consumeKeyed(handle.key, 'confirm') + + expect(fn).not.toHaveBeenCalled() +}) diff --git a/skill/zustand-store-pruning/SKILL.md b/skill/zustand-store-pruning/SKILL.md index 1cf5332150a1..ea281fbe9ee6 100644 --- a/skill/zustand-store-pruning/SKILL.md +++ b/skill/zustand-store-pruning/SKILL.md @@ -139,6 +139,9 @@ For listener-driven multi-step flows, separate callback plumbing from UI state: - If `incomingCallMap` or `customResponseIncomingCallMap` only need to keep live response handlers across navigation, move banners, form state, and selections out of the feature store first - Keep those live handlers in a dedicated transient handle module such as `shared/stores/flow-handles.tsx`, not in a feature store field or a per-feature singleton map - Model the shared handle module after existing `dispatch.dynamic.*` patterns: use an `owner` plus `slot` for named handlers, and keyed one-shot registrations for cases like confirm screens that need an opaque token in route params +- Prefer scoped registrations that return a disposer, and keep that disposer next to the registration site. Call it in `finally`, effect cleanup, or other owner teardown paths instead of reconstructing cleanup later from owner/slot strings. +- Clear named handlers when the flow step or RPC finishes. Do not rely on an `active`/stale guard alone, because leaving closures in the registry retains response objects and can leak memory. If an older flow can finish after a newer one starts, the disposer must be token- or generation-scoped so the old `finally` does not clear the new handlers. +- For keyed handlers, also keep a disposer and call it if the owning route or flow exits without consuming the token. Consuming the token should remain one-shot, and disposer cleanup after consume should be a no-op. - Add thin feature-local wrappers next to the flow, for example `registerResetPrompt` or `submitResetPrompt`, so most call sites stay typed and readable - Register the module's `clearAll` with the shared reset plumbing so `resetAllStores()` clears these runtime handles too - Do not put screen data, waiting state, validation errors, or caches into the transient handle module. It is only for live callbacks or resolvers that must survive route changes From b9ec6880f1ed90648bbc59aa763bfb85f8efd020 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 20 Apr 2026 14:26:40 -0400 Subject: [PATCH 3/3] WIP --- shared/login/reset/account-reset.test.ts | 93 ++++++++++++++++-------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/shared/login/reset/account-reset.test.ts b/shared/login/reset/account-reset.test.ts index fbb93d8ab15b..5b3a3cf9c7d3 100644 --- a/shared/login/reset/account-reset.test.ts +++ b/shared/login/reset/account-reset.test.ts @@ -38,6 +38,8 @@ afterEach(() => { resetAllStores() }) +const flush = async () => new Promise(resolve => setImmediate(resolve)) + test('startAccountReset navigates into the reset flow', () => { startAccountReset(true, 'alice') @@ -52,8 +54,9 @@ test('startAccountReset navigates into the reset flow', () => { test('enterResetPipeline exposes a submit handler for the confirm screen and starts provision on confirm', async () => { const result = jest.fn() + let finishListener = () => {} - jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(listener => { + jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(async listener => { listener.customResponseIncomingCallMap?.['keybase.1.loginUi.promptResetAccount']?.( { prompt: { @@ -63,23 +66,31 @@ test('enterResetPipeline exposes a submit handler for the confirm screen and sta } as any, {result} as any ) + await new Promise(resolve => { + finishListener = resolve + }) return undefined as any }) - enterResetPipeline({username: 'alice'}) - await Promise.resolve() + try { + enterResetPipeline({username: 'alice'}) + await flush() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'resetConfirm', params: {hasWallet: true, resetKey}}, - true - ) + const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params + ?.resetKey as string + expect(mockNavigateAppend).toHaveBeenCalledWith( + {name: 'resetConfirm', params: {hasWallet: true, resetKey}}, + true + ) - submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.confirmReset) + submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.confirmReset) - expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.confirmReset) - expect(mockStartProvision).toHaveBeenCalledWith('alice', true) + expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.confirmReset) + expect(mockStartProvision).toHaveBeenCalledWith('alice', true) + } finally { + finishListener() + await flush() + } }) test('enterResetPipeline responds and starts the reset flow for non-complete prompts', async () => { @@ -112,8 +123,9 @@ test('enterResetPipeline responds and starts the reset flow for non-complete pro test('submitResetPrompt sends cancel responses back to the login flow', async () => { const result = jest.fn() + let finishListener = () => {} - jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(listener => { + jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(async listener => { listener.customResponseIncomingCallMap?.['keybase.1.loginUi.promptResetAccount']?.( { prompt: { @@ -123,23 +135,32 @@ test('submitResetPrompt sends cancel responses back to the login flow', async () } as any, {result} as any ) + await new Promise(resolve => { + finishListener = resolve + }) return undefined as any }) - enterResetPipeline({username: 'alice'}) - await Promise.resolve() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string - submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.cancelReset) - - expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.cancelReset) - expect(mockNavUpToScreen).toHaveBeenCalledWith('login') + try { + enterResetPipeline({username: 'alice'}) + await flush() + const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params + ?.resetKey as string + submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.cancelReset) + + expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.cancelReset) + expect(mockNavUpToScreen).toHaveBeenCalledWith('login') + } finally { + finishListener() + await flush() + } }) test('submitResetPrompt sends nothing responses back to the login flow', async () => { const result = jest.fn() + let finishListener = () => {} - jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(listener => { + jest.spyOn(T.RPCGen, 'accountEnterResetPipelineRpcListener').mockImplementation(async listener => { listener.customResponseIncomingCallMap?.['keybase.1.loginUi.promptResetAccount']?.( { prompt: { @@ -149,17 +170,25 @@ test('submitResetPrompt sends nothing responses back to the login flow', async ( } as any, {result} as any ) + await new Promise(resolve => { + finishListener = resolve + }) return undefined as any }) - enterResetPipeline({username: 'alice'}) - await Promise.resolve() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string - submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.nothing) - - expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing) - expect(mockNavUpToScreen).toHaveBeenCalledWith('login') + try { + enterResetPipeline({username: 'alice'}) + await flush() + const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params + ?.resetKey as string + submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.nothing) + + expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing) + expect(mockNavUpToScreen).toHaveBeenCalledWith('login') + } finally { + finishListener() + await flush() + } }) test('enterResetPipeline disposes an unconsumed reset prompt when the listener exits', async () => { @@ -183,12 +212,12 @@ test('enterResetPipeline disposes an unconsumed reset prompt when the listener e }) enterResetPipeline({username: 'alice'}) - await Promise.resolve() + await flush() const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params ?.resetKey as string finishListener() - await Promise.resolve() + await flush() submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.confirmReset)