fix(core): the fonts gate starts loading declared faces no laid-out text uses yet (#6415)

* fix(core): the fonts gate starts loading declared faces no laid-out text uses yet

Before composition scripts run, waitForFonts forces one layout and awaits
document.fonts.ready. A face that only script-built text uses has not started
loading at that point, so ready does not wait for it and the script measures
the fallback face. Start every face still unloaded after the forced layout,
under the existing timeout; a load that rejects falls through to the same
timeout warning instead of stopping the scripts.

Refs #5112

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(core): keep the fonts gate's comment share level; cover a face that never settles

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
jbernard077
2026-10-26 14:56:59 +00:00
committed by GitHub
co-authored by Claude Fable 5.1
parent d507323ef9
commit 36a24aeb85
2 changed files with 81 additions and 2 deletions
+4 -1
View File
@@ -10,8 +10,11 @@ export const FONT_WAIT_TIMEOUT_MS = 5000;
export async function waitForFonts(): Promise<void> {
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<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
fonts.ready.then(() => false),
@@ -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<unknown> };
// 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<void>((resolve) => (faceLoaded = resolve));
}),
};
const inUse: Face = { family: "Inter", status: "loaded", load: vi.fn() };
serveFonts(new Promise<void>((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(() => {});