From 8bbd5a6496f0ac491e5b48956bb1d692dd7dcea5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 12:25:31 -0700 Subject: [PATCH] fix(cli): settle post-seek font subset loads before audit screenshots --- .../capture/captureCompositionFrame.test.ts | 16 +++++++- .../src/capture/captureCompositionFrame.ts | 41 +++++++++++++++---- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index c2e83396cd..84bcb73be5 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -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); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 2b0d164094..49c9af67b4 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -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((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((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(() => {}); }