diff --git a/packages/core/src/runtime/afterFonts.ts b/packages/core/src/runtime/afterFonts.ts index 389b75d91e..9157e9df69 100644 --- a/packages/core/src/runtime/afterFonts.ts +++ b/packages/core/src/runtime/afterFonts.ts @@ -10,8 +10,11 @@ export const FONT_WAIT_TIMEOUT_MS = 5000; export async function waitForFonts(): Promise { const fonts = document.fonts; if (!fonts) return; - // Layout starts loading the faces laid-out text uses; with none loading, ready resolves at once. + // Layout starts the faces laid-out text uses; load() starts the rest so ready covers them too. void document.body?.offsetHeight; + for (const face of Array.from(fonts)) { + if (face.status === "unloaded") void face.load().catch(() => {}); + } let timer: ReturnType | undefined; const timedOut = await Promise.race([ fonts.ready.then(() => false), diff --git a/packages/core/src/runtime/entry.afterFonts.test.ts b/packages/core/src/runtime/entry.afterFonts.test.ts index 01c76bb46a..9b5b2d4cfa 100644 --- a/packages/core/src/runtime/entry.afterFonts.test.ts +++ b/packages/core/src/runtime/entry.afterFonts.test.ts @@ -6,7 +6,7 @@ import { } from "../compiler/scriptRuns"; import { FONT_WAIT_TIMEOUT_MS, PREVIEW_SCRIPT_SLICE_MS } from "./afterFonts"; -type Face = { family: string; status: FontFaceLoadStatus }; +type Face = { family: string; status: FontFaceLoadStatus; load?: () => Promise }; // jsdom runs scripts in its own global, so they report through the shared document. const logs = (entry: string) => @@ -162,6 +162,82 @@ describe("runtime entry: composition scripts after web fonts", () => { }, ); + it("starts loading a declared face no laid-out text uses, so scripts run once it is in", async () => { + let fontsLoaded = () => {}; + let faceLoaded = () => {}; + const scriptOnly: Face = { + family: "Caveat", + status: "unloaded", + load: vi.fn(() => { + scriptOnly.status = "loading"; + return new Promise((resolve) => (faceLoaded = resolve)); + }), + }; + const inUse: Face = { family: "Inter", status: "loaded", load: vi.fn() }; + serveFonts(new Promise((resolve) => (fontsLoaded = resolve)), [scriptOnly, inUse]); + mountDeferredScripts(); + + await parseThenLoad(); + await Promise.resolve(); + expect(scriptOnly.load).toHaveBeenCalledTimes(1); + expect(inUse.load).not.toHaveBeenCalled(); + expect(log()).toBe(""); + + faceLoaded(); + scriptOnly.status = "loaded"; + fontsLoaded(); + await vi.waitFor(() => expect(window.__player).toBeDefined()); + expect(log()).toBe("a in-place b ready "); + }); + + it("runs the scripts at the timeout when a declared face fails to load or never settles", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const posted = vi.spyOn(window.parent, "postMessage").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const missing: Face = { + family: "Missing Face", + status: "unloaded", + load: vi.fn(() => { + missing.status = "error"; + return Promise.reject(new Error("404")); + }), + }; + const silent: Face = { + family: "Silent Face", + status: "unloaded", + load: vi.fn(() => { + silent.status = "loading"; + return new Promise(() => {}); + }), + }; + serveFonts(new Promise(() => {}), [ + missing, + silent, + { family: "Hanken Grotesk", status: "loading" }, + ]); + mountDeferredScripts(); + + await parseThenLoad(); + await vi.advanceTimersByTimeAsync(FONT_WAIT_TIMEOUT_MS - 1); + expect(missing.load).toHaveBeenCalledTimes(1); + expect(silent.load).toHaveBeenCalledTimes(1); + expect(log()).toBe(""); + + await vi.advanceTimersByTimeAsync(1); + expect(log()).toMatch(/^a .*b /); + expect(posted).toHaveBeenCalledWith( + expect.objectContaining({ + type: "diagnostic", + code: "runtime_font_wait_timeout", + details: { + loadingFamilies: ["Silent Face", "Hanken Grotesk"], + timeoutMs: FONT_WAIT_TIMEOUT_MS, + }, + }), + "*", + ); + }); + it("runs the scripts at the font timeout and reports the families still loading", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const posted = vi.spyOn(window.parent, "postMessage").mockImplementation(() => {});