Skip to content

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

@eashish93

Description

@eashish93

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions