Skip to content

fix(fonts): stabilize next/font/google runtime registrations#956

Merged
james-elicx merged 4 commits intocloudflare:mainfrom
NathanDrake2406:nathan/font-google-phase1
Apr 29, 2026
Merged

fix(fonts): stabilize next/font/google runtime registrations#956
james-elicx merged 4 commits intocloudflare:mainfrom
NathanDrake2406:nathan/font-google-phase1

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

Fixes #883.

The next/font/google runtime shim now gives each (family, options) registration a deterministic class identity instead of using a module-level counter. Repeated equivalent calls now reuse the same className and variable class, so dev HMR re-evaluation no longer appends a new pair of SSR style rules every time the app module reloads.

This also removes the shim-only :root { --font-... } emission. Google font variables now live only on the returned .variable class, matching Next.js behavior.

Why

The issue is not really three separate bugs. Counter runaway, stale head rules, and pinned :root variables all come from the same bad invariant: the shim treated every runtime call as a new font registration even when the call represented the same font and options.

Next.js avoids this shape entirely:

Approach

  • Replace classCounter++ with a deterministic FNV-based identity over the font family, effective font-family string, CSS variable name, public font options, and self-hosted CSS.
  • Reuse existing class/style dedupe sets by making equivalent calls produce equivalent class names.
  • Remove injectedRootVariables and the :root rule path.
  • Keep dynamic/proxy font calls on the existing runtime fallback. A full dev-time virtual CSS module transform can be a separate architecture PR.

Validation

  • Added regression tests for stable class identity, no :root font variable emission, and no SSR style growth for repeated equivalent calls.
  • Updated class-name shape assertions from counter-only suffixes to stable alphanumeric suffixes.
  • vp test run tests/font-google.test.ts
  • vp test run tests/shims.test.ts -t "next/font/google shim"
  • vp check
  • vp run vinext#build

Risks / follow-ups

Apps that accidentally relied on vinext injecting font variables at :root without applying the returned .variable class will need to apply font.variable, which is the Next.js-compatible usage. This PR intentionally does not attempt the larger Phase 2 architecture where dev font calls become Vite-owned virtual CSS modules.

Repeated next/font/google calls currently mint counter-based class names and append new SSR style rules. During dev HMR, the app module can re-evaluate while the shim module stays alive, so stale classes and links accumulate and the first emitted :root variable keeps winning.

The bad invariant was treating each runtime call as a new font registration. Use a deterministic identity for each family and options pair, remove the non-Next :root variable rule, and keep variable declarations scoped to the returned variable class.

Regression coverage verifies stable class identities, no :root emission, and no SSR style growth for repeated equivalent calls.
Copilot AI review requested due to automatic review settings April 29, 2026 07:53
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 29, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@956

commit: 2f8de11

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Stabilizes the next/font/google runtime shim so equivalent (family, options) registrations reuse deterministic class identities, preventing SSR/head growth during dev HMR and aligning variable behavior more closely with Next.js.

Changes:

  • Replace the module-level incrementing classCounter with a deterministic hash-based identity for className/variable generation.
  • Remove shim-only :root { --font-... } variable emission so variables live only on the returned .variable class.
  • Add/update regression tests for stable identities, lack of :root emission, and no SSR-style growth across repeated equivalent calls.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/vinext/src/shims/font-google-base.ts Implements deterministic font registration identities and removes :root variable injection path.
tests/font-google.test.ts Updates class-name assertions and adds regressions covering stable identity + no :root emission + no SSR style growth.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +111 to +112
function fontClassSegment(family: string): string {
return family.toLowerCase().replace(/\s+/g, "_");
Comment thread tests/font-google.test.ts Outdated
Comment on lines 349 to 351
const styles = mod.getSSRFontStyles();
const newStyles = styles.slice(beforeStyles);
const hasVar = newStyles.some((s: string) => s.includes("--font-inter"));
const hasVar = styles.some((s: string) => s.includes("--font-inter"));
expect(hasVar).toBe(true);
Comment on lines +151 to +156
normalizeStringOption(options.style),
normalizeStringOption(options.subsets),
options.display ?? "",
normalizeBooleanOption(options.preload),
normalizeStringOption(options.fallback),
normalizeStringOrBooleanOption(options.adjustFontFallback),
The first PR version left two edge cases in the new stable identity path: crafted font family names could still leak unsafe selector characters into class names, and omitted display hashed differently from explicit display: swap even though both generate the same font URL.

Sanitize the readable family segment used in selectors, normalize display to the same default used by buildGoogleFontsUrl, and tighten the CSS variable regression so it checks CSS added by the current call instead of accumulated module state.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Stabilizes the next/font/google runtime shim in vinext by generating deterministic class identities per (family, options) registration, preventing SSR <style> growth during dev/HMR re-evaluations and aligning variable behavior with Next.js (no shim-only :root emission).

Changes:

  • Replace counter-based class naming with a deterministic hashed identity for class/variable names.
  • Remove shim-only :root { --font-... } variable emission; variables live only on the returned .variable class.
  • Update/add regression tests for stable identity, no :root emission, and no SSR style growth on repeated equivalent calls.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/vinext/src/shims/font-google-base.ts Implements deterministic identity hashing, sanitizes class segments, removes :root variable rule path, updates proxy trap typing.
tests/font-google.test.ts Updates class-name shape assertions and adds regression tests for stable identity / SSR style dedupe / no :root variables.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +154 to +158
normalizeStringOption(options.weight),
normalizeStringOption(options.style),
normalizeStringOption(options.subsets),
options.display ?? "swap",
normalizeBooleanOption(options.preload),
Equivalent Google font loader options could generate the same CSS and font URL while still producing different class identities. That reintroduced duplicate SSR style rules when option arrays used a different order or representation.

The identity hash now canonicalizes unordered option sets, default-style equivalence, and fallback whitespace before hashing. A focused regression test covers reordered weights and subsets, explicit normal style, and trimmed fallback names.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix — the root cause analysis is solid and the deterministic identity approach is the right call. The normalization work and test coverage are well thought out. A few items to address:

Issues to fix:

  1. Fallback normalization inconsistency: normalizeFallbackOption preserves order (which is correct for CSS semantics) but doesn't normalize the default fallback. Calling Inter({}) and Inter({ fallback: ["sans-serif"] }) produce identical fontFamily strings and identical CSS output, but different hashes, so they won't dedupe.
  2. The fontFamily input to createFontIdentity is redundant with the individual normalized option inputs — it's computed from family + fallback, both of which are already in the hash. This isn't a bug but it couples the hash to sanitization implementation details (e.g. if escapeCSSString changes, hashes change).

Nits:
3. The normalization test at line 248 expects fallback: [" Arial ", "Helvetica"] to match fallback: ["Arial", "Helvetica"], but this only works because fontFamily (which includes the trimmed/sanitized fallback from sanitizeFallback) is in the hash AND normalizeFallbackOption also trims. It works, but the dual-path normalization is fragile — if someone changes sanitizeFallback to stop trimming, the fontFamily component diverges but normalizeFallbackOption still trims, and the test still passes by accident.
4. FNV-1a with 32 bits and base-36 gives ~2 billion possible values, which is fine for class name uniqueness in practice, but worth a brief comment noting the collision probability is acceptable for this use case.

Overall this is in good shape. The two substantive items are the default-fallback normalization gap (#1) and the redundant fontFamily hash input (#2).


function normalizeFallbackOption(value: string[] | undefined): string {
if (!value) return "";
return value.map((item) => item.trim()).join(",");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This normalizes whitespace but doesn't normalize the absence of the fallback option vs an explicit default. When options.fallback is undefined, fontLoader defaults it to ["sans-serif"] (line 442), so both Inter({}) and Inter({ fallback: ["sans-serif"] }) produce identical fontFamily strings and identical CSS — but normalizeFallbackOption(undefined) returns "" while normalizeFallbackOption(["sans-serif"]) returns "sans-serif", giving them different hashes.

This means the dedupe goal is partially defeated for the most common case (omitting fallback since ["sans-serif"] is the default).

Suggested change
return value.map((item) => item.trim()).join(",");
function normalizeFallbackOption(value: string[] | undefined, defaultFallback: string[] = ["sans-serif"]): string {
const resolved = value ?? defaultFallback;
return resolved.map((item) => item.trim()).join(",");
}

Or alternatively, resolve the default before calling createFontIdentity and pass the resolved array.

return hashString(
[
family,
fontFamily,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including fontFamily in the hash is redundant — it's fully determined by family + options.fallback, which are already hashed individually (lines 173 and 182). This creates a fragile coupling: the hash now depends on the exact output format of sanitizeFallback/escapeCSSString. If those ever change their quoting style (e.g., switching to double quotes), all hashes change and previously-cached identities break.

Consider dropping fontFamily from the hash input and relying on the already-normalized family + normalizeFallbackOption(options.fallback) instead.

Suggested change
fontFamily,
return hashString(
[
family,
cssVarName,
normalizeWeightOption(options.weight),
normalizeStyleOption(options.style),
normalizeStringSetOption(options.subsets),
options.display ?? "swap",
normalizeBooleanOption(options.preload),
normalizeFallbackOption(options.fallback),
normalizeStringOrBooleanOption(options.adjustFontFallback),
normalizeStringSetOption(options.axes),
options._selfHostedCSS ?? "",
].join("\0"),
);

const cssVarName = options.variable
? (sanitizeCSSVarName(options.variable) ?? defaultVarName)
: defaultVarName;
const id = createFontIdentity(family, options, cssVarName, fontFamily);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fontFamily passed here is already resolved with the default fallback (options.fallback ?? ["sans-serif"]), but options.fallback inside createFontIdentity is still the raw undefined. This means the hash sees the resolved fontFamily string (with sans-serif) but normalizeFallbackOption(options.fallback) returns "" when fallback was omitted.

Two options:

  1. Resolve options.fallback to the default before calling createFontIdentity
  2. Drop fontFamily from the hash (see my other comment) and normalize fallback with its default

Either way, the pre-resolution and post-resolution views of fallback should be consistent in the hash.

Comment thread tests/font-google.test.ts Outdated
Sen({ subsets: ["latin"], variable: "--font-primary" });
Outfit({ subsets: ["latin"], variable: "--font-primary" });

const styles = getSSRFontStyles().join("\n");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As Copilot flagged — this test's :root assertion operates on the full accumulated ssrFontStyles, which includes styles from all prior tests in the file. The assertion expect(styles).not.toContain(":root") only proves no prior test injected :root either, not specifically that Sen/Outfit didn't.

This isn't a false positive today because the :root path is fully removed, but if a future change reintroduces :root emission for a different code path, this test would start failing for the wrong reason. Consider capturing getSSRFontStyles().length before the Sen/Outfit calls and asserting only on the newly-added slice:

Suggested change
const styles = getSSRFontStyles().join("\n");
const { createFontLoader, getSSRFontStyles } =
await import("../packages/vinext/src/shims/font-google.js");
const before = getSSRFontStyles();
const Sen = createFontLoader("Sen");
const Outfit = createFontLoader("Outfit");
Sen({ subsets: ["latin"], variable: "--font-primary" });
Outfit({ subsets: ["latin"], variable: "--font-primary" });
const added = getSSRFontStyles().slice(before.length).join("\n");
expect(added).not.toContain(":root");
expect(added).toContain("--font-primary: 'Sen'");
expect(added).toContain("--font-primary: 'Outfit'");

Comment thread tests/font-google.test.ts
expect(explicit.variable).toBe(implicit.variable);
});

it("normalizes identity inputs that emit equivalent Google font output", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice test — this covers the key normalization cases (weight reorder, style ["normal"] collapse, subset reorder, fallback whitespace). Consider also adding an explicit case for the default-fallback equivalence, which is arguably the most common real-world scenario:

const withDefault = CanonicalIdentity({ weight: ["400"] });
const withExplicit = CanonicalIdentity({ weight: ["400"], fallback: ["sans-serif"] });
expect(withExplicit.className).toBe(withDefault.className);

(This would currently fail due to the normalization gap I flagged on the implementation.)

hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36).padStart(7, "0");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: padStart(7, "0") means hashes always have 7 characters, but (2^32).toString(36) is "1z141z4" — also 7 characters. So padding only matters for small hash values. The max is 7 chars, which is fine. Just noting this is a cosmetic choice that works correctly.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 29, 2026

Review posted successfully. Here's a summary of what I flagged:

Key issues:

  1. Default fallback normalization gap (font-google-base.ts:144): Inter({}) and Inter({ fallback: ["sans-serif"] }) produce identical CSS but different hashes because normalizeFallbackOption(undefined) returns "" while the resolved fallback produces "sans-serif". This partially defeats the dedupe goal for the most common usage pattern.

  2. Redundant fontFamily in hash (font-google-base.ts:175): The computed fontFamily string is fully determined by family + options.fallback, which are already hashed individually. Including it couples the hash to escapeCSSString/sanitizeFallback implementation details.

  3. Inconsistency between resolved and raw fallback (font-google-base.ts:451): fontFamily sees the resolved default ["sans-serif"] but options.fallback inside createFontIdentity is still undefined.

Test feedback:

  1. :root test uses accumulated state (font-google.test.ts:265): Should snapshot before and assert only on the newly-added slice to avoid false failures from unrelated future changes.

  2. Missing default-fallback equivalence test (font-google.test.ts:237): The normalization test covers weight/style/subset reorder but not the most common real-world case — omitted vs explicit default fallback.

github run

Omitting next/font/google fallback produced the same CSS as fallback: ["sans-serif"] but still hashed a different raw option shape. That left the most common default call path outside the deterministic identity dedupe.

The identity hash now uses the resolved fallback list and no longer includes the serialized font-family CSS string. Regression coverage verifies implicit and explicit default fallback calls share class and variable identities, and the :root assertion now checks only styles added by its own test.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix. The root cause analysis is correct — counter-based identity was the single invariant violation behind all three reported symptoms. The deterministic FNV hash approach is the right call for Phase 1, and the normalization work is thorough.

All previous review feedback has been addressed:

  • Default fallback normalization gap: fixed by passing the resolved fallback (already defaulted to ["sans-serif"]) into createFontIdentity as a separate parameter, so normalizeFallbackOption always sees the resolved array.
  • Redundant fontFamily in hash: removed.
  • :root test accumulated state: fixed with beforeStyles slice.
  • Default-fallback equivalence test: added (line 256).
  • display: "swap" default normalization: handled via options.display ?? "swap".
  • CSS injection via class selectors: handled by fontClassSegment sanitization.
  • Proxy get typing: improved with string | symbol + early guard.

One minor nit below (dead code). Not blocking.

// The proxy intercepts property access and returns a loader for that font.
const googleFonts = new Proxy({} as Record<string, (options?: FontOptions) => FontResult>, {
get(_target, prop: string) {
const googleFontLoaders: Record<string, FontLoader> = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: googleFontLoaders is declared and used as the Proxy target, but the get trap never populates it — every property access creates a fresh FontLoader closure via createFontLoader(family). This makes the typed record effectively dead code.

Not a correctness issue since identity stability is at the fontLoader() call level. But you could cache on the target to avoid re-creating the closure on every property access (e.g., HMR re-evaluation hitting const inter = Inter(...) re-evaluates Inter from the proxy each time):

get(_target, prop: string | symbol) {
    if (typeof prop !== "string") return undefined;
    if (prop === "__esModule") return true;
    if (prop === "default") return googleFonts;
    if (prop in _target) return _target[prop];
    const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
    return (_target[prop] = createFontLoader(family));
}

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 29, 2026

Review submitted successfully. Here's a summary:

Approved PR #956 with one minor nit.

All previous review feedback from both Copilot and the earlier bonk review has been addressed across the 4 commits. The key fixes:

  1. Default fallback normalization — resolved by passing the already-defaulted fallback array into createFontIdentity as a separate parameter, so the hash always sees ["sans-serif"] regardless of whether the user omitted or explicitly specified it.
  2. Redundant fontFamily in hash — removed, eliminating the fragile coupling to escapeCSSString/sanitizeFallback implementation details.
  3. Test isolation:root test and valid-variable test now use beforeStyles slicing instead of accumulated module state.

The one nit I flagged: googleFontLoaders is declared as the Proxy target but never populated by the get trap, making it dead code. Caching loaders on the target would avoid re-creating closures on every property access during HMR.

github run

@james-elicx james-elicx merged commit c63155d into cloudflare:main Apr 29, 2026
26 checks passed
@NathanDrake2406 NathanDrake2406 deleted the nathan/font-google-phase1 branch May 6, 2026 04:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

next/font/google shim: HMR accumulates stale font classes and :root CSS variable gets pinned to the first font

3 participants