fix(mobile): arm the created-workspace activate recovery after hydration as before

This commit is contained in:
Jinwoo-H
2026-09-07 19:03:01 -04:00
parent cad0337ff3
commit ad2cd96066
4 changed files with 84 additions and 23 deletions
@@ -414,6 +414,52 @@ describe('HostProtocolGate', () => {
})
})
it('sends one activate when hydration lands after the creation route is consumed', async () => {
vi.useFakeTimers()
startupScope.created = '1'
// Slow hydration is the window the recovery timer must not beat.
startupScope.ensureSessionTabs = () => new Promise<void>((resolve) => setTimeout(resolve, 3000))
const { activate, client, settle } = clientWithDeferredStatus()
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderStartupGate()
await connectWithPendingStatus(client)
await settleVerdict(settle)
await act(async () => {
await vi.advanceTimersByTimeAsync(3000)
})
// Hydration landed: a tab claims the active handle and autocreate consumes ?created=1.
startupScope.activeHandleRef.current = 'term-1'
startupScope.created = undefined
await act(async () => {
renderer?.update(startupGateElement())
await Promise.resolve()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(activate).toHaveBeenCalledTimes(1)
})
it('never activates when the verdict comes back blocked', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { activate, client, settle } = clientWithDeferredStatus()
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderStartupGate()
await connectWithPendingStatus(client)
await act(async () => {
settle({ protocolVersion: 1, minCompatibleMobileVersion: 0 })
await Promise.resolve()
})
const output = renderedText(renderer)
expect(output).toContain('Update Orca on your computer')
expect(output).not.toContain('StartupProbe')
expect(activate).not.toHaveBeenCalled()
})
it('skips the created-workspace activate recovery when a terminal is already active', async () => {
vi.useFakeTimers()
startupScope.created = '1'
@@ -62,12 +62,12 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = 'c4e5825b6fe1ba9c64b5555fb3b0c6d37e7a72558cb5bd27df306b6649555891'
const HEAD_HOOK_BINDING_SHA256 = '969b8bc0193af0e817dcdb682fbed4cfd5a44c228b3e664860c4c94dbd426b4f'
const HEAD_MAIN_HOOK_SHA256 = '06878d6a74b932f5279fdf0bbe89f4f6410e2890271da386deb3b6c04ed178bf'
const HEAD_HOOK_BINDING_SHA256 = 'c7997ece2e5643276f7569455cc24d88381626b410f590281b1ca5b7539ec739'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
const HEAD_EFFECT_SHA256 = '327c84aa19f531e2303cd8efa6cbe35304965131697c5081f09edba29fa3d645'
const HEAD_EFFECT_SHA256 = '258135b4b1809f15033abcbfd12cd6249f4985feb7ac9d320b6ba275a4e090ca'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'0e553eb5ec7aeda8f8336b8da85ff87eb3657a21fa32d3c75c9cc32e36860244'
@@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(268)
expect(main.hooks).toHaveLength(269)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(77)
@@ -149,7 +149,7 @@ describe('mobile session startup', () => {
it('loads session tabs without waiting for desktop activation', () => {
const readsEffect = sliceBetween(
'void (async () => {',
'hydrationRef.current = (async () => {',
'return () => {\n disposed = true',
startupSource
)
@@ -188,6 +188,11 @@ describe('mobile session startup', () => {
expect(activateEffect).toContain('notifyClients: false')
expect(activateEffect).toContain("navigation: 'caller'")
expect(activateEffect).not.toContain('await ensureSessionTabs()')
// The recovery arms only once hydration settled, so a consumed `created` cannot add a second send.
expect(activateEffect).toContain('await hydrationRef.current')
expect(activateEffect.indexOf('await hydrationRef.current')).toBeLessThan(
activateEffect.indexOf('}, 1800)')
)
// The created-workspace recovery still yields to a terminal that claimed the route first.
expect(activateEffect).toContain('if (activeHandleRef.current)')
expect(activateEffect).toContain('}, 1800)')
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import type { RpcSuccess } from '../transport/types'
import { headlessActivationNeedsHostRenderer } from '../worktree/worktree-activation-result'
import { createInitialSessionAutoCreateState } from './use-initial-session-terminal-autocreate'
@@ -45,6 +45,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
fetchTerminals,
ensureSessionTabs
} = scope
// Holds the in-flight tab/terminal hydration so the activation effect can wait on it.
const hydrationRef = useRef<Promise<void> | null>(null)
useEffect(() => {
// Why: Expo reuses this screen across worktrees; reset route state so it can't open stale UI or reject the next snapshot.
sessionTabActionSheetRequestSeqRef.current += 1
@@ -116,7 +118,7 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
}
timers.push(setTimeout(fn, ms))
}
void (async () => {
hydrationRef.current = (async () => {
await ensureSessionTabs().catch(() => null)
if (disposed) {
return
@@ -177,26 +179,34 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
} else {
addTimer(() => {
if (activeHandleRef.current) {
// Why: hydration can claim the active handle and consume `created`; arming the recovery
// before it settles would let this route send a second activate once `created` clears.
void (async () => {
await hydrationRef.current
if (disposed) {
return
}
void (async () => {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
addTimer(() => {
if (activeHandleRef.current) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
void (async () => {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
})()
}
return () => {
disposed = true