mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 00:09:23 +02:00
[GH-ISSUE #883] next/font/google shim: HMR accumulates stale font classes and :root CSS variable gets pinned to the first font #196
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#196
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/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: Expected: one class for the current font (__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.@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, soclassCounter,injectedRootVariables, andssrFontStyleskeep growing.Comparing against Next.js (
packages/next/src/build/webpack/loaders/next-font-loader/):Inter({...})intoimport inter from 'next/font/google/target.css?{...args}'. Each unique(family, args)becomes its own bundler-owned CSS module URL.<style>tag detached. No accumulator anywhere.: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:rootrule 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):classCounter++withsha1(family + canonical(options)).slice(0,8). Idempotent identity.:rootinjection andinjectedRootVariables. Match Next.js exactly.:rootafter font swap, repeat calls don't growssrFontStyles).This kills the
:rootpinning entirely and collapses 22 rules into 1 per unique(family, options). Breaking for users who relied on the implicit:rootand don't apply.variableanywhere; ship as 0.1.0 with a release note.Phase 2 (architectural parity):
plugins/fonts.tsto rewrite call sites in dev as well as build, intoimport x from 'virtual:vinext-font?import=Sen&data=...'.virtual:vinext-font-css?..., which Vite serves through its normal CSS pipeline.*.module.css. ThegetSSRFontStyles/getSSRFontLinks/getSSRFontPreloadsaccumulators in the entries and dev server can be deleted.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.