Summary
Two related bugs in the next/font/google shim (dist/shims/font-google-base.js) surface when developing with HMR:
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).
: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
- 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>;
}
bun dev (or npm run dev).
- In
layout.tsx, change Sen → Outfit → Jost → back to Sen, saving each time.
- 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
- 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:
- 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.
- 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.
Summary
Two related bugs in the
next/font/googleshim (dist/shims/font-google-base.js) surface when developing with HMR:classCounteraccumulates 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 inapp/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).:root { --font-primary: … }is only injected once (guarded byinjectedRootVariables). Whichever font is imported first during an HMR session cements--font-primaryat the module level — subsequent font swaps update the.__variable_…_Nclass but never the:rootrule. Any CSS that readsvar(--font-primary)from:rootkeeps using the first font, not the currently-selected one.Environment
@cloudflare/vite-plugin+tailwindcss/vite+vinext()@cloudflare/vite-pluginReproduction
next/font/googleinapp/layout.tsx:bun dev(ornpm run dev).layout.tsx, changeSen→Outfit→Jost→ back toSen, saving each time.curlit:__font_sen_0).Actual: many classes across every font I touched, e.g.:
<style data-vinext-fonts>tag. There is exactly one:root { --font-primary: …; }rule, pinned to the first font (Outfitin my case), even though the currentappFontisSen.Impact
:rootCSS variables — anyone wiring--font-sans: var(--font-primary)in their Tailwind v4@theme inline(common pattern to drive Tailwind preflight fromnext/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.<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.fontson 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:classCounter,injectedFonts,injectedClassRules,injectedVariableRules,injectedRootVariables,ssrFontStyles,ssrFontUrls,ssrFontPreloadsare 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.:rootto be rewritten pervariablename when the font family changes — the current guardif (!injectedRootVariables.has(cssVarName))prevents any subsequent font registered against the same variable from ever updating the:rootvalue. 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:
so that Tailwind preflight's
html { font-family: var(--default-font-family, var(--font-sans)) }picks up thenext/fontvariable. This works fine in real Next.js (wherenext/fontalways keeps:root's--font-primarycurrent), but silently breaks with the vinext shim after any HMR font swap —--font-primaryis pinned to the first font. Worth calling out in the shim's README, even if the:rootdedup bug is fixed.Workaround for users hitting this now
Restart the dev server after changing the font in
layout.tsx. TheclassCounterresets to0, the:rootrule is re-emitted with the current font, and the head only contains the one relevant class.