Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/cli/src/capture/captureCompositionFrame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,26 @@ afterEach(() => {
});

describe("seekCompositionTimeline", () => {
it("keeps the existing raced double-frame settle as the default", async () => {
it("keeps the raced double-frame settle and adds a bounded font wait by default", async () => {
const { page, evaluate, waitForFunction } = fakeSeekPage();

await seekCompositionTimeline(page, 1.25);

expect(waitForFunction).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(2);
expect(evaluate).toHaveBeenCalledTimes(3);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false);
expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)");
// Post-seek font settle: a seek can reveal glyphs whose unicode-range
// subsets only start loading after the next layout (CJK snapshot reports).
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500);
});

it("waitForFontsMs: 0 disables the post-seek font wait", async () => {
const { page, evaluate } = fakeSeekPage();

await seekCompositionTimeline(page, 1.25, { waitForFontsMs: 0 });

expect(evaluate).toHaveBeenCalledTimes(2);
});

it("prefers renderSeek so the runtime synchronizes clip visibility", async () => {
Expand Down Expand Up @@ -128,6 +139,7 @@ describe("seekCompositionTimeline", () => {
fallbackToBridgeAndTimelines: true,
waitForPreferredSeekTargetMs: 500,
animationFrameSettle: "none",
waitForFontsMs: 0,
settleMs: 150,
});
await vi.advanceTimersByTimeAsync(150);
Expand Down
41 changes: 33 additions & 8 deletions packages/cli/src/capture/captureCompositionFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport
const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000;
const CAPTURE_SETTLE_MS = 1500;
const PREFERRED_SEEK_TARGET_WAIT_MS = 500;
const DEFAULT_POST_SEEK_FONT_WAIT_MS = 500;

// The audit-grade seek tuning shared by check and the deprecated inspect/layout:
// bridge+timeline fallback, ordered double-rAF settle, bounded font wait, sleep.
Expand Down Expand Up @@ -257,8 +258,13 @@ export async function seekCompositionTimeline(
);
}

if (options.waitForFontsMs !== undefined) {
await waitForCompositionFonts(page, options.waitForFontsMs);
// On by default: every seek caller here is a visual-audit path (snapshot,
// check, compare, validate, layout) and a seek that reveals unrequested
// glyphs must not screenshot before their font subsets load. Costs one
// evaluate + one rAF when nothing is loading. Pass 0 to disable.
const waitForFontsMs = options.waitForFontsMs ?? DEFAULT_POST_SEEK_FONT_WAIT_MS;
if (waitForFontsMs > 0) {
await waitForCompositionFonts(page, waitForFontsMs);
}
if (options.settleMs !== undefined) {
const settleMs = Math.max(0, options.settleMs);
Expand Down Expand Up @@ -299,12 +305,31 @@ export async function waitForCompositionFonts(
.evaluate((ms: number) => {
const fonts = Reflect.get(document, "fonts");
if (typeof fonts !== "object" || fonts === null) return Promise.resolve();
const ready = Reflect.get(fonts, "ready");
if (!ready) return Promise.resolve();
return Promise.race([
Promise.resolve(ready).then(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, ms)),
]);
// A seek can reveal glyphs whose font faces were never requested —
// CJK @font-face splits into unicode-range subsets that only load when
// first laid out. `fonts.ready` is already-resolved at that moment, so
// awaiting it immediately races the load request and screenshots blank
// glyphs (wild reports: Traditional Chinese / Microsoft YaHei check
// snapshots). Force a synchronous layout so pending subsets actually
// start loading, give the loader one frame to flip `fonts.status`,
// THEN await readiness.
void document.body?.offsetHeight;
return new Promise<void>((resolveWait) => {
const deadline = setTimeout(resolveWait, ms);
requestAnimationFrame(() => {
const status = Reflect.get(fonts, "status");
const ready = Reflect.get(fonts, "ready");
if (status !== "loading" || !ready) {
clearTimeout(deadline);
resolveWait();
return;
}
Promise.resolve(ready).then(() => {
clearTimeout(deadline);
resolveWait();
});
});
});
}, timeoutMs)
.catch(() => {});
}
Expand Down
Loading