Skip to content

Commit 6a56ea4

Browse files
committed
fix(preview): apply viewport changes when the panel is hidden
preview_resize only updated the CSS/React chrome. If the browser panel was hidden, the guest never changed size and wait timed out. Resize now persists the setting, then applies a CDP device-metrics override so the guest viewport changes even when the tab is not visible.
1 parent 949feb6 commit 6a56ea4

10 files changed

Lines changed: 181 additions & 3 deletions

File tree

apps/desktop/src/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres
7575
export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";
7676
export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";
7777
export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for";
78+
export const PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL = "desktop:preview-automation-set-viewport";
7879
export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";
7980
export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
8081
export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";

apps/desktop/src/ipc/methods/preview.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DesktopPreviewAnnotationThemeInputSchema,
33
DesktopPreviewArtifactInputSchema,
44
DesktopPreviewAutomationClickInputSchema,
5+
DesktopPreviewAutomationSetViewportInputSchema,
56
DesktopPreviewAutomationEvaluateInputSchema,
67
DesktopPreviewAutomationPressInputSchema,
78
DesktopPreviewAutomationScrollInputSchema,
@@ -289,6 +290,19 @@ export const automationSnapshot = DesktopIpc.makeIpcMethod({
289290
}),
290291
});
291292

293+
export const automationSetViewport = DesktopIpc.makeIpcMethod({
294+
channel: IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL,
295+
payload: DesktopPreviewAutomationSetViewportInputSchema,
296+
result: Schema.Void,
297+
handler: Effect.fn("desktop.ipc.preview.automationSetViewport")(function* (input) {
298+
const manager = yield* PreviewManager.PreviewManager;
299+
yield* manager.automationSetViewport(
300+
input.tabId,
301+
"clear" in input ? { clear: true } : { width: input.width, height: input.height },
302+
);
303+
}),
304+
});
305+
292306
export const automationClick = DesktopIpc.makeIpcMethod({
293307
channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL,
294308
payload: DesktopPreviewAutomationClickInputSchema,
@@ -386,6 +400,7 @@ export const methods = [
386400
closePictureInPicture,
387401
automationStatus,
388402
automationSnapshot,
403+
automationSetViewport,
389404
automationClick,
390405
automationType,
391406
automationPress,

apps/desktop/src/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
232232
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }),
233233
snapshot: (tabId) =>
234234
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }),
235+
setViewport: (tabId, input) =>
236+
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, {
237+
tabId,
238+
...input,
239+
}),
235240
click: (tabId, input) =>
236241
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }),
237242
type: (tabId, input) =>

apps/desktop/src/preview/Manager.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2251,6 +2251,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22512251
yield* applyColorScheme(tabId, wc, colorScheme);
22522252
});
22532253

2254+
const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* (
2255+
tabId: string,
2256+
input: { readonly width: number; readonly height: number } | { readonly clear: true },
2257+
) {
2258+
const wc = yield* requireWebContents(tabId);
2259+
yield* withControlSession(tabId, wc, "resize", (send) =>
2260+
"clear" in input
2261+
? send("Emulation.clearDeviceMetricsOverride")
2262+
: send("Emulation.setDeviceMetricsOverride", {
2263+
width: input.width,
2264+
height: input.height,
2265+
deviceScaleFactor: 1,
2266+
mobile: input.width < 768,
2267+
}),
2268+
);
2269+
});
2270+
22542271
const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
22552272
tabId: string,
22562273
) {
@@ -3544,6 +3561,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
35443561
saveRecording,
35453562
setAnnotationTheme,
35463563
setColorScheme,
3564+
automationSetViewport,
35473565
setMainWindow,
35483566
startRecording,
35493567
closePictureInPicture,
@@ -3846,6 +3864,10 @@ export class PreviewManager extends Context.Service<
38463864
tabId: string,
38473865
colorScheme: DesktopPreviewColorScheme,
38483866
) => Effect.Effect<void, PreviewManagerError>;
3867+
readonly automationSetViewport: (
3868+
tabId: string,
3869+
input: { readonly width: number; readonly height: number } | { readonly clear: true },
3870+
) => Effect.Effect<void, PreviewManagerError>;
38493871
readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
38503872
readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
38513873
readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
@@ -3944,6 +3966,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
39443966
reapplyZoom: operations.reapplyZoom,
39453967
hardReload: operations.hardReload,
39463968
setColorScheme: operations.setColorScheme,
3969+
automationSetViewport: operations.automationSetViewport,
39473970
openDevTools: operations.openDevTools,
39483971
clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
39493972
yield* browserSession

