Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mobile/src/browser/MobileBrowserPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
} from './browser-touch-geometry'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
import { useBrowserScreencastReconnectSignal } from './use-browser-screencast-reconnect-signal'

export type MobileBrowserTab = {
type: 'browser'
Expand Down Expand Up @@ -164,6 +165,7 @@ export function MobileBrowserPane({
const [layout, setLayout] = useState<BrowserTouchLayout | null>(null)
const [appActive, setAppActive] = useState(AppState.currentState === 'active')
const streamGenerationRef = useRef(0)
const reconnectSignal = useBrowserScreencastReconnectSignal(client)
const layoutRef = useRef<BrowserTouchLayout | null>(null)
const frameMetadataRef = useRef<BrowserScreencastFrameMetadata | null>(
cachedInitialFrame?.metadata ?? null
Expand Down Expand Up @@ -541,6 +543,7 @@ export function MobileBrowserPane({
}
}, [
appActive,
reconnectSignal,
applyFrameThrottled,
clearFrameThrottle,
client,
Expand Down
11 changes: 11 additions & 0 deletions mobile/src/browser/mobile-browser-pane-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,15 @@ describe('MobileBrowserPane source invariants', () => {
expect(mirrorBlock).toContain('zoomRef.current = zoom')
expect(mirrorBlock).toContain('}, [dialog, frameMetadata, layout, zoom])')
})

it('re-subscribes the screencast stream when the transport reconnects', () => {
// Why: after a relay migrateTo / direct-socket reconnect the desktop tears down
// Page.startScreencast and only restarts it on a fresh browser.screencast
// subscribe. The pane must depend on a reconnect signal so its subscribe effect
// tears down and re-creates the stream (and resets the double-buffer render
// state); otherwise the display freezes on the last frame while input still lands.
expect(source).toContain('useBrowserScreencastReconnectSignal')
const depArray = sliceBetween(' appActive,', 'worktreeId\n ])')
expect(depArray).toContain('reconnectSignal')
})
})
116 changes: 116 additions & 0 deletions mobile/src/browser/use-browser-screencast-reconnect-signal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { useBrowserScreencastReconnectSignal } from './use-browser-screencast-reconnect-signal'

function createMockClient(initial: ConnectionState): {
client: RpcClient
emit: (next: ConnectionState) => void
listenerCount: () => number
} {
let state: ConnectionState = initial
let listener: ((next: ConnectionState) => void) | null = null
const onStateChange = vi.fn((l: (next: ConnectionState) => void) => {
listener = l
return () => {
listener = null
}
})
const client = { getState: () => state, onStateChange } as unknown as RpcClient
return {
client,
emit: (next) => {
state = next
listener?.(next)
},
listenerCount: () => (listener ? 1 : 0)
}
}

describe('useBrowserScreencastReconnectSignal', () => {
// Why: the mobile browser pane freezes on the last decoded frame after a relay
// migrateTo / direct-socket reconnect because its render state is never reset.
// This signal drives that reset, so it must bump on a REconnect only.
let renderer: ReactTestRenderer | null = null
let signal = 0

afterEach(() => {
act(() => renderer?.unmount())
renderer = null
signal = 0
})

function Harness({ client }: { client: RpcClient | null }): null {
signal = useBrowserScreencastReconnectSignal(client)
return null
}

it('returns 0 and does not bump on the first connect', () => {
const { client, emit } = createMockClient('disconnected')
act(() => {
renderer = create(createElement(Harness, { client }))
})
expect(signal).toBe(0)
act(() => emit('connected'))
expect(signal).toBe(0)
})

it('records the first connect, then bumps on each later reconnect from disconnected', () => {
const { client, emit } = createMockClient('disconnected')
act(() => {
renderer = create(createElement(Harness, { client }))
})
expect(signal).toBe(0)
act(() => emit('connected')) // first connect: recorded, not yet a reconnect
expect(signal).toBe(0)
act(() => emit('disconnected'))
act(() => emit('connected')) // first reconnect after the initial connect
expect(signal).toBe(1)
act(() => emit('disconnected'))
act(() => emit('connected')) // a second reconnect bumps again
expect(signal).toBe(2)
})

it('bumps once per reconnect (connected -> away -> connected)', () => {
const { client, emit } = createMockClient('connected')
act(() => {
renderer = create(createElement(Harness, { client }))
})
expect(signal).toBe(0)
act(() => emit('disconnected'))
act(() => emit('connected'))
expect(signal).toBe(1)
act(() => emit('reconnecting'))
act(() => emit('connected'))
expect(signal).toBe(2)
})

it('does not bump on a duplicate connected transition', () => {
const { client, emit } = createMockClient('connected')
act(() => {
renderer = create(createElement(Harness, { client }))
})
act(() => emit('connected'))
expect(signal).toBe(0)
})

it('is 0 and subscribes to nothing without a client', () => {
act(() => {
renderer = create(createElement(Harness, { client: null }))
})
expect(signal).toBe(0)
})

it('unsubscribes on unmount', () => {
const { client, listenerCount } = createMockClient('connected')
act(() => {
renderer = create(createElement(Harness, { client }))
})
expect(listenerCount()).toBe(1)
act(() => renderer?.unmount())
expect(listenerCount()).toBe(0)
renderer = null
})
})
29 changes: 29 additions & 0 deletions mobile/src/browser/use-browser-screencast-reconnect-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'

// Why: the browser screencast pane must tear down and recreate its browser.screencast
// subscription (resetting the double-buffer render state) when the transport reconnects,
// or the display freezes on the last decoded frame while input RPCs keep landing.
// Returns a value that changes only on a REconnect, to drive a subscribe-effect dep.
export function useBrowserScreencastReconnectSignal(client: RpcClient | null): number {
const [reconnectSignal, setReconnectSignal] = useState(0)
useEffect(() => {
if (!client) {
return
}
let prev = client.getState()
let everConnected = prev === 'connected'
return client.onStateChange((next) => {
// Why: a fresh entry into 'connected' (direct-socket recovery or relay migrateTo)
// after we were already connected once is a reconnect — bump so dependents reset.
if (next === 'connected' && prev !== 'connected') {
if (everConnected) {
setReconnectSignal((value) => value + 1)
}
everConnected = true
}
prev = next
})
}, [client])
return reconnectSignal
}