diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 683b972bfa8e..97ac3f5d4974 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -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' @@ -164,6 +165,7 @@ export function MobileBrowserPane({ const [layout, setLayout] = useState(null) const [appActive, setAppActive] = useState(AppState.currentState === 'active') const streamGenerationRef = useRef(0) + const reconnectSignal = useBrowserScreencastReconnectSignal(client) const layoutRef = useRef(null) const frameMetadataRef = useRef( cachedInitialFrame?.metadata ?? null @@ -541,6 +543,7 @@ export function MobileBrowserPane({ } }, [ appActive, + reconnectSignal, applyFrameThrottled, clearFrameThrottle, client, diff --git a/mobile/src/browser/mobile-browser-pane-source.test.ts b/mobile/src/browser/mobile-browser-pane-source.test.ts index 69cd5af5391e..f7d7c551899b 100644 --- a/mobile/src/browser/mobile-browser-pane-source.test.ts +++ b/mobile/src/browser/mobile-browser-pane-source.test.ts @@ -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') + }) }) diff --git a/mobile/src/browser/use-browser-screencast-reconnect-signal.test.tsx b/mobile/src/browser/use-browser-screencast-reconnect-signal.test.tsx new file mode 100644 index 000000000000..5980238e24a2 --- /dev/null +++ b/mobile/src/browser/use-browser-screencast-reconnect-signal.test.tsx @@ -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 + }) +}) diff --git a/mobile/src/browser/use-browser-screencast-reconnect-signal.ts b/mobile/src/browser/use-browser-screencast-reconnect-signal.ts new file mode 100644 index 000000000000..09c008e1fc76 --- /dev/null +++ b/mobile/src/browser/use-browser-screencast-reconnect-signal.ts @@ -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 +}