authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-29 00:26:33+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 14:26:33-07:00
logff1761012af46ceb5f1ecc9b2be00aae288a1691
treea1a0c899a9e22f4a9a81a3776f3d401e705bdbc9
parenta40aef4ccbc10b7bcd7bc817151db8d5a9b1c257
signature Signed by PGP key B5690EEEBB952194

fix(desktop): stop hidden previews draining battery (#8567)


9 files changed, 113 insertions(+), 31 deletions(-)

apps/web/src/browser/ElectronBrowserHost.tsx+4-1
...@@ -29,6 +29,8 @@ export function ElectronBrowserHost() {...@@ -29,6 +29,8 @@ export function ElectronBrowserHost() {
29 previewState.serverEpoch,29 previewState.serverEpoch,
30 snapshot.tabId,30 snapshot.tabId,
31 ),31 ),
32 pictureInPicture:
33 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
32 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,34 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
33 }))35 }))
34 : [];36 : [];
...@@ -80,7 +82,7 @@ export function ElectronBrowserHost() {...@@ -80,7 +82,7 @@ export function ElectronBrowserHost() {
80 if (!isElectron) return null;82 if (!isElectron) return null;
81 return (83 return (
82 <div className="contents" data-electron-browser-host>84 <div className="contents" data-electron-browser-host>
83 {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => {85 {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
84 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;86 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
85 return (87 return (
86 <HostedBrowserWebview88 <HostedBrowserWebview
...@@ -90,6 +92,7 @@ export function ElectronBrowserHost() {...@@ -90,6 +92,7 @@ export function ElectronBrowserHost() {
90 runtimeTabId={runtimeTabId}92 runtimeTabId={runtimeTabId}
91 initialUrl={url}93 initialUrl={url}
92 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}94 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
95 pictureInPicture={pictureInPicture}
93 zoomFactor={zoomFactor}96 zoomFactor={zoomFactor}
94 />97 />
95 );98 );
apps/web/src/browser/HostedBrowserWebview.tsx+11-1
...@@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";...@@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
1010
11import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";11import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { useActiveBrowserRecordingTabIds } from "./browserRecording";
12import {13import {
13 browserViewportSettingKey,14 browserViewportSettingKey,
14 resolveBrowserViewportLayout,15 resolveBrowserViewportLayout,
...@@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: {...@@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: {
47 readonly runtimeTabId: string;48 readonly runtimeTabId: string;
48 readonly initialUrl: string | null;49 readonly initialUrl: string | null;
49 readonly viewport: PreviewViewportSetting;50 readonly viewport: PreviewViewportSetting;
51 readonly pictureInPicture: boolean;
50 readonly zoomFactor: number;52 readonly zoomFactor: number;
51}) {53}) {
52 const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props;54 const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } =
55 props;
53 const config = usePreviewWebviewConfig(threadRef.environmentId);56 const config = usePreviewWebviewConfig(threadRef.environmentId);
54 const [initialSrc] = useState(() => initialUrl ?? "about:blank");57 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
55 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);58 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
...@@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: {...@@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: {
70 };73 };
71 }),74 }),
72 );75 );
76 const backgroundActivity = useBrowserSurfaceStore(
77 (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
78 );
79 const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
73 usePreviewBridge({ threadRef, tabId, runtimeTabId });80 usePreviewBridge({ threadRef, tabId, runtimeTabId });
7481
75 useEffect(() => {82 useEffect(() => {
...@@ -231,8 +238,10 @@ export function HostedBrowserWebview(props: {...@@ -231,8 +238,10 @@ export function HostedBrowserWebview(props: {
231238
232 if (!config) return null;239 if (!config) return null;
233240
241 const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
234 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({242 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
235 active,243 active,
244 renderingActive,
236 cornerRadius: presentation.cornerRadius,245 cornerRadius: presentation.cornerRadius,
237 rect: lastRect,246 rect: lastRect,
238 hiddenSize,247 hiddenSize,
...@@ -244,6 +253,7 @@ export function HostedBrowserWebview(props: {...@@ -244,6 +253,7 @@ export function HostedBrowserWebview(props: {
244 className="fixed overflow-hidden bg-muted/35"253 className="fixed overflow-hidden bg-muted/35"
245 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}254 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
246 onScroll={syncContentPresentation}255 onScroll={syncContentPresentation}
256 data-preview-rendering={renderingActive ? "active" : "suspended"}
247 data-preview-viewport={runtimeTabId}257 data-preview-viewport={runtimeTabId}
248 >258 >
249 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>259 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
apps/web/src/browser/browserRecording.test.ts+4-4
...@@ -179,10 +179,10 @@ describe("browser recording", () => {...@@ -179,10 +179,10 @@ describe("browser recording", () => {
179179
180 it("starts recording for a visible tab", async () => {180 it("starts recording for a visible tab", async () => {
181 await startBrowserRecording("recording-tab");181 await startBrowserRecording("recording-tab");
182182 const startupEvents = [...events];
183 expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
184183
185 await stopBrowserRecording("recording-tab");184 await stopBrowserRecording("recording-tab");
185 expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
186 });186 });
187187
188 it("records a hidden tab without requiring it to become visible", async () => {188 it("records a hidden tab without requiring it to become visible", async () => {
...@@ -195,11 +195,11 @@ describe("browser recording", () => {...@@ -195,11 +195,11 @@ describe("browser recording", () => {
195 };195 };
196196
197 await startBrowserRecording("recording-tab");197 await startBrowserRecording("recording-tab");
198 const startupEvents = [...events];
198199
199 expect(startScreencast).toHaveBeenCalledWith("recording-tab");200 expect(startScreencast).toHaveBeenCalledWith("recording-tab");
200 expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
201
202 await stopBrowserRecording("recording-tab");201 await stopBrowserRecording("recording-tab");
202 expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
203 });203 });
204204
205 it("fails startup instead of locking a fallback size when no frame arrives", async () => {205 it("fails startup instead of locking a fallback size when no frame arrives", async () => {
apps/web/src/browser/browserRecording.ts+8-6
...@@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {...@@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {
122const activeRecordings = new Map<string, ActiveRecording>();122const activeRecordings = new Map<string, ActiveRecording>();
123let unsubscribeFrames: (() => void) | null = null;123let unsubscribeFrames: (() => void) | null = null;
124124
125const publishActiveRecordingTabIds = (): void => {
126 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
127 tabIds: new Set(activeRecordings.keys()),
128 });
129};
130
125export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;131export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
126export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000;132export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000;
127133
...@@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => {...@@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => {
228 unsubscribeFrames?.();234 unsubscribeFrames?.();
229 unsubscribeFrames = null;235 unsubscribeFrames = null;
230 }236 }
231 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {237 publishActiveRecordingTabIds();
232 tabIds: new Set(activeRecordings.keys()),
233 });
234};238};
235239
236const cleanupFailedRecordingStart = async (240const cleanupFailedRecordingStart = async (
...@@ -377,6 +381,7 @@ export async function startBrowserRecording(...@@ -377,6 +381,7 @@ export async function startBrowserRecording(
377 lifecycle: { phase: "starting" },381 lifecycle: { phase: "starting" },
378 };382 };
379 activeRecordings.set(tabId, recording);383 activeRecordings.set(tabId, recording);
384 publishActiveRecordingTabIds();
380 try {385 try {
381 try {386 try {
382 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);387 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
...@@ -487,9 +492,6 @@ export async function startBrowserRecording(...@@ -487,9 +492,6 @@ export async function startBrowserRecording(
487 if (recording.lifecycle.phase === "starting") {492 if (recording.lifecycle.phase === "starting") {
488 recording.lifecycle = { phase: "recording" };493 recording.lifecycle = { phase: "recording" };
489 }494 }
490 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
491 tabIds: new Set(activeRecordings.keys()),
492 });
493 return startedAt;495 return startedAt;
494 } finally {496 } finally {
495 settleStartup?.();497 settleStartup?.();
apps/web/src/browser/browserSurfaceStore.test.ts+13-1
...@@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";...@@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 acquireBrowserSurface,4 acquireBrowserSurface,
5 acquireBrowserSurfaceActivity,
5 resolveBrowserSurfacePanelRect,6 resolveBrowserSurfacePanelRect,
6 useBrowserSurfaceStore,7 useBrowserSurfaceStore,
7} from "./browserSurfaceStore";8} from "./browserSurfaceStore";
89
9describe("browserSurfaceStore", () => {10describe("browserSurfaceStore", () => {
10 beforeEach(() => {11 beforeEach(() => {
11 useBrowserSurfaceStore.setState({ byTabId: {} });12 useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
13 });
14
15 it("keeps concurrent background work active until every lease is released", () => {
16 const first = acquireBrowserSurfaceActivity("background-browser");
17 const second = acquireBrowserSurfaceActivity("background-browser");
18
19 first();
20 expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBe(1);
21
22 second();
23 expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBeUndefined();
12 });24 });
1325
14 it("freezes the source content dimensions for a fitted presentation", () => {26 it("freezes the source content dimensions for a fitted presentation", () => {
apps/web/src/browser/browserSurfaceStore.ts+26
...@@ -29,7 +29,9 @@ export interface BrowserSurfaceContentPresentation {...@@ -29,7 +29,9 @@ export interface BrowserSurfaceContentPresentation {
29}29}
3030
31interface BrowserSurfaceStoreState {31interface BrowserSurfaceStoreState {
32 readonly activityByTabId: Record<string, number>;
32 readonly byTabId: Record<string, BrowserSurfacePresentation>;33 readonly byTabId: Record<string, BrowserSurfacePresentation>;
34 readonly acquireActivity: (tabId: string) => () => void;
33 readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void;35 readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void;
34 readonly present: (36 readonly present: (
35 tabId: string,37 tabId: string,
...@@ -63,7 +65,28 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect):...@@ -63,7 +65,28 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect):
63 left.height === right.height;65 left.height === right.height;
6466
65export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({67export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({
68 activityByTabId: {},
66 byTabId: {},69 byTabId: {},
70 acquireActivity: (tabId) => {
71 let released = false;
72 set((state) => ({
73 activityByTabId: {
74 ...state.activityByTabId,
75 [tabId]: (state.activityByTabId[tabId] ?? 0) + 1,
76 },
77 }));
78 return () => {
79 if (released) return;
80 released = true;
81 set((state) => {
82 const count = state.activityByTabId[tabId] ?? 0;
83 const activityByTabId = { ...state.activityByTabId };
84 if (count <= 1) delete activityByTabId[tabId];
85 else activityByTabId[tabId] = count - 1;
86 return { activityByTabId };
87 });
88 };
89 },
67 claim: (tabId, owner, fitSourceContent) =>90 claim: (tabId, owner, fitSourceContent) =>
68 set((state) => {91 set((state) => {
69 const current = state.byTabId[tabId];92 const current = state.byTabId[tabId];
...@@ -171,6 +194,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =...@@ -171,6 +194,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
171 }),194 }),
172}));195}));
173196
197export const acquireBrowserSurfaceActivity = (tabId: string): (() => void) =>
198 useBrowserSurfaceStore.getState().acquireActivity(tabId);
199
174export function acquireBrowserSurface(200export function acquireBrowserSurface(
175 tabId: string,201 tabId: string,
176 fitSourceContent = false,202 fitSourceContent = false,
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+23-1
...@@ -10,6 +10,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {...@@ -10,6 +10,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {
10 expect(10 expect(
11 resolveHostedBrowserWebviewWrapperStyle({11 resolveHostedBrowserWebviewWrapperStyle({
12 active: true,12 active: true,
13 renderingActive: true,
13 rect: { x: 12, y: 34, width: 800, height: 600 },14 rect: { x: 12, y: 34, width: 800, height: 600 },
14 hiddenSize: { width: 1280, height: 800 },15 hiddenSize: { width: 1280, height: 800 },
15 }),16 }),
...@@ -27,6 +28,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {...@@ -27,6 +28,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {
27 expect(28 expect(
28 resolveHostedBrowserWebviewWrapperStyle({29 resolveHostedBrowserWebviewWrapperStyle({
29 active: true,30 active: true,
31 renderingActive: true,
30 cornerRadius: 12,32 cornerRadius: 12,
31 rect: { x: 12, y: 34, width: 360, height: 203 },33 rect: { x: 12, y: 34, width: 360, height: 203 },
32 hiddenSize: { width: 1280, height: 800 },34 hiddenSize: { width: 1280, height: 800 },
...@@ -40,9 +42,10 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {...@@ -40,9 +42,10 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {
40 });42 });
41 });43 });
4244
43 it("keeps an inactive webview paintable while moving it offscreen", () => {45 it("suspends painting for an inactive webview", () => {
44 const style = resolveHostedBrowserWebviewWrapperStyle({46 const style = resolveHostedBrowserWebviewWrapperStyle({
45 active: false,47 active: false,
48 renderingActive: false,
46 rect: { x: 12, y: 34, width: 800, height: 600 },49 rect: { x: 12, y: 34, width: 800, height: 600 },
47 hiddenSize: { width: 393, height: 852 },50 hiddenSize: { width: 393, height: 852 },
48 });51 });
...@@ -54,6 +57,25 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {...@@ -54,6 +57,25 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {
54 height: 852,57 height: 852,
55 zIndex: -1,58 zIndex: -1,
56 pointerEvents: "none",59 pointerEvents: "none",
60 visibility: "hidden",
61 });
62 });
63
64 it("keeps an active background task paintable offscreen", () => {
65 const style = resolveHostedBrowserWebviewWrapperStyle({
66 active: false,
67 renderingActive: true,
68 rect: null,
69 hiddenSize: { width: 1280, height: 800 },
70 });
71
72 expect(style).toEqual({
73 left: HIDDEN_BROWSER_WEBVIEW_OFFSET,
74 top: HIDDEN_BROWSER_WEBVIEW_OFFSET,
75 width: 1280,
76 height: 800,
77 zIndex: -1,
78 pointerEvents: "none",
57 visibility: "visible",79 visibility: "visible",
58 });80 });
59 });81 });
apps/web/src/browser/hostedBrowserWebviewStyle.ts+4-6
...@@ -13,18 +13,19 @@ export interface HostedBrowserWebviewWrapperStyle {...@@ -13,18 +13,19 @@ export interface HostedBrowserWebviewWrapperStyle {
13 readonly zIndex: number;13 readonly zIndex: number;
14 readonly pointerEvents: "auto" | "none";14 readonly pointerEvents: "auto" | "none";
15 readonly borderRadius?: number;15 readonly borderRadius?: number;
16 readonly visibility?: "visible";16 readonly visibility?: "hidden" | "visible";
17}17}
1818
19export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000;19export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000;
2020
21export function resolveHostedBrowserWebviewWrapperStyle(input: {21export function resolveHostedBrowserWebviewWrapperStyle(input: {
22 readonly active: boolean;22 readonly active: boolean;
23 readonly renderingActive: boolean;
23 readonly cornerRadius?: number;24 readonly cornerRadius?: number;
24 readonly rect: BrowserSurfaceRect | null;25 readonly rect: BrowserSurfaceRect | null;
25 readonly hiddenSize: HostedBrowserWebviewSize;26 readonly hiddenSize: HostedBrowserWebviewSize;
26}): HostedBrowserWebviewWrapperStyle {27}): HostedBrowserWebviewWrapperStyle {
27 const { active, cornerRadius = 0, hiddenSize, rect } = input;28 const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input;
28 if (active && rect) {29 if (active && rect) {
29 return {30 return {
30 left: rect.x,31 left: rect.x,
...@@ -44,9 +45,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {...@@ -44,9 +45,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
44 height: hiddenSize.height,45 height: hiddenSize.height,
45 zIndex: -1,46 zIndex: -1,
46 pointerEvents: "none",47 pointerEvents: "none",
47 // Keep the guest CSS-visible even while physically offscreen. Electron48 visibility: renderingActive ? "visible" : "hidden",
48 // webviews can keep metadata/status alive under `visibility:hidden` while
49 // CDP Runtime/Input commands stall, which breaks offscreen automation.
50 visibility: "visible",
51 };49 };
52}50}
apps/web/src/components/preview/PreviewAutomationHosts.tsx+20-11
...@@ -37,7 +37,10 @@ import {...@@ -37,7 +37,10 @@ import {
37 stopBrowserRecording,37 stopBrowserRecording,
38} from "~/browser/browserRecording";38} from "~/browser/browserRecording";
39import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";39import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
40import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";40import {
41 acquireBrowserSurfaceActivity,
42 useBrowserSurfaceStore,
43} from "~/browser/browserSurfaceStore";
41import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";44import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
42import { runBrowserViewportMutation } from "~/browser/browserViewportActions";45import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
43import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";46import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
...@@ -98,7 +101,7 @@ const waitForDesktopOverlay = async (...@@ -98,7 +101,7 @@ const waitForDesktopOverlay = async (
98 operation,101 operation,
99 requestId,102 requestId,
100 });103 });
101 if (state.desktopByTabId[tabId] && previewBridge) {104 if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) {
102 const status = await previewBridge.automation.status(runtimeTabId);105 const status = await previewBridge.automation.status(runtimeTabId);
103 if (status.available) return;106 if (status.available) return;
104 }107 }
...@@ -121,6 +124,11 @@ const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null =>...@@ -121,6 +124,11 @@ const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null =>
121 (candidate) => candidate.getAttribute("data-preview-tab") === tabId,124 (candidate) => candidate.getAttribute("data-preview-tab") === tabId,
122 ) ?? null;125 ) ?? null;
123126
127const isPreviewWebviewRendering = (runtimeTabId: string): boolean => {
128 const wrapper = findPreviewWebview(runtimeTabId)?.closest<HTMLElement>("[data-preview-viewport]");
129 return wrapper?.getAttribute("data-preview-rendering") === "active";
130};
131
124const readWebviewViewport = async (132const readWebviewViewport = async (
125 webview: ExecutablePreviewWebview,133 webview: ExecutablePreviewWebview,
126): Promise<PreviewRenderedViewportSize | null> => {134): Promise<PreviewRenderedViewportSize | null> => {
...@@ -212,8 +220,12 @@ const currentStatus = async (...@@ -212,8 +220,12 @@ const currentStatus = async (
212 const visible = runtimeTabId220 const visible = runtimeTabId
213 ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false)221 ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false)
214 : false;222 : false;
223 const renderingActive = runtimeTabId ? isPreviewWebviewRendering(runtimeTabId) : false;
215 const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined;224 const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined;
216 const viewport = runtimeTabId ? await readRenderedViewport(runtimeTabId).catch(() => null) : null;225 const viewport =
226 runtimeTabId && renderingActive
227 ? await readRenderedViewport(runtimeTabId).catch(() => null)
228 : null;
217 const viewportStatus = {229 const viewportStatus = {
218 ...(viewportSetting === undefined ? {} : { viewportSetting }),230 ...(viewportSetting === undefined ? {} : { viewportSetting }),
219 ...(viewport === null ? {} : { viewport }),231 ...(viewport === null ? {} : { viewport }),
...@@ -307,6 +319,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -307,6 +319,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
307 threadId: request.threadId,319 threadId: request.threadId,
308 };320 };
309 let tabId = request.tabId ?? null;321 let tabId = request.tabId ?? null;
322 const browserActivity = { release: null as (() => void) | null };
310 try {323 try {
311 let state = readThreadPreviewState(threadRef);324 let state = readThreadPreviewState(threadRef);
312 const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId);325 const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId);
...@@ -340,6 +353,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -340,6 +353,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
340 }353 }
341 const readyState = readThreadPreviewState(threadRef);354 const readyState = readThreadPreviewState(threadRef);
342 const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId);355 const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId);
356 browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId);
343 await waitForDesktopOverlay(357 await waitForDesktopOverlay(
344 threadRef,358 threadRef,
345 request.requestId,359 request.requestId,
...@@ -440,14 +454,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -440,14 +454,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
440 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);454 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
441 }455 }
442 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {456 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
443 await waitForDesktopOverlay(457 await requireReadyTab();
444 threadRef,
445 request.requestId,
446 activeTabId,
447 activeRuntimeTabId,
448 request.operation,
449 request.timeoutMs,
450 );
451 }458 }
452 if (shouldPresentPreview) {459 if (shouldPresentPreview) {
453 // React commits the thread-bound surface asynchronously. Settle460 // React commits the thread-bound surface asynchronously. Settle
...@@ -678,6 +685,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -678,6 +685,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
678 tabId,685 tabId,
679 cause,686 cause,
680 });687 });
688 } finally {
689 browserActivity.release?.();
681 }690 }
682 },691 },
683 [environmentId, listPreviews, open, registry, resize],692 [environmentId, listPreviews, open, registry, resize],