fix(cli): settle post-seek font subset loads before audit screenshots

This commit is contained in:
Vance Ingalls
2026-07-11 14:14:08 -07:00
parent 1b95af8bbb
commit 8bbd5a6496
2 changed files with 47 additions and 10 deletions
@@ -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 () => {
@@ -128,6 +139,7 @@ describe("seekCompositionTimeline", () => {
fallbackToBridgeAndTimelines: true,
waitForPreferredSeekTargetMs: 500,
animationFrameSettle: "none",
waitForFontsMs: 0,
settleMs: 150,
});
await vi.advanceTimersByTimeAsync(150);
@@ -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.
@@ -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);
@@ -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(() => {});
}