Skip to content

Commit 25566b3

Browse files
ScottN-PVclaude
andcommitted
fix(server): size a desktop-drawn snapshot from one page state when its zoom changes mid-read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 726a8ed commit 25566b3

2 files changed

Lines changed: 110 additions & 14 deletions

File tree

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,85 @@ it.live("sizes a desktop-drawn snapshot by the zoom the page has, not one still
11691169
).pipe(Effect.provide(layer)),
11701170
);
11711171

1172+
// A 150% display at 100% zoom and the same display at 200%: both are 1280x800 in
1173+
// device-independent pixels, so either state alone gives the same clip.
1174+
const beforeZoom = { ratio: 1.5, width: 1280, height: 800, zoom: 1 };
1175+
const afterZoom = { ratio: 3, width: 640, height: 400, zoom: 2 };
1176+
1177+
/** A desktop page that applies its pending zoom right after the given read. */
1178+
const applyZoomAfter = (
1179+
context: ReturnType<typeof makeContext>,
1180+
read: "evaluate" | "first-layout-metrics" | "every-evaluate",
1181+
) => {
1182+
let current = beforeZoom;
1183+
let layoutReads = 0;
1184+
for (const session of context.sessions) {
1185+
const send = session.send.getMockImplementation()!;
1186+
session.send.mockImplementation(async (method, input) => {
1187+
if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } };
1188+
if (method === "Page.createIsolatedWorld") return { executionContextId: 7 };
1189+
if (method === "Runtime.evaluate" && (input as { contextId?: number })?.contextId === 7) {
1190+
const { zoom: _zoom, ...reported } = current;
1191+
if (read === "evaluate") current = afterZoom;
1192+
if (read === "every-evaluate") current = current === beforeZoom ? afterZoom : beforeZoom;
1193+
return { result: { value: reported } };
1194+
}
1195+
if (method === "Page.getLayoutMetrics") {
1196+
const reply = { cssVisualViewport: { pageX: 0, pageY: 0, zoom: current.zoom } };
1197+
layoutReads += 1;
1198+
if (read === "first-layout-metrics" && layoutReads === 1) current = afterZoom;
1199+
return reply;
1200+
}
1201+
return send(method, input);
1202+
});
1203+
}
1204+
};
1205+
1206+
it.live.each(["evaluate", "first-layout-metrics"] as const)(
1207+
"sizes a desktop-drawn snapshot from one page state when the zoom lands after its %s read",
1208+
(read) =>
1209+
Effect.scoped(
1210+
Effect.gen(function* () {
1211+
desktopRendersNext = true;
1212+
const { browser, broker, tabId } = yield* ready;
1213+
const manager = yield* Manager.PreviewManager;
1214+
yield* browser.attachViewer(viewerInput(tabId, false));
1215+
const context = desktopConnections[0]!.context;
1216+
yield* manager.adjust({ threadId: scope.thread.threadId, tabId, zoomFactor: 2 });
1217+
applyZoomAfter(context, read);
1218+
yield* broker.invoke<PreviewAutomationSnapshot>({
1219+
scope,
1220+
tabId,
1221+
operation: "snapshot",
1222+
input: {},
1223+
});
1224+
expect(captureClips(context)).toEqual([{ width: 1280, height: 800, scale: 1280 / 1920 }]);
1225+
}),
1226+
).pipe(Effect.provide(layer)),
1227+
);
1228+
1229+
it.live("fails a desktop-drawn snapshot whose zoom keeps changing during its read", () =>
1230+
Effect.scoped(
1231+
Effect.gen(function* () {
1232+
desktopRendersNext = true;
1233+
const { browser, broker, tabId } = yield* ready;
1234+
yield* browser.attachViewer(viewerInput(tabId, false));
1235+
const context = desktopConnections[0]!.context;
1236+
applyZoomAfter(context, "every-evaluate");
1237+
const failure = yield* Effect.flip(
1238+
broker.invoke<PreviewAutomationSnapshot>({
1239+
scope,
1240+
tabId,
1241+
operation: "snapshot",
1242+
input: {},
1243+
}),
1244+
);
1245+
expect(JSON.stringify(failure)).toContain("zoom kept changing");
1246+
expect(captureClips(context)).toEqual([]);
1247+
}),
1248+
).pipe(Effect.provide(layer)),
1249+
);
1250+
11721251
it.live("starts a zoomed desktop-drawn page's clip where it is scrolled to", () =>
11731252
Effect.scoped(
11741253
Effect.gen(function* () {

‎apps/server/src/preview/ServerBrowserPage.ts‎

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -176,20 +176,37 @@ export const readPageMetrics = async (cdp: CDPSession) => {
176176
// Chromium reuses a named world; an unnamed one is created again on each call.
177177
worldName: "t3-preview-metrics",
178178
});
179-
const { result } = await cdp.send("Runtime.evaluate", {
180-
expression: "({ ratio: devicePixelRatio, width: innerWidth, height: innerHeight })",
181-
contextId: executionContextId,
182-
returnByValue: true,
183-
});
184-
const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics");
185-
return {
186-
...(result.value as {
187-
readonly ratio: number;
188-
readonly width: number;
189-
readonly height: number;
190-
}),
191-
zoom: cssVisualViewport.zoom ?? 1,
192-
};
179+
const readZoom = async () =>
180+
(await cdp.send("Page.getLayoutMetrics")).cssVisualViewport.zoom ?? 1;
181+
// The desktop applies a published zoom on its own schedule. Reading the zoom on both sides of
182+
// the ratio and viewport read catches a change in between, which would pair old CSS sizes
183+
// with the new zoom.
184+
let zoom = await readZoom();
185+
for (let attempt = 1; ; attempt += 1) {
186+
const { result } = await cdp.send("Runtime.evaluate", {
187+
expression: "({ ratio: devicePixelRatio, width: innerWidth, height: innerHeight })",
188+
contextId: executionContextId,
189+
returnByValue: true,
190+
});
191+
const zoomAfter = await readZoom();
192+
if (zoomAfter === zoom) {
193+
return {
194+
...(result.value as {
195+
readonly ratio: number;
196+
readonly width: number;
197+
readonly height: number;
198+
}),
199+
zoom,
200+
};
201+
}
202+
// A capture sized from a torn read is cropped and oversized, so fail instead.
203+
if (attempt === 3) {
204+
throw new Error(
205+
"The page's zoom kept changing while the snapshot read its size. Try the snapshot again.",
206+
);
207+
}
208+
zoom = zoomAfter;
209+
}
193210
};
194211

195212
// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets.

0 commit comments

Comments
 (0)