apps/web/src/components/preview/PreviewAutomationHosts.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
4848
import { useAtomCommand } from "~/state/use-atom-command";
4949

5050
import { previewBridge } from "./previewBridge";
51+
import { applyPreviewGuestViewport } from "./previewGuestViewport";
5152
import {
5253
PreviewAutomationOperationError,
5354
PreviewAutomationOverlayTimeoutError,
@@ -178,6 +179,14 @@ const waitForRenderedViewport = async (
178179
const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null;
179180
const declaredViewport = readDeclaredViewport(webview);
180181
const renderedViewport = webview ? await readWebviewViewport(webview) : null;
182+
if (
183+
setting._tag !== "fill" &&
184+
renderedViewport &&
185+
Math.abs(renderedViewport.width - setting.width) <= 1 &&
186+
Math.abs(renderedViewport.height - setting.height) <= 1
187+
) {
188+
return renderedViewport;
189+
}
181190
if (
182191
renderedViewport &&
183192
isPreviewViewportReady({
@@ -497,7 +506,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
497506
const ready = await requireReadyTab();
498507
const input = request.input as PreviewAutomationResizeInput;
499508
const setting = resolvePreviewViewport(input);
500-
const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => {
509+
const setViewport = ready.bridge.automation.setViewport;
510+
const persistViewport = async () => {
501511
const operationState = assertPreviewRuntimeCurrent(
502512
threadRef,
503513
ready.tabId,
@@ -518,11 +528,22 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
518528
return raiseAtomCommandFailure(result);
519529
}
520530
updatePreviewServerSnapshot(threadRef, result.value);
531+
try {
532+
await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting);
533+
} catch (error) {
534+
await applyPreviewGuestViewport(
535+
setViewport,
536+
ready.runtimeTabId,
537+
previousSetting,
538+
).catch(() => undefined);
539+
throw error;
540+
}
521541
return {
522542
previousSetting,
523543
serverEpoch: operationState.serverEpoch,
524544
};
525-
});
545+
};
546+
const applied = await runBrowserViewportMutation(ready.runtimeTabId, persistViewport);
526547
let viewport: PreviewRenderedViewportSize;
527548
try {
528549
viewport = await waitForRenderedViewport(
@@ -562,6 +583,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
562583
});
563584
if (rollback._tag !== "Failure") {
564585
updatePreviewServerSnapshot(threadRef, rollback.value);
586+
await applyPreviewGuestViewport(
587+
setViewport,
588+
ready.runtimeTabId,
589+
applied.previousSetting,
590+
).catch(() => undefined);
565591
}
566592
}
567593
});

apps/web/src/components/preview/PreviewView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/prev
3434
import { useRightPanelStore } from "~/rightPanelStore";
3535

3636
import { previewBridge } from "./previewBridge";
37+
import { applyPreviewGuestViewport } from "./previewGuestViewport";
3738
import { subscribePreviewAction } from "./previewActionBus";
3839
import { openPreviewSession } from "./openPreviewSession";
3940
import { PreviewChromeRow } from "./PreviewChromeRow";
@@ -239,8 +240,15 @@ export function PreviewView({
239240
throw error;
240241
}
241242
updatePreviewServerSnapshot(threadRef, result.value);
243+
if (runtimeTabId) {
244+
await applyPreviewGuestViewport(
245+
previewBridge?.automation.setViewport,
246+
runtimeTabId,
247+
nextViewport,
248+
);
249+
}
242250
},
243-
[resize, tabId, threadRef],
251+
[resize, runtimeTabId, tabId, threadRef],
244252
);
245253

246254
const handleToggleDeviceToolbar = () => {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it, vi } from "vite-plus/test";
2+
3+
import { applyPreviewGuestViewport, previewGuestViewportOverride } from "./previewGuestViewport";
4+
5+
describe("previewGuestViewportOverride", () => {
6+
it("clears fill mode and uses explicit dimensions for fixed viewports", () => {
7+
expect(previewGuestViewportOverride({ _tag: "fill" })).toEqual({ clear: true });
8+
expect(previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 })).toEqual({
9+
width: 1024,
10+
height: 768,
11+
});
12+
expect(
13+
previewGuestViewportOverride({
14+
_tag: "preset",
15+
presetId: "iphone-12-pro",
16+
width: 390,
17+
height: 844,
18+
}),
19+
).toEqual({ width: 390, height: 844 });
20+
});
21+
});
22+
23+
describe("applyPreviewGuestViewport", () => {
24+
it("skips older desktops and applies the mapped override otherwise", async () => {
25+
await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" });
26+
27+
const setViewport = vi.fn(async () => undefined);
28+
await applyPreviewGuestViewport(setViewport, "tab-1", {
29+
_tag: "freeform",
30+
width: 800,
31+
height: 600,
32+
});
33+
expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 800, height: 600 });
34+
});
35+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { PreviewViewportSetting } from "@t3tools/contracts";
2+
3+
export type PreviewGuestViewportOverride =
4+
| { readonly clear: true }
5+
| { readonly width: number; readonly height: number };
6+
7+
export type PreviewGuestViewportApplier = (
8+
tabId: string,
9+
input: PreviewGuestViewportOverride,
10+
) => Promise<void>;
11+
12+
/** Maps a stored viewport setting onto the desktop CDP override. */
13+
export function previewGuestViewportOverride(
14+
setting: PreviewViewportSetting,
15+
): PreviewGuestViewportOverride {
16+
return setting._tag === "fill"
17+
? { clear: true }
18+
: { width: setting.width, height: setting.height };
19+
}
20+
21+
/** Applies or clears the guest CDP metrics override. No-op on older desktops. */
22+
export async function applyPreviewGuestViewport(
23+
setViewport: PreviewGuestViewportApplier | undefined,
24+
tabId: string,
25+
setting: PreviewViewportSetting,
26+
): Promise<void> {
27+
if (!setViewport) return;
28+
await setViewport(tabId, previewGuestViewportOverride(setting));
29+
}

packages/contracts/src/ipc.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,18 @@ export interface DesktopPreviewTabDefaults {
974974
readonly colorScheme?: DesktopPreviewColorScheme | undefined;
975975
}
976976

977+
export const DesktopPreviewAutomationSetViewportInputSchema = Schema.Union([
978+
Schema.Struct({
979+
tabId: DesktopPreviewTabIdSchema,
980+
width: Schema.Int.check(Schema.isGreaterThan(0)),
981+
height: Schema.Int.check(Schema.isGreaterThan(0)),
982+
}),
983+
Schema.Struct({
984+
tabId: DesktopPreviewTabIdSchema,
985+
clear: Schema.Literal(true),
986+
}),
987+
]);
988+
977989
export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
978990
tabId: DesktopPreviewTabIdSchema,
979991
webContentsId: Schema.Int.check(Schema.isGreaterThan(0)),
@@ -1187,6 +1199,10 @@ export interface DesktopPreviewBridge {
11871199
automation: {
11881200
status: (tabId: string) => Promise<PreviewAutomationStatus>;
11891201
snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>;
1202+
setViewport: (
1203+
tabId: string,
1204+
input: { readonly width: number; readonly height: number } | { readonly clear: true },
1205+
) => Promise<void>;
11901206
click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>;
11911207
type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>;
11921208
press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>;

packages/contracts/src/preview.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
PreviewSessionSnapshot,
1212
PreviewViewportSetting,
1313
} from "./preview.ts";
14+
import { DesktopPreviewAutomationSetViewportInputSchema } from "./ipc.ts";
1415
import {
1516
PreviewAutomationHost,
1617
PreviewAutomationError,
@@ -32,6 +33,9 @@ const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResul
3233
const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
3334
const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
3435
const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
36+
const decodeSetViewportInput = Schema.decodeUnknownSync(
37+
DesktopPreviewAutomationSetViewportInputSchema,
38+
);
3539

3640
describe("PreviewAutomationOpenInput", () => {
3741
it("accepts the inline preview visibility flag", () => {
@@ -223,6 +227,22 @@ describe("PreviewAutomationStatus", () => {
223227
});
224228
});
225229

230+
describe("DesktopPreviewAutomationSetViewportInputSchema", () => {
231+
it("accepts a complete size or an explicit clear, and rejects a partial size", () => {
232+
expect(decodeSetViewportInput({ tabId: "tab-1", width: 800, height: 600 })).toEqual({
233+
tabId: "tab-1",
234+
width: 800,
235+
height: 600,
236+
});
237+
expect(decodeSetViewportInput({ tabId: "tab-1", clear: true })).toEqual({
238+
tabId: "tab-1",
239+
clear: true,
240+
});
241+
expect(() => decodeSetViewportInput({ tabId: "tab-1", width: 800 })).toThrow();
242+
expect(() => decodeSetViewportInput({ tabId: "tab-1" })).toThrow();
243+
});
244+
});
245+
226246
describe("PreviewEvent", () => {
227247
it("decodes opened", () => {
228248
const event = decodePreviewEvent({

0 commit comments

Comments
 (0)