mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): retire host edit navigation after leaving the route (#31692)
Saving an endpoint after the user leaves can pop a second screen. Retire navigation ownership while allowing the endpoint save and client refresh to finish.
This commit is contained in:
@@ -37,33 +37,55 @@ export default function EditHostScreen() {
|
||||
// Why: setSaving is async, so a second trigger before the re-render could
|
||||
// still read stale state and re-enter handleSave; the ref closes that race.
|
||||
const savingRef = useRef(false)
|
||||
const routeRevision = useRef(0)
|
||||
const leave = () => {
|
||||
routeRevision.current += 1
|
||||
router.back()
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!hostId) {
|
||||
setLoadError('Missing host.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
const found = hosts.find((h) => h.id === hostId) ?? null
|
||||
if (!found) {
|
||||
setLoadError('This host was removed from this phone.')
|
||||
setHost(null)
|
||||
const load = useCallback(
|
||||
async (revision: number) => {
|
||||
if (!hostId) {
|
||||
setLoadError('Missing host.')
|
||||
return
|
||||
}
|
||||
setHost(found)
|
||||
// The field edits the phone's override; an empty field means "use the desktop's name".
|
||||
setName(found.personalName ?? '')
|
||||
setAddress(displayHostEndpoint(found.endpoint))
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load host.')
|
||||
setHost(null)
|
||||
}
|
||||
}, [hostId])
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
if (revision !== routeRevision.current) {
|
||||
return
|
||||
}
|
||||
const found = hosts.find((h) => h.id === hostId) ?? null
|
||||
if (!found) {
|
||||
setLoadError('This host was removed from this phone.')
|
||||
setHost(null)
|
||||
return
|
||||
}
|
||||
setHost(found)
|
||||
// The field edits the phone's override; an empty field means "use the desktop's name".
|
||||
setName(found.personalName ?? '')
|
||||
setAddress(displayHostEndpoint(found.endpoint))
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
if (revision !== routeRevision.current) {
|
||||
return
|
||||
}
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load host.')
|
||||
setHost(null)
|
||||
}
|
||||
},
|
||||
[hostId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const revision = ++routeRevision.current
|
||||
setHost(null)
|
||||
setLoadError(null)
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
void load(revision)
|
||||
return () => {
|
||||
routeRevision.current += 1
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const endpointEdit = useMemo(
|
||||
@@ -98,6 +120,7 @@ export default function EditHostScreen() {
|
||||
return
|
||||
}
|
||||
|
||||
const revision = routeRevision.current
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
@@ -110,9 +133,11 @@ export default function EditHostScreen() {
|
||||
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
if (revision === routeRevision.current) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,9 +151,11 @@ export default function EditHostScreen() {
|
||||
// best-effort re-prime; persisted data is unaffected
|
||||
}
|
||||
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
router.back()
|
||||
if (revision === routeRevision.current) {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
leave()
|
||||
}
|
||||
|
||||
if (nextEndpoint !== undefined) {
|
||||
// Why: the live client, even one riding the relay, and its primed profile hold the old address.
|
||||
@@ -141,7 +168,7 @@ export default function EditHostScreen() {
|
||||
<View style={styles.topRow}>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
onPress={leave}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
@@ -169,7 +196,7 @@ export default function EditHostScreen() {
|
||||
{loadError ? (
|
||||
<View style={styles.errorState}>
|
||||
<Text style={styles.errorText}>{loadError}</Text>
|
||||
<Pressable style={styles.secondaryButton} onPress={() => router.back()}>
|
||||
<Pressable style={styles.secondaryButton} onPress={leave}>
|
||||
<Text style={styles.secondaryButtonText}>Go back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -74,7 +74,7 @@ function setFieldValue(
|
||||
value: string
|
||||
): void {
|
||||
const input = renderer.root
|
||||
.findAllByType('TextInput')
|
||||
.findAll((node) => String(node.type) === 'TextInput')
|
||||
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
|
||||
if (!input) {
|
||||
throw new Error(`${accessibilityLabel} input not found`)
|
||||
@@ -86,7 +86,7 @@ function setFieldValue(
|
||||
|
||||
function findSaveButton(renderer: ReactTestRenderer) {
|
||||
const button = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.findAll((node) => String(node.type) === 'Pressable')
|
||||
.find((node) => node.props.accessibilityLabel === 'Save host')
|
||||
if (!button) {
|
||||
throw new Error('Save button not found')
|
||||
@@ -104,16 +104,18 @@ async function pressSave(renderer: ReactTestRenderer): Promise<void> {
|
||||
}
|
||||
|
||||
function findText(renderer: ReactTestRenderer, match: string): boolean {
|
||||
return renderer.root.findAllByType('Text').some((node) => {
|
||||
const children = node.props.children
|
||||
if (typeof children === 'string') {
|
||||
return children.includes(match)
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.join('').includes(match)
|
||||
}
|
||||
return false
|
||||
})
|
||||
return renderer.root
|
||||
.findAll((node) => String(node.type) === 'Text')
|
||||
.some((node) => {
|
||||
const children = node.props.children
|
||||
if (typeof children === 'string') {
|
||||
return children.includes(match)
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.join('').includes(match)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
describe('edit host handleSave', () => {
|
||||
@@ -261,6 +263,28 @@ describe('edit host handleSave', () => {
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
it('does not navigate again if the user leaves while the save is pending', async () => {
|
||||
let resolveSave = () => {}
|
||||
dependencies.updateHostNameAndEndpoint.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
||||
await pressSave(renderer)
|
||||
act(() => {
|
||||
renderer.root.findByProps({ accessibilityLabel: 'Back' }).props.onPress()
|
||||
renderer.unmount()
|
||||
})
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
resolveSave()
|
||||
})
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
expect(dependencies.refreshHostClient).toHaveBeenCalledWith('host-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit host load() error states', () => {
|
||||
@@ -282,7 +306,7 @@ describe('edit host load() error states', () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
expect(findText(renderer, 'Missing host.')).toBe(true)
|
||||
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
expect(renderer.root.findAll((node) => String(node.type) === 'TextInput')).toHaveLength(0)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
@@ -292,7 +316,7 @@ describe('edit host load() error states', () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
expect(findText(renderer, 'This host was removed from this phone.')).toBe(true)
|
||||
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
expect(renderer.root.findAll((node) => String(node.type) === 'TextInput')).toHaveLength(0)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
@@ -302,7 +326,7 @@ describe('edit host load() error states', () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
expect(findText(renderer, 'storage unreadable')).toBe(true)
|
||||
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
expect(renderer.root.findAll((node) => String(node.type) === 'TextInput')).toHaveLength(0)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
@@ -25,7 +25,6 @@ src/files/MobileFileExplorerPanel.test.ts
|
||||
src/files/MobileFileMarkdownPreview.test.ts
|
||||
src/hooks/mobile-dictation-desktop-start.test.ts
|
||||
src/host-edit-route-accessibility.test.ts
|
||||
src/host-edit-save-flow.test.ts
|
||||
src/mock-server-key-pair.test.ts
|
||||
src/notifications/notification-consent-ownership.test.ts
|
||||
src/notifications/push-dismissal-native-races.test.ts
|
||||
|
||||
Reference in New Issue
Block a user