fix(producer): cache the google fonts css lookup per process (#4097)

* fix(producer): cache the google fonts css lookup per process

Studio compiles every composition in a project separately for its
sidebar thumbnails and for gsap-animation extraction. Each compile
called injectDeterministicFontFaces, which re-fetched the same font
family's CSS metadata from fonts.googleapis.com over the network every
single time -- only the woff2 binary files were cached to disk, not
the CSS lookup itself. A project with a dozen small compositions and
three declared font families fired dozens of redundant network round
trips serially, holding the editor's asset-readiness overlay well past
its own 8-second timeout.

Adds an in-process cache for the CSS lookup keyed by the exact request
URL, self-evicting on failure so a later call still retries. Measured
on a real project: gsap-animation extraction across 12 compositions
dropped from ~16.4s to ~2.9s wall time, and per-composition thumbnail
generation dropped from 9-11s each to 10-14ms each once the cache is
warm.

* fix(producer): isolate the google fonts css cache from any one caller's abort

An adversarial review of the process-wide CSS-lookup cache found two real
bugs before it shipped. First, the cached promise carried whichever caller
reached it first's AbortSignal, so one render's cancellation could fail a
completely unrelated concurrent render sharing the same font family. Second,
the cache has no reset between test files running in one process, so two
sibling test suites asserting per-call fetch counts against the same
Google Fonts URL silently saw each other's cached result.

Strips the caller-specific AbortSignal from the shared fetch and races each
caller's own signal against it independently, so a caller can still bail out
on its own cancellation without affecting anyone else awaiting the same
lookup. Clears the cache in the two sibling test files' own beforeEach.

* fix(producer): key the google fonts css cache by fail-closed mode and drop transient statuses
This commit is contained in:
Miguel Ángel
2026-09-18 13:47:24 -04:00
committed by GitHub
parent 15460ec541
commit a7ec66b124
4 changed files with 152 additions and 11 deletions
@@ -16,11 +16,17 @@
* so they are hermetic.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
import { _clearGoogleFontCssCacheForTests } from "./deterministicFonts.js";
// The Google Fonts CSS cache is process-lifetime and keyed by URL; several
// tests here request the same family, so a leftover entry from an earlier
// test would silently skip this test's own fetchImpl.
beforeEach(() => _clearGoogleFontCssCacheForTests());
let cacheDir: string;
let prevCacheEnv: string | undefined;
@@ -14,8 +14,9 @@
* The tests inject `fetchImpl` so no real network call happens.
*/
import { describe, expect, it } from "bun:test";
import { beforeEach, describe, expect, it } from "bun:test";
import {
_clearGoogleFontCssCacheForTests,
FONT_FETCH_FAILED,
FONT_FETCH_UNAVAILABLE,
FontFetchError,
@@ -23,6 +24,11 @@ import {
injectDeterministicFontFaces,
} from "./deterministicFonts.js";
// The Google Fonts CSS cache is process-lifetime and keyed by URL; several
// tests here request the same unresolved-font URL, so a leftover entry from
// an earlier test would silently skip this test's own fetchImpl.
beforeEach(() => _clearGoogleFontCssCacheForTests());
// HTML that requests a font NOT in FONT_ALIASES, so the resolver falls
// through to the Google Fonts fetch path. (Bundled fonts like Inter
// bypass fetch entirely.)
@@ -1,7 +1,11 @@
import { describe, expect, it } from "bun:test";
import { beforeEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
FONT_ALIASES,
FONT_ALIAS_KEYS,
_clearGoogleFontCssCacheForTests,
injectDeterministicFontFaces,
} from "./deterministicFonts.js";
@@ -45,6 +49,87 @@ describe("existing font-face recognition", () => {
});
});
describe("Google Fonts CSS request caching", () => {
beforeEach(() => _clearGoogleFontCssCacheForTests());
it("reuses one CSS lookup across separate compiles requesting the same family", async () => {
// Same character repertoire (just reordered) so both compute the same
// Google Fonts `text=` subset and thus the same request URL — isolates
// the cache from font-text extraction, covered elsewhere.
const htmlA = `<html><body><p style="font-family: 'Cache Probe Font';">Hello</p></body></html>`;
const htmlB = `<html><body><p style="font-family: 'Cache Probe Font';">olleH</p></body></html>`;
let requests = 0;
const fetchImpl = Object.assign(
async () => {
requests += 1;
return new Response("", { status: 404 });
},
{ preconnect: fetch.preconnect },
);
await injectDeterministicFontFaces(htmlA, { fetchImpl, allowSystemFontCapture: false });
await injectDeterministicFontFaces(htmlB, { fetchImpl, allowSystemFontCapture: false });
expect(requests).toBe(1);
});
it("does not fail a concurrent caller when another caller sharing the lookup aborts", async () => {
const prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR;
const cacheDir = mkdtempSync(join(tmpdir(), "hf-font-abort-isolation-"));
process.env.HYPERFRAMES_FONT_CACHE_DIR = cacheDir;
try {
const htmlA = `<html><head></head><body><p style="font-family: 'Abort Isolation Font';">Hello</p></body></html>`;
const htmlB = `<html><head></head><body><p style="font-family: 'Abort Isolation Font';">olleH</p></body></html>`;
const woff2Url = "https://fonts.gstatic.com/s/abortisolationfont/v1/font.woff2";
const cssBody =
`@font-face { font-style: normal; font-weight: 400; ` +
`src: url(${woff2Url}) format('woff2'); }`;
let resolveCss: (() => void) | undefined;
// Mirrors real fetch(): the CSS request rejects if its own `init.signal`
// aborts, so this proves the shared lookup isn't wired to any one
// caller's signal. The woff2 request always succeeds immediately —
// only the shared CSS lookup is under test here.
const fetchImpl = Object.assign(
async (url: string, init?: RequestInit) => {
if (!url.includes("css2")) return new Response(new Uint8Array([0, 1, 2, 3]));
return new Promise<Response>((resolve, reject) => {
resolveCss = () => resolve(new Response(cssBody, { status: 200 }));
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError")),
);
});
},
{ preconnect: fetch.preconnect },
);
const controllerA = new AbortController();
// A must populate the cache entry first, and both resolve to a real
// face (not a 404) so failClosedFontFetch's own "unresolved font"
// check can't confound the result with a leaked abort.
const resultA = injectDeterministicFontFaces(htmlA, {
fetchImpl,
allowSystemFontCapture: false,
failClosedFontFetch: true,
abortSignal: controllerA.signal,
});
while (!resolveCss) await new Promise((r) => setTimeout(r, 0));
const resultB = injectDeterministicFontFaces(htmlB, {
fetchImpl,
allowSystemFontCapture: false,
failClosedFontFetch: true,
});
await new Promise((r) => setTimeout(r, 0));
controllerA.abort();
resolveCss();
await expect(resultA).rejects.toThrow();
expect(await resultB).toContain("@font-face");
} finally {
if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv;
rmSync(cacheDir, { recursive: true, force: true });
}
});
});
describe("FONT_ALIASES cross-platform coverage", () => {
it("maps macOS sans-serif system fonts to inter", () => {
expect(FONT_ALIASES["sf pro"]).toBe("inter");
@@ -1118,6 +1118,57 @@ async function ensureWoff2DataUri(
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
}
// Per-process cache for Google Fonts CSS lookups, keyed by request URL —
// repeat compiles of the same family reuse one network round trip. A
// rejected lookup evicts itself so a later call still retries.
const googleFontCssCache = new Map<string, Promise<{ ok: true; body: string } | { ok: false }>>();
/** Test-only reset — the cache is otherwise process-lifetime, shared across calls. */
export function _clearGoogleFontCssCacheForTests(): void {
googleFontCssCache.clear();
}
/** Rejects with `signal`'s own abort reason without cancelling `promise` itself. */
function raceAgainstAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(callerAbortReason(signal));
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(callerAbortReason(signal));
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
});
}
function fetchGoogleFontCss(
url: string,
familyName: string,
options: InternalFontFetchOptions,
): Promise<{ ok: true; body: string } | { ok: false }> {
// Fail-closed callers retry and throw where lenient ones don't, so they never share an entry.
const key = `${options.failClosedFontFetch ? "closed" : "open"}:${url}`;
let shared = googleFontCssCache.get(key);
if (!shared) {
// The shared fetch must not carry any one caller's abortSignal, or that
// caller cancelling would fail the lookup for every other waiter.
shared = fetchFontResource(
url,
{ headers: { "User-Agent": WOFF2_USER_AGENT } },
(response) => response.text(),
familyName,
"Google Fonts CSS",
{ ...options, abortSignal: undefined },
).then((result) => {
if (result.ok) return { ok: true as const, body: result.body };
// A transient status must not stick for the process lifetime.
if (isRetryableFontFetchStatus(result.response.status)) googleFontCssCache.delete(key);
return { ok: false as const };
});
googleFontCssCache.set(key, shared);
shared.catch(() => googleFontCssCache.delete(key));
}
return raceAgainstAbort(shared, options.abortSignal);
}
async function fetchGoogleFont(
familyName: string,
options: InternalFontFetchOptions,
@@ -1136,14 +1187,7 @@ async function fetchGoogleFont(
let cssText: string;
try {
const cssResult = await fetchFontResource(
url,
{ headers: { "User-Agent": WOFF2_USER_AGENT } },
(response) => response.text(),
familyName,
"Google Fonts CSS",
options,
);
const cssResult = await fetchGoogleFontCss(url, familyName, options);
if (!cssResult.ok) {
// 4xx is a *deterministic* answer from Google Fonts that this
// family is not served (e.g. HTTP 400 for "Segoe UI", "Arial",