From d5d0a0871f6ed2bece5f952c374d6dc062ab1785 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 09:45:27 +0200 Subject: [PATCH 01/20] 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. --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 15 ++++++++ apps/desktop/src/preload.ts | 5 +++ apps/desktop/src/preview/Manager.ts | 23 ++++++++++++ .../preview/PreviewAutomationHosts.tsx | 30 ++++++++++++++-- .../src/components/preview/PreviewView.tsx | 10 +++++- .../preview/previewGuestViewport.test.ts | 35 +++++++++++++++++++ .../preview/previewGuestViewport.ts | 29 +++++++++++++++ packages/contracts/src/ipc.ts | 16 +++++++++ packages/contracts/src/preview.test.ts | 20 +++++++++++ 10 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/preview/previewGuestViewport.test.ts create mode 100644 apps/web/src/components/preview/previewGuestViewport.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 90e7add6229e..a54445af1a07 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -80,6 +80,7 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL = "desktop:preview-automation-set-viewport"; export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5229d36c31f1..2b69c72bbaef 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -2,6 +2,7 @@ import { DesktopPreviewAnnotationThemeInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationSetViewportInputSchema, DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, @@ -299,6 +300,19 @@ export const automationSnapshot = DesktopIpc.makeIpcMethod({ }), }); +export const automationSetViewport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewAutomationSetViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationSetViewport")(function* (input) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationSetViewport( + input.tabId, + "clear" in input ? { clear: true } : { width: input.width, height: input.height }, + ); + }), +}); + export const automationClick = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, payload: DesktopPreviewAutomationClickInputSchema, @@ -397,6 +411,7 @@ export const methods = [ closePictureInPicture, automationStatus, automationSnapshot, + automationSetViewport, automationClick, automationType, automationPress, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3e181e2ca698..c01926a6981f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -269,6 +269,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), snapshot: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + setViewport: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, { + tabId, + ...input, + }), click: (tabId, input) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), type: (tabId, input) => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8ee312110d86..754e532d9923 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2611,6 +2611,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "resize", (send) => + "clear" in input + ? send("Emulation.clearDeviceMetricsOverride") + : send("Emulation.setDeviceMetricsOverride", { + width: input.width, + height: input.height, + deviceScaleFactor: 1, + mobile: input.width < 768, + }), + ); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -4096,6 +4113,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAudioMuted, setColorScheme, + automationSetViewport, setMainWindow, startRecording, closePictureInPicture, @@ -4439,6 +4457,10 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly automationSetViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -4538,6 +4560,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, + automationSetViewport: operations.automationSetViewport, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 1faf928b1cf5..6ac2c36882f2 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -51,6 +51,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { PreviewAutomationOperationError, PreviewAutomationOverlayTimeoutError, @@ -186,6 +187,14 @@ const waitForRenderedViewport = async ( const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; const declaredViewport = readDeclaredViewport(webview); const renderedViewport = webview ? await readWebviewViewport(webview) : null; + if ( + setting._tag !== "fill" && + renderedViewport && + Math.abs(renderedViewport.width - setting.width) <= 1 && + Math.abs(renderedViewport.height - setting.height) <= 1 + ) { + return renderedViewport; + } if ( renderedViewport && isPreviewViewportReady({ @@ -504,7 +513,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const ready = await requireReadyTab(); const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); - const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => { + const setViewport = ready.bridge.automation.setViewport; + const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( threadRef, ready.tabId, @@ -525,11 +535,22 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return raiseAtomCommandFailure(result); } updatePreviewServerSnapshot(threadRef, result.value); + try { + await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); + } catch (error) { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + previousSetting, + ).catch(() => undefined); + throw error; + } return { previousSetting, serverEpoch: operationState.serverEpoch, }; - }); + }; + const applied = await runBrowserViewportMutation(ready.runtimeTabId, persistViewport); let viewport: PreviewRenderedViewportSize; try { viewport = await waitForRenderedViewport( @@ -569,6 +590,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }); if (rollback._tag !== "Failure") { updatePreviewServerSnapshot(threadRef, rollback.value); + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + applied.previousSetting, + ).catch(() => undefined); } } }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 063314863fec..f32e279f61b7 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -34,6 +34,7 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/prev import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; +import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; @@ -238,8 +239,15 @@ export function PreviewView({ throw error; } updatePreviewServerSnapshot(threadRef, result.value); + if (runtimeTabId) { + await applyPreviewGuestViewport( + previewBridge?.automation.setViewport, + runtimeTabId, + nextViewport, + ); + } }, - [resize, tabId, threadRef], + [resize, runtimeTabId, tabId, threadRef], ); const handleToggleDeviceToolbar = () => { diff --git a/apps/web/src/components/preview/previewGuestViewport.test.ts b/apps/web/src/components/preview/previewGuestViewport.test.ts new file mode 100644 index 000000000000..0b2fd30a7fe8 --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyPreviewGuestViewport, previewGuestViewportOverride } from "./previewGuestViewport"; + +describe("previewGuestViewportOverride", () => { + it("clears fill mode and uses explicit dimensions for fixed viewports", () => { + expect(previewGuestViewportOverride({ _tag: "fill" })).toEqual({ clear: true }); + expect(previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 })).toEqual({ + width: 1024, + height: 768, + }); + expect( + previewGuestViewportOverride({ + _tag: "preset", + presetId: "iphone-12-pro", + width: 390, + height: 844, + }), + ).toEqual({ width: 390, height: 844 }); + }); +}); + +describe("applyPreviewGuestViewport", () => { + it("skips older desktops and applies the mapped override otherwise", async () => { + await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" }); + + const setViewport = vi.fn(async () => undefined); + await applyPreviewGuestViewport(setViewport, "tab-1", { + _tag: "freeform", + width: 800, + height: 600, + }); + expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 800, height: 600 }); + }); +}); diff --git a/apps/web/src/components/preview/previewGuestViewport.ts b/apps/web/src/components/preview/previewGuestViewport.ts new file mode 100644 index 000000000000..26c69912c717 --- /dev/null +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -0,0 +1,29 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +export type PreviewGuestViewportOverride = + | { readonly clear: true } + | { readonly width: number; readonly height: number }; + +export type PreviewGuestViewportApplier = ( + tabId: string, + input: PreviewGuestViewportOverride, +) => Promise; + +/** Maps a stored viewport setting onto the desktop CDP override. */ +export function previewGuestViewportOverride( + setting: PreviewViewportSetting, +): PreviewGuestViewportOverride { + return setting._tag === "fill" + ? { clear: true } + : { width: setting.width, height: setting.height }; +} + +/** Applies or clears the guest CDP metrics override. No-op on older desktops. */ +export async function applyPreviewGuestViewport( + setViewport: PreviewGuestViewportApplier | undefined, + tabId: string, + setting: PreviewViewportSetting, +): Promise { + if (!setViewport) return; + await setViewport(tabId, previewGuestViewportOverride(setting)); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 88dc2b26b280..e7657a495d63 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -958,6 +958,18 @@ export interface DesktopPreviewTabDefaults { readonly colorScheme?: DesktopPreviewColorScheme | undefined; } +export const DesktopPreviewAutomationSetViewportInputSchema = Schema.Union([ + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + width: Schema.Int.check(Schema.isGreaterThan(0)), + height: Schema.Int.check(Schema.isGreaterThan(0)), + }), + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + clear: Schema.Literal(true), + }), +]); + export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), @@ -1194,6 +1206,10 @@ export interface DesktopPreviewBridge { automation: { status: (tabId: string) => Promise; snapshot: (tabId: string) => Promise; + setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Promise; click: (tabId: string, input: PreviewAutomationClickInput) => Promise; type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; press: (tabId: string, input: PreviewAutomationPressInput) => Promise; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745ef8..b9306b4e4996 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -11,6 +11,7 @@ import { PreviewSessionSnapshot, PreviewViewportSetting, } from "./preview.ts"; +import { DesktopPreviewAutomationSetViewportInputSchema } from "./ipc.ts"; import { PreviewAutomationHost, PreviewAutomationError, @@ -32,6 +33,9 @@ const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResul const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); +const decodeSetViewportInput = Schema.decodeUnknownSync( + DesktopPreviewAutomationSetViewportInputSchema, +); describe("PreviewAutomationOpenInput", () => { it("accepts the inline preview visibility flag", () => { @@ -223,6 +227,22 @@ describe("PreviewAutomationStatus", () => { }); }); +describe("DesktopPreviewAutomationSetViewportInputSchema", () => { + it("accepts a complete size or an explicit clear, and rejects a partial size", () => { + expect(decodeSetViewportInput({ tabId: "tab-1", width: 800, height: 600 })).toEqual({ + tabId: "tab-1", + width: 800, + height: 600, + }); + expect(decodeSetViewportInput({ tabId: "tab-1", clear: true })).toEqual({ + tabId: "tab-1", + clear: true, + }); + expect(() => decodeSetViewportInput({ tabId: "tab-1", width: 800 })).toThrow(); + expect(() => decodeSetViewportInput({ tabId: "tab-1" })).toThrow(); + }); +}); + describe("PreviewEvent", () => { it("decodes opened", () => { const event = decodePreviewEvent({ From b64bb2fdb98b562f884e2c44d495c1548de343b4 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 10:37:31 +0200 Subject: [PATCH 02/20] fix(preview): keep guest viewport and server snapshot in lockstep A size-only ready check could resolve resize before React committed the webview geometry. A failed CDP apply also left the persisted snapshot on the new size. Resize now waits for the existing ready gates, rolls the snapshot back when the override fails, and re-applies the committed setting when the guest attaches. --- .../preview/PreviewAutomationHosts.tsx | 19 ++++++++++-------- .../src/components/preview/PreviewView.tsx | 20 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 6ac2c36882f2..49f3f99c69d6 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -187,14 +187,6 @@ const waitForRenderedViewport = async ( const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; const declaredViewport = readDeclaredViewport(webview); const renderedViewport = webview ? await readWebviewViewport(webview) : null; - if ( - setting._tag !== "fill" && - renderedViewport && - Math.abs(renderedViewport.width - setting.width) <= 1 && - Math.abs(renderedViewport.height - setting.height) <= 1 - ) { - return renderedViewport; - } if ( renderedViewport && isPreviewViewportReady({ @@ -538,6 +530,17 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) try { await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); } catch (error) { + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: previousSetting, + }, + }); + if (rollback._tag !== "Failure") { + updatePreviewServerSnapshot(threadRef, rollback.value); + } await applyPreviewGuestViewport( setViewport, ready.runtimeTabId, diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index f32e279f61b7..c2d8b1577b67 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -239,17 +239,21 @@ export function PreviewView({ throw error; } updatePreviewServerSnapshot(threadRef, result.value); - if (runtimeTabId) { - await applyPreviewGuestViewport( - previewBridge?.automation.setViewport, - runtimeTabId, - nextViewport, - ); - } }, - [resize, runtimeTabId, tabId, threadRef], + [resize, tabId, threadRef], ); + const viewportOverrideKey = + viewport._tag === "fill" ? "fill" : `${viewport._tag}:${viewport.width}x${viewport.height}`; + useEffect(() => { + if (!runtimeTabId || !desktopOverlay?.hasWebContents) return; + void applyPreviewGuestViewport( + previewBridge?.automation.setViewport, + runtimeTabId, + viewport, + ).catch(() => undefined); + }, [desktopOverlay?.hasWebContents, runtimeTabId, viewport, viewportOverrideKey]); + const handleToggleDeviceToolbar = () => { if (!runtimeTabId) return; if (viewport._tag !== "fill") { From b05215f19ac814dfe2e156d715d0a01cf94dc317 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 10:55:12 +0200 Subject: [PATCH 03/20] fix(preview): apply human viewport overrides without agent control The restore effect depended on the viewport object, so every preview event re-sent CDP and flashed the agent-controlling badge. Toolbar resizes now use a setViewport path that does not take agent control, and the effect keys only on the viewport size string. --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 13 +++++ apps/desktop/src/preload.ts | 5 ++ apps/desktop/src/preview/Manager.test.ts | 55 +++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 33 +++++++++++ .../src/components/preview/PreviewView.tsx | 8 ++- packages/contracts/src/ipc.ts | 9 +++ 7 files changed, 121 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a54445af1a07..980946cb874f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -59,6 +59,7 @@ export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 2b69c72bbaef..9683914bdb52 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -164,6 +164,18 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +export const setViewport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewAutomationSetViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setViewport")(function* (input) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setViewport( + input.tabId, + "clear" in input ? { clear: true } : { width: input.width, height: input.height }, + ); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -397,6 +409,7 @@ export const methods = [ hardReload, setColorScheme, setAudioMuted, + setViewport, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c01926a6981f..3c733a3919b6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -220,6 +220,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setViewport: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, { + tabId, + ...input, + }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 75271d76386a..0065b27a8725 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1698,6 +1698,61 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("applies a guest viewport override without taking agent control", () => + withManager((manager) => + Effect.gen(function* () { + const sendCommand = vi.fn(async () => undefined); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const controllers: Array = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + yield* manager.createTab("tab_viewport"); + yield* manager.registerWebview("tab_viewport", 42); + yield* manager.setViewport("tab_viewport", { width: 390, height: 844 }); + yield* manager.setViewport("tab_viewport", { clear: true }); + + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); + expect(controllers).not.toContain("agent"); + + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 754e532d9923..a7ea98333ea2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2611,6 +2611,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( + tabId: string, + wc: Electron.WebContents, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) { + yield* ensureControlSession(wc); + yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () => + "clear" in input + ? wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride") + : wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { + width: input.width, + height: input.height, + deviceScaleFactor: 1, + mobile: input.width < 768, + }), + ); + }); + + // Human/toolbar path. Must not take agent control or write a resize action. + const setViewport = Effect.fn("PreviewManager.setViewport")(function* ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) { + const wc = yield* requireWebContents(tabId); + yield* applyViewportOverride(tabId, wc, input); + }); + const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* ( tabId: string, input: { readonly width: number; readonly height: number } | { readonly clear: true }, @@ -4113,6 +4140,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAudioMuted, setColorScheme, + setViewport, automationSetViewport, setMainWindow, startRecording, @@ -4457,6 +4485,10 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Effect.Effect; readonly automationSetViewport: ( tabId: string, input: { readonly width: number; readonly height: number } | { readonly clear: true }, @@ -4560,6 +4592,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, + setViewport: operations.setViewport, automationSetViewport: operations.automationSetViewport, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index c2d8b1577b67..38f92f9668fe 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -245,14 +245,16 @@ export function PreviewView({ const viewportOverrideKey = viewport._tag === "fill" ? "fill" : `${viewport._tag}:${viewport.width}x${viewport.height}`; + const viewportRef = useRef(viewport); + viewportRef.current = viewport; useEffect(() => { if (!runtimeTabId || !desktopOverlay?.hasWebContents) return; void applyPreviewGuestViewport( - previewBridge?.automation.setViewport, + previewBridge?.setViewport, runtimeTabId, - viewport, + viewportRef.current, ).catch(() => undefined); - }, [desktopOverlay?.hasWebContents, runtimeTabId, viewport, viewportOverrideKey]); + }, [desktopOverlay?.hasWebContents, runtimeTabId, viewportOverrideKey]); const handleToggleDeviceToolbar = () => { if (!runtimeTabId) return; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e7657a495d63..31a179cff56c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1163,6 +1163,15 @@ export interface DesktopPreviewBridge { * allowed; it simply takes effect once the page plays something. */ setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + /** + * Apply or clear a guest device-metrics override without taking agent + * control. Used by the toolbar and restore path so a human resize does + * not flash the agent-controlling badge. + */ + setViewport: ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => Promise; /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ From 0b5135c6b1a5eff93cbd38bc5556cbcdf2205742 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 11:17:53 +0200 Subject: [PATCH 04/20] fix(preview): keep landscape phones mobile and sync drag to the guest Width-only mobile detection treated 844x390 phones as desktop. The guest also stayed pinned to the last committed size while the frame was dragged. mobile now uses the shortest side. HostedBrowserWebview applies a frame-throttled CDP override from the effective (including drag) viewport. --- apps/desktop/src/preview/Manager.test.ts | 7 ++++++ apps/desktop/src/preview/Manager.ts | 25 ++++++++++--------- apps/web/src/browser/HostedBrowserWebview.tsx | 14 +++++++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 0065b27a8725..c706ccec3601 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1738,6 +1738,7 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_viewport"); yield* manager.registerWebview("tab_viewport", 42); yield* manager.setViewport("tab_viewport", { width: 390, height: 844 }); + yield* manager.setViewport("tab_viewport", { width: 844, height: 390 }); yield* manager.setViewport("tab_viewport", { clear: true }); expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { @@ -1746,6 +1747,12 @@ describe("PreviewManager", () => { deviceScaleFactor: 1, mobile: true, }); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 844, + height: 390, + deviceScaleFactor: 1, + mobile: true, + }); expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); expect(controllers).not.toContain("agent"); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a7ea98333ea2..285cfbbe8caf 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2611,6 +2611,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const deviceMetricsOverride = (input: { readonly width: number; readonly height: number }) => ({ + width: input.width, + height: input.height, + deviceScaleFactor: 1, + // Shortest side, so landscape phones stay mobile (844x390, not width-only). + mobile: Math.min(input.width, input.height) < 768, + }); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( tabId: string, wc: Electron.WebContents, @@ -2620,12 +2628,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () => "clear" in input ? wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride") - : wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { - width: input.width, - height: input.height, - deviceScaleFactor: 1, - mobile: input.width < 768, - }), + : wc.debugger.sendCommand( + "Emulation.setDeviceMetricsOverride", + deviceMetricsOverride(input), + ), ); }); @@ -2646,12 +2652,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* withControlSession(tabId, wc, "resize", (send) => "clear" in input ? send("Emulation.clearDeviceMetricsOverride") - : send("Emulation.setDeviceMetricsOverride", { - width: input.width, - height: input.height, - deviceScaleFactor: 1, - mobile: input.width < 768, - }), + : send("Emulation.setDeviceMetricsOverride", deviceMetricsOverride(input)), ); }); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 77c65264aa94..9ed4de55b542 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -5,6 +5,7 @@ import { useShallow } from "zustand/react/shallow"; import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; +import { applyPreviewGuestViewport } from "~/components/preview/previewGuestViewport"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn, isMacPlatform } from "~/lib/utils"; @@ -197,6 +198,19 @@ export function HostedBrowserWebview(props: { deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); + const guestViewportKey = browserViewportSettingKey(effectiveViewport); + const guestViewportRef = useRef(effectiveViewport); + guestViewportRef.current = effectiveViewport; + useEffect(() => { + const setViewport = previewBridge?.setViewport; + if (!setViewport) return; + const frame = window.requestAnimationFrame(() => { + void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( + () => undefined, + ); + }); + return () => window.cancelAnimationFrame(frame); + }, [guestViewportKey, runtimeTabId, webviewGeneration]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( From d99b7a9bf758843b0495b1222c5aa9c53a7cfadd Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 12:20:56 +0200 Subject: [PATCH 05/20] fix(preview): apply guest viewport only after the webview is registered The HostedBrowserWebview override ran one frame after mount, before registerWebview finished, then swallowed the failure. Crash recovery and mini-player never retried. The override now runs after a successful register, and drag updates wait for hasWebContents. --- apps/web/src/browser/HostedBrowserWebview.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 9ed4de55b542..3608ac2a137e 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -8,6 +8,7 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { applyPreviewGuestViewport } from "~/components/preview/previewGuestViewport"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn, isMacPlatform } from "~/lib/utils"; +import { useThreadPreviewState } from "~/previewStateStore"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; @@ -59,6 +60,7 @@ export function HostedBrowserWebview(props: { const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); + const guestViewportRef = useRef(viewport); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( @@ -79,6 +81,8 @@ export function HostedBrowserWebview(props: { ); const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId); usePreviewBridge({ threadRef, tabId, runtimeTabId }); + const hasWebContents = + useThreadPreviewState(threadRef).desktopByTabId[tabId]?.hasWebContents === true; useEffect(() => { crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; @@ -121,6 +125,15 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); + if (disposed || webviewRef.current !== webview) return; + const setViewport = previewBridge?.setViewport; + if (setViewport) { + await applyPreviewGuestViewport( + setViewport, + runtimeTabId, + guestViewportRef.current, + ).catch(() => undefined); + } } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. @@ -199,18 +212,17 @@ export function HostedBrowserWebview(props: { aspectRatio: lockedAspectRatio, }); const guestViewportKey = browserViewportSettingKey(effectiveViewport); - const guestViewportRef = useRef(effectiveViewport); guestViewportRef.current = effectiveViewport; useEffect(() => { const setViewport = previewBridge?.setViewport; - if (!setViewport) return; + if (!setViewport || !hasWebContents) return; const frame = window.requestAnimationFrame(() => { void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( () => undefined, ); }); return () => window.cancelAnimationFrame(frame); - }, [guestViewportKey, runtimeTabId, webviewGeneration]); + }, [guestViewportKey, hasWebContents, runtimeTabId]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( From 153ddee84e945634b5d8e81c61659c49174990f9 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Mon, 17 Aug 2026 20:42:17 +0200 Subject: [PATCH 06/20] fix(preview): leave guest viewport overrides to HostedBrowserWebview PreviewView reapplied the committed size after the webview owner already set the live override. That raced drags and keyboard resizes. The active-tab effect is gone; HostedBrowserWebview is the single owner. --- apps/web/src/components/preview/PreviewView.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 38f92f9668fe..063314863fec 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -34,7 +34,6 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/prev import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; -import { applyPreviewGuestViewport } from "./previewGuestViewport"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; @@ -243,19 +242,6 @@ export function PreviewView({ [resize, tabId, threadRef], ); - const viewportOverrideKey = - viewport._tag === "fill" ? "fill" : `${viewport._tag}:${viewport.width}x${viewport.height}`; - const viewportRef = useRef(viewport); - viewportRef.current = viewport; - useEffect(() => { - if (!runtimeTabId || !desktopOverlay?.hasWebContents) return; - void applyPreviewGuestViewport( - previewBridge?.setViewport, - runtimeTabId, - viewportRef.current, - ).catch(() => undefined); - }, [desktopOverlay?.hasWebContents, runtimeTabId, viewportOverrideKey]); - const handleToggleDeviceToolbar = () => { if (!runtimeTabId) return; if (viewport._tag !== "fill") { From 4eb85a9653779ff4e72b853f4e5b4a4c4d2ecde2 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:31:31 +0200 Subject: [PATCH 07/20] style(preview): drop extra blank line in viewport override test --- apps/desktop/src/preview/Manager.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c706ccec3601..7bb83c9a5087 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1755,7 +1755,6 @@ describe("PreviewManager", () => { }); expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); expect(controllers).not.toContain("agent"); - }), ), ); From 73feef67d506830020216f92aaa704a0de337b1d Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:46:15 +0200 Subject: [PATCH 08/20] fix(preview): restore viewport after DevTools and gate guest rollback Device-metrics overrides live on the CDP session, so DevTools and webview swaps dropped a fixed viewport while the toolbar still showed it. Remember the last override and re-apply it from restoreControlSession, like color scheme. Guest rollback now rechecks runtime/epoch and only persists previous if the guest actually rolled back, so a replaced tab or a failed CDP clear cannot desync store and guest. --- apps/desktop/src/preview/Manager.test.ts | 57 ++++++++++++ apps/desktop/src/preview/Manager.ts | 34 ++++++- .../preview/PreviewAutomationHosts.tsx | 88 +++++++++---------- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7bb83c9a5087..0699d6444d40 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1759,6 +1759,63 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("re-applies a guest viewport override after a webview swap", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = (id: number) => { + const sendCommand = vi.fn(async () => undefined); + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + + yield* manager.createTab("tab_viewport_restore"); + yield* manager.registerWebview("tab_viewport_restore", 42); + yield* manager.setViewport("tab_viewport_restore", { width: 390, height: 844 }); + + const replacement = makeWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_viewport_restore", 43); + yield* Effect.yieldNow; + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + }); + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 285cfbbe8caf..1459715401d5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -573,6 +573,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); + const viewportOverridesRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); @@ -1991,6 +1994,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(tab)) return; + yield* SynchronizedRef.update(viewportOverridesRef, (overrides) => + replaceMap(overrides, (copy) => { + copy.delete(tabId); + }), + ); const closedTab = tab.value; if (closedTab.webContentsId != null) { yield* Effect.all( @@ -2527,10 +2535,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Re-establish the control session after a detach, restoring any - // color-scheme override the tab carries. The scheme is read after the - // session attaches so a concurrent setColorScheme is not overwritten with - // a stale snapshot. + // Re-establish the control session after a detach, restoring color-scheme + // and viewport overrides the tab carries. Both live on the CDP debugger + // session, so they are lost on webview swap and DevTools open/close. + // Values are read after attach so a concurrent setColorScheme/setViewport + // is not overwritten with a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -2553,6 +2562,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); } + const viewportOverride = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + if (viewportOverride) { + yield* applyViewportOverride(tabId, wc, viewportOverride); + } }).pipe(Effect.ignore); const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( @@ -2619,6 +2632,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function mobile: Math.min(input.width, input.height) < 768, }); + const rememberViewportOverride = ( + tabId: string, + input: { readonly width: number; readonly height: number } | { readonly clear: true }, + ) => + SynchronizedRef.update(viewportOverridesRef, (overrides) => + replaceMap(overrides, (copy) => { + if ("clear" in input) copy.delete(tabId); + else copy.set(tabId, { width: input.width, height: input.height }); + }), + ); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( tabId: string, wc: Electron.WebContents, @@ -2641,6 +2665,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: { readonly width: number; readonly height: number } | { readonly clear: true }, ) { const wc = yield* requireWebContents(tabId); + yield* rememberViewportOverride(tabId, input); yield* applyViewportOverride(tabId, wc, input); }); @@ -2649,6 +2674,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: { readonly width: number; readonly height: number } | { readonly clear: true }, ) { const wc = yield* requireWebContents(tabId); + yield* rememberViewportOverride(tabId, input); yield* withControlSession(tabId, wc, "resize", (send) => "clear" in input ? send("Emulation.clearDeviceMetricsOverride") diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 49f3f99c69d6..99f727d285e5 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -506,6 +506,47 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); const setViewport = ready.bridge.automation.setViewport; + const rollbackGuestIfCurrent = async ( + previousSetting: PreviewViewportSetting, + operationServerEpoch: string | null, + ) => { + const latestState = readThreadPreviewState(threadRef); + const latestSetting = + latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + if ( + !shouldRollbackPreviewViewport( + previousSetting, + setting, + latestSetting, + operationServerEpoch, + latestState.serverEpoch, + ) + ) { + return; + } + try { + assertPreviewRuntimeCurrent(threadRef, ready.tabId, ready.runtimeTabId, request); + } catch { + return; + } + try { + await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, previousSetting); + } catch { + // Guest still has the requested override; leave the store matching it. + return; + } + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: previousSetting, + }, + }); + if (rollback._tag !== "Failure") { + updatePreviewServerSnapshot(threadRef, rollback.value); + } + }; const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( threadRef, @@ -530,22 +571,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) try { await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); } catch (error) { - const rollback = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: previousSetting, - }, - }); - if (rollback._tag !== "Failure") { - updatePreviewServerSnapshot(threadRef, rollback.value); - } - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - previousSetting, - ).catch(() => undefined); + await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch); throw error; } return { @@ -571,35 +597,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); } catch (cause) { await runBrowserViewportMutation(ready.runtimeTabId, async () => { - const latestState = readThreadPreviewState(threadRef); - const latestSetting = - latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - if ( - shouldRollbackPreviewViewport( - applied.previousSetting, - setting, - latestSetting, - applied.serverEpoch, - latestState.serverEpoch, - ) - ) { - const rollback = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: applied.previousSetting, - }, - }); - if (rollback._tag !== "Failure") { - updatePreviewServerSnapshot(threadRef, rollback.value); - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - applied.previousSetting, - ).catch(() => undefined); - } - } + await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch); }); throw cause; } From 8ad7221367307b9560d8362ac08362b0d2321838 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:55:18 +0200 Subject: [PATCH 09/20] fix(preview): scale desktop CDP viewport overrides by page zoom setDeviceMetricsOverride is a widget DIP size, so page zoom still divides it. Desktop presets now send setting * zoom so innerWidth matches the toolbar. Phone sizes stay unscaled because mobile emulation pins zoom to 1. --- apps/web/src/browser/HostedBrowserWebview.tsx | 14 +++++--- .../preview/PreviewAutomationHosts.tsx | 14 ++++++-- .../preview/previewGuestViewport.test.ts | 35 +++++++++++++++---- .../preview/previewGuestViewport.ts | 27 +++++++++++--- 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 3608ac2a137e..5c617bbc4978 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -61,6 +61,7 @@ export function HostedBrowserWebview(props: { const wrapperRef = useRef(null); const webviewRef = useRef(null); const guestViewportRef = useRef(viewport); + const guestZoomRef = useRef(1); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( @@ -132,6 +133,7 @@ export function HostedBrowserWebview(props: { setViewport, runtimeTabId, guestViewportRef.current, + guestZoomRef.current, ).catch(() => undefined); } } @@ -169,6 +171,7 @@ export function HostedBrowserWebview(props: { const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + guestZoomRef.current = normalizedZoomFactor; const viewportWidth = viewport._tag === "fill" ? null : viewport.width; const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = @@ -217,12 +220,15 @@ export function HostedBrowserWebview(props: { const setViewport = previewBridge?.setViewport; if (!setViewport || !hasWebContents) return; const frame = window.requestAnimationFrame(() => { - void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( - () => undefined, - ); + void applyPreviewGuestViewport( + setViewport, + runtimeTabId, + guestViewportRef.current, + guestZoomRef.current, + ).catch(() => undefined); }); return () => window.cancelAnimationFrame(frame); - }, [guestViewportKey, hasWebContents, runtimeTabId]); + }, [guestViewportKey, hasWebContents, runtimeTabId, normalizedZoomFactor]); const fittedSourceViewport = presentation.fitSourceContent && lastRect ? resolveFittedBrowserViewport( diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 99f727d285e5..62057deb7f44 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -530,7 +530,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return; } try { - await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, previousSetting); + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + previousSetting, + latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + ); } catch { // Guest still has the requested override; leave the store matching it. return; @@ -569,7 +574,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } updatePreviewServerSnapshot(threadRef, result.value); try { - await applyPreviewGuestViewport(setViewport, ready.runtimeTabId, setting); + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + setting, + operationState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + ); } catch (error) { await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch); throw error; diff --git a/apps/web/src/components/preview/previewGuestViewport.test.ts b/apps/web/src/components/preview/previewGuestViewport.test.ts index 0b2fd30a7fe8..45f4ac9038ec 100644 --- a/apps/web/src/components/preview/previewGuestViewport.test.ts +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -18,6 +18,24 @@ describe("previewGuestViewportOverride", () => { }), ).toEqual({ width: 390, height: 844 }); }); + + it("scales desktop overrides by page zoom so innerWidth matches the toolbar", () => { + expect( + previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 }, 1.25), + ).toEqual({ width: 1280, height: 960 }); + }); + + it("does not scale phone sizes; mobile emulation pins page zoom to 1", () => { + expect( + previewGuestViewportOverride( + { _tag: "preset", presetId: "iphone-12-pro", width: 390, height: 844 }, + 1.25, + ), + ).toEqual({ width: 390, height: 844 }); + expect( + previewGuestViewportOverride({ _tag: "freeform", width: 844, height: 390 }, 1.25), + ).toEqual({ width: 844, height: 390 }); + }); }); describe("applyPreviewGuestViewport", () => { @@ -25,11 +43,16 @@ describe("applyPreviewGuestViewport", () => { await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" }); const setViewport = vi.fn(async () => undefined); - await applyPreviewGuestViewport(setViewport, "tab-1", { - _tag: "freeform", - width: 800, - height: 600, - }); - expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 800, height: 600 }); + await applyPreviewGuestViewport( + setViewport, + "tab-1", + { + _tag: "freeform", + width: 1024, + height: 768, + }, + 1.25, + ); + expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 1280, height: 960 }); }); }); diff --git a/apps/web/src/components/preview/previewGuestViewport.ts b/apps/web/src/components/preview/previewGuestViewport.ts index 26c69912c717..91c86e31976e 100644 --- a/apps/web/src/components/preview/previewGuestViewport.ts +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -9,13 +9,31 @@ export type PreviewGuestViewportApplier = ( input: PreviewGuestViewportOverride, ) => Promise; +/** Keep in sync with PreviewManager.deviceMetricsOverride. */ +const PREVIEW_GUEST_MOBILE_MAX_SHORTEST_SIDE = 768; + +const normalizeZoomFactor = (zoomFactor: number): number => + Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + +/** Shortest side, so landscape phones stay mobile (844x390, not width-only). */ +export function previewGuestViewportIsMobile(width: number, height: number): boolean { + return Math.min(width, height) < PREVIEW_GUEST_MOBILE_MAX_SHORTEST_SIDE; +} + /** Maps a stored viewport setting onto the desktop CDP override. */ export function previewGuestViewportOverride( setting: PreviewViewportSetting, + zoomFactor = 1, ): PreviewGuestViewportOverride { - return setting._tag === "fill" - ? { clear: true } - : { width: setting.width, height: setting.height }; + if (setting._tag === "fill") return { clear: true }; + const zoom = normalizeZoomFactor(zoomFactor); + // The override is a widget DIP size; page zoom still divides it. Mobile + // emulation pins page zoom to 1, so those sizes stay in CSS pixels. + const scale = previewGuestViewportIsMobile(setting.width, setting.height) ? 1 : zoom; + return { + width: Math.max(1, Math.round(setting.width * scale)), + height: Math.max(1, Math.round(setting.height * scale)), + }; } /** Applies or clears the guest CDP metrics override. No-op on older desktops. */ @@ -23,7 +41,8 @@ export async function applyPreviewGuestViewport( setViewport: PreviewGuestViewportApplier | undefined, tabId: string, setting: PreviewViewportSetting, + zoomFactor = 1, ): Promise { if (!setViewport) return; - await setViewport(tabId, previewGuestViewportOverride(setting)); + await setViewport(tabId, previewGuestViewportOverride(setting, zoomFactor)); } From 40779121f69417cb2e6e8aac852191fc0d295bdd Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sun, 23 Aug 2026 09:56:39 +0200 Subject: [PATCH 10/20] fix(preview): keep store and guest matched on resize rollback Apply-failure never put the new override on the guest, so the store still rolls back even if the guest CDP clear throws. Timeout still rolls the guest first and only persists previous if that apply succeeds; a failed store resize puts the guest back on the requested size. --- .../preview/PreviewAutomationHosts.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 62057deb7f44..5274fbe77b86 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -509,6 +509,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const rollbackGuestIfCurrent = async ( previousSetting: PreviewViewportSetting, operationServerEpoch: string | null, + guestHasRequestedOverride: boolean, ) => { const latestState = readThreadPreviewState(threadRef); const latestSetting = @@ -529,16 +530,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } catch { return; } + const zoomFactor = latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1; + let guestRolledBack = !guestHasRequestedOverride; try { await applyPreviewGuestViewport( setViewport, ready.runtimeTabId, previousSetting, - latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, + zoomFactor, ); + guestRolledBack = true; } catch { - // Guest still has the requested override; leave the store matching it. - return; + if (guestHasRequestedOverride) return; } const rollback = await resize({ environmentId, @@ -550,6 +553,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }); if (rollback._tag !== "Failure") { updatePreviewServerSnapshot(threadRef, rollback.value); + return; + } + if (guestHasRequestedOverride && guestRolledBack) { + await applyPreviewGuestViewport( + setViewport, + ready.runtimeTabId, + setting, + zoomFactor, + ).catch(() => undefined); } }; const persistViewport = async () => { @@ -581,7 +593,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) operationState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, ); } catch (error) { - await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch); + await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch, false); throw error; } return { @@ -607,7 +619,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); } catch (cause) { await runBrowserViewportMutation(ready.runtimeTabId, async () => { - await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch); + await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch, true); }); throw cause; } From d75ac835db24b83f2558384195a757213c3e0973 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 18:03:24 -0700 Subject: [PATCH 11/20] fix(preview): make viewport overrides deterministic --- apps/desktop/src/preview/Manager.test.ts | 91 +++++++++-- apps/desktop/src/preview/Manager.ts | 114 ++++++++++---- apps/web/src/browser/HostedBrowserWebview.tsx | 12 +- .../browser/browserViewportActions.test.ts | 42 ++++- .../web/src/browser/browserViewportActions.ts | 32 +++- .../preview/PreviewAutomationHosts.tsx | 144 ++++++++++-------- .../preview/previewGuestViewport.test.ts | 38 ++--- .../preview/previewGuestViewport.ts | 25 +-- .../preview/previewViewportRollback.test.ts | 40 ++++- .../preview/previewViewportRollback.ts | 17 +++ 10 files changed, 376 insertions(+), 179 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 0699d6444d40..9501c33fe46b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1698,7 +1698,7 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("applies a guest viewport override without taking agent control", () => + effectIt.effect("applies logical viewport sizes without mobile emulation or a DPR override", () => withManager((manager) => Effect.gen(function* () { const sendCommand = vi.fn(async () => undefined); @@ -1735,23 +1735,23 @@ describe("PreviewManager", () => { controllers.push(state.controller); }), ); - yield* manager.createTab("tab_viewport"); + yield* manager.createTab("tab_viewport", { zoomFactor: 0.5 }); yield* manager.registerWebview("tab_viewport", 42); - yield* manager.setViewport("tab_viewport", { width: 390, height: 844 }); + yield* manager.setViewport("tab_viewport", { width: 1024, height: 768 }); yield* manager.setViewport("tab_viewport", { width: 844, height: 390 }); yield* manager.setViewport("tab_viewport", { clear: true }); expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { - width: 390, - height: 844, - deviceScaleFactor: 1, - mobile: true, + width: 512, + height: 384, + deviceScaleFactor: 0, + mobile: false, }); expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { - width: 844, - height: 390, - deviceScaleFactor: 1, - mobile: true, + width: 422, + height: 195, + deviceScaleFactor: 0, + mobile: false, }); expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride"); expect(controllers).not.toContain("agent"); @@ -1809,13 +1809,78 @@ describe("PreviewManager", () => { expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, - deviceScaleFactor: 1, - mobile: true, + deviceScaleFactor: 0, + mobile: false, }); }), ), ); + effectIt.effect("restores a newer human viewport after a queued agent resize", () => + withManager((manager) => + Effect.gen(function* () { + const evaluationStarted = Promise.withResolvers(); + const releaseEvaluation = Promise.withResolvers(); + const sendCommand = vi.fn( + async (method: string, params?: Record): Promise => { + if (method === "Runtime.evaluate" && params?.expression === "holdViewportQueue") { + evaluationStarted.resolve(); + return releaseEvaluation.promise; + } + return { result: { value: null } }; + }, + ); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_viewport_order"); + yield* manager.registerWebview("tab_viewport_order", 42); + const evaluation = yield* manager + .automationEvaluate("tab_viewport_order", { expression: "holdViewportQueue" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => evaluationStarted.promise); + const resize = yield* manager + .automationSetViewport("tab_viewport_order", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + yield* manager.setViewport("tab_viewport_order", { width: 1440, height: 900 }); + releaseEvaluation.resolve({ result: { value: null } }); + yield* Fiber.join(evaluation); + yield* Fiber.join(resize); + + const viewportCalls = sendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ); + expect(viewportCalls.at(-1)?.[1]).toMatchObject({ width: 1440, height: 900 }); + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 1459715401d5..ad7ce996df71 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -98,6 +98,14 @@ export interface PreviewTabState { updatedAt: string; } +type PreviewViewportOverride = + | { readonly width: number; readonly height: number } + | { readonly clear: true }; + +interface PreviewViewportIntent { + readonly input: PreviewViewportOverride; +} + /** Discrete zoom levels mirroring Chrome's preset list. */ const ZOOM_LEVELS: ReadonlyArray = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, @@ -574,7 +582,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); const viewportOverridesRef = yield* SynchronizedRef.make< - ReadonlyMap + ReadonlyMap >(new Map()); const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); @@ -2564,7 +2572,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const viewportOverride = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); if (viewportOverride) { - yield* applyViewportOverride(tabId, wc, viewportOverride); + yield* applyViewportOverride(tabId, wc, viewportOverride.input); } }).pipe(Effect.ignore); @@ -2624,61 +2632,101 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - const deviceMetricsOverride = (input: { readonly width: number; readonly height: number }) => ({ - width: input.width, - height: input.height, - deviceScaleFactor: 1, - // Shortest side, so landscape phones stay mobile (844x390, not width-only). - mobile: Math.min(input.width, input.height) < 768, + const deviceMetricsOverride = Effect.fnUntraced(function* ( + tabId: string, + input: { readonly width: number; readonly height: number }, + ) { + const zoomFactor = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.zoomFactor ?? 1; + return { + width: Math.max(1, Math.round(input.width * zoomFactor)), + height: Math.max(1, Math.round(input.height * zoomFactor)), + // Zero leaves Chromium's current device scale factor unchanged. + deviceScaleFactor: 0, + // A fixed CSS viewport is not full mobile-device emulation. Setting this + // true also changes Chromium's layout viewport when the page has no + // viewport meta tag. + mobile: false, + }; }); - const rememberViewportOverride = ( - tabId: string, - input: { readonly width: number; readonly height: number } | { readonly clear: true }, - ) => - SynchronizedRef.update(viewportOverridesRef, (overrides) => - replaceMap(overrides, (copy) => { - if ("clear" in input) copy.delete(tabId); - else copy.set(tabId, { width: input.width, height: input.height }); - }), - ); + const rememberViewportOverride = (tabId: string, input: PreviewViewportOverride) => + SynchronizedRef.modify(viewportOverridesRef, (overrides) => { + const intent: PreviewViewportIntent = { input }; + return [ + intent, + replaceMap(overrides, (copy) => { + copy.set(tabId, intent); + }), + ] as const; + }); const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( tabId: string, wc: Electron.WebContents, - input: { readonly width: number; readonly height: number } | { readonly clear: true }, + input: PreviewViewportOverride, ) { yield* ensureControlSession(wc); + if ("clear" in input) { + yield* attemptPromise( + { operation: "applyViewportOverride", tabId, webContentsId: wc.id }, + () => wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride"), + ); + return; + } + const metrics = yield* deviceMetricsOverride(tabId, input); yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () => - "clear" in input - ? wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride") - : wc.debugger.sendCommand( - "Emulation.setDeviceMetricsOverride", - deviceMetricsOverride(input), - ), + wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", metrics), ); }); + const settleViewportIntent = Effect.fnUntraced(function* ( + tabId: string, + wc: Electron.WebContents, + intent: PreviewViewportIntent, + applyInitial: (input: PreviewViewportOverride) => Effect.Effect, + ) { + const reconcileLatest = Effect.gen(function* () { + let applied = intent; + while (true) { + const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + if (!latest || latest === applied) return; + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (current?.webContentsId !== wc.id || wc.isDestroyed()) return; + yield* applyViewportOverride(tabId, wc, latest.input); + applied = latest; + } + }); + yield* applyInitial(intent.input).pipe(Effect.ensuring(reconcileLatest.pipe(Effect.ignore))); + }); + // Human/toolbar path. Must not take agent control or write a resize action. const setViewport = Effect.fn("PreviewManager.setViewport")(function* ( tabId: string, - input: { readonly width: number; readonly height: number } | { readonly clear: true }, + input: PreviewViewportOverride, ) { const wc = yield* requireWebContents(tabId); - yield* rememberViewportOverride(tabId, input); - yield* applyViewportOverride(tabId, wc, input); + const intent = yield* rememberViewportOverride(tabId, input); + yield* settleViewportIntent(tabId, wc, intent, (latest) => + applyViewportOverride(tabId, wc, latest), + ); }); const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* ( tabId: string, - input: { readonly width: number; readonly height: number } | { readonly clear: true }, + input: PreviewViewportOverride, ) { const wc = yield* requireWebContents(tabId); - yield* rememberViewportOverride(tabId, input); + const intent = yield* rememberViewportOverride(tabId, input); yield* withControlSession(tabId, wc, "resize", (send) => - "clear" in input - ? send("Emulation.clearDeviceMetricsOverride") - : send("Emulation.setDeviceMetricsOverride", deviceMetricsOverride(input)), + settleViewportIntent(tabId, wc, intent, (latest) => { + if ("clear" in latest) { + return send("Emulation.clearDeviceMetricsOverride").pipe(Effect.asVoid); + } + return deviceMetricsOverride(tabId, latest).pipe( + Effect.flatMap((metrics) => send("Emulation.setDeviceMetricsOverride", metrics)), + Effect.asVoid, + ); + }), ); }); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 5c617bbc4978..5fac964d2b66 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -61,7 +61,6 @@ export function HostedBrowserWebview(props: { const wrapperRef = useRef(null); const webviewRef = useRef(null); const guestViewportRef = useRef(viewport); - const guestZoomRef = useRef(1); const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); const presentation = useBrowserSurfaceStore( @@ -133,7 +132,6 @@ export function HostedBrowserWebview(props: { setViewport, runtimeTabId, guestViewportRef.current, - guestZoomRef.current, ).catch(() => undefined); } } @@ -171,7 +169,6 @@ export function HostedBrowserWebview(props: { const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; - guestZoomRef.current = normalizedZoomFactor; const viewportWidth = viewport._tag === "fill" ? null : viewport.width; const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = @@ -220,12 +217,9 @@ export function HostedBrowserWebview(props: { const setViewport = previewBridge?.setViewport; if (!setViewport || !hasWebContents) return; const frame = window.requestAnimationFrame(() => { - void applyPreviewGuestViewport( - setViewport, - runtimeTabId, - guestViewportRef.current, - guestZoomRef.current, - ).catch(() => undefined); + void applyPreviewGuestViewport(setViewport, runtimeTabId, guestViewportRef.current).catch( + () => undefined, + ); }); return () => window.cancelAnimationFrame(frame); }, [guestViewportKey, hasWebContents, runtimeTabId, normalizedZoomFactor]); diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index a7633fd0963d..87da55145535 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -117,16 +117,16 @@ describe("browserViewportActions", () => { const firstResult = expect(first).rejects.toThrow( "Timed out committing the browser viewport for tab tab-timeout", ); - const second = commitBrowserViewportChange("tab-timeout", { - _tag: "freeform", - width: 900, - height: 700, - }); await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); await firstResult; expect(handler).toHaveBeenCalledTimes(1); + const second = commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 900, + height: 700, + }); releaseFirst?.(); await second; @@ -137,4 +137,36 @@ describe("browserViewportActions", () => { vi.useRealTimers(); } }); + + it("expires a queued commit before its handler can write", async () => { + vi.useFakeTimers(); + try { + let releaseBackground: (() => void) | undefined; + const backgroundPending = new Promise((resolve) => { + releaseBackground = resolve; + }); + const background = runBrowserViewportMutation("tab-queued-timeout", () => backgroundPending); + const handler = vi.fn(async () => undefined); + const unsubscribe = subscribeBrowserViewportChange("tab-queued-timeout", handler); + const commit = commitBrowserViewportChange("tab-queued-timeout", { + _tag: "freeform", + width: 900, + height: 700, + }); + const result = expect(commit).rejects.toThrow( + "Timed out committing the browser viewport for tab tab-queued-timeout", + ); + + await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); + await result; + releaseBackground?.(); + await background; + await vi.runAllTimersAsync(); + + expect(handler).not.toHaveBeenCalled(); + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index b80f68af3f00..eef783c0d704 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -2,6 +2,11 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; +interface BrowserViewportMutationDeadline { + readonly deadlineAt: number; + readonly timeoutError: () => Error; +} + export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; export class BrowserViewportCommitTimeoutError extends Error { @@ -46,16 +51,26 @@ const queueBrowserViewportMutation = ( export function runBrowserViewportMutation( tabId: string, mutation: () => Promise, + deadline?: BrowserViewportMutationDeadline, ): Promise { - return queueBrowserViewportMutation(tabId, mutation).execution; + return queueBrowserViewportMutation(tabId, () => { + if (deadline && Date.now() >= deadline.deadlineAt) { + return Promise.reject(deadline.timeoutError()); + } + return mutation(); + }).execution; } -const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise => { +const runHandlerBeforeDeadline = ( + tabId: string, + operation: Promise, + deadlineAt: number, +): Promise => { let timeoutId: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timeoutId = setTimeout( () => reject(new BrowserViewportCommitTimeoutError(tabId)), - BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, + Math.max(0, deadlineAt - Date.now()), ); }); return Promise.race([operation, timeout]).finally(() => { @@ -77,14 +92,15 @@ export function commitBrowserViewportChange( tabId: string, setting: PreviewViewportSetting, ): Promise { - const { started } = queueBrowserViewportMutation(tabId, () => { + const deadlineAt = Date.now() + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS; + const { execution } = queueBrowserViewportMutation(tabId, () => { + if (Date.now() >= deadlineAt) { + return Promise.reject(new BrowserViewportCommitTimeoutError(tabId)); + } const handler = handlers.get(tabId); return handler ? handler(setting) : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); }); - // The queue follows the real handler lifetime, while the caller-facing - // timeout starts only once this commit reaches the front of that queue. - const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); - return result; + return runHandlerBeforeDeadline(tabId, execution, deadlineAt); } diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 5274fbe77b86..e9f566f67ea0 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -76,7 +76,10 @@ import { resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; import { isPreviewViewportReady } from "./previewViewportReadiness"; -import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; +import { + applyPreviewViewportRollback, + shouldRollbackPreviewViewport, +} from "./previewViewportRollback"; const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; @@ -171,6 +174,7 @@ const waitForRenderedViewport = async ( tabId: string, runtimeTabId: string, setting: PreviewViewportSetting, + deadlineAt: number, timeoutMs: number, context: { readonly requestId: PreviewAutomationRequest["requestId"]; @@ -179,8 +183,7 @@ const waitForRenderedViewport = async ( readonly threadId: PreviewAutomationRequest["threadId"]; }, ): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { + while (Date.now() <= deadlineAt) { assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context); try { const webview = findPreviewWebview(runtimeTabId); @@ -211,6 +214,22 @@ const waitForRenderedViewport = async ( }); }; +const runBeforeDeadline = ( + deadlineAt: number, + operation: () => Promise, + timeoutError: () => Error, +): Promise => { + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) return Promise.reject(timeoutError()); + let timeoutId: number | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = window.setTimeout(() => reject(timeoutError()), remainingMs); + }); + return Promise.race([Promise.resolve().then(operation), timeout]).finally(() => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }); +}; + const currentStatus = async ( threadRef: ScopedThreadRef, requestedTabId: string | null, @@ -315,6 +334,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { + const requestDeadlineAt = Date.now() + request.timeoutMs; const threadRef: ScopedThreadRef = { environmentId, threadId: request.threadId, @@ -361,7 +381,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) readyTabId, runtimeTabId, request.operation, - request.timeoutMs, + Math.max(1, requestDeadlineAt - Date.now()), ); return { bridge, @@ -505,11 +525,19 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const ready = await requireReadyTab(); const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); - const setViewport = ready.bridge.automation.setViewport; - const rollbackGuestIfCurrent = async ( + const timeoutMs = input.timeoutMs ?? request.timeoutMs; + const deadlineAt = Math.min(requestDeadlineAt, Date.now() + timeoutMs); + const timeoutError = () => + new PreviewAutomationViewportTimeoutError({ + requestId: request.requestId, + environmentId, + threadId: request.threadId, + tabId: ready.tabId, + timeoutMs, + }); + const rollbackViewportIfCurrent = async ( previousSetting: PreviewViewportSetting, operationServerEpoch: string | null, - guestHasRequestedOverride: boolean, ) => { const latestState = readThreadPreviewState(threadRef); const latestSetting = @@ -530,39 +558,27 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } catch { return; } - const zoomFactor = latestState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1; - let guestRolledBack = !guestHasRequestedOverride; - try { - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - previousSetting, - zoomFactor, - ); - guestRolledBack = true; - } catch { - if (guestHasRequestedOverride) return; - } - const rollback = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: previousSetting, + await applyPreviewViewportRollback({ + previous: previousSetting, + requested: setting, + // This direct path bypasses the agent-control semaphore and + // supersedes any stuck automation CDP command. + applyGuest: (viewport) => + applyPreviewGuestViewport(ready.bridge.setViewport, ready.runtimeTabId, viewport), + rollbackServer: async () => { + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: previousSetting, + }, + }); + if (rollback._tag === "Failure") return false; + updatePreviewServerSnapshot(threadRef, rollback.value); + return true; }, }); - if (rollback._tag !== "Failure") { - updatePreviewServerSnapshot(threadRef, rollback.value); - return; - } - if (guestHasRequestedOverride && guestRolledBack) { - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - setting, - zoomFactor, - ).catch(() => undefined); - } }; const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( @@ -585,31 +601,37 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return raiseAtomCommandFailure(result); } updatePreviewServerSnapshot(threadRef, result.value); - try { - await applyPreviewGuestViewport( - setViewport, - ready.runtimeTabId, - setting, - operationState.desktopByTabId[ready.tabId]?.zoomFactor ?? 1, - ); - } catch (error) { - await rollbackGuestIfCurrent(previousSetting, operationState.serverEpoch, false); - throw error; - } return { previousSetting, serverEpoch: operationState.serverEpoch, }; }; - const applied = await runBrowserViewportMutation(ready.runtimeTabId, persistViewport); - let viewport: PreviewRenderedViewportSize; + const mutationDeadline = { deadlineAt, timeoutError }; + const applied = await runBrowserViewportMutation( + ready.runtimeTabId, + persistViewport, + mutationDeadline, + ); try { - viewport = await waitForRenderedViewport( + // Native CDP can outlive the server request. Keep it outside the + // shared server-mutation queue so toolbar resizes can proceed. + await runBeforeDeadline( + deadlineAt, + () => + applyPreviewGuestViewport( + ready.bridge.automation.setViewport, + ready.runtimeTabId, + setting, + ), + timeoutError, + ); + const viewport = await waitForRenderedViewport( threadRef, ready.tabId, ready.runtimeTabId, setting, - input.timeoutMs ?? request.timeoutMs, + deadlineAt, + timeoutMs, { requestId: request.requestId, operation: request.operation, @@ -617,17 +639,17 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) threadId: request.threadId, }, ); + return { + tabId: ready.tabId, + setting, + viewport, + } satisfies PreviewAutomationResizeResult; } catch (cause) { - await runBrowserViewportMutation(ready.runtimeTabId, async () => { - await rollbackGuestIfCurrent(applied.previousSetting, applied.serverEpoch, true); - }); + await runBrowserViewportMutation(ready.runtimeTabId, () => + rollbackViewportIfCurrent(applied.previousSetting, applied.serverEpoch), + ).catch(() => undefined); throw cause; } - return { - tabId: ready.tabId, - setting, - viewport, - } satisfies PreviewAutomationResizeResult; } case "setColorScheme": { const ready = await requireReadyTab(); diff --git a/apps/web/src/components/preview/previewGuestViewport.test.ts b/apps/web/src/components/preview/previewGuestViewport.test.ts index 45f4ac9038ec..169084953828 100644 --- a/apps/web/src/components/preview/previewGuestViewport.test.ts +++ b/apps/web/src/components/preview/previewGuestViewport.test.ts @@ -19,22 +19,11 @@ describe("previewGuestViewportOverride", () => { ).toEqual({ width: 390, height: 844 }); }); - it("scales desktop overrides by page zoom so innerWidth matches the toolbar", () => { - expect( - previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 }, 1.25), - ).toEqual({ width: 1280, height: 960 }); - }); - - it("does not scale phone sizes; mobile emulation pins page zoom to 1", () => { - expect( - previewGuestViewportOverride( - { _tag: "preset", presetId: "iphone-12-pro", width: 390, height: 844 }, - 1.25, - ), - ).toEqual({ width: 390, height: 844 }); - expect( - previewGuestViewportOverride({ _tag: "freeform", width: 844, height: 390 }, 1.25), - ).toEqual({ width: 844, height: 390 }); + it("leaves page-zoom conversion to the desktop manager", () => { + expect(previewGuestViewportOverride({ _tag: "freeform", width: 1024, height: 768 })).toEqual({ + width: 1024, + height: 768, + }); }); }); @@ -43,16 +32,11 @@ describe("applyPreviewGuestViewport", () => { await applyPreviewGuestViewport(undefined, "tab-1", { _tag: "fill" }); const setViewport = vi.fn(async () => undefined); - await applyPreviewGuestViewport( - setViewport, - "tab-1", - { - _tag: "freeform", - width: 1024, - height: 768, - }, - 1.25, - ); - expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 1280, height: 960 }); + await applyPreviewGuestViewport(setViewport, "tab-1", { + _tag: "freeform", + width: 1024, + height: 768, + }); + expect(setViewport).toHaveBeenCalledWith("tab-1", { width: 1024, height: 768 }); }); }); diff --git a/apps/web/src/components/preview/previewGuestViewport.ts b/apps/web/src/components/preview/previewGuestViewport.ts index 91c86e31976e..ec4b31290efe 100644 --- a/apps/web/src/components/preview/previewGuestViewport.ts +++ b/apps/web/src/components/preview/previewGuestViewport.ts @@ -9,30 +9,14 @@ export type PreviewGuestViewportApplier = ( input: PreviewGuestViewportOverride, ) => Promise; -/** Keep in sync with PreviewManager.deviceMetricsOverride. */ -const PREVIEW_GUEST_MOBILE_MAX_SHORTEST_SIDE = 768; - -const normalizeZoomFactor = (zoomFactor: number): number => - Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; - -/** Shortest side, so landscape phones stay mobile (844x390, not width-only). */ -export function previewGuestViewportIsMobile(width: number, height: number): boolean { - return Math.min(width, height) < PREVIEW_GUEST_MOBILE_MAX_SHORTEST_SIDE; -} - -/** Maps a stored viewport setting onto the desktop CDP override. */ +/** Maps a stored CSS viewport setting onto the desktop bridge input. */ export function previewGuestViewportOverride( setting: PreviewViewportSetting, - zoomFactor = 1, ): PreviewGuestViewportOverride { if (setting._tag === "fill") return { clear: true }; - const zoom = normalizeZoomFactor(zoomFactor); - // The override is a widget DIP size; page zoom still divides it. Mobile - // emulation pins page zoom to 1, so those sizes stay in CSS pixels. - const scale = previewGuestViewportIsMobile(setting.width, setting.height) ? 1 : zoom; return { - width: Math.max(1, Math.round(setting.width * scale)), - height: Math.max(1, Math.round(setting.height * scale)), + width: setting.width, + height: setting.height, }; } @@ -41,8 +25,7 @@ export async function applyPreviewGuestViewport( setViewport: PreviewGuestViewportApplier | undefined, tabId: string, setting: PreviewViewportSetting, - zoomFactor = 1, ): Promise { if (!setViewport) return; - await setViewport(tabId, previewGuestViewportOverride(setting, zoomFactor)); + await setViewport(tabId, previewGuestViewportOverride(setting)); } diff --git a/apps/web/src/components/preview/previewViewportRollback.test.ts b/apps/web/src/components/preview/previewViewportRollback.test.ts index 287e2d87ce2a..70b5821ed2bd 100644 --- a/apps/web/src/components/preview/previewViewportRollback.test.ts +++ b/apps/web/src/components/preview/previewViewportRollback.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; +import { + applyPreviewViewportRollback, + shouldRollbackPreviewViewport, +} from "./previewViewportRollback"; describe("shouldRollbackPreviewViewport", () => { const fill = { _tag: "fill" } as const; @@ -33,4 +36,37 @@ describe("shouldRollbackPreviewViewport", () => { shouldRollbackPreviewViewport(requested, requested, requested, "server-a", "server-a"), ).toBe(false); }); + + it("restores the requested guest size when the server rollback fails", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const applyGuest = vi.fn(async () => undefined); + + await applyPreviewViewportRollback({ + previous, + requested, + applyGuest, + rollbackServer: async () => false, + }); + + expect(applyGuest).toHaveBeenNthCalledWith(1, previous); + expect(applyGuest).toHaveBeenNthCalledWith(2, requested); + }); + + it("restores the requested guest size when the server rollback throws", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const applyGuest = vi.fn(async () => undefined); + + await expect( + applyPreviewViewportRollback({ + previous, + requested, + applyGuest, + rollbackServer: async () => { + throw new Error("rollback unavailable"); + }, + }), + ).rejects.toThrow("rollback unavailable"); + expect(applyGuest).toHaveBeenNthCalledWith(1, previous); + expect(applyGuest).toHaveBeenNthCalledWith(2, requested); + }); }); diff --git a/apps/web/src/components/preview/previewViewportRollback.ts b/apps/web/src/components/preview/previewViewportRollback.ts index c36bf52fd02c..5a45d351a9ac 100644 --- a/apps/web/src/components/preview/previewViewportRollback.ts +++ b/apps/web/src/components/preview/previewViewportRollback.ts @@ -2,6 +2,23 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; +export async function applyPreviewViewportRollback(options: { + readonly previous: PreviewViewportSetting; + readonly requested: PreviewViewportSetting; + readonly applyGuest: (setting: PreviewViewportSetting) => Promise; + readonly rollbackServer: () => Promise; +}): Promise { + void options.applyGuest(options.previous).catch(() => undefined); + let serverRolledBack = false; + try { + serverRolledBack = await options.rollbackServer(); + } finally { + if (!serverRolledBack) { + void options.applyGuest(options.requested).catch(() => undefined); + } + } +} + export function shouldRollbackPreviewViewport( previous: PreviewViewportSetting, requested: PreviewViewportSetting, From 135d602d86da436f563efee913ef1936c3b967a6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 18:14:44 -0700 Subject: [PATCH 12/20] fix(preview): close viewport deadline races --- apps/desktop/src/preview/Manager.test.ts | 74 +++++++++++++++++ apps/desktop/src/preview/Manager.ts | 79 ++++++++++++++----- .../browser/browserViewportActions.test.ts | 37 +++++++++ .../web/src/browser/browserViewportActions.ts | 41 +++++++--- .../preview/PreviewAutomationHosts.tsx | 55 +++++++++++-- .../preview/previewViewportRollback.test.ts | 1 + .../preview/previewViewportRollback.ts | 6 +- 7 files changed, 255 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 9501c33fe46b..230b83b76151 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1881,6 +1881,80 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("drops a viewport intent when its tab closes during native apply", () => + withManager((manager) => + Effect.gen(function* () { + const applyStarted = Promise.withResolvers(); + const releaseApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string) => { + if (method === "Emulation.setDeviceMetricsOverride") { + applyStarted.resolve(); + await releaseApply.promise; + } + }); + const replacementSendCommand = vi.fn(async () => undefined); + const makeWebContents = ( + id: number, + sendCommand: (method: string, params?: Record) => Promise, + ) => + ({ + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }) as never; + const first = makeWebContents(42, firstSendCommand); + const replacement = makeWebContents(43, replacementSendCommand); + fromId.mockImplementation((id) => (id === 42 ? first : id === 43 ? replacement : null)); + + yield* manager.createTab("tab_viewport_close"); + yield* manager.registerWebview("tab_viewport_close", 42); + const setter = yield* manager + .setViewport("tab_viewport_close", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => applyStarted.promise); + yield* manager.closeTab("tab_viewport_close"); + releaseApply.resolve(); + + const exit = yield* Fiber.await(setter); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + }); + } + + yield* manager.createTab("tab_viewport_close"); + yield* manager.registerWebview("tab_viewport_close", 43); + yield* Effect.yieldNow; + expect(replacementSendCommand).not.toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.anything(), + ); + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index ad7ce996df71..9a5b3b72d55c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2660,6 +2660,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); + const prepareViewportIntent = (tabId: string, input: PreviewViewportOverride) => + withTabLifecycleLock( + tabId, + Effect.gen(function* () { + if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const wc = yield* requireWebContents(tabId); + const intent = yield* rememberViewportOverride(tabId, input); + return { + wc, + intent, + generation: tabLifecycleGenerations.get(tabId), + }; + }), + ); + const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* ( tabId: string, wc: Electron.WebContents, @@ -2683,20 +2700,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, wc: Electron.WebContents, intent: PreviewViewportIntent, + generation: number | undefined, applyInitial: (input: PreviewViewportOverride) => Effect.Effect, ) { - const reconcileLatest = Effect.gen(function* () { - let applied = intent; - while (true) { - const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); - if (!latest || latest === applied) return; - const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (current?.webContentsId !== wc.id || wc.isDestroyed()) return; - yield* applyViewportOverride(tabId, wc, latest.input); - applied = latest; - } - }); - yield* applyInitial(intent.input).pipe(Effect.ensuring(reconcileLatest.pipe(Effect.ignore))); + const initialExit = yield* Effect.exit(applyInitial(intent.input)); + const reconcileExit = yield* Effect.exit( + Effect.gen(function* () { + let applied = Exit.isSuccess(initialExit) ? intent : undefined; + let appliedWebContents = Exit.isSuccess(initialExit) ? wc : undefined; + while (true) { + const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + if (!latest || tabLifecycleGenerations.get(tabId) !== generation) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!current) return yield* new PreviewTabNotFoundError({ tabId }); + if (current.webContentsId == null) { + return yield* new PreviewWebviewNotInitializedError({ tabId }); + } + const currentWebContents = webContents.fromId(current.webContentsId); + if (!currentWebContents || currentWebContents.isDestroyed()) { + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: current.webContentsId, + }); + } + if (latest === applied && currentWebContents === appliedWebContents) return; + yield* applyViewportOverride(tabId, currentWebContents, latest.input); + applied = latest; + appliedWebContents = currentWebContents; + } + }), + ); + if (Exit.isFailure(initialExit)) return yield* Effect.failCause(initialExit.cause); + if (Exit.isFailure(reconcileExit)) return yield* Effect.failCause(reconcileExit.cause); }); // Human/toolbar path. Must not take agent control or write a resize action. @@ -2704,10 +2741,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, input: PreviewViewportOverride, ) { - const wc = yield* requireWebContents(tabId); - const intent = yield* rememberViewportOverride(tabId, input); - yield* settleViewportIntent(tabId, wc, intent, (latest) => - applyViewportOverride(tabId, wc, latest), + const prepared = yield* prepareViewportIntent(tabId, input); + yield* settleViewportIntent( + tabId, + prepared.wc, + prepared.intent, + prepared.generation, + (latest) => applyViewportOverride(tabId, prepared.wc, latest), ); }); @@ -2715,10 +2755,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, input: PreviewViewportOverride, ) { - const wc = yield* requireWebContents(tabId); - const intent = yield* rememberViewportOverride(tabId, input); - yield* withControlSession(tabId, wc, "resize", (send) => - settleViewportIntent(tabId, wc, intent, (latest) => { + const prepared = yield* prepareViewportIntent(tabId, input); + yield* withControlSession(tabId, prepared.wc, "resize", (send) => + settleViewportIntent(tabId, prepared.wc, prepared.intent, prepared.generation, (latest) => { if ("clear" in latest) { return send("Emulation.clearDeviceMetricsOverride").pipe(Effect.asVoid); } diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index 87da55145535..e32d5f65778f 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -169,4 +169,41 @@ describe("browserViewportActions", () => { vi.useRealTimers(); } }); + + it("releases the queue when an in-flight background mutation reaches its deadline", async () => { + vi.useFakeTimers(); + try { + let releaseFirst: (() => void) | undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const calls: string[] = []; + const timeoutError = () => new Error("background viewport mutation expired"); + const first = runBrowserViewportMutation( + "tab-background-deadline", + async () => { + calls.push("first"); + await firstPending; + }, + { + deadlineAt: Date.now() + 1_000, + timeoutError, + }, + ); + const firstResult = expect(first).rejects.toThrow("background viewport mutation expired"); + const second = runBrowserViewportMutation("tab-background-deadline", async () => { + calls.push("second"); + }); + + await vi.advanceTimersByTimeAsync(1_000); + await firstResult; + await second; + expect(calls).toEqual(["first", "second"]); + + releaseFirst?.(); + await vi.runAllTimersAsync(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index eef783c0d704..d780f887b40e 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -20,9 +20,26 @@ export class BrowserViewportCommitTimeoutError extends Error { const handlers = new Map(); const commitTails = new Map>(); +const runOperationBeforeDeadline = ( + operation: Promise, + deadline: BrowserViewportMutationDeadline, +): Promise => { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(deadline.timeoutError()), + Math.max(0, deadline.deadlineAt - Date.now()), + ); + }); + return Promise.race([operation, timeout]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); +}; + const queueBrowserViewportMutation = ( tabId: string, start: () => Promise, + deadline?: BrowserViewportMutationDeadline, ): { readonly started: Promise<{ readonly operation: Promise }>; readonly execution: Promise; @@ -31,10 +48,21 @@ const queueBrowserViewportMutation = ( const started = previous .catch(() => undefined) .then(() => ({ - operation: Promise.resolve().then(start), + operation: + deadline && Date.now() >= deadline.deadlineAt + ? Promise.reject(deadline.timeoutError()) + : Promise.resolve().then(start), })); - const execution = started.then(({ operation }) => operation); - const tail = execution.then(() => undefined); + const operation = started.then(({ operation: startedOperation }) => startedOperation); + const execution = deadline ? runOperationBeforeDeadline(operation, deadline) : operation; + // If this mutation reached the front, its deadline releases the queue even + // when the underlying request never settles. A mutation that expires while + // waiting still follows the prior tail and cannot overtake it. + const tail = started + .then(({ operation: startedOperation }) => + deadline ? runOperationBeforeDeadline(startedOperation, deadline) : startedOperation, + ) + .then(() => undefined); commitTails.set(tabId, tail); const clear = () => { if (commitTails.get(tabId) === tail) commitTails.delete(tabId); @@ -53,12 +81,7 @@ export function runBrowserViewportMutation( mutation: () => Promise, deadline?: BrowserViewportMutationDeadline, ): Promise { - return queueBrowserViewportMutation(tabId, () => { - if (deadline && Date.now() >= deadline.deadlineAt) { - return Promise.reject(deadline.timeoutError()); - } - return mutation(); - }).execution; + return queueBrowserViewportMutation(tabId, mutation, deadline).execution; } const runHandlerBeforeDeadline = ( diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index e9f566f67ea0..66a86277a0d1 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -575,11 +575,31 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }, }); if (rollback._tag === "Failure") return false; + const currentState = readThreadPreviewState(threadRef); + const currentSetting = + currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + if ( + !shouldRollbackPreviewViewport( + previousSetting, + setting, + currentSetting, + operationServerEpoch, + currentState.serverEpoch, + ) + ) { + return true; + } updatePreviewServerSnapshot(threadRef, rollback.value); return true; }, }); }; + let rollbackState: + | { + readonly previousSetting: PreviewViewportSetting; + readonly serverEpoch: string | null; + } + | undefined; const persistViewport = async () => { const operationState = assertPreviewRuntimeCurrent( threadRef, @@ -589,6 +609,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); const previousSetting = operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + rollbackState = { + previousSetting, + serverEpoch: operationState.serverEpoch, + }; const result = await resize({ environmentId, input: { @@ -597,6 +621,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) viewport: setting, }, }); + if (Date.now() >= deadlineAt) throw timeoutError(); if (result._tag === "Failure") { return raiseAtomCommandFailure(result); } @@ -607,12 +632,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }; }; const mutationDeadline = { deadlineAt, timeoutError }; - const applied = await runBrowserViewportMutation( - ready.runtimeTabId, - persistViewport, - mutationDeadline, - ); try { + const applied = await runBrowserViewportMutation( + ready.runtimeTabId, + persistViewport, + mutationDeadline, + ); + rollbackState = applied; // Native CDP can outlive the server request. Keep it outside the // shared server-mutation queue so toolbar resizes can proceed. await runBeforeDeadline( @@ -645,9 +671,22 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) viewport, } satisfies PreviewAutomationResizeResult; } catch (cause) { - await runBrowserViewportMutation(ready.runtimeTabId, () => - rollbackViewportIfCurrent(applied.previousSetting, applied.serverEpoch), - ).catch(() => undefined); + const pendingRollback = rollbackState; + if (pendingRollback) { + const rollbackDeadline = { + deadlineAt: Date.now() + timeoutMs, + timeoutError, + }; + void runBrowserViewportMutation( + ready.runtimeTabId, + () => + rollbackViewportIfCurrent( + pendingRollback.previousSetting, + pendingRollback.serverEpoch, + ), + rollbackDeadline, + ).catch(() => undefined); + } throw cause; } } diff --git a/apps/web/src/components/preview/previewViewportRollback.test.ts b/apps/web/src/components/preview/previewViewportRollback.test.ts index 70b5821ed2bd..4b7e54414370 100644 --- a/apps/web/src/components/preview/previewViewportRollback.test.ts +++ b/apps/web/src/components/preview/previewViewportRollback.test.ts @@ -13,6 +13,7 @@ describe("shouldRollbackPreviewViewport", () => { expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-a")).toBe( true, ); + expect(shouldRollbackPreviewViewport(fill, requested, fill, "server-a", "server-a")).toBe(true); }); it("does not overwrite a newer resize, replacement server, or repeated setting", () => { diff --git a/apps/web/src/components/preview/previewViewportRollback.ts b/apps/web/src/components/preview/previewViewportRollback.ts index 5a45d351a9ac..ec751094e6b5 100644 --- a/apps/web/src/components/preview/previewViewportRollback.ts +++ b/apps/web/src/components/preview/previewViewportRollback.ts @@ -27,9 +27,13 @@ export function shouldRollbackPreviewViewport( currentServerEpoch: string | null, ): boolean { const requestedKey = browserViewportSettingKey(requested); + const latestKey = browserViewportSettingKey(latest); + // A persistence call can time out before its successful response updates the + // client snapshot. Re-send the previous value when the snapshot still shows + // it so a late server commit cannot survive the timeout. return ( currentServerEpoch === operationServerEpoch && - browserViewportSettingKey(latest) === requestedKey && + (latestKey === requestedKey || latestKey === browserViewportSettingKey(previous)) && browserViewportSettingKey(previous) !== requestedKey ); } From 9fb1bf4f8e08030db57e67dda3f5b9597f747233 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 18:18:20 -0700 Subject: [PATCH 13/20] fix(preview): reconcile restored viewport intent --- apps/desktop/src/preview/Manager.test.ts | 40 ++++++++++++++----- apps/desktop/src/preview/Manager.ts | 12 ++++-- .../preview/PreviewAutomationHosts.tsx | 12 ++++++ .../preview/previewViewportRollback.test.ts | 16 ++++++++ .../preview/previewViewportRollback.ts | 3 +- 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 230b83b76151..7d9a7cc69fbd 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1759,11 +1759,16 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("re-applies a guest viewport override after a webview swap", () => + effectIt.effect("restores the latest viewport when a webview swap races a newer setting", () => withManager((manager) => Effect.gen(function* () { - const makeWebContents = (id: number) => { - const sendCommand = vi.fn(async () => undefined); + const makeWebContents = ( + id: number, + sendCommand: ( + method: string, + params?: Record, + ) => Promise = vi.fn(async () => undefined), + ) => { return { sendCommand, wc: { @@ -1801,17 +1806,30 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_viewport_restore", 42); yield* manager.setViewport("tab_viewport_restore", { width: 390, height: 844 }); - const replacement = makeWebContents(43); + const restoreStarted = Promise.withResolvers(); + const releaseRestore = Promise.withResolvers(); + const appliedWidths: number[] = []; + let holdRestore = true; + const replacementSendCommand = vi.fn( + async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (holdRestore) { + holdRestore = false; + restoreStarted.resolve(); + await releaseRestore.promise; + } + if (typeof params?.width === "number") appliedWidths.push(params.width); + }, + ); + const replacement = makeWebContents(43, replacementSendCommand); fromId.mockReturnValue(replacement.wc); yield* manager.registerWebview("tab_viewport_restore", 43); - yield* Effect.yieldNow; + yield* Effect.promise(() => restoreStarted.promise); + yield* manager.setViewport("tab_viewport_restore", { width: 1024, height: 768 }); + releaseRestore.resolve(); + yield* settle(() => appliedWidths.at(-1) === 1024 && appliedWidths.length >= 3); - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", { - width: 390, - height: 844, - deviceScaleFactor: 0, - mobile: false, - }); + expect(appliedWidths).toEqual([1024, 390, 1024]); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 9a5b3b72d55c..e49c93ba9257 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2570,9 +2570,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); } - const viewportOverride = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); - if (viewportOverride) { - yield* applyViewportOverride(tabId, wc, viewportOverride.input); + const viewportIntent = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + if (viewportIntent) { + yield* settleViewportIntent( + tabId, + wc, + viewportIntent, + tabLifecycleGenerations.get(tabId), + (input) => applyViewportOverride(tabId, wc, input), + ); } }).pipe(Effect.ignore); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 66a86277a0d1..286e8dd3ff48 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -565,6 +565,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) // supersedes any stuck automation CDP command. applyGuest: (viewport) => applyPreviewGuestViewport(ready.bridge.setViewport, ready.runtimeTabId, viewport), + shouldRestoreRequested: () => { + const currentState = readThreadPreviewState(threadRef); + const currentSetting = + currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + return shouldRollbackPreviewViewport( + previousSetting, + setting, + currentSetting, + operationServerEpoch, + currentState.serverEpoch, + ); + }, rollbackServer: async () => { const rollback = await resize({ environmentId, diff --git a/apps/web/src/components/preview/previewViewportRollback.test.ts b/apps/web/src/components/preview/previewViewportRollback.test.ts index 4b7e54414370..299ae80f4a72 100644 --- a/apps/web/src/components/preview/previewViewportRollback.test.ts +++ b/apps/web/src/components/preview/previewViewportRollback.test.ts @@ -70,4 +70,20 @@ describe("shouldRollbackPreviewViewport", () => { expect(applyGuest).toHaveBeenNthCalledWith(1, previous); expect(applyGuest).toHaveBeenNthCalledWith(2, requested); }); + + it("does not restore the requested guest size after a newer resize", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const applyGuest = vi.fn(async () => undefined); + + await applyPreviewViewportRollback({ + previous, + requested, + applyGuest, + rollbackServer: async () => false, + shouldRestoreRequested: () => false, + }); + + expect(applyGuest).toHaveBeenCalledOnce(); + expect(applyGuest).toHaveBeenCalledWith(previous); + }); }); diff --git a/apps/web/src/components/preview/previewViewportRollback.ts b/apps/web/src/components/preview/previewViewportRollback.ts index ec751094e6b5..ff6e139c5b87 100644 --- a/apps/web/src/components/preview/previewViewportRollback.ts +++ b/apps/web/src/components/preview/previewViewportRollback.ts @@ -7,13 +7,14 @@ export async function applyPreviewViewportRollback(options: { readonly requested: PreviewViewportSetting; readonly applyGuest: (setting: PreviewViewportSetting) => Promise; readonly rollbackServer: () => Promise; + readonly shouldRestoreRequested?: () => boolean; }): Promise { void options.applyGuest(options.previous).catch(() => undefined); let serverRolledBack = false; try { serverRolledBack = await options.rollbackServer(); } finally { - if (!serverRolledBack) { + if (!serverRolledBack && (options.shouldRestoreRequested?.() ?? true)) { void options.applyGuest(options.requested).catch(() => undefined); } } From 4d107fb5d1379570112dac9ae32abe26eebf0905 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 21:58:22 -0700 Subject: [PATCH 14/20] fix(preview): guard viewport rollback writes --- apps/desktop/src/preview/Manager.test.ts | 82 ++++++++ apps/server/src/preview/Manager.test.ts | 84 ++++++++ apps/server/src/preview/Manager.ts | 38 +++- .../preview/PreviewAutomationHosts.tsx | 161 ++++++--------- .../preview/previewAutomationTarget.test.ts | 22 +- .../preview/previewAutomationTarget.ts | 7 +- .../preview/previewViewportRollback.test.ts | 130 +++++++----- .../preview/previewViewportRollback.ts | 63 +++--- apps/web/src/previewStateStore.test.ts | 116 +++++++++++ apps/web/src/previewStateStore.ts | 193 ++++++++++++------ packages/contracts/src/ipc.ts | 3 +- packages/contracts/src/preview.test.ts | 71 +++++++ packages/contracts/src/preview.ts | 40 +++- packages/contracts/src/rpc.ts | 3 +- 14 files changed, 758 insertions(+), 255 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7d9a7cc69fbd..5c70c1449069 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1759,6 +1759,88 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("converts logical viewport sizes at each supported test zoom", () => + withManager((manager) => + Effect.gen(function* () { + const cases = [ + { tabId: "tab_zoom_50", webContentsId: 50, zoomFactor: 0.5, width: 512, height: 384 }, + { tabId: "tab_zoom_80", webContentsId: 80, zoomFactor: 0.8, width: 819, height: 614 }, + { tabId: "tab_zoom_100", webContentsId: 100, zoomFactor: 1, width: 1024, height: 768 }, + { + tabId: "tab_zoom_125", + webContentsId: 125, + zoomFactor: 1.25, + width: 1280, + height: 960, + }, + ] as const; + const commandsById = new Map( + cases.map( + ({ webContentsId }) => + [ + webContentsId, + vi.fn(async (_method: string, _params?: Record) => undefined), + ] as const, + ), + ); + const webContentsById = new Map(); + for (const { webContentsId } of cases) { + webContentsById.set(webContentsId, { + id: webContentsId, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: commandsById.get(webContentsId), + on: vi.fn(), + off: vi.fn(), + }, + }); + } + fromId.mockImplementation( + (id) => (id === undefined ? null : (webContentsById.get(id) ?? null)) as never, + ); + + for (const testCase of cases) { + yield* manager.createTab(testCase.tabId, { zoomFactor: testCase.zoomFactor }); + yield* manager.registerWebview(testCase.tabId, testCase.webContentsId); + yield* manager.automationSetViewport(testCase.tabId, { width: 1024, height: 768 }); + const viewportCalls = commandsById + .get(testCase.webContentsId) + ?.mock.calls.filter(([method]) => method.includes("DeviceMetrics")); + const expectedCall = [ + "Emulation.setDeviceMetricsOverride", + { + width: testCase.width, + height: testCase.height, + deviceScaleFactor: 0, + mobile: false, + }, + ]; + expect(viewportCalls?.length).toBeGreaterThanOrEqual(1); + for (const call of viewportCalls ?? []) { + expect(call).toEqual(expectedCall); + } + } + }), + ), + ); + effectIt.effect("restores the latest viewport when a webview swap races a newer setting", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 8b3dabfa3386..1f0403349cdd 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -232,6 +232,90 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("rejects a stale viewport rollback after the setting changes away and back", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const opened = yield* manager.open({ threadId }); + const requested = { _tag: "freeform" as const, width: 900, height: 600 }; + const intermediate = { _tag: "freeform" as const, width: 1024, height: 768 }; + + const firstWrite = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: requested, + }); + yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: intermediate, + }); + const latestWrite = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: requested, + }); + yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/after-resize", + }); + const firstVersion = firstWrite.stateVersion; + const latestVersion = latestWrite.stateVersion; + expect(firstVersion).toBeDefined(); + expect(latestVersion).toBeDefined(); + expect(firstWrite.previousViewport).toEqual({ _tag: "fill" }); + expect(latestWrite.previousViewport).toEqual(intermediate); + if (!firstVersion || !latestVersion) return; + + const writeEvents = (yield* collector.drain).filter((event) => event.type === "resized"); + expect(firstVersion.revision).toBe(writeEvents[0]?.revision); + expect(latestVersion.revision).toBe(writeEvents[2]?.revision); + const beforeConflict = yield* manager.list({ threadId }); + const error = yield* Effect.flip( + manager.resize({ + threadId, + tabId: opened.tabId, + viewport: { _tag: "fill" }, + expectedStateVersion: firstVersion, + }), + ); + + expect(error).toMatchObject({ + _tag: "PreviewResizeConflictError", + expectedStateVersion: firstVersion, + actualStateVersion: latestVersion, + }); + const afterConflict = yield* manager.list({ threadId }); + expect(afterConflict.revision).toBe(beforeConflict.revision); + expect(afterConflict.sessions[0]?.viewport).toEqual(requested); + expect(yield* collector.drain).toEqual([]); + + const epochError = yield* Effect.flip( + manager.resize({ + threadId, + tabId: opened.tabId, + viewport: { _tag: "fill" }, + expectedStateVersion: { ...latestVersion, serverEpoch: "restarted-server" }, + }), + ); + expect(epochError._tag).toBe("PreviewResizeConflictError"); + expect((yield* manager.list({ threadId })).revision).toBe(beforeConflict.revision); + expect((yield* manager.list({ threadId })).sessions[0]?.viewport).toEqual(requested); + expect(yield* collector.drain).toEqual([]); + + const rollback = yield* manager.resize({ + threadId, + tabId: opened.tabId, + viewport: latestWrite.previousViewport ?? { _tag: "fill" }, + expectedStateVersion: latestVersion, + }); + expect(rollback.viewport).toEqual(intermediate); + expect(rollback.stateVersion?.revision).toBe(beforeConflict.revision + 1); + }), + ); + it.effect("reportStatus emits failed for LoadFailed nav", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 09bbe0a41c76..a80aa9518b21 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -21,6 +21,8 @@ import { type PreviewRefreshInput, type PreviewReportStatusInput, type PreviewResizeInput, + PreviewResizeConflictError, + type PreviewResizeResult, FILL_PREVIEW_VIEWPORT, PreviewSessionLookupError, type PreviewSessionSnapshot, @@ -51,7 +53,7 @@ export class PreviewManager extends Context.Service< readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; readonly resize: ( input: PreviewResizeInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; readonly close: (input: PreviewCloseInput) => Effect.Effect; readonly list: (input: PreviewListInput) => Effect.Effect; @@ -64,6 +66,7 @@ interface PreviewSessionState { readonly threadId: string; readonly tabId: string; readonly snapshot: PreviewSessionSnapshot; + readonly viewportRevision: number; } interface ManagerState { @@ -173,6 +176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { tabId: string, mutator: ( session: PreviewSessionState, + currentRevision: number, ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>, ): Effect.Effect => { type ModifyResult = @@ -187,7 +191,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { state, ] as readonly [ModifyResult, ManagerState]); } - return mutator(session).pipe( + return mutator(session, state.revision).pipe( Effect.flatMap( Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { const revision = emit ? state.revision + 1 : state.revision; @@ -240,6 +244,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { threadId: input.threadId, tabId, snapshot, + viewportRevision: revision, }); yield* PubSub.publish(eventsPubSub, { type: "opened", @@ -343,15 +348,38 @@ export const make = Effect.gen(function* PreviewManagerMake() { return yield* mutateExistingSession( input.threadId, input.tabId, - Effect.fn("PreviewManager.resizeSession")(function* (session) { + Effect.fn("PreviewManager.resizeSession")(function* (session, currentRevision) { + const actualStateVersion = { + serverEpoch, + revision: session.viewportRevision, + } as const; + if ( + input.expectedStateVersion && + (input.expectedStateVersion.serverEpoch !== actualStateVersion.serverEpoch || + input.expectedStateVersion.revision !== actualStateVersion.revision) + ) { + return yield* new PreviewResizeConflictError({ + threadId: input.threadId, + tabId: input.tabId, + expectedStateVersion: input.expectedStateVersion, + actualStateVersion, + }); + } const updatedAt = yield* currentIsoTimestamp; + const revision = currentRevision + 1; + const previousViewport = session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT; const snapshot: PreviewSessionSnapshot = { ...session.snapshot, viewport: input.viewport, updatedAt, }; + const result: PreviewResizeResult = { + ...snapshot, + stateVersion: { serverEpoch, revision }, + previousViewport, + }; return { - next: { ...session, snapshot }, + next: { ...session, snapshot, viewportRevision: revision }, emit: { type: "resized", threadId: session.threadId, @@ -359,7 +387,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { createdAt: snapshot.updatedAt, snapshot, }, - result: snapshot, + result, }; }), ); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 286e8dd3ff48..b9a46c52f603 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -31,6 +31,7 @@ import { } from "~/previewStateStore"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; import { readActiveBrowserRecordingTargets, startBrowserRecording, @@ -78,7 +79,8 @@ import { import { isPreviewViewportReady } from "./previewViewportReadiness"; import { applyPreviewViewportRollback, - shouldRollbackPreviewViewport, + createPreviewViewportRollbackState, + type PreviewViewportRollbackState, } from "./previewViewportRollback"; const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; @@ -536,23 +538,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) timeoutMs, }); const rollbackViewportIfCurrent = async ( - previousSetting: PreviewViewportSetting, - operationServerEpoch: string | null, + rollbackState: PreviewViewportRollbackState, ) => { - const latestState = readThreadPreviewState(threadRef); - const latestSetting = - latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - if ( - !shouldRollbackPreviewViewport( - previousSetting, - setting, - latestSetting, - operationServerEpoch, - latestState.serverEpoch, - ) - ) { - return; - } + const { previousSetting } = rollbackState; try { assertPreviewRuntimeCurrent(threadRef, ready.tabId, ready.runtimeTabId, request); } catch { @@ -560,97 +548,74 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } await applyPreviewViewportRollback({ previous: previousSetting, - requested: setting, // This direct path bypasses the agent-control semaphore and // supersedes any stuck automation CDP command. applyGuest: (viewport) => applyPreviewGuestViewport(ready.bridge.setViewport, ready.runtimeTabId, viewport), - shouldRestoreRequested: () => { - const currentState = readThreadPreviewState(threadRef); - const currentSetting = - currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - return shouldRollbackPreviewViewport( - previousSetting, - setting, - currentSetting, - operationServerEpoch, - currentState.serverEpoch, - ); - }, rollbackServer: async () => { const rollback = await resize({ environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: previousSetting, - }, + input: rollbackState.input, }); if (rollback._tag === "Failure") return false; + updatePreviewServerSnapshot(threadRef, rollback.value); + const rollbackVersion = rollback.value.stateVersion; const currentState = readThreadPreviewState(threadRef); - const currentSetting = + const currentViewport = currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - if ( - !shouldRollbackPreviewViewport( - previousSetting, - setting, - currentSetting, - operationServerEpoch, - currentState.serverEpoch, - ) - ) { - return true; - } - updatePreviewServerSnapshot(threadRef, rollback.value); - return true; + return ( + rollbackVersion !== undefined && + currentState.serverEpoch === rollbackVersion.serverEpoch && + currentState.serverRevisionByTabId[ready.tabId] === rollbackVersion.revision && + browserViewportSettingKey(currentViewport) === + browserViewportSettingKey(previousSetting) + ); }, }); }; - let rollbackState: - | { - readonly previousSetting: PreviewViewportSetting; - readonly serverEpoch: string | null; - } - | undefined; + type RollbackState = PreviewViewportRollbackState | undefined; + let resolveRollbackState = (_state: RollbackState): void => undefined; + const rollbackStateReady = new Promise((resolve) => { + resolveRollbackState = resolve; + }); + let persistenceStarted = false; const persistViewport = async () => { - const operationState = assertPreviewRuntimeCurrent( - threadRef, - ready.tabId, - ready.runtimeTabId, - request, - ); - const previousSetting = - operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; - rollbackState = { - previousSetting, - serverEpoch: operationState.serverEpoch, - }; - const result = await resize({ - environmentId, - input: { + persistenceStarted = true; + try { + assertPreviewRuntimeCurrent(threadRef, ready.tabId, ready.runtimeTabId, request); + const result = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: setting, + }, + }); + if (result._tag === "Failure") { + resolveRollbackState(undefined); + return raiseAtomCommandFailure(result); + } + const applied = createPreviewViewportRollbackState({ + result: result.value, threadId: request.threadId, tabId: ready.tabId, - viewport: setting, - }, - }); - if (Date.now() >= deadlineAt) throw timeoutError(); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); + }); + resolveRollbackState(applied); + if (Date.now() >= deadlineAt) throw timeoutError(); + updatePreviewServerSnapshot(threadRef, result.value); + return applied; + } catch (error) { + resolveRollbackState(undefined); + throw error; } - updatePreviewServerSnapshot(threadRef, result.value); - return { - previousSetting, - serverEpoch: operationState.serverEpoch, - }; }; const mutationDeadline = { deadlineAt, timeoutError }; try { - const applied = await runBrowserViewportMutation( + await runBrowserViewportMutation( ready.runtimeTabId, persistViewport, mutationDeadline, ); - rollbackState = applied; // Native CDP can outlive the server request. Keep it outside the // shared server-mutation queue so toolbar resizes can proceed. await runBeforeDeadline( @@ -683,22 +648,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) viewport, } satisfies PreviewAutomationResizeResult; } catch (cause) { - const pendingRollback = rollbackState; - if (pendingRollback) { - const rollbackDeadline = { - deadlineAt: Date.now() + timeoutMs, - timeoutError, - }; - void runBrowserViewportMutation( - ready.runtimeTabId, - () => - rollbackViewportIfCurrent( - pendingRollback.previousSetting, - pendingRollback.serverEpoch, - ), - rollbackDeadline, - ).catch(() => undefined); - } + if (!persistenceStarted) resolveRollbackState(undefined); + void rollbackStateReady + .then((pendingRollback) => { + if (!pendingRollback) return; + return runBrowserViewportMutation( + ready.runtimeTabId, + () => rollbackViewportIfCurrent(pendingRollback), + { + deadlineAt: Date.now() + timeoutMs, + timeoutError, + }, + ); + }) + .catch(() => undefined); throw cause; } } diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts index 11a70951b8f2..2580fbf34fd3 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.test.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.test.ts @@ -21,7 +21,7 @@ describe("preview automation target selection", () => { const active = snapshot("tab-active"); expect( needsPreviewAutomationSessionSync( - { snapshot: active, sessions: { [active.tabId]: active } }, + { snapshot: active, sessions: { [active.tabId]: active }, serverEpoch: "server-a" }, undefined, ), ).toBe(true); @@ -29,11 +29,29 @@ describe("preview automation target selection", () => { it("refreshes an explicit tab only when it is absent locally", () => { const active = snapshot("tab-active"); - const state = { snapshot: active, sessions: { [active.tabId]: active } }; + const state = { + snapshot: active, + sessions: { [active.tabId]: active }, + serverEpoch: "server-a", + }; expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false); expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true); }); + it("refreshes an explicit local tab when the server epoch is unknown", () => { + const active = snapshot("tab-active"); + expect( + needsPreviewAutomationSessionSync( + { + snapshot: active, + sessions: { [active.tabId]: active }, + serverEpoch: null, + }, + active.tabId, + ), + ).toBe(true); + }); + it("does not report the active tab under an unknown requested tab id", () => { const active = snapshot("tab-active"); expect( diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts index f1858f87d18c..28b4bc60b4ff 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.ts @@ -5,11 +5,16 @@ interface PreviewAutomationSessionIndex { readonly sessions: Readonly>; } +interface PreviewAutomationSyncState extends PreviewAutomationSessionIndex { + readonly serverEpoch: string | null; +} + export function needsPreviewAutomationSessionSync( - state: PreviewAutomationSessionIndex, + state: PreviewAutomationSyncState, requestedTabId: string | undefined, ): boolean { return ( + state.serverEpoch === null || Object.keys(state.sessions).length === 0 || requestedTabId === undefined || state.sessions[requestedTabId] === undefined diff --git a/apps/web/src/components/preview/previewViewportRollback.test.ts b/apps/web/src/components/preview/previewViewportRollback.test.ts index 299ae80f4a72..95db4176b6f9 100644 --- a/apps/web/src/components/preview/previewViewportRollback.test.ts +++ b/apps/web/src/components/preview/previewViewportRollback.test.ts @@ -1,89 +1,117 @@ +import { ThreadId, type PreviewResizeResult } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyPreviewViewportRollback, - shouldRollbackPreviewViewport, + createPreviewViewportRollbackState, } from "./previewViewportRollback"; -describe("shouldRollbackPreviewViewport", () => { - const fill = { _tag: "fill" } as const; +describe("preview viewport rollback", () => { const requested = { _tag: "freeform", width: 900, height: 600 } as const; - it("rolls back a timed-out request that still owns the latest setting", () => { - expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-a")).toBe( - true, - ); - expect(shouldRollbackPreviewViewport(fill, requested, fill, "server-a", "server-a")).toBe(true); + it("uses the server predecessor and write version for a guarded rollback", () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const stateVersion = { serverEpoch: "server-a", revision: 4 } as const; + const rollback = createPreviewViewportRollbackState({ + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + result: { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport: requested, + updatedAt: "2026-01-01T00:00:00.000Z", + stateVersion, + previousViewport: previous, + }, + }); + + expect(rollback).toEqual({ + previousSetting: previous, + stateVersion, + input: { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + viewport: previous, + expectedStateVersion: stateVersion, + }, + }); + expect(rollback?.input.expectedStateVersion).toBe(stateVersion); }); - it("does not overwrite a newer resize, replacement server, or repeated setting", () => { - expect( - shouldRollbackPreviewViewport( - fill, - requested, - { - _tag: "freeform", - width: 1024, - height: 768, - }, - "server-a", - "server-a", - ), - ).toBe(false); - expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-b")).toBe( - false, - ); + it("skips rollback unless the server returns both write fields", () => { + const result = { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + navStatus: { _tag: "Idle" as const }, + canGoBack: false, + canGoForward: false, + viewport: requested, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const create = (resizeResult: PreviewResizeResult) => + createPreviewViewportRollbackState({ + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + result: resizeResult, + }); + + expect(create(result)).toBeUndefined(); expect( - shouldRollbackPreviewViewport(requested, requested, requested, "server-a", "server-a"), - ).toBe(false); + create({ ...result, stateVersion: { serverEpoch: "server-a", revision: 2 } }), + ).toBeUndefined(); + expect(create({ ...result, previousViewport: { _tag: "fill" } })).toBeUndefined(); + }); + + it("changes the guest only after the guarded server rollback succeeds", async () => { + const previous = { _tag: "freeform", width: 800, height: 600 } as const; + const order: string[] = []; + const applyGuest = vi.fn(async () => { + order.push("guest"); + }); + + await applyPreviewViewportRollback({ + previous, + applyGuest, + rollbackServer: async () => { + order.push("server"); + return true; + }, + }); + + expect(order).toEqual(["server", "guest"]); + expect(applyGuest).toHaveBeenCalledOnce(); + expect(applyGuest).toHaveBeenCalledWith(previous); }); - it("restores the requested guest size when the server rollback fails", async () => { + it("leaves the guest unchanged when the guarded rollback conflicts", async () => { const previous = { _tag: "freeform", width: 800, height: 600 } as const; const applyGuest = vi.fn(async () => undefined); await applyPreviewViewportRollback({ previous, - requested, applyGuest, rollbackServer: async () => false, }); - expect(applyGuest).toHaveBeenNthCalledWith(1, previous); - expect(applyGuest).toHaveBeenNthCalledWith(2, requested); + expect(applyGuest).not.toHaveBeenCalled(); }); - it("restores the requested guest size when the server rollback throws", async () => { + it("leaves the guest unchanged when the server rollback throws", async () => { const previous = { _tag: "freeform", width: 800, height: 600 } as const; const applyGuest = vi.fn(async () => undefined); await expect( applyPreviewViewportRollback({ previous, - requested, applyGuest, rollbackServer: async () => { throw new Error("rollback unavailable"); }, }), ).rejects.toThrow("rollback unavailable"); - expect(applyGuest).toHaveBeenNthCalledWith(1, previous); - expect(applyGuest).toHaveBeenNthCalledWith(2, requested); - }); - - it("does not restore the requested guest size after a newer resize", async () => { - const previous = { _tag: "freeform", width: 800, height: 600 } as const; - const applyGuest = vi.fn(async () => undefined); - - await applyPreviewViewportRollback({ - previous, - requested, - applyGuest, - rollbackServer: async () => false, - shouldRestoreRequested: () => false, - }); - - expect(applyGuest).toHaveBeenCalledOnce(); - expect(applyGuest).toHaveBeenCalledWith(previous); + expect(applyGuest).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/preview/previewViewportRollback.ts b/apps/web/src/components/preview/previewViewportRollback.ts index ff6e139c5b87..40599ac50dce 100644 --- a/apps/web/src/components/preview/previewViewportRollback.ts +++ b/apps/web/src/components/preview/previewViewportRollback.ts @@ -1,40 +1,41 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; +import type { + PreviewResizeInput, + PreviewResizeResult, + PreviewStateVersion, + PreviewViewportSetting, +} from "@t3tools/contracts"; -import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; +export interface PreviewViewportRollbackState { + readonly previousSetting: PreviewViewportSetting; + readonly stateVersion: PreviewStateVersion; + readonly input: PreviewResizeInput; +} + +export function createPreviewViewportRollbackState(options: { + readonly result: PreviewResizeResult; + readonly threadId: PreviewResizeInput["threadId"]; + readonly tabId: PreviewResizeInput["tabId"]; +}): PreviewViewportRollbackState | undefined { + const stateVersion = options.result.stateVersion; + const previousSetting = options.result.previousViewport; + if (!stateVersion || !previousSetting) return undefined; + return { + previousSetting, + stateVersion, + input: { + threadId: options.threadId, + tabId: options.tabId, + viewport: previousSetting, + expectedStateVersion: stateVersion, + }, + }; +} export async function applyPreviewViewportRollback(options: { readonly previous: PreviewViewportSetting; - readonly requested: PreviewViewportSetting; readonly applyGuest: (setting: PreviewViewportSetting) => Promise; readonly rollbackServer: () => Promise; - readonly shouldRestoreRequested?: () => boolean; }): Promise { + if (!(await options.rollbackServer())) return; void options.applyGuest(options.previous).catch(() => undefined); - let serverRolledBack = false; - try { - serverRolledBack = await options.rollbackServer(); - } finally { - if (!serverRolledBack && (options.shouldRestoreRequested?.() ?? true)) { - void options.applyGuest(options.requested).catch(() => undefined); - } - } -} - -export function shouldRollbackPreviewViewport( - previous: PreviewViewportSetting, - requested: PreviewViewportSetting, - latest: PreviewViewportSetting, - operationServerEpoch: string | null, - currentServerEpoch: string | null, -): boolean { - const requestedKey = browserViewportSettingKey(requested); - const latestKey = browserViewportSettingKey(latest); - // A persistence call can time out before its successful response updates the - // client snapshot. Re-send the previous value when the snapshot still shows - // it so a late server commit cannot survive the timeout. - return ( - currentServerEpoch === operationServerEpoch && - (latestKey === requestedKey || latestKey === browserViewportSettingKey(previous)) && - browserViewportSettingKey(previous) !== requestedKey - ); } diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 321dd68c09aa..d866f94751dc 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -412,6 +412,122 @@ describe("previewStateStore (single-tab)", () => { expect(state.sessions[background.tabId]).toEqual(resized); }); + it("orders resize responses with their server write version", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const currentSnapshot = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + const current = { + ...currentSnapshot, + stateVersion: { serverEpoch, revision: 3 }, + previousViewport: { _tag: "fill" as const }, + }; + updatePreviewServerSnapshot(ref, current); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:03.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(currentSnapshot); + expect(state.serverEpoch).toBe(serverEpoch); + expect(state.serverRevision).toBe(1); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); + }); + + it("does not let one tab's resize response skip earlier events for other tabs", () => { + const first = makeSnapshot({ tabId: "tab_a" }); + const second = makeSnapshot({ tabId: "tab_b" }); + reconcilePreviewServerSessions(ref, { + sessions: [first, second], + serverEpoch, + revision: 3, + }); + const resizedSecond = { + ...second, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + updatePreviewServerSnapshot(ref, { + ...resizedSecond, + stateVersion: { serverEpoch, revision: 6 }, + }); + + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: second.tabId, + createdAt: "2026-01-01T00:00:03.000Z", + serverEpoch, + revision: 4, + snapshot: { + ...second, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + applyPreviewServerEventImpl(ref, { + type: "closed", + threadId: "thread-1", + tabId: first.tabId, + createdAt: "2026-01-01T00:00:04.000Z", + serverEpoch, + revision: 5, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({ [second.tabId]: resizedSecond }); + expect(state.serverRevision).toBe(5); + expect(state.serverRevisionByTabId).toMatchObject({ + [first.tabId]: 5, + [second.tabId]: 6, + }); + }); + + it("keeps a tab-specific resize newer than an in-flight list response", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 3, + }); + const resized = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + updatePreviewServerSnapshot(ref, { + ...resized, + stateVersion: { serverEpoch, revision: 6 }, + }); + + reconcilePreviewServerSessions(ref, { + sessions: [ + { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + ], + serverEpoch, + revision: 5, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(resized); + expect(state.serverRevision).toBe(5); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(6); + }); + it("reconciles an authoritative session list without focusing a background tab", () => { const active = makeSnapshot({ tabId: "tab_a" }); const stale = makeSnapshot({ diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index eb1052feeb87..0d4064b08c99 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -12,6 +12,7 @@ import { type DesktopPreviewFavicon, type PreviewEvent, type PreviewListResult, + type PreviewResizeResult, type PreviewSessionSnapshot, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -47,6 +48,8 @@ export interface ThreadPreviewState { serverEpoch: string | null; /** Latest ordered server revision applied from a list response or event. */ serverRevision: number; + /** Latest server revision that established each tab's state. */ + serverRevisionByTabId: Readonly>; } const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ @@ -59,6 +62,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ recentlySeenUrls: [] as string[], serverEpoch: null, serverRevision: 0, + serverRevisionByTabId: {}, }); const emptyPreviewStateAtom = Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( @@ -190,65 +194,76 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; if (event.revision < current.serverRevision) return current; - const next = (() => { - switch (event.type) { - case "opened": - case "navigated": - case "resized": { - const snapshot = event.snapshot; - if (current.suppressedTabIds.has(snapshot.tabId)) return current; - const recentlySeenUrls = - snapshot.navStatus._tag === "Idle" - ? current.recentlySeenUrls - : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); - const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; - const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; - const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; - return { - ...current, - sessions, - activeTabId: activeTabId ?? snapshot.tabId, - snapshot: activeSnapshot, - desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, - recentlySeenUrls, - }; - } - case "failed": { - const existing = current.sessions[event.tabId]; - if (!existing) return current; - const failedSnapshot = { - ...existing, - navStatus: { - _tag: "LoadFailed" as const, - url: event.url, - title: event.title, - code: event.code, - description: event.description, - }, - updatedAt: event.createdAt, - }; - const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; - return { - ...current, - sessions, - snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, - }; - } - case "closed": { - const closed = removeSession(current, event.tabId); - if (!closed.suppressedTabIds.has(event.tabId)) return closed; - const suppressedTabIds = new Set(closed.suppressedTabIds); - suppressedTabIds.delete(event.tabId); - return { ...closed, suppressedTabIds }; - } - } - })(); - return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch + const tabRevision = current.serverRevisionByTabId[event.tabId] ?? 0; + const next = + event.revision < tabRevision + ? current + : (() => { + switch (event.type) { + case "opened": + case "navigated": + case "resized": { + const snapshot = event.snapshot; + if (current.suppressedTabIds.has(snapshot.tabId)) return current; + const recentlySeenUrls = + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; + const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; + return { + ...current, + sessions, + activeTabId: activeTabId ?? snapshot.tabId, + snapshot: activeSnapshot, + desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, + recentlySeenUrls, + }; + } + case "failed": { + const existing = current.sessions[event.tabId]; + if (!existing) return current; + const failedSnapshot = { + ...existing, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }; + const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; + return { + ...current, + sessions, + snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, + }; + } + case "closed": { + const closed = removeSession(current, event.tabId); + if (!closed.suppressedTabIds.has(event.tabId)) return closed; + const suppressedTabIds = new Set(closed.suppressedTabIds); + suppressedTabIds.delete(event.tabId); + return { ...closed, suppressedTabIds }; + } + } + })(); + const serverRevisionByTabId = + event.revision > tabRevision + ? { ...current.serverRevisionByTabId, [event.tabId]: event.revision } + : current.serverRevisionByTabId; + return next.serverRevision === event.revision && + next.serverEpoch === event.serverEpoch && + next.serverRevisionByTabId === serverRevisionByTabId ? next : { ...next, serverEpoch: event.serverEpoch, serverRevision: event.revision, + serverRevisionByTabId, }; }); } @@ -292,23 +307,42 @@ export function applyPreviewServerSnapshot( */ export function updatePreviewServerSnapshot( ref: ScopedThreadRef, - snapshot: PreviewSessionSnapshot, + snapshot: PreviewResizeResult, ): void { updateThreadPreviewState(ref, (current) => { - if (current.suppressedTabIds.has(snapshot.tabId)) return current; - const existing = current.sessions[snapshot.tabId]; - if (existing && existing.updatedAt > snapshot.updatedAt) return current; - const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const stateVersion = snapshot.stateVersion; + if (stateVersion) { + if (current.serverEpoch !== null && stateVersion.serverEpoch !== current.serverEpoch) { + return current; + } + const tabRevision = current.serverRevisionByTabId[snapshot.tabId] ?? 0; + if (stateVersion.revision < tabRevision) return current; + } + const { + stateVersion: _stateVersion, + previousViewport: _previousViewport, + ...sessionSnapshot + } = snapshot; + if (current.suppressedTabIds.has(sessionSnapshot.tabId)) return current; + const existing = current.sessions[sessionSnapshot.tabId]; + if (existing && existing.updatedAt > sessionSnapshot.updatedAt) return current; + const sessions = { ...current.sessions, [sessionSnapshot.tabId]: sessionSnapshot }; const activeTabId = - current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId; - const activeSnapshot = sessions[activeTabId] ?? snapshot; + current.activeTabId && sessions[current.activeTabId] + ? current.activeTabId + : sessionSnapshot.tabId; + const activeSnapshot = sessions[activeTabId] ?? sessionSnapshot; return { ...current, sessions, activeTabId, snapshot: activeSnapshot, desktopOverlay: current.desktopByTabId[activeTabId] ?? null, - recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot), + recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, sessionSnapshot), + serverEpoch: stateVersion?.serverEpoch ?? current.serverEpoch, + serverRevisionByTabId: stateVersion + ? { ...current.serverRevisionByTabId, [sessionSnapshot.tabId]: stateVersion.revision } + : current.serverRevisionByTabId, }; }); } @@ -328,14 +362,33 @@ export function reconcilePreviewServerSessions( const snapshots = result.sessions; const sessions: Record = {}; const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set(); + const listedTabIds = new Set(snapshots.map((snapshot) => snapshot.tabId)); let recentlySeenUrls = current.recentlySeenUrls; for (const snapshot of snapshots) { if (currentSuppressedTabIds.has(snapshot.tabId)) continue; const existing = sameServer ? current.sessions[snapshot.tabId] : undefined; - const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot; + const hasNewerTabState = + sameServer && (current.serverRevisionByTabId[snapshot.tabId] ?? 0) > result.revision; + if (hasNewerTabState && !existing) continue; + const next = + existing && (hasNewerTabState || existing.updatedAt > snapshot.updatedAt) + ? existing + : snapshot; sessions[next.tabId] = next; recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next); } + if (sameServer) { + for (const [tabId, existing] of Object.entries(current.sessions)) { + if ( + !listedTabIds.has(tabId) && + !currentSuppressedTabIds.has(tabId) && + (current.serverRevisionByTabId[tabId] ?? 0) > result.revision + ) { + sessions[tabId] = existing; + recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, existing); + } + } + } const fallback = latestSnapshot(sessions); const activeTabId = @@ -353,6 +406,19 @@ export function reconcilePreviewServerSessions( snapshots.some((snapshot) => snapshot.tabId === tabId), ), ); + const knownTabIds = sameServer + ? new Set([ + ...Object.keys(current.serverRevisionByTabId), + ...Object.keys(current.sessions), + ...Object.keys(sessions), + ]) + : new Set(Object.keys(sessions)); + const serverRevisionByTabId = Object.fromEntries( + [...knownTabIds].map((tabId) => [ + tabId, + Math.max(sameServer ? (current.serverRevisionByTabId[tabId] ?? 0) : 0, result.revision), + ]), + ); return { ...current, sessions, @@ -364,6 +430,7 @@ export function reconcilePreviewServerSessions( recentlySeenUrls, serverEpoch: result.serverEpoch, serverRevision: result.revision, + serverRevisionByTabId, }; }); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 31a179cff56c..40f1a2e24a51 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -60,6 +60,7 @@ import type { PreviewRefreshInput, PreviewReportStatusInput, PreviewResizeInput, + PreviewResizeResult, PreviewSessionSnapshot, } from "./preview.ts"; import { @@ -1374,7 +1375,7 @@ export interface EnvironmentApi { preview: { open: (input: typeof PreviewOpenInput.Encoded) => Promise; navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise; - resize: (input: typeof PreviewResizeInput.Encoded) => Promise; + resize: (input: typeof PreviewResizeInput.Encoded) => Promise; refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise; close: (input: typeof PreviewCloseInput.Encoded) => Promise; list: (input: typeof PreviewListInput.Encoded) => Promise; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index b9306b4e4996..c11da581c1e0 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -6,8 +6,11 @@ import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, DiscoveredLocalServer, PREVIEW_URL_MAX_LENGTH, + PreviewError, PreviewEvent, PreviewNavStatus, + PreviewResizeInput, + PreviewResizeResult, PreviewSessionSnapshot, PreviewViewportSetting, } from "./preview.ts"; @@ -23,6 +26,10 @@ import { const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); +const decodePreviewError = Schema.decodeUnknownSync(PreviewError); +const decodePreviewResizeInput = Schema.decodeUnknownSync(PreviewResizeInput); +const decodePreviewResizeResult = Schema.decodeUnknownSync(PreviewResizeResult); +const encodePreviewResizeResult = Schema.encodeSync(PreviewResizeResult); const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls); @@ -126,6 +133,70 @@ describe("PreviewViewportSetting", () => { }); }); +describe("PreviewResizeInput and PreviewResizeResult", () => { + const snapshot = { + threadId: "thread-1", + tabId: "preview-thread-1", + navStatus: { _tag: "Idle" as const }, + canGoBack: false, + canGoForward: false, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + + it("accepts old resize messages and guarded rollback messages", () => { + expect( + decodePreviewResizeInput({ + threadId: "thread-1", + tabId: "preview-thread-1", + viewport: { _tag: "fill" }, + }), + ).not.toHaveProperty("expectedStateVersion"); + expect( + decodePreviewResizeInput({ + threadId: "thread-1", + tabId: "preview-thread-1", + viewport: { _tag: "fill" }, + expectedStateVersion: { serverEpoch: "server-a", revision: 2 }, + }).expectedStateVersion, + ).toEqual({ serverEpoch: "server-a", revision: 2 }); + }); + + it("decodes old results and exposes the write version from new servers", () => { + expect(decodePreviewResizeResult(snapshot)).not.toHaveProperty("stateVersion"); + expect( + decodePreviewResizeResult({ + ...snapshot, + stateVersion: { serverEpoch: "server-a", revision: 2 }, + previousViewport: { _tag: "fill" }, + }), + ).toMatchObject({ + stateVersion: { serverEpoch: "server-a", revision: 2 }, + previousViewport: { _tag: "fill" }, + }); + }); + + it("keeps new resize results readable as old session snapshots", () => { + const encoded = encodePreviewResizeResult({ + ...snapshot, + stateVersion: { serverEpoch: "server-a", revision: 2 }, + previousViewport: { _tag: "fill" }, + }); + expect(decodeSnapshot(encoded)).toMatchObject(snapshot); + }); + + it("decodes a typed viewport write conflict", () => { + const error = decodePreviewError({ + _tag: "PreviewResizeConflictError", + threadId: "thread-1", + tabId: "preview-thread-1", + expectedStateVersion: { serverEpoch: "server-a", revision: 2 }, + actualStateVersion: { serverEpoch: "server-a", revision: 4 }, + }); + expect(error._tag).toBe("PreviewResizeConflictError"); + }); +}); + describe("PreviewAutomationResizeInput", () => { it("requires fields that match the selected mode", () => { expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" }); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index a1b743afc673..fc23a6648787 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -173,6 +173,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; +export const PreviewStateVersion = Schema.Struct({ + serverEpoch: TrimmedNonEmptyString, + revision: PositiveInt, +}); +export type PreviewStateVersion = typeof PreviewStateVersion.Type; + export const PreviewOpenInput = Schema.Struct({ threadId: ThreadId, /** Omit to create an empty (Idle) tab the user can type into. */ @@ -214,9 +220,23 @@ export const PreviewResizeInput = Schema.Struct({ threadId: ThreadId, tabId: PreviewTabId, viewport: PreviewViewportSetting, + /** Apply only while the viewport write identified by this version is current. */ + expectedStateVersion: Schema.optional(PreviewStateVersion), }); export type PreviewResizeInput = typeof PreviewResizeInput.Type; +/** + * The version is optional so new clients can still resize through older + * servers. New servers always return it and use it to guard rollback writes. + */ +export const PreviewResizeResult = Schema.Struct({ + ...PreviewSessionSnapshot.fields, + stateVersion: Schema.optional(PreviewStateVersion), + /** The viewport replaced by this write. Missing when an older server responds. */ + previousViewport: Schema.optional(PreviewViewportSetting), +}); +export type PreviewResizeResult = typeof PreviewResizeResult.Type; + export const PreviewCloseInput = Schema.Struct({ threadId: ThreadId, tabId: Schema.optional(PreviewTabId), @@ -341,5 +361,23 @@ export class PreviewInvalidUrlError extends Schema.TaggedErrorClass()( + "PreviewResizeConflictError", + { + threadId: Schema.String, + tabId: Schema.String, + expectedStateVersion: PreviewStateVersion, + actualStateVersion: PreviewStateVersion, + }, +) { + override get message() { + return "The preview viewport changed before this resize could be applied."; + } +} + +export const PreviewError = Schema.Union([ + PreviewSessionLookupError, + PreviewInvalidUrlError, + PreviewResizeConflictError, +]); export type PreviewError = typeof PreviewError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7cd674485f94..41c7e8007e19 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -155,6 +155,7 @@ import { PreviewRefreshInput, PreviewReportStatusInput, PreviewResizeInput, + PreviewResizeResult, PreviewSessionSnapshot, } from "./preview.ts"; import { @@ -842,7 +843,7 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { payload: PreviewResizeInput, - success: PreviewSessionSnapshot, + success: PreviewResizeResult, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); From cc06ca3a8bb3817b9296dcb184085bd61407984b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 22:27:08 -0700 Subject: [PATCH 15/20] fix(preview): close remaining viewport races --- .../browser/browserViewportActions.test.ts | 18 +++-- .../web/src/browser/browserViewportActions.ts | 41 ++++------- .../preview/PreviewAutomationHosts.tsx | 25 ++++++- apps/web/src/previewStateStore.test.ts | 70 +++++++++++++++++++ 4 files changed, 120 insertions(+), 34 deletions(-) diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index e32d5f65778f..fb7731a01a39 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -99,7 +99,7 @@ describe("browserViewportActions", () => { unsubscribe(); }); - it("does not let a timed-out handler overtake a newer viewport commit", async () => { + it("releases a newer viewport commit when an earlier handler times out", async () => { vi.useFakeTimers(); try { let releaseFirst: (() => void) | undefined; @@ -118,8 +118,7 @@ describe("browserViewportActions", () => { "Timed out committing the browser viewport for tab tab-timeout", ); - await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); - await firstResult; + await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS - 1); expect(handler).toHaveBeenCalledTimes(1); const second = commitBrowserViewportChange("tab-timeout", { @@ -127,11 +126,22 @@ describe("browserViewportActions", () => { width: 900, height: 700, }); - releaseFirst?.(); + await vi.advanceTimersByTimeAsync(1); + await firstResult; await second; expect(handler).toHaveBeenCalledTimes(2); expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); + releaseFirst?.(); + await Promise.resolve(); + + await commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 1_000, + height: 800, + }); + expect(handler).toHaveBeenCalledTimes(3); + expect(handler.mock.calls[2]?.[0]).toMatchObject({ width: 1_000, height: 800 }); unsubscribe(); } finally { vi.useRealTimers(); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index d780f887b40e..ceb7c4d4d66f 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -84,23 +84,6 @@ export function runBrowserViewportMutation( return queueBrowserViewportMutation(tabId, mutation, deadline).execution; } -const runHandlerBeforeDeadline = ( - tabId: string, - operation: Promise, - deadlineAt: number, -): Promise => { - let timeoutId: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timeoutId = setTimeout( - () => reject(new BrowserViewportCommitTimeoutError(tabId)), - Math.max(0, deadlineAt - Date.now()), - ); - }); - return Promise.race([operation, timeout]).finally(() => { - if (timeoutId !== undefined) clearTimeout(timeoutId); - }); -}; - export function subscribeBrowserViewportChange( tabId: string, handler: BrowserViewportHandler, @@ -116,14 +99,18 @@ export function commitBrowserViewportChange( setting: PreviewViewportSetting, ): Promise { const deadlineAt = Date.now() + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS; - const { execution } = queueBrowserViewportMutation(tabId, () => { - if (Date.now() >= deadlineAt) { - return Promise.reject(new BrowserViewportCommitTimeoutError(tabId)); - } - const handler = handlers.get(tabId); - return handler - ? handler(setting) - : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); - }); - return runHandlerBeforeDeadline(tabId, execution, deadlineAt); + const deadline = { + deadlineAt, + timeoutError: () => new BrowserViewportCommitTimeoutError(tabId), + }; + return queueBrowserViewportMutation( + tabId, + () => { + const handler = handlers.get(tabId); + return handler + ? handler(setting) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + }, + deadline, + ).execution; } diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index b9a46c52f603..60588c7b0975 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -443,6 +443,14 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) activeSnapshot, ); if (defaultViewport) { + const timeoutError = () => + new PreviewAutomationViewportTimeoutError({ + requestId: request.requestId, + environmentId, + threadId: request.threadId, + tabId: activeTabId, + timeoutMs: request.timeoutMs, + }); const resizeResult = await runBrowserViewportMutation( activeRuntimeTabId, async () => { @@ -461,6 +469,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }, }); }, + { deadlineAt: requestDeadlineAt, timeoutError }, ); if (resizeResult._tag === "Failure") { return raiseAtomCommandFailure(resizeResult); @@ -616,6 +625,16 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) persistViewport, mutationDeadline, ); + const currentState = assertPreviewRuntimeCurrent( + threadRef, + ready.tabId, + ready.runtimeTabId, + request, + ); + // The store contains either this response or a newer same-tab + // event. Always send that authoritative value to the guest. + const appliedSetting = + currentState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; // Native CDP can outlive the server request. Keep it outside the // shared server-mutation queue so toolbar resizes can proceed. await runBeforeDeadline( @@ -624,7 +643,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) applyPreviewGuestViewport( ready.bridge.automation.setViewport, ready.runtimeTabId, - setting, + appliedSetting, ), timeoutError, ); @@ -632,7 +651,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) threadRef, ready.tabId, ready.runtimeTabId, - setting, + appliedSetting, deadlineAt, timeoutMs, { @@ -644,7 +663,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); return { tabId: ready.tabId, - setting, + setting: appliedSetting, viewport, } satisfies PreviewAutomationResizeResult; } catch (cause) { diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index d866f94751dc..1bbb1648f478 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -444,6 +444,76 @@ describe("previewStateStore (single-tab)", () => { expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); }); + it("rejects a resize response superseded by a same-tab server event", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const currentSnapshot = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1200, height: 800 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: currentSnapshot.updatedAt, + serverEpoch, + revision: 3, + snapshot: currentSnapshot, + }); + + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + // Make the stale response look newer by wall time. Its server revision + // must still lose to the event delivered by another connection. + updatedAt: "2026-01-01T00:00:03.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(currentSnapshot); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); + }); + + it("applies a same-tab server event that follows a resize response", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:02.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + const currentSnapshot = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1200, height: 800 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }; + + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: currentSnapshot.updatedAt, + serverEpoch, + revision: 3, + snapshot: currentSnapshot, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(currentSnapshot); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); + }); + it("does not let one tab's resize response skip earlier events for other tabs", () => { const first = makeSnapshot({ tabId: "tab_a" }); const second = makeSnapshot({ tabId: "tab_b" }); From b2020b5eb37aec79c2e78f64cc56ae9829ec5055 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 23:14:40 -0700 Subject: [PATCH 16/20] fix(preview): roll back failed viewport state --- apps/desktop/src/preview/Manager.test.ts | 379 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 75 ++++- apps/web/src/previewStateStore.test.ts | 122 +++++++- apps/web/src/previewStateStore.ts | 27 +- 4 files changed, 587 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 5c70c1449069..e95f0193f779 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -321,6 +321,38 @@ const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service" }; }); +const makeViewportWebContents = ( + id: number, + sendCommand: (method: string, params?: Record) => Promise, + isDevToolsOpened: () => boolean = () => false, +): Electron.WebContents => + ({ + id, + isDestroyed: () => false, + isDevToolsOpened, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }) as never; + const TEST_FAVICON = "data:image/png;base64,cG5n"; const makeSourcePng = (width = 1, height = 1): Buffer => { @@ -1759,6 +1791,353 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("does not retry or restore viewport intents rejected by the guest", () => + withManager((manager) => + Effect.gen(function* () { + const rejectedSendCommand = vi.fn(async (method: string) => { + if (method === "Emulation.setDeviceMetricsOverride") { + throw new Error("guest refused viewport"); + } + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, rejectedSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_rejected"); + yield* manager.registerWebview("tab_viewport_rejected", 42); + yield* Effect.yieldNow; + + const humanExit = yield* manager + .setViewport("tab_viewport_rejected", { width: 1024, height: 768 }) + .pipe(Effect.exit); + const automationExit = yield* manager + .automationSetViewport("tab_viewport_rejected", { width: 844, height: 390 }) + .pipe(Effect.exit); + + expect(Exit.isFailure(humanExit)).toBe(true); + expect(Exit.isFailure(automationExit)).toBe(true); + expect( + rejectedSendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ), + ).toHaveLength(2); + + yield* manager.registerWebview("tab_viewport_rejected", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ), + ); + expect( + replacementSendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ), + ).toHaveLength(0); + }), + ), + ); + + effectIt.effect("restores the prior viewport after a newer native apply fails", () => + withManager((manager) => + Effect.gen(function* () { + let rejectViewport = false; + const firstSendCommand = vi.fn( + async (method: string, _params?: Record) => { + if (rejectViewport && method === "Emulation.setDeviceMetricsOverride") { + throw new Error("guest refused viewport"); + } + }, + ); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_prior"); + yield* manager.registerWebview("tab_viewport_prior", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_prior", { width: 390, height: 844 }); + + rejectViewport = true; + const exit = yield* manager + .setViewport("tab_viewport_prior", { width: 1024, height: 768 }) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_prior", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 390, + ), + ); + expect(replacementSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 390, height: 844 }), + ); + }), + ), + ); + + effectIt.effect("restores the prior viewport when automation control setup fails", () => + withManager((manager) => + Effect.gen(function* () { + const firstSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const blockedSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const finalSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const blocked = makeViewportWebContents(43, blockedSendCommand, () => true); + const final = makeViewportWebContents(44, finalSendCommand); + fromId.mockImplementation((id) => { + if (id === 42) return first as never; + if (id === 43) return blocked as never; + if (id === 44) return final as never; + return null; + }); + + yield* manager.createTab("tab_viewport_setup_failure"); + yield* manager.registerWebview("tab_viewport_setup_failure", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_setup_failure", { width: 390, height: 844 }); + yield* manager.registerWebview("tab_viewport_setup_failure", 43); + yield* Effect.yieldNow; + + const exit = yield* manager + .automationSetViewport("tab_viewport_setup_failure", { width: 1024, height: 768 }) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_setup_failure", 44); + yield* settle(() => + finalSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 390, + ), + ); + expect(finalSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 390, height: 844 }), + ); + }), + ), + ); + + effectIt.effect("keeps a newer viewport intent when an older apply fails", () => + withManager((manager) => + Effect.gen(function* () { + const olderApplyStarted = Promise.withResolvers(); + const releaseOlderApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (params?.width === 1024) { + olderApplyStarted.resolve(); + await releaseOlderApply.promise; + throw new Error("older viewport failed"); + } + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_newer_intent"); + yield* manager.registerWebview("tab_viewport_newer_intent", 42); + yield* Effect.yieldNow; + + const older = yield* manager + .setViewport("tab_viewport_newer_intent", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => olderApplyStarted.promise); + yield* manager.setViewport("tab_viewport_newer_intent", { width: 1440, height: 900 }); + releaseOlderApply.resolve(); + + const olderExit = yield* Fiber.await(older); + expect(Exit.isFailure(olderExit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_newer_intent", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 1440, + ), + ); + expect(replacementSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 1440, height: 900 }), + ); + }), + ), + ); + + effectIt.effect("skips rejected prior intents when overlapping viewport applies fail", () => + withManager((manager) => + Effect.gen(function* () { + const firstApplyStarted = Promise.withResolvers(); + const secondApplyStarted = Promise.withResolvers(); + const releaseFirstApply = Promise.withResolvers(); + const releaseSecondApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (params?.width === 1024) { + firstApplyStarted.resolve(); + await releaseFirstApply.promise; + throw new Error("first viewport failed"); + } + if (params?.width === 1440) { + secondApplyStarted.resolve(); + await releaseSecondApply.promise; + throw new Error("second viewport failed"); + } + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_overlapping_failures"); + yield* manager.registerWebview("tab_viewport_overlapping_failures", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_overlapping_failures", { + width: 390, + height: 844, + }); + + const firstApply = yield* manager + .setViewport("tab_viewport_overlapping_failures", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => firstApplyStarted.promise); + const secondApply = yield* manager + .setViewport("tab_viewport_overlapping_failures", { width: 1440, height: 900 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => secondApplyStarted.promise); + + releaseFirstApply.resolve(); + const firstExit = yield* Fiber.await(firstApply); + expect(Exit.isFailure(firstExit)).toBe(true); + releaseSecondApply.resolve(); + const secondExit = yield* Fiber.await(secondApply); + expect(Exit.isFailure(secondExit)).toBe(true); + + yield* manager.registerWebview("tab_viewport_overlapping_failures", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 390, + ), + ); + expect( + replacementSendCommand.mock.calls + .filter(([method]) => method === "Emulation.setDeviceMetricsOverride") + .map(([, params]) => params?.width), + ).toEqual([390]); + }), + ), + ); + + effectIt.effect( + "restores a successful overlapping predecessor when the newer viewport fails", + () => + withManager((manager) => + Effect.gen(function* () { + const priorApplyStarted = Promise.withResolvers(); + const newerApplyStarted = Promise.withResolvers(); + const releasePriorApply = Promise.withResolvers(); + const releaseNewerApply = Promise.withResolvers(); + let newerApplyCount = 0; + const firstSendCommand = vi.fn( + async (method: string, params?: Record) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + if (params?.width === 1024) { + priorApplyStarted.resolve(); + await releasePriorApply.promise; + } + if (params?.width === 1440) { + newerApplyCount += 1; + if (newerApplyCount === 1) { + newerApplyStarted.resolve(); + await releaseNewerApply.promise; + throw new Error("newer viewport failed"); + } + } + }, + ); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_successful_predecessor"); + yield* manager.registerWebview("tab_viewport_successful_predecessor", 42); + yield* Effect.yieldNow; + yield* manager.setViewport("tab_viewport_successful_predecessor", { + width: 390, + height: 844, + }); + + const priorApply = yield* manager + .setViewport("tab_viewport_successful_predecessor", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => priorApplyStarted.promise); + const newerApply = yield* manager + .setViewport("tab_viewport_successful_predecessor", { width: 1440, height: 900 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => newerApplyStarted.promise); + + releasePriorApply.resolve(); + const priorExit = yield* Fiber.await(priorApply); + expect(Exit.isSuccess(priorExit)).toBe(true); + releaseNewerApply.resolve(); + const newerExit = yield* Fiber.await(newerApply); + expect(Exit.isFailure(newerExit)).toBe(true); + + const liveViewportCalls = firstSendCommand.mock.calls.filter( + ([method]) => method === "Emulation.setDeviceMetricsOverride", + ); + expect(liveViewportCalls.at(-1)?.[1]).toMatchObject({ width: 1024, height: 768 }); + + yield* manager.registerWebview("tab_viewport_successful_predecessor", 43); + yield* settle(() => + replacementSendCommand.mock.calls.some( + ([method, params]) => + method === "Emulation.setDeviceMetricsOverride" && params?.width === 1024, + ), + ); + expect( + replacementSendCommand.mock.calls + .filter(([method]) => method === "Emulation.setDeviceMetricsOverride") + .map(([, params]) => params?.width), + ).toEqual([1024]); + }), + ), + ); + effectIt.effect("converts logical viewport sizes at each supported test zoom", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e49c93ba9257..820c7db0c5d2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -584,6 +584,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const viewportOverridesRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); + const viewportIntentPredecessors = new WeakMap< + PreviewViewportIntent, + PreviewViewportIntent | undefined + >(); + const rejectedViewportIntents = new WeakSet(); const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); @@ -2658,6 +2663,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const rememberViewportOverride = (tabId: string, input: PreviewViewportOverride) => SynchronizedRef.modify(viewportOverridesRef, (overrides) => { const intent: PreviewViewportIntent = { input }; + viewportIntentPredecessors.set(intent, overrides.get(tabId)); return [ intent, replaceMap(overrides, (copy) => { @@ -2666,6 +2672,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); + const restorePreviousViewportOverride = (tabId: string, intent: PreviewViewportIntent) => + SynchronizedRef.modify(viewportOverridesRef, (overrides) => { + rejectedViewportIntents.add(intent); + if (overrides.get(tabId) !== intent) return [false, overrides] as const; + let previousIntent = viewportIntentPredecessors.get(intent); + while (previousIntent && rejectedViewportIntents.has(previousIntent)) { + previousIntent = viewportIntentPredecessors.get(previousIntent); + } + return [ + true, + replaceMap(overrides, (copy) => { + if (previousIntent) copy.set(tabId, previousIntent); + else copy.delete(tabId); + }), + ] as const; + }); + + const acceptViewportOverride = (intent: PreviewViewportIntent) => + Effect.sync(() => { + viewportIntentPredecessors.delete(intent); + }); + const prepareViewportIntent = (tabId: string, input: PreviewViewportOverride) => withTabLifecycleLock( tabId, @@ -2702,6 +2730,41 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const reconcileViewportRollback = Effect.fnUntraced(function* ( + tabId: string, + generation: number | undefined, + ) { + let applied: PreviewViewportIntent | undefined; + let appliedWebContents: Electron.WebContents | undefined; + while (true) { + if ( + tabLifecycleGenerations.get(tabId) !== generation || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { + return; + } + const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!current || current.webContentsId == null) return; + const currentWebContents = webContents.fromId(current.webContentsId); + if (!currentWebContents || currentWebContents.isDestroyed()) return; + if (latest === applied && currentWebContents === appliedWebContents) return; + yield* applyViewportOverride(tabId, currentWebContents, latest?.input ?? { clear: true }); + applied = latest; + appliedWebContents = currentWebContents; + } + }); + + const rollbackViewportOverride = ( + tabId: string, + intent: PreviewViewportIntent, + generation: number | undefined, + ) => + Effect.gen(function* () { + const restored = yield* restorePreviousViewportOverride(tabId, intent); + if (restored) yield* reconcileViewportRollback(tabId, generation); + }).pipe(Effect.ignore); + const settleViewportIntent = Effect.fnUntraced(function* ( tabId: string, wc: Electron.WebContents, @@ -2710,10 +2773,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function applyInitial: (input: PreviewViewportOverride) => Effect.Effect, ) { const initialExit = yield* Effect.exit(applyInitial(intent.input)); + if (Exit.isFailure(initialExit)) return yield* Effect.failCause(initialExit.cause); const reconcileExit = yield* Effect.exit( Effect.gen(function* () { - let applied = Exit.isSuccess(initialExit) ? intent : undefined; - let appliedWebContents = Exit.isSuccess(initialExit) ? wc : undefined; + let applied = intent; + let appliedWebContents = wc; while (true) { const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); if (!latest || tabLifecycleGenerations.get(tabId) !== generation) { @@ -2738,7 +2802,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }), ); - if (Exit.isFailure(initialExit)) return yield* Effect.failCause(initialExit.cause); if (Exit.isFailure(reconcileExit)) return yield* Effect.failCause(reconcileExit.cause); }); @@ -2754,6 +2817,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function prepared.intent, prepared.generation, (latest) => applyViewportOverride(tabId, prepared.wc, latest), + ).pipe( + Effect.tap(() => acceptViewportOverride(prepared.intent)), + Effect.onError(() => rollbackViewportOverride(tabId, prepared.intent, prepared.generation)), ); }); @@ -2772,6 +2838,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.asVoid, ); }), + ).pipe( + Effect.tap(() => acceptViewportOverride(prepared.intent)), + Effect.onError(() => rollbackViewportOverride(tabId, prepared.intent, prepared.generation)), ); }); diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 1bbb1648f478..108f2f56e288 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -557,12 +557,130 @@ describe("previewStateStore (single-tab)", () => { const state = readThreadPreviewState(ref); expect(state.sessions).toEqual({ [second.tabId]: resizedSecond }); expect(state.serverRevision).toBe(5); - expect(state.serverRevisionByTabId).toMatchObject({ - [first.tabId]: 5, + expect(state.serverRevisionByTabId).toEqual({ [second.tabId]: 6, }); }); + it("rejects a late resize response after pruning a closed tab revision", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + applyPreviewServerEventImpl(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:03.000Z", + serverEpoch, + revision: 3, + }); + + expect(readThreadPreviewState(ref).serverRevisionByTabId).toEqual({}); + + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:04.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({}); + expect(state.serverRevisionByTabId).toEqual({}); + }); + + it("prunes tab revisions that an authoritative list removes", () => { + const closed = makeSnapshot({ tabId: "tab_closed" }); + const active = makeSnapshot({ tabId: "tab_active" }); + reconcilePreviewServerSessions(ref, { + sessions: [closed, active], + serverEpoch, + revision: 1, + }); + + reconcilePreviewServerSessions(ref, { + sessions: [active], + serverEpoch, + revision: 2, + }); + + expect(readThreadPreviewState(ref).serverRevisionByTabId).toEqual({ + [active.tabId]: 2, + }); + }); + + it("retains a pending-close revision until a failed close restores the tab", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + beginPreviewSessionClose(ref, snapshot.tabId); + + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 3, + }); + cancelPreviewSessionClose(ref, snapshot, snapshot.tabId); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:04.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(snapshot); + expect(state.serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); + }); + + it("accepts a newer list that reuses a closed tab id", () => { + const original = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [original], + serverEpoch, + revision: 1, + }); + applyPreviewServerEventImpl(ref, { + type: "closed", + threadId: "thread-1", + tabId: original.tabId, + createdAt: "2026-01-01T00:00:02.000Z", + serverEpoch, + revision: 2, + }); + reconcilePreviewServerSessions(ref, { + sessions: [original], + serverEpoch, + revision: 1, + }); + expect(readThreadPreviewState(ref).sessions).toEqual({}); + + reconcilePreviewServerSessions(ref, { + sessions: [ + makeSnapshot({ + navStatus: { _tag: "Success", url: "https://reopened.example", title: "Reopened" }, + updatedAt: "2026-01-01T00:00:03.000Z", + }), + ], + serverEpoch, + revision: 3, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[original.tabId]?.navStatus).toEqual({ + _tag: "Success", + url: "https://reopened.example", + title: "Reopened", + }); + expect(state.serverRevisionByTabId).toEqual({ [original.tabId]: 3 }); + }); + it("keeps a tab-specific resize newer than an in-flight list response", () => { const snapshot = makeSnapshot(); reconcilePreviewServerSessions(ref, { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 0d4064b08c99..68dac7aeaa14 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -48,7 +48,7 @@ export interface ThreadPreviewState { serverEpoch: string | null; /** Latest ordered server revision applied from a list response or event. */ serverRevision: number; - /** Latest server revision that established each tab's state. */ + /** Latest server revision that established each live or pending-close tab's state. */ serverRevisionByTabId: Readonly>; } @@ -251,10 +251,18 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven } } })(); - const serverRevisionByTabId = - event.revision > tabRevision + const serverRevisionByTabId = (() => { + if (!next.sessions[event.tabId]) { + if (current.serverRevisionByTabId[event.tabId] === undefined) { + return current.serverRevisionByTabId; + } + const { [event.tabId]: _removed, ...remaining } = current.serverRevisionByTabId; + return remaining; + } + return event.revision > tabRevision ? { ...current.serverRevisionByTabId, [event.tabId]: event.revision } : current.serverRevisionByTabId; + })(); return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch && next.serverRevisionByTabId === serverRevisionByTabId @@ -317,6 +325,9 @@ export function updatePreviewServerSnapshot( } const tabRevision = current.serverRevisionByTabId[snapshot.tabId] ?? 0; if (stateVersion.revision < tabRevision) return current; + if (!current.sessions[snapshot.tabId] && stateVersion.revision <= current.serverRevision) { + return current; + } } const { stateVersion: _stateVersion, @@ -406,15 +417,9 @@ export function reconcilePreviewServerSessions( snapshots.some((snapshot) => snapshot.tabId === tabId), ), ); - const knownTabIds = sameServer - ? new Set([ - ...Object.keys(current.serverRevisionByTabId), - ...Object.keys(current.sessions), - ...Object.keys(sessions), - ]) - : new Set(Object.keys(sessions)); + const revisionTabIds = new Set([...Object.keys(sessions), ...suppressedTabIds]); const serverRevisionByTabId = Object.fromEntries( - [...knownTabIds].map((tabId) => [ + [...revisionTabIds].map((tabId) => [ tabId, Math.max(sameServer ? (current.serverRevisionByTabId[tabId] ?? 0) : 0, result.revision), ]), From 814d1a08cf9d10d20933728576dbe3b577be693c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 27 Aug 2026 23:28:19 -0700 Subject: [PATCH 17/20] fix(web): retain pending preview revisions --- apps/web/src/previewStateStore.test.ts | 50 ++++++++++++++++++++++++++ apps/web/src/previewStateStore.ts | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 108f2f56e288..1702b5130ff5 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -639,6 +639,56 @@ describe("previewStateStore (single-tab)", () => { expect(state.serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); }); + it("retains ignored event revisions until a failed close restores the tab", () => { + const snapshot = makeSnapshot(); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + beginPreviewSessionClose(ref, snapshot.tabId); + + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:03.000Z", + serverEpoch, + revision: 3, + snapshot: { + ...snapshot, + viewport: { _tag: "freeform", width: 1200, height: 800 }, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + applyPreviewServerEventImpl(ref, { + type: "resized", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:02.000Z", + serverEpoch, + revision: 2, + snapshot: { + ...snapshot, + viewport: { _tag: "freeform", width: 1000, height: 700 }, + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + expect(readThreadPreviewState(ref).serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); + + cancelPreviewSessionClose(ref, snapshot, snapshot.tabId); + updatePreviewServerSnapshot(ref, { + ...snapshot, + viewport: { _tag: "freeform", width: 900, height: 600 }, + updatedAt: "2026-01-01T00:00:04.000Z", + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(snapshot); + expect(state.serverRevisionByTabId).toEqual({ [snapshot.tabId]: 3 }); + }); + it("accepts a newer list that reuses a closed tab id", () => { const original = makeSnapshot(); reconcilePreviewServerSessions(ref, { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 68dac7aeaa14..db37e3f1a99c 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -252,7 +252,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven } })(); const serverRevisionByTabId = (() => { - if (!next.sessions[event.tabId]) { + if (!next.sessions[event.tabId] && !next.suppressedTabIds.has(event.tabId)) { if (current.serverRevisionByTabId[event.tabId] === undefined) { return current.serverRevisionByTabId; } From 3ec0123129ef4c258b6c763130325d543ad89d1d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 16:32:12 -0700 Subject: [PATCH 18/20] fix(web): prefer preview revisions to timestamps --- apps/web/src/previewStateStore.test.ts | 47 ++++++++++++++++++++++++++ apps/web/src/previewStateStore.ts | 10 +++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 1702b5130ff5..625f926cccd1 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -444,6 +444,29 @@ describe("previewStateStore (single-tab)", () => { expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(3); }); + it("applies an ordered resize response when the server clock moves backward", () => { + const snapshot = makeSnapshot({ updatedAt: "2026-01-01T00:00:02.000Z" }); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const resized = { + ...snapshot, + viewport: { _tag: "freeform" as const, width: 1024, height: 768 }, + updatedAt: "2026-01-01T00:00:01.000Z", + }; + + updatePreviewServerSnapshot(ref, { + ...resized, + stateVersion: { serverEpoch, revision: 2 }, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(resized); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(2); + }); + it("rejects a resize response superseded by a same-tab server event", () => { const snapshot = makeSnapshot(); reconcilePreviewServerSessions(ref, { @@ -766,6 +789,30 @@ describe("previewStateStore (single-tab)", () => { expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(6); }); + it("applies a newer ordered list when the server clock moves backward", () => { + const snapshot = makeSnapshot({ updatedAt: "2026-01-01T00:00:02.000Z" }); + reconcilePreviewServerSessions(ref, { + sessions: [snapshot], + serverEpoch, + revision: 1, + }); + const listed = { + ...snapshot, + navStatus: { _tag: "Success" as const, url: "https://example.com", title: "Example" }, + updatedAt: "2026-01-01T00:00:01.000Z", + }; + + reconcilePreviewServerSessions(ref, { + sessions: [listed], + serverEpoch, + revision: 2, + }); + + const state = readThreadPreviewState(ref); + expect(state.sessions[snapshot.tabId]).toEqual(listed); + expect(state.serverRevisionByTabId[snapshot.tabId]).toBe(2); + }); + it("reconciles an authoritative session list without focusing a background tab", () => { const active = makeSnapshot({ tabId: "tab_a" }); const stale = makeSnapshot({ diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index db37e3f1a99c..9c8670d0faae 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -336,7 +336,7 @@ export function updatePreviewServerSnapshot( } = snapshot; if (current.suppressedTabIds.has(sessionSnapshot.tabId)) return current; const existing = current.sessions[sessionSnapshot.tabId]; - if (existing && existing.updatedAt > sessionSnapshot.updatedAt) return current; + if (!stateVersion && existing && existing.updatedAt > sessionSnapshot.updatedAt) return current; const sessions = { ...current.sessions, [sessionSnapshot.tabId]: sessionSnapshot }; const activeTabId = current.activeTabId && sessions[current.activeTabId] @@ -378,11 +378,13 @@ export function reconcilePreviewServerSessions( for (const snapshot of snapshots) { if (currentSuppressedTabIds.has(snapshot.tabId)) continue; const existing = sameServer ? current.sessions[snapshot.tabId] : undefined; - const hasNewerTabState = - sameServer && (current.serverRevisionByTabId[snapshot.tabId] ?? 0) > result.revision; + const tabRevision = sameServer ? (current.serverRevisionByTabId[snapshot.tabId] ?? 0) : 0; + const hasNewerTabState = sameServer && tabRevision > result.revision; if (hasNewerTabState && !existing) continue; const next = - existing && (hasNewerTabState || existing.updatedAt > snapshot.updatedAt) + existing && + (hasNewerTabState || + (tabRevision === result.revision && existing.updatedAt > snapshot.updatedAt)) ? existing : snapshot; sessions[next.tabId] = next; From 5f8634c649b305fd8bb33c43676d5ea3c0462879 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 16:41:19 -0700 Subject: [PATCH 19/20] fix(web): set automation viewport when opening tab --- .../preview/PreviewAutomationHosts.tsx | 48 ++----------------- .../previewAutomationOpenReadiness.test.ts | 15 ++---- .../preview/previewAutomationOpenReadiness.ts | 29 ++++------- 3 files changed, 18 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 60588c7b0975..44956ef82fb1 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -61,8 +61,8 @@ import { PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { - previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + previewAutomationOpenViewport, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; import { @@ -413,14 +413,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const configuredViewport = browserDefaultOpenViewport(await resolveBrowserDefaults()); const result = await open({ environmentId, input: { threadId: request.threadId, ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), - // An agent that didn't state a size gets the user's - // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: previewAutomationOpenViewport(configuredViewport), }, }); if (result._tag === "Failure") { @@ -437,47 +436,6 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) readThreadPreviewState(threadRef).serverEpoch, activeTabId, ); - if (activeSnapshot) { - const defaultViewport = previewAutomationDefaultViewport( - reusedExistingTab, - activeSnapshot, - ); - if (defaultViewport) { - const timeoutError = () => - new PreviewAutomationViewportTimeoutError({ - requestId: request.requestId, - environmentId, - threadId: request.threadId, - tabId: activeTabId, - timeoutMs: request.timeoutMs, - }); - const resizeResult = await runBrowserViewportMutation( - activeRuntimeTabId, - async () => { - assertPreviewRuntimeCurrent( - threadRef, - activeTabId, - activeRuntimeTabId, - request, - ); - return await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: activeTabId, - viewport: defaultViewport, - }, - }); - }, - { deadlineAt: requestDeadlineAt, timeoutError }, - ); - if (resizeResult._tag === "Failure") { - return raiseAtomCommandFailure(resizeResult); - } - activeSnapshot = resizeResult.value; - updatePreviewServerSnapshot(threadRef, resizeResult.value); - } - } const shouldPresentPreview = shouldOpenPreviewMiniPlayer( input, (await resolveBrowserDefaults()).autoShowFloatingPreview, diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts index dc8836c14492..00e9e687456c 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, - previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + previewAutomationOpenViewport, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; @@ -62,19 +62,14 @@ describe("preview automation open readiness", () => { }); it("gives newly-created automation tabs a stable desktop viewport", () => { - expect(previewAutomationDefaultViewport(false, snapshot({ _tag: "Idle" }))).toEqual( + expect(previewAutomationOpenViewport({ _tag: "fill" })).toEqual( DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, ); }); - it("preserves reused and already-fixed browser viewports", () => { - expect(previewAutomationDefaultViewport(true, snapshot({ _tag: "Idle" }))).toBeNull(); - expect( - previewAutomationDefaultViewport(false, { - ...snapshot({ _tag: "Idle" }), - viewport: { _tag: "freeform", width: 900, height: 600 }, - }), - ).toBeNull(); + it("preserves the configured fixed browser viewport", () => { + const configured = { _tag: "freeform", width: 900, height: 600 } as const; + expect(previewAutomationOpenViewport(configured)).toBe(configured); }); }); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts index 7f525b425501..cd083c5474e5 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts @@ -1,8 +1,7 @@ -import { - FILL_PREVIEW_VIEWPORT, - type PreviewAutomationOpenInput, - type PreviewSessionSnapshot, - type PreviewViewportSetting, +import type { + PreviewAutomationOpenInput, + PreviewSessionSnapshot, + PreviewViewportSetting, } from "@t3tools/contracts"; /** @@ -36,19 +35,11 @@ export function previewAutomationOpenNeedsOverlay( return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; } -/** - * Whether a freshly opened automation tab still needs a viewport applied. - * - * A configured browser default is sent with `preview.open`, so the snapshot - * already carries it and nothing is needed here. Fill means the user has no - * stated preference, which is where the agent fallback applies. - */ -export function previewAutomationDefaultViewport( - reusedExistingTab: boolean, - snapshot: PreviewSessionSnapshot, -): PreviewViewportSetting | null { - const viewport = snapshot.viewport ?? FILL_PREVIEW_VIEWPORT; - return !reusedExistingTab && viewport._tag === "fill" +/** Select the viewport sent with a new automation tab's `preview.open`. */ +export function previewAutomationOpenViewport( + configuredViewport: PreviewViewportSetting, +): PreviewViewportSetting { + return configuredViewport._tag === "fill" ? DEFAULT_PREVIEW_AUTOMATION_VIEWPORT - : null; + : configuredViewport; } From 9425d6e2fc2d1f451c4ae424719bc9889a67da89 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:15:39 -0700 Subject: [PATCH 20/20] fix(preview): reconcile viewport swaps after apply failures --- apps/desktop/src/preview/Manager.test.ts | 107 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 79 +++++++++++------ 2 files changed, 157 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index e95f0193f779..7e99b0353a6f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2295,6 +2295,113 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("retries a viewport intent when its captured webview is replaced", () => + withManager((manager) => + Effect.gen(function* () { + const applyStarted = Promise.withResolvers(); + const releaseApply = Promise.withResolvers(); + const firstSendCommand = vi.fn(async (method: string) => { + if (method !== "Emulation.setDeviceMetricsOverride") return; + applyStarted.resolve(); + await releaseApply.promise; + throw new Error("replaced guest rejected viewport"); + }); + const replacementSendCommand = vi.fn( + async (_method: string, _params?: Record) => undefined, + ); + const first = makeViewportWebContents(42, firstSendCommand); + const replacement = makeViewportWebContents(43, replacementSendCommand); + fromId.mockImplementation( + (id) => (id === 42 ? first : id === 43 ? replacement : null) as never, + ); + + yield* manager.createTab("tab_viewport_replaced_apply"); + yield* manager.registerWebview("tab_viewport_replaced_apply", 42); + yield* Effect.yieldNow; + + const setter = yield* manager + .setViewport("tab_viewport_replaced_apply", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => applyStarted.promise); + yield* manager.registerWebview("tab_viewport_replaced_apply", 43); + releaseApply.resolve(); + yield* Fiber.join(setter); + + expect(replacementSendCommand).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ width: 1024, height: 768 }), + ); + }), + ), + ); + + effectIt.effect("clears a rejected viewport after a concurrent session restore", () => + withManager((manager) => + Effect.gen(function* () { + const initialApplyStarted = Promise.withResolvers(); + const releaseInitialApply = Promise.withResolvers(); + const restoreApplyStarted = Promise.withResolvers(); + const releaseRestoreApply = Promise.withResolvers(); + const firstClearApplied = Promise.withResolvers(); + const secondClearApplied = Promise.withResolvers(); + let viewportApplyCount = 0; + let viewportClearCount = 0; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Emulation.clearDeviceMetricsOverride") { + viewportClearCount += 1; + if (viewportClearCount === 1) firstClearApplied.resolve(); + if (viewportClearCount === 2) secondClearApplied.resolve(); + return; + } + if (method !== "Emulation.setDeviceMetricsOverride") return; + viewportApplyCount += 1; + if (viewportApplyCount === 1) { + initialApplyStarted.resolve(); + await releaseInitialApply.promise; + throw new Error("guest rejected viewport"); + } + if (viewportApplyCount === 2) { + restoreApplyStarted.resolve(); + await releaseRestoreApply.promise; + } + }); + let onDevToolsClosed: (() => void) | undefined; + const wc = Object.assign(makeViewportWebContents(42, sendCommand), { + once: vi.fn((event: string, listener: () => void) => { + if (event === "devtools-closed") onDevToolsClosed = listener; + }), + openDevTools: vi.fn(), + }); + fromId.mockReturnValue(wc); + + yield* manager.createTab("tab_viewport_rejected_restore"); + yield* manager.registerWebview("tab_viewport_rejected_restore", 42); + yield* Effect.yieldNow; + + const setter = yield* manager + .setViewport("tab_viewport_rejected_restore", { width: 1024, height: 768 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => initialApplyStarted.promise); + yield* manager.openDevTools("tab_viewport_rejected_restore"); + expect(onDevToolsClosed).toBeDefined(); + onDevToolsClosed?.(); + yield* Effect.promise(() => restoreApplyStarted.promise); + + releaseInitialApply.resolve(); + const setterExit = yield* Fiber.await(setter); + expect(Exit.isFailure(setterExit)).toBe(true); + yield* Effect.promise(() => firstClearApplied.promise); + + releaseRestoreApply.resolve(); + yield* Effect.promise(() => secondClearApplied.promise); + const viewportCalls = sendCommand.mock.calls.filter(([method]) => + method.includes("DeviceMetricsOverride"), + ); + expect(viewportCalls.at(-1)?.[0]).toBe("Emulation.clearDeviceMetricsOverride"); + }), + ), + ); + effectIt.effect("restores a newer human viewport after a queued agent resize", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 820c7db0c5d2..bd7e94f280a4 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2765,6 +2765,31 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (restored) yield* reconcileViewportRollback(tabId, generation); }).pipe(Effect.ignore); + const currentViewportTarget = Effect.fnUntraced(function* ( + tabId: string, + generation: number | undefined, + ) { + if (tabLifecycleGenerations.get(tabId) !== generation) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!current) return yield* new PreviewTabNotFoundError({ tabId }); + if (current.webContentsId == null) { + return yield* new PreviewWebviewNotInitializedError({ tabId }); + } + const currentWebContents = webContents.fromId(current.webContentsId); + if (!currentWebContents || currentWebContents.isDestroyed()) { + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: current.webContentsId, + }); + } + return { + intent: (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId), + wc: currentWebContents, + }; + }); + const settleViewportIntent = Effect.fnUntraced(function* ( tabId: string, wc: Electron.WebContents, @@ -2773,36 +2798,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function applyInitial: (input: PreviewViewportOverride) => Effect.Effect, ) { const initialExit = yield* Effect.exit(applyInitial(intent.input)); - if (Exit.isFailure(initialExit)) return yield* Effect.failCause(initialExit.cause); - const reconcileExit = yield* Effect.exit( - Effect.gen(function* () { - let applied = intent; - let appliedWebContents = wc; - while (true) { - const latest = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId); - if (!latest || tabLifecycleGenerations.get(tabId) !== generation) { - return yield* new PreviewTabNotFoundError({ tabId }); - } - const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (!current) return yield* new PreviewTabNotFoundError({ tabId }); - if (current.webContentsId == null) { - return yield* new PreviewWebviewNotInitializedError({ tabId }); - } - const currentWebContents = webContents.fromId(current.webContentsId); - if (!currentWebContents || currentWebContents.isDestroyed()) { - return yield* new PreviewWebContentsNotFoundError({ - tabId, - webContentsId: current.webContentsId, - }); - } - if (latest === applied && currentWebContents === appliedWebContents) return; - yield* applyViewportOverride(tabId, currentWebContents, latest.input); - applied = latest; - appliedWebContents = currentWebContents; + if (Exit.isFailure(initialExit)) { + const current = yield* currentViewportTarget(tabId, generation); + if (current.intent !== intent || current.wc === wc) { + return yield* Effect.failCause(initialExit.cause); + } + } + + let applied = Exit.isSuccess(initialExit) ? intent : undefined; + let appliedWebContents = Exit.isSuccess(initialExit) ? wc : undefined; + while (true) { + const current = yield* currentViewportTarget(tabId, generation); + if (current.intent === applied && current.wc === appliedWebContents) return; + + const applyExit = yield* Effect.exit( + applyViewportOverride(tabId, current.wc, current.intent?.input ?? { clear: true }), + ); + if (Exit.isFailure(applyExit)) { + const afterFailure = yield* currentViewportTarget(tabId, generation); + if (afterFailure.intent !== current.intent || afterFailure.wc === current.wc) { + return yield* Effect.failCause(applyExit.cause); } - }), - ); - if (Exit.isFailure(reconcileExit)) return yield* Effect.failCause(reconcileExit.cause); + continue; + } + applied = current.intent; + appliedWebContents = current.wc; + } }); // Human/toolbar path. Must not take agent control or write a resize action.