[GH-ISSUE #883] next/font/google shim: HMR accumulates stale font classes and :root CSS variable gets pinned to the first font #196

Closed
opened 2026-05-06 12:38:04 +02:00 by BreizhHardware · 1 comment

Originally created by @eashish93 on GitHub (Apr 24, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/883

Summary

Two related bugs in the next/font/google shim (dist/shims/font-google-base.js) surface when developing with HMR:

  1. classCounter accumulates across hot reloads, causing the SSR head to emit dozens of .__font_…_N { font-family: … } rules for previously-selected fonts. In a session where I was iterating on the app font in app/layout.tsx, the head ended up with 22 rules across 9 font families (Outfit, Saira, Jost, Sen, Encode Sans, Cabin, Reddit Sans, REM, AR One Sans).
  2. :root { --font-primary: … } is only injected once (guarded by injectedRootVariables). Whichever font is imported first during an HMR session cements --font-primary at the module level — subsequent font swaps update the .__variable_…_N class but never the :root rule. Any CSS that reads var(--font-primary) from :root keeps using the first font, not the currently-selected one.

Environment

  • vinext: 0.0.43
  • Vite: @cloudflare/vite-plugin + tailwindcss/vite + vinext()
  • React 19 (App Router, RSC)
  • Tailwind CSS v4
  • Runtime: Cloudflare Workers via @cloudflare/vite-plugin

Reproduction

  1. Create an App Router project with next/font/google in app/layout.tsx:
    import { Sen } from 'next/font/google';
    const appFont = Sen({ subsets: ['latin'], variable: '--font-primary' });
    export default function RootLayout({ children }) {
      return <html className={appFont.className}><body>{children}</body></html>;
    }
    
  2. bun dev (or npm run dev).
  3. In layout.tsx, change SenOutfitJost → back to Sen, saving each time.
  4. Reload the page and curl it:
    curl -s http://localhost:5100/ | grep -o '__font_[a-z_]*_[0-9]*' | sort -u
    
    Expected: one class for the current font (__font_sen_0).
    Actual: many classes across every font I touched, e.g.:
    __font_jost_7
    __font_outfit_0
    __font_outfit_1
    __font_outfit_2
    __font_saira_6
    __font_sen_8
    __font_sen_19
    __font_sen_20
    __font_sen_21
    __font_sen_22
    
  5. Inspect the <style data-vinext-fonts> tag. There is exactly one :root { --font-primary: …; } rule, pinned to the first font (Outfit in my case), even though the current appFont is Sen.

Impact

  • Head bloat in dev — 22+ rules for a single page is jarring; drowns out relevant CSS when debugging.
  • Incorrect :root CSS variables — anyone wiring --font-sans: var(--font-primary) in their Tailwind v4 @theme inline (common pattern to drive Tailwind preflight from next/font) gets the first font that was ever called during the dev session, not the current one. A full dev-server restart is required to see the actual effect of changing the font.
  • Google Fonts CDN fetch amplification — each accumulated family still gets a <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=…" /> in the head, multiplying Google Fonts CDN requests on every page load. In my case this also appears to saturate connections — document.fonts on the rendered page was missing 4 of the 9 requested families (Sen, Cabin, Reddit Sans, AR One Sans), which in turn caused the page to render in the system sans fallback instead of the intended font.

Likely fix

In src/shims/font-google-base.ts:

  1. Reset state on HMR: classCounter, injectedFonts, injectedClassRules, injectedVariableRules, injectedRootVariables, ssrFontStyles, ssrFontUrls, ssrFontPreloads are all module-level. They should be cleared when the module is hot-replaced (e.g. import.meta.hot?.dispose(() => { … })), or keyed on a dev-session identifier so stale entries don't accumulate.
  2. Allow :root to be rewritten per variable name when the font family changes — the current guard if (!injectedRootVariables.has(cssVarName)) prevents any subsequent font registered against the same variable from ever updating the :root value. Either key by (cssVarName + family) or overwrite by always emitting the latest value.

Aside — Tailwind v4 interop note

The common minform/hexere-style pattern is:

@theme inline {
  --font-sans: var(--font-primary), ui-sans-serif, ;
}

so that Tailwind preflight's html { font-family: var(--default-font-family, var(--font-sans)) } picks up the next/font variable. This works fine in real Next.js (where next/font always keeps :root's --font-primary current), but silently breaks with the vinext shim after any HMR font swap — --font-primary is pinned to the first font. Worth calling out in the shim's README, even if the :root dedup bug is fixed.

Workaround for users hitting this now

Restart the dev server after changing the font in layout.tsx. The classCounter resets to 0, the :root rule is re-emitted with the current font, and the head only contains the one relevant class.

Originally created by @eashish93 on GitHub (Apr 24, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/883 ## Summary Two related bugs in the `next/font/google` shim (`dist/shims/font-google-base.js`) surface when developing with HMR: 1. **`classCounter` accumulates across hot reloads**, causing the SSR head to emit dozens of `.__font_…_N { font-family: … }` rules for previously-selected fonts. In a session where I was iterating on the app font in `app/layout.tsx`, the head ended up with **22 rules across 9 font families** (Outfit, Saira, Jost, Sen, Encode Sans, Cabin, Reddit Sans, REM, AR One Sans). 2. **`:root { --font-primary: … }` is only injected once** (guarded by `injectedRootVariables`). Whichever font is imported first during an HMR session cements `--font-primary` at the module level — subsequent font swaps update the `.__variable_…_N` class but never the `:root` rule. Any CSS that reads `var(--font-primary)` from `:root` keeps using the first font, not the currently-selected one. ## Environment - vinext: **0.0.43** - Vite: `@cloudflare/vite-plugin` + `tailwindcss/vite` + `vinext()` - React 19 (App Router, RSC) - Tailwind CSS v4 - Runtime: Cloudflare Workers via `@cloudflare/vite-plugin` ## Reproduction 1. Create an App Router project with `next/font/google` in `app/layout.tsx`: ```tsx import { Sen } from 'next/font/google'; const appFont = Sen({ subsets: ['latin'], variable: '--font-primary' }); export default function RootLayout({ children }) { return <html className={appFont.className}><body>{children}</body></html>; } ``` 2. `bun dev` (or `npm run dev`). 3. In `layout.tsx`, change `Sen` → `Outfit` → `Jost` → back to `Sen`, saving each time. 4. Reload the page and `curl` it: ``` curl -s http://localhost:5100/ | grep -o '__font_[a-z_]*_[0-9]*' | sort -u ``` Expected: one class for the current font (`__font_sen_0`). Actual: many classes across every font I touched, e.g.: ``` __font_jost_7 __font_outfit_0 __font_outfit_1 __font_outfit_2 __font_saira_6 __font_sen_8 __font_sen_19 __font_sen_20 __font_sen_21 __font_sen_22 ``` 5. Inspect the `<style data-vinext-fonts>` tag. There is exactly one `:root { --font-primary: …; }` rule, pinned to the *first* font (`Outfit` in my case), even though the current `appFont` is `Sen`. ## Impact - **Head bloat in dev** — 22+ rules for a single page is jarring; drowns out relevant CSS when debugging. - **Incorrect `:root` CSS variables** — anyone wiring `--font-sans: var(--font-primary)` in their Tailwind v4 `@theme inline` (common pattern to drive Tailwind preflight from `next/font`) gets the *first* font that was ever called during the dev session, not the current one. A full dev-server restart is required to see the actual effect of changing the font. - **Google Fonts CDN fetch amplification** — each accumulated family still gets a `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=…" />` in the head, multiplying Google Fonts CDN requests on every page load. In my case this also appears to saturate connections — `document.fonts` on the rendered page was missing 4 of the 9 requested families (`Sen`, `Cabin`, `Reddit Sans`, `AR One Sans`), which in turn caused the page to render in the system sans fallback instead of the intended font. ## Likely fix In `src/shims/font-google-base.ts`: 1. **Reset state on HMR**: `classCounter`, `injectedFonts`, `injectedClassRules`, `injectedVariableRules`, `injectedRootVariables`, `ssrFontStyles`, `ssrFontUrls`, `ssrFontPreloads` are all module-level. They should be cleared when the module is hot-replaced (e.g. `import.meta.hot?.dispose(() => { … })`), or keyed on a dev-session identifier so stale entries don't accumulate. 2. **Allow `:root` to be rewritten per `variable` name when the font family changes** — the current guard `if (!injectedRootVariables.has(cssVarName))` prevents any subsequent font registered against the same variable from ever updating the `:root` value. Either key by `(cssVarName + family)` or overwrite by always emitting the latest value. ## Aside — Tailwind v4 interop note The common minform/hexere-style pattern is: ```css @theme inline { --font-sans: var(--font-primary), ui-sans-serif, …; } ``` so that Tailwind preflight's `html { font-family: var(--default-font-family, var(--font-sans)) }` picks up the `next/font` variable. This works fine in real Next.js (where `next/font` always keeps `:root`'s `--font-primary` current), but silently breaks with the vinext shim after any HMR font swap — `--font-primary` is pinned to the first font. Worth calling out in the shim's README, even if the `:root` dedup bug is fixed. ## Workaround for users hitting this now Restart the dev server after changing the font in `layout.tsx`. The `classCounter` resets to `0`, the `:root` rule is re-emitted with the current font, and the head only contains the one relevant class.
Author
Owner

@NathanDrake2406 commented on GitHub (Apr 25, 2026):

Investigated this. All three symptoms (counter runaway, pinned :root, head bloat with link saturation) trace to one root cause: the shim is a module-level runtime accumulator with no notion of who registered what. HMR re-evaluates the consumer (app/layout.tsx) but never the shim, so classCounter, injectedRootVariables, and ssrFontStyles keep growing.

Comparing against Next.js (packages/next/src/build/webpack/loaders/next-font-loader/):

  1. SWC rewrites Inter({...}) into import inter from 'next/font/google/target.css?{...args}'. Each unique (family, args) becomes its own bundler-owned CSS module URL.
  2. Class names are content-hashed (sha1 of CSS, first 6 chars). Same call always collides on the same id, so repeats are no-ops.
  3. css-loader owns lifecycle. Old import gone from the graph, old <style> tag detached. No accumulator anywhere.
  4. :root { --font-primary: ... } is never emitted. Variables only live on .variable { --font-primary: ... }. The cascade from <html className={font.variable}> does the global propagation. The vinext :root rule was a well-intentioned shortcut that became the pinning bug.

Proposed fix in two phases:

Phase 1 (small, ships now, ~30 LOC in shims/font-google-base.ts):

  • Replace classCounter++ with sha1(family + canonical(options)).slice(0,8). Idempotent identity.
  • Delete the :root injection and injectedRootVariables. Match Next.js exactly.
  • Add three regression tests (idempotent class names, no :root after font swap, repeat calls don't grow ssrFontStyles).

This kills the :root pinning entirely and collapses 22 rules into 1 per unique (family, options). Breaking for users who relied on the implicit :root and don't apply .variable anywhere; ship as 0.1.0 with a release note.

Phase 2 (architectural parity):

  • Extend plugins/fonts.ts to rewrite call sites in dev as well as build, into import x from 'virtual:vinext-font?import=Sen&data=...'.
  • The virtual JS module side-effect-imports virtual:vinext-font-css?..., which Vite serves through its normal CSS pipeline.
  • Vite CSS HMR then handles add/remove/dedupe, exactly as it does for *.module.css. The getSSRFontStyles/getSSRFontLinks/getSSRFontPreloads accumulators in the entries and dev server can be deleted.
  • Dynamic call sites (e.g. fonts[name]({...})) fall back to the runtime path, which still benefits from Phase 1's stable hashed ids.

Roll out behind a vinext({ fonts: { transform: 'static' } }) flag, then flip default once an example app has baked it in.

Happy to start on PR 1 if there's appetite.

cc @james-elicx for review since you've been deep in this area.

<!-- gh-comment-id:4318088108 --> @NathanDrake2406 commented on GitHub (Apr 25, 2026): Investigated this. All three symptoms (counter runaway, pinned `:root`, head bloat with link saturation) trace to one root cause: the shim is a module-level runtime accumulator with no notion of who registered what. HMR re-evaluates the consumer (`app/layout.tsx`) but never the shim, so `classCounter`, `injectedRootVariables`, and `ssrFontStyles` keep growing. Comparing against Next.js (`packages/next/src/build/webpack/loaders/next-font-loader/`): 1. SWC rewrites `Inter({...})` into `import inter from 'next/font/google/target.css?{...args}'`. Each unique `(family, args)` becomes its own bundler-owned CSS module URL. 2. Class names are content-hashed (sha1 of CSS, first 6 chars). Same call always collides on the same id, so repeats are no-ops. 3. css-loader owns lifecycle. Old import gone from the graph, old `<style>` tag detached. No accumulator anywhere. 4. `:root { --font-primary: ... }` is **never emitted**. Variables only live on `.variable { --font-primary: ... }`. The cascade from `<html className={font.variable}>` does the global propagation. The vinext `:root` rule was a well-intentioned shortcut that became the pinning bug. Proposed fix in two phases: **Phase 1 (small, ships now, ~30 LOC in `shims/font-google-base.ts`):** - Replace `classCounter++` with `sha1(family + canonical(options)).slice(0,8)`. Idempotent identity. - Delete the `:root` injection and `injectedRootVariables`. Match Next.js exactly. - Add three regression tests (idempotent class names, no `:root` after font swap, repeat calls don't grow `ssrFontStyles`). This kills the `:root` pinning entirely and collapses 22 rules into 1 per unique `(family, options)`. Breaking for users who relied on the implicit `:root` and don't apply `.variable` anywhere; ship as 0.1.0 with a release note. **Phase 2 (architectural parity):** - Extend `plugins/fonts.ts` to rewrite call sites in dev as well as build, into `import x from 'virtual:vinext-font?import=Sen&data=...'`. - The virtual JS module side-effect-imports `virtual:vinext-font-css?...`, which Vite serves through its normal CSS pipeline. - Vite CSS HMR then handles add/remove/dedupe, exactly as it does for `*.module.css`. The `getSSRFontStyles`/`getSSRFontLinks`/`getSSRFontPreloads` accumulators in the entries and dev server can be deleted. - Dynamic call sites (e.g. `fonts[name]({...})`) fall back to the runtime path, which still benefits from Phase 1's stable hashed ids. Roll out behind a `vinext({ fonts: { transform: 'static' } })` flag, then flip default once an example app has baked it in. Happy to start on PR 1 if there's appetite. cc @james-elicx for review since you've been deep in this area.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#196
No description provided.