fix(producer): deterministic font faces win over later font stylesheets (#4359)

This commit is contained in:
Miguel Ángel
2026-09-23 17:55:28 -04:00
committed by GitHub
parent 922be55e55
commit 00ea884d8f
2 changed files with 79 additions and 1 deletions
@@ -0,0 +1,67 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
let previousCacheDir: string | undefined;
let testCacheDir: string;
beforeAll(() => {
previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
testCacheDir = mkdtempSync(join(tmpdir(), "hf-font-order-"));
process.env.HYPERFRAMES_FONT_CACHE_DIR = testCacheDir;
});
afterAll(() => {
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
rmSync(testCacheDir, { recursive: true, force: true });
});
const fetchImpl = (async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith("https://fonts.googleapis.com/")) {
return new Response(
`@font-face { font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/s/order/${process.pid}.woff2) format('woff2'); }`,
{ status: 200 },
);
}
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
}) as typeof fetch;
let familySequence = 0;
function family(): string {
familySequence += 1;
return `OrderTestFont${process.pid}${familySequence}`;
}
async function inject(html: string): Promise<string> {
return injectDeterministicFontFaces(html, { fetchImpl, allowSystemFontCapture: false });
}
// A stylesheet declared after the deterministic faces wins the cascade for the same family
// whenever it has loaded, so early frames could differ between render workers.
const link = (name: string) =>
`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${name}" />`;
const style = (name: string) => `<style>body { font-family: "${name}", sans-serif; }</style>`;
describe("deterministic font faces come after every stylesheet", () => {
it.each([
[
"a Google Fonts link in the head",
(n: string) => `<head>${link(n)}${style(n)}</head><body>text</body>`,
],
[
"a stylesheet link in the body",
(n: string) => `<head>${style(n)}</head><body>text${link(n)}</body>`,
],
])("follows %s", async (_case, page) => {
const result = await inject(`<!doctype html><html>${page(family())}</html>`);
const injected = result.indexOf("data-hyperframes-deterministic-fonts");
expect(injected).toBeGreaterThan(-1);
expect(injected).toBeGreaterThan(result.indexOf("fonts.googleapis.com/css2"));
expect(injected).toBeGreaterThan(result.indexOf("<style>body"));
});
});
@@ -1448,6 +1448,17 @@ function resolveFontFetchRetryPolicy(
};
}
/**
* Last in document order: a stylesheet declared after the deterministic faces would win the
* cascade for the same family whenever it had loaded, so early frames could differ between renders.
*/
function placeAfterEveryStylesheet(document: Document, head: Element, styleEl: Element): void {
const stylesheets = document.querySelectorAll("style, link[rel~=stylesheet]");
const lastStylesheet = stylesheets[stylesheets.length - 1];
if (lastStylesheet) lastStylesheet.after(styleEl);
else head.appendChild(styleEl);
}
export async function injectDeterministicFontFaces(
html: string,
options: InjectDeterministicFontFacesOptions = {},
@@ -1508,7 +1519,7 @@ export async function injectDeterministicFontFaces(
const styleEl = document.createElement("style");
styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
styleEl.textContent = css;
head.insertBefore(styleEl, head.firstChild);
placeAfterEveryStylesheet(document, head, styleEl);
defaultLogger.info(
`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,