fix(mobile): refresh custom shortcuts when returning to a session (#34951)

* fix(mobile): refresh custom shortcuts when returning to a session

* fix(mobile): adopt accepted custom-key writes while the session is mounted

A focus or foreground reread could land between queued Settings writes and
keep an intermediate list. Subscribe to accepted writes, and fold the refresh
regressions into the existing lifecycle test fixture.

* test(mobile): type session scope custom keys without an assertion

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 20:35:39 -07:00
committed by GitHub
co-authored by OrcaWin
parent 29e6910efc
commit d5e2c0b169
3 changed files with 156 additions and 15 deletions
@@ -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<T>() {
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<string | null>) {
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<string | null>()
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<string | null>()
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])
})
})
@@ -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(() => {
+16 -6
View File
@@ -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<CustomKey[]> {
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<CustomKey[]> {
}
}
export function loadCustomKeys(): Promise<CustomKey[]> {
return readStoredCustomKeys().catch(() => [])
// Propagate read failures so writes cannot invent an empty baseline; malformed stored keys may be replaced.
async function readStoredCustomKeys(): Promise<CustomKey[]> {
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<CustomKey[]> {
return readStoredCustomKeys().catch(() => fallback)
}
function writeCustomKeys(keys: CustomKey[]): Promise<void> {