diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index cded289e692..c4464bc54d2 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -12,7 +12,7 @@ import { type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' import { customKeyModalStyles as styles } from './CustomKeyModal.styles' -import { noteMirroredWrite } from '../storage/mirrored-storage-keys' +import { noteMirroredWrite, readMirroredStorage } from '../storage/mirrored-storage-keys' const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' @@ -79,8 +79,19 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise { const value = JSON.stringify(keys) // Noted before it is persisted: the hybrid shell hands this key to the page on every `init`, // built synchronously, so a write that only reached the store would be one `init` behind. + const held = readMirroredStorage([CUSTOM_ACCESSORY_KEYS_STORAGE_KEY])[ + CUSTOM_ACCESSORY_KEYS_STORAGE_KEY + ] noteMirroredWrite(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, value) - await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, value) + try { + await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, value) + } catch (error) { + // Rolled back, because the note is what the next `init` reads: on the page a write over the + // cap rejects rather than dropping, and a note left standing would hand the page — and every + // native reader answered from this map — the value the store refused. + noteMirroredWrite(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, held ?? null) + throw error + } } export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { diff --git a/mobile/src/components/custom-key-modal-save-refusal.test.tsx b/mobile/src/components/custom-key-modal-save-refusal.test.tsx index a66d38f95e2..c1c96edda1e 100644 --- a/mobile/src/components/custom-key-modal-save-refusal.test.tsx +++ b/mobile/src/components/custom-key-modal-save-refusal.test.tsx @@ -53,6 +53,14 @@ vi.mock('lucide-react-native', () => ({ ChevronLeft: hosts.View })) vi.mock('./BottomDrawer', () => ({ BottomDrawer: hosts.View })) import { CustomKeyModal } from './CustomKeyModal' +import { readMirroredStorage } from '../storage/mirrored-storage-keys' + +const CUSTOM_KEYS = 'orca:custom-accessory-keys' + +/** What a later `init` would carry for this key, which is the map and not the store. */ +function mirrored(): string | undefined { + return readMirroredStorage([CUSTOM_KEYS])[CUSTOM_KEYS] +} /** The one label a node renders, flattened, without walking a fiber into a cycle. */ function labelOf(node: { props: { children?: unknown } }): string { @@ -117,6 +125,7 @@ beforeEach(() => { describe('adding a custom key when the store refuses the write', () => { it('does not let the refusal escape as an unhandled rejection', async () => { store.refuse = true + const before = mirrored() const unhandled = vi.fn() process.on('unhandledRejection', unhandled) const onKeysChanged = vi.fn() @@ -140,6 +149,10 @@ describe('adding a custom key when the store refuses the write', () => { // is the failure the allowlist exists to avoid. expect(onKeysChanged).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() + // And the mirror is back where it started. `saveCustomKeys` notes the write before it + // persists, because a reader is answered from the map; a refused write that left the note + // standing would put the key the store rejected into the next `init`. + expect(mirrored()).toBe(before) }) it('reports the key and closes when the store takes it, so the case above is the refusal', async () => {