Files
orca/mobile/src/components/NewWorktreeModal.test.tsx
T
Brennan Benson 8cf7c6926e fix(mobile): create-worktree sheet dies after picking a source and loses the picked PR (#16917)
* fix(mobile): keep the create form on screen through drawer swaps and survive reconnects

The create-worktree flow could reach a state where the shared modal host was
mounted with no sheet in it: a full-screen transparent window that swallows
every tap with no way out. Frame analysis of the reported recording and a live
simulator repro both land on the same state - the form sheet laid out at the
right frame with progress=1, backdrop painting, sheet not painted.

- Keep the form sheet mounted through every drawer transition, so the host
  Modal is never on screen without a sheet, and drop the render-read pin ref.
- Re-assert a pinned sheet's enter transform when it takes the window back
  from a fill picker; nothing re-applied it before.
- Key the form session on hostId, not on the RpcClient object: useHostClient
  swaps that object on every reconnect, which silently remounted the form and
  threw away the picked source.
- Run the pasted-item lookup concurrently with the provider fan-out instead of
  after it (measured 2631ms -> 1480ms for a typed PR number).

* fix(mobile): remount the sheet view on window hand-back so a rebuilt native view repaints

On-device confirmation showed the committed hand-back re-assert never
reaches the native view: progress already sits at 1 and translateY at 0,
so withTiming produces no style delta, and the dead screen stayed
reachable (1/25 on the committed build; 1/9 with a sub-pixel value
nudge, which lands on the stale native binding when the view was rebuilt
with a new tag). Remounting the sheet's Animated.View on an epoch keyed
to the hand-back mounts a fresh native view with the style computed from
the current shared values - progress is already 1, so it paints in place
with no visible animation. 0 dead in 50 attempts on the remount build
under the same churn condition that reproduced the dead screen on base.

LANE-REPORT.md carries the full confirmation evidence and limits.

* chore: drop the stray lane report from the repo root

It is a working artifact, not source, and the root directory guard blocks
any new top-level entry.

* test(mobile): assert the sheet subtree rebuild directly, not through a test-only prop

The hand-back test proved the remount by reading an epoch-keyed nativeID that
existed only for it — production markup shaped by a test, and an assertion a
future refactor could satisfy without rebuilding anything. Count mounts of the
sheet's content instead, which is the property the fix actually depends on, and
drop the nativeID.

Also stop typing test renderers as 'ReactTestRenderer | null'. The static
analysis job installs no mobile/node_modules, so that type is unresolvable
there and the union trips no-redundant-type-constituents on every added line.

The hand-back re-assert is not dead code as the old comment implied: the drawer
swap hands back at 166ms, before the 180ms enter animation ends.

* fix(mobile): keep the create form when a render is thrown away

The session key was built from counters mutated during render. A blurred screen
suspends this subtree (react-native-screens freezes via react-freeze), so React
runs the component and then discards that render — but the counter bumps
survive it. The next committed render then produced a new key and remounted the
form, throwing away the picked source for a host switch or a close that never
committed.

Hold the open epoch in state, which React discards with the render that set it,
and put the host in the key directly instead of counting host changes.
2026-08-28 15:45:54 -07:00

543 lines
18 KiB
TypeScript

import { createElement, Suspense, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Image: 'Image',
Keyboard: { dismiss: vi.fn() },
Linking: { openURL: vi.fn() },
Modal: 'Modal',
ScrollView: 'ScrollView',
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Switch: 'Switch',
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
// Every icon in the drawer tree renders as a host element named after itself.
vi.mock(
'lucide-react-native',
() =>
new Proxy(
{},
{
get: (_target, name) => (typeof name === 'string' ? name : undefined),
has: () => true
}
)
)
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('./bottom-drawer-modal-host', () => ({ BottomDrawerModalHost: 'BottomDrawerModalHost' }))
vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' }))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import { NewWorktreeModal } from './NewWorktreeModal'
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
const repos = [
{
id: 'repo-1',
displayName: 'orca',
path: '/src/orca',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
function pickerItems(
renderer: ReactTestRenderer,
title: string
): { label: string; detail: string }[] {
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
const picker = pickers.find((node) => node.props.title === title)
return picker?.props.items ?? []
}
function sourceInputs(renderer: ReactTestRenderer) {
return renderer.root.findAll(
(node) =>
node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source'
)
}
async function flushUpdates(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
describe('NewWorktreeModal project targets', () => {
let renderer: ReactTestRenderer
beforeEach(() => {
setCachedRepos('host-1', repos)
})
afterEach(() => {
act(() => renderer?.unmount())
})
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.reject(new Error('connection closed'))
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
})
)
await Promise.resolve()
})
await act(async () => {
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
])
})
it('groups same-name checkouts under one project with separate run targets', async () => {
const listedRepos = [
...repos,
{
id: 'repo-2',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const client = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
} as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
})
)
await Promise.resolve()
})
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
expect.objectContaining({
label: 'SSH · build-server',
detail: '/home/dev/orca'
})
])
})
it('loads SSH state before detecting agents on the selected remote target', async () => {
const remoteRepo = {
id: 'repo-remote',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
setCachedRepos('host-ssh', [remoteRepo])
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } })
}
if (method === 'ssh.getState') {
return Promise.resolve({
ok: true,
result: {
state: {
targetId: 'build-server',
status: 'connected',
error: null,
reconnectAttempt: 0
}
}
})
}
if (method === 'preflight.detectRemoteAgents') {
return Promise.resolve({ ok: true, result: ['codex'] })
}
if (method === 'settings.get') {
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
}
if (method === 'ui.get') {
return Promise.resolve({ ok: true, result: { ui: {} } })
}
if (method === 'preflight.check') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'linear.status') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
}
if (method === 'repo.hooks') {
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-ssh',
onCreated: () => {},
onClose: () => {}
})
)
})
await flushUpdates()
expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' })
expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
connectionId: 'build-server'
})
})
it('keeps folder workspaces on the local execution and text-only source path', async () => {
const folderRepo = {
id: 'folder-1',
displayName: 'notes',
path: '/src/notes',
kind: 'folder'
}
setCachedRepos('host-folder', [folderRepo])
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [folderRepo] } })
}
if (method === 'preflight.detectAgents') {
return Promise.resolve({ ok: true, result: ['codex'] })
}
if (method === 'settings.get') {
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
}
if (method === 'ui.get') {
return Promise.resolve({ ok: true, result: { ui: {} } })
}
if (method === 'preflight.check') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'linear.status') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
if (method === 'repo.hooks') {
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-folder',
onCreated: () => {},
onClose: () => {}
})
)
})
await flushUpdates()
expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
})
it('ignores a stale repo list after the client changes', async () => {
let resolveOldList: ((value: unknown) => void) | undefined
const oldList = new Promise((resolve) => {
resolveOldList = resolve
})
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
const oldClient = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return oldList
}
return new Promise(() => {})
})
} as unknown as RpcClient
const freshClient = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
} as unknown as RpcClient
const modalProps = {
visible: true,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient }))
})
const sourceInput = sourceInputs(renderer)[0]!
act(() => sourceInput.props.onChangeText('stale-client-name'))
expect(sourceInput.props.value).toBe('stale-client-name')
await act(async () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
await Promise.resolve()
})
// The swap must not restart the form session — see the reconnect test below.
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
'stale-client-name',
'stale-client-name'
])
resolveOldList?.({ ok: true, result: { repos } })
await flushUpdates()
expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })])
})
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
const client = { sendRequest } as unknown as RpcClient
const modalProps = {
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
})
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
// One repo.list per opening. A third means the previous session's instance
// re-ran its visible-gated effects before the remount key caught up.
const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list')
expect(repoListCalls).toHaveLength(2)
})
// A reconnect / forceReconnect / foreground revival hands the same host a NEW
// RpcClient object (see useHostClient). Keying the form session on that object
// remounted the modal mid-edit and silently threw away the picked source.
it('keeps the picked source when a reconnect swaps the client for the same host', async () => {
const makeClient = () =>
({
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
}) as unknown as RpcClient
const modalProps = {
visible: true,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() }))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('feat/keep-me'))
await act(async () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() }))
await Promise.resolve()
})
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
'feat/keep-me',
'feat/keep-me'
])
})
// react-native-screens freezes a blurred screen by suspending its subtree
// (react-freeze), so React runs this component and then throws that render away.
// Anything the render mutated in place outlives the work React discarded.
function suspendableTree(frozen: () => boolean, child: ReactElement) {
const never = new Promise<void>(() => {})
const Freezer = () => {
if (frozen()) {
throw never
}
return null
}
// The modal renders first, so its render completes before the freeze throws.
return createElement(Suspense, { fallback: null }, child, createElement(Freezer, null))
}
it('keeps the form when a host switch renders but never commits', async () => {
setCachedRepos('host-2', repos)
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
let frozen = false
const tree = (hostId: string) =>
suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, hostId }))
await act(async () => {
renderer = create(tree('host-1'))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me'))
frozen = true
await act(async () => {
renderer.update(tree('host-2'))
})
frozen = false
await act(async () => {
renderer.update(tree('host-1'))
})
// host-2 never committed, so host-1's session was never superseded.
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['keep-me', 'keep-me'])
})
it('keeps the form when a close renders but never commits', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = {
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
let frozen = false
const tree = (visible: boolean) =>
suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, visible }))
await act(async () => {
renderer = create(tree(true))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me-too'))
frozen = true
await act(async () => {
renderer.update(tree(false))
})
frozen = false
await act(async () => {
renderer.update(tree(true))
})
// The drawer never committed a closed state, so this is not a reopening.
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
'keep-me-too',
'keep-me-too'
])
})
it('starts a fresh form session when the modal switches hosts', async () => {
setCachedRepos('host-2', repos)
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-1' }))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('host-one-name'))
await act(async () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-2' }))
})
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
})
it('starts with fresh form state after closing and reopening', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = {
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
})
const sourceInput = sourceInputs(renderer)[0]!
act(() => sourceInput.props.onChangeText('previous-workspace'))
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
})
})