diff --git a/mobile/src/session/mobile-session-preference-startup.test.ts b/mobile/src/session/mobile-session-preference-startup.test.ts index 2c0197e24aa..548da69e5ef 100644 --- a/mobile/src/session/mobile-session-preference-startup.test.ts +++ b/mobile/src/session/mobile-session-preference-startup.test.ts @@ -3,6 +3,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_ACCESSORY_KEYS } from '../terminal/terminal-accessory-keys' +import { saveCustomKeys, type CustomKey } from '../terminal/terminal-custom-keys' import { useMobileSessionLifecycle } from './use-mobile-session-lifecycle' import { useMobileSessionPreferenceFocus } from './use-mobile-session-preference-focus' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' @@ -37,7 +38,7 @@ vi.mock('expo-router', async () => { } }) vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { getItem: vi.fn() } + default: { getItem: vi.fn(), setItem: vi.fn() } })) vi.mock('../transport/host-store', () => ({ loadHosts: vi.fn().mockResolvedValue([]) })) vi.mock('../terminal/terminal-foreground-recovery', () => ({ @@ -53,10 +54,21 @@ function deferred() { return { promise, resolve } } +function refocus() { + act(() => { + for (const [effect, cleanup] of focusEffects.callbacks) { + cleanup?.() + focusEffects.callbacks.set(effect, effect()) + } + }) +} + function sessionScope() { + const customKeys: CustomKey[] = [] return { hostId: 'host', connState: 'connecting', + customKeys, setTerminalTextScale: vi.fn(), terminalTextScaleRevisionRef: { current: 0 }, setAutocompleteEnabled: vi.fn(), @@ -79,6 +91,7 @@ beforeEach(() => { focusEffects.callbacks.clear() focusEffects.appStateListeners.clear() vi.mocked(AsyncStorage.getItem).mockReset().mockResolvedValue(null) + vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined) }) afterEach(() => { act(() => renderer?.unmount()) @@ -330,3 +343,89 @@ describe('saved session settings before the first render', () => { ) }) }) + +describe('session custom shortcuts after saved settings change', () => { + const oldKey = { id: 'old', label: 'Old', bytes: 'old', enter: false } + const newKey = { id: 'new', label: 'New', bytes: 'new\r', enter: false } + const otherKey = { id: 'other', label: 'Other', bytes: 'other', enter: false } + + function storeCustomKeys(read: () => Promise) { + vi.mocked(AsyncStorage.getItem).mockImplementation((key) => + key === 'orca:custom-accessory-keys' ? read() : Promise.resolve(null) + ) + } + + async function mountWithKeys(keys: CustomKey[]) { + storeCustomKeys(async () => JSON.stringify(keys)) + const scope = sessionScope() + function Harness() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members. + useMobileSessionLifecycle(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + scope.customKeys = keys + await act(async () => renderer?.update(createElement(Harness))) + scope.setCustomKeys.mockClear() + return { scope, rerender: () => renderer?.update(createElement(Harness)) } + } + + it('rereads saved shortcuts on focus and on foreground', async () => { + const { scope } = await mountWithKeys([oldKey]) + storeCustomKeys(async () => JSON.stringify([otherKey])) + await act(async () => refocus()) + expect(scope.setCustomKeys).toHaveBeenLastCalledWith([otherKey]) + storeCustomKeys(async () => JSON.stringify([newKey])) + await act(async () => { + for (const listener of focusEffects.appStateListeners) { + listener('active') + } + }) + expect(scope.setCustomKeys).toHaveBeenLastCalledWith([newKey]) + }) + + it('keeps the live shortcuts when the refresh read fails', async () => { + const { scope } = await mountWithKeys([oldKey]) + storeCustomKeys(() => Promise.reject(new Error('storage unavailable'))) + await act(async () => refocus()) + expect(scope.setCustomKeys).toHaveBeenCalledTimes(1) + expect(scope.setCustomKeys).toHaveBeenLastCalledWith(scope.customKeys) + }) + + it('keeps a newer in-session edit when an older focus read settles', async () => { + const { scope, rerender } = await mountWithKeys([oldKey]) + const read = deferred() + storeCustomKeys(() => read.promise) + refocus() + scope.customKeys = [newKey] + await act(async () => rerender()) + await act(async () => read.resolve(JSON.stringify([oldKey]))) + expect(scope.setCustomKeys).not.toHaveBeenCalled() + }) + + it('ends on the last queued Settings write when focus reads between writes', async () => { + const { scope } = await mountWithKeys([oldKey]) + let stored = JSON.stringify([oldKey]) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (_key, value) => { + stored = value + }) + const intermediateRead = deferred() + let focusRead = true + storeCustomKeys(async () => { + if (focusRead) { + focusRead = false + return intermediateRead.promise + } + return stored + }) + refocus() + await act(async () => { + await saveCustomKeys([otherKey]) + intermediateRead.resolve(stored) + await saveCustomKeys([otherKey, newKey]) + }) + expect(scope.setCustomKeys).toHaveBeenLastCalledWith([otherKey, newKey]) + }) +}) diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index 171e6779ae4..7e94cb592be 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -8,7 +8,7 @@ import { recoverActiveTerminalAfterForeground, shouldRecoverTerminalOnAppStateChange } from '../terminal/terminal-foreground-recovery' -import { loadCustomKeys } from '../terminal/terminal-custom-keys' +import { loadCustomKeys, subscribeStoredCustomKeys } from '../terminal/terminal-custom-keys' import type { MobileSessionTabReconciliationModel } from './use-mobile-session-tab-reconciliation' export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationModel) { @@ -17,6 +17,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM const { hostId, connState, + customKeys, setCustomKeys, setVisibleBuiltInIds, setHostEndpoint, @@ -48,21 +49,41 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM } }, [hostId]) + const customKeysRef = useRef(customKeys) useEffect(() => { - let active = true - void settleSessionPreferenceRead(loadCustomKeys()).then((keys) => { - if (active) { - if (keys) { + customKeysRef.current = customKeys + }, [customKeys]) + const customKeysReadRevisionRef = useRef(0) + const refreshCustomKeys = useCallback(() => { + const revision = ++customKeysReadRevisionRef.current + const keysAtStart = customKeysRef.current + void settleSessionPreferenceRead(loadCustomKeys(keysAtStart)).then((keys) => { + if (customKeysReadRevisionRef.current === revision) { + if (keys && customKeysRef.current === keysAtStart) { setCustomKeys(keys) } setCustomKeysLoaded(true) } }) return () => { - active = false + if (customKeysReadRevisionRef.current === revision) { + customKeysReadRevisionRef.current += 1 + } } }, []) + useEffect( + () => + subscribeStoredCustomKeys((keys) => { + // An accepted write is at least as new as any read still in flight. + customKeysReadRevisionRef.current += 1 + customKeysRef.current = keys + setCustomKeys(keys) + setCustomKeysLoaded(true) + }), + [] + ) + const layoutReadRevisionRef = useRef(0) const refreshAccessoryLayout = useCallback(() => { const revision = ++layoutReadRevisionRef.current @@ -81,19 +102,30 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM } }, []) - useFocusEffect(refreshAccessoryLayout) + useFocusEffect( + useCallback(() => { + const releaseKeys = refreshCustomKeys() + const releaseLayout = refreshAccessoryLayout() + return () => { + releaseKeys() + releaseLayout() + } + }, [refreshAccessoryLayout, refreshCustomKeys]) + ) useEffect(() => { const sub = AppState.addEventListener('change', (s: AppStateStatus) => { if (s === 'active') { + refreshCustomKeys() refreshAccessoryLayout() } }) return () => { + customKeysReadRevisionRef.current += 1 layoutReadRevisionRef.current += 1 sub.remove() } - }, [refreshAccessoryLayout]) + }, [refreshAccessoryLayout, refreshCustomKeys]) const pendingForegroundRecoveryRef = useRef(false) useEffect(() => { diff --git a/mobile/src/terminal/terminal-custom-keys.ts b/mobile/src/terminal/terminal-custom-keys.ts index 1bff112a23f..4360322ec0b 100644 --- a/mobile/src/terminal/terminal-custom-keys.ts +++ b/mobile/src/terminal/terminal-custom-keys.ts @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { persistMirrored } from '../storage/mirrored-storage-keys' +import { persistMirrored, subscribeMirroredKey } from '../storage/mirrored-storage-keys' const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' @@ -25,9 +25,7 @@ function isCustomKey(value: unknown): value is CustomKey { ) } -// Propagate read failures so writes cannot invent an empty baseline; malformed stored keys may be replaced. -async function readStoredCustomKeys(): Promise { - const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY) +function parseStoredCustomKeys(raw: string | null): CustomKey[] { try { const parsed: unknown = JSON.parse(raw ?? '[]') return Array.isArray(parsed) ? parsed.filter(isCustomKey) : [] @@ -36,8 +34,20 @@ async function readStoredCustomKeys(): Promise { } } -export function loadCustomKeys(): Promise { - return readStoredCustomKeys().catch(() => []) +// Propagate read failures so writes cannot invent an empty baseline; malformed stored keys may be replaced. +async function readStoredCustomKeys(): Promise { + return parseStoredCustomKeys(await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)) +} + +// Accepted writes only; a read racing queued Settings writes can return an intermediate list. +export function subscribeStoredCustomKeys(listener: (keys: CustomKey[]) => void): () => void { + return subscribeMirroredKey(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, (raw) => + listener(parseStoredCustomKeys(raw)) + ) +} + +export function loadCustomKeys(fallback: CustomKey[] = []): Promise { + return readStoredCustomKeys().catch(() => fallback) } function writeCustomKeys(keys: CustomKey[]): Promise {