From cc550333205c8d1adb084db5bfbf610d76565e9c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:15:58 -0700 Subject: [PATCH] perf(emulator): stop decoding frames for hidden emulator panes A backgrounded but still-attached emulator kept the frame stream running: continuous WebCodecs H.264 decode + ctx.drawImage (Android) or Blob + createObjectURL per MJPEG frame (iOS) painted to a visibility:hidden canvas, with frames still flowing over IPC (and the network for paired SSH/remote clients) to no viewer. This stacks per open worktree holding a live emulator. EmulatorPane already threads isActive (it gates autoAttachOnMount) but never passed it to EmulatorDeviceFrame, so showStream gated only on isLive. Gate it on isActive too. The overlay slot already sets visibility:hidden when isActive is false, so this can never blank a visible stream. The session stays attached (autoAttachOnMount untouched), so the stream re-fires within a frame on re-show via the existing enabled effect. Mirrors the browser pane's park-when-hidden. Both stream hooks tear down on disable: useEmulatorVideoStream stops the H.264 forwarding at the source (stopVideoStream) and closes the decoder; useEmulatorFrameStream calls stopFrameStream and revokes the Blob URL. Test asserts the IPC contract (not just DOM): no startFrameStream while the pane is inactive, and stopFrameStream on hide. Reverting the gate fails 2/3. Co-authored-by: Orca --- .../components/emulator-pane/EmulatorPane.tsx | 1 + .../emulator-device-frame-visibility.test.tsx | 103 ++++++++++++++++++ .../emulator-device-frame.input.test.tsx | 1 + .../emulator-pane/emulator-device-frame.tsx | 12 +- 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index d0609eb0ad5..742ff08df29 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -80,6 +80,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula deviceName={displayName} loading={loading} isLive={isLive} + isActive={isActive} onTap={(x, y) => void sendTap(x, y)} onGesture={(points) => void sendGesture(points)} /> diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx new file mode 100644 index 00000000000..12409234ff4 --- /dev/null +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EmulatorDeviceFrame } from './emulator-device-frame' + +// Why: a backgrounded but still-attached emulator must stop streaming frames. +// The perf contract is that no frame stream is started (no per-frame IPC / MJPEG +// blob churn / H.264 decode) while the pane is inactive, and that a running +// stream is torn down when the pane is hidden — so this asserts the IPC calls, +// not just the rendered DOM. + +type FrameListener = (data: { streamId: string; bytes: ArrayBuffer }) => void +type ErrorListener = (data: { streamId: string; message: string }) => void + +let container: HTMLDivElement +let root: Root +let startFrameStream: ReturnType +let stopFrameStream: ReturnType +let streamCounter: number + +beforeEach(() => { + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + streamCounter = 0 + startFrameStream = vi.fn(async () => ({ streamId: `stream-${++streamCounter}` })) + stopFrameStream = vi.fn(async () => {}) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:emulator-frame') + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + emulator: { + startFrameStream, + stopFrameStream, + onFrameStreamFrame: (_listener: FrameListener) => () => {}, + onFrameStreamError: (_listener: ErrorListener) => () => {} + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + delete (URL as Partial).createObjectURL + delete (URL as Partial).revokeObjectURL + delete (window as { api?: unknown }).api + vi.restoreAllMocks() +}) + +async function renderFrame(isActive: boolean): Promise { + await act(async () => { + root.render( + + ) + }) +} + +describe('EmulatorDeviceFrame visibility gating', () => { + it('streams frames while the pane is active', async () => { + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledWith( + expect.objectContaining({ streamUrl: 'http://127.0.0.1:3100/stream.mjpeg' }) + ) + }) + + it('does not start a frame stream while the pane is inactive', async () => { + await renderFrame(false) + expect(startFrameStream).not.toHaveBeenCalled() + }) + + it('tears the stream down when the pane becomes inactive and resumes when active again', async () => { + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + // Parking the pane must stop the stream at the source (no more IPC frames). + await renderFrame(false) + expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' }) + + // Re-showing re-fires the stream; the session was never detached. + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx index fb7fa74135b..b32824bb9fc 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx @@ -99,6 +99,7 @@ function renderFrame(props?: { wsUrl="ws://127.0.0.1:3100/ws" loading={false} isLive={true} + isActive={true} onTap={props?.onTap ?? vi.fn()} onGesture={props?.onGesture ?? vi.fn()} /> diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx index 5d03be16158..7f9e2cda8ca 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx @@ -39,6 +39,9 @@ type EmulatorDeviceFrameProps = { deviceName?: string loading: boolean isLive: boolean + /** False when the pane is backgrounded (hidden tab/worktree). Gates the frame + * stream so a parked emulator stops decoding, matching the pane's visibility. */ + isActive: boolean onTap: (x: number, y: number) => void onGesture: (points: EmulatorGesturePoint[]) => void } @@ -65,6 +68,7 @@ export function EmulatorDeviceFrame({ deviceName, loading, isLive, + isActive, onTap, onGesture }: EmulatorDeviceFrameProps) { @@ -327,7 +331,13 @@ export function EmulatorDeviceFrame({ setStreamError(true) }, []) - const showStream = isLive && Boolean(previewUrl) + // Why: only decode frames while the pane is visible. A backgrounded but still + // attached emulator otherwise keeps running the WebCodecs H.264 decode / MJPEG + // blob churn against a hidden canvas, with frames still flowing over IPC (and + // the network for paired SSH/remote clients). The session stays attached, so + // the stream re-fires within a frame on re-show. Mirrors the browser pane's + // park-when-hidden behavior. + const showStream = isActive && isLive && Boolean(previewUrl) const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19 const screenAspectRatioStyle = streamSize ? `${streamSize.width} / ${streamSize.height}`