Skip to content

fix(font): resolve next/font/local paths inside node_modules packages - #1780

Merged
james-elicx merged 2 commits into
mainfrom
fix/local-font-node-modules
Jun 5, 2026
Merged

fix(font): resolve next/font/local paths inside node_modules packages#1780
james-elicx merged 2 commits into
mainfrom
fix/local-font-node-modules

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Problem

vinext's localFont shim works (CSS classes + @font-face are generated), but for npm packages that wrap next/font/local the font file URL was the raw relative path from the package module rather than a Vite-resolvable URL.

For example, geist/dist/mono.js calls:

import localFont from "next/font/local";
export const GeistMono = localFont({
  src: "./fonts/geist-mono/GeistMono-Variable.woff2",
  variable: "--font-geist-mono",
});

The generated @font-face { src: url(...) } referenced ./fonts/geist-mono/GeistMono-Variable.woff2 (relative to the package's dist/ dir), which 404s at request time.

Root cause

createLocalFontsPlugin (packages/vinext/src/plugins/fonts.ts) excluded node_modules files — both via the filter (filter.id.exclude: /node_modules/) and a defensive if (id.includes("node_modules")) return null; guard. So the transform that promotes relative src paths to Vite asset imports never ran on package files. This affects any npm package wrapping next/font/local.

Fix

Remove the node_modules exclusion from both the filter and the defensive guard so package files are transformed like user source — matching Next.js, whose font loader also runs on these package files. The code: "next/font/local" filter plus the required default-import check keep the transform from touching unrelated modules, and the font-local shim guard still skips vinext's own shim when it lives under node_modules/vinext/.../shims/font-local.*.

Verification

  • Reproduced at the unit level: the transform previously returned null for a node_modules/geist/dist/mono.js id.
  • Verified end-to-end with a synthetic node_modules font package built through the full App Router pipeline: before the fix the relative path leaked; after the fix the font is emitted as a hashed asset (_next/static/MyMono-Variable-<hash>.woff2) and the raw relative path no longer appears in any bundle. (The App Router renders the package server-side via noExternal, so the asset import resolves rather than being bypassed by the dep optimizer.)

Tests

  • Replaced the test that encoded the buggy behavior (returns null for node_modules files) with a regression test asserting geist-style node_modules packages are transformed.
  • Added a test confirming vinext's own font-local shim is still skipped inside node_modules.
  • All 37 font-local tests and 1058 shim tests pass; vp check is clean.

The vinext:local-fonts transform excluded node_modules files, so npm
packages that wrap next/font/local (e.g. geist) never had their relative
font src paths promoted to Vite asset imports. The raw relative path
(e.g. ./fonts/geist-mono/GeistMono-Variable.woff2) leaked into the
runtime @font-face src and 404'd at request time.

Remove the node_modules exclusion from the filter and defensive guard so
package files are transformed like user source, matching Next.js's font
loader. The code filter plus the required default-import check keep the
transform from touching unrelated modules, and the font-local shim guard
still skips vinext's own shim when installed as a dependency.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1780
npm i https://pkg.pr.new/vinext@1780

commit: d0a0517

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx
james-elicx marked this pull request as ready for review June 5, 2026 23:51

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(font): resolve next/font/local paths inside node_modules packages

The fix is correct and well-targeted. Removing the node_modules exclusion brings vinext:local-fonts in line with vinext:google-fonts (which never excluded node_modules — see its filter at packages/vinext/src/plugins/fonts.ts:717-722), and matches Next.js's font loader, which runs on package files too. The code: "next/font/local" filter + the required default-import check (fonts.ts:1154-1156) keep the transform from touching unrelated modules. Tests pass and vp check is clean.

One correctness concern worth considering before merge:

The id.includes("font-local") shim guard is now a broader false-positive surface (fonts.ts:1149)

if (id.includes("font-local")) return null;

Before this PR, this substring check only ran against user source (node_modules was excluded). Now it runs against every node_modules file that imports next/font/local. The intent is to skip vinext's own shim at node_modules/vinext/.../shims/font-local.js, but includes("font-local") is a loose substring match anywhere in the path. Any legitimate third-party package that both wraps next/font/local and has font-local somewhere in its resolved path would now be silently skipped, and its relative font paths would leak again — the exact bug this PR fixes. Realistic examples:

  • a package named font-local-loader or @scope/font-local-something
  • a package that ships fonts under a directory like dist/font-local/... (the id is the importing module path, but a wrapper module living in such a dir would match)

Compare with the sibling vinext:google-fonts plugin, which avoids this by taking shimsDir as a parameter and using a precise prefix check:

// createGoogleFontsPlugin(fontGoogleShimPath, shimsDir)
if (id.startsWith(shimsDir)) return null;   // fonts.ts:728

createLocalFontsPlugin() currently takes no arguments (fonts.ts:1117, wired at index.ts:3927), so it can't do the same precise check. Two options:

  1. Preferred — mirror google-fonts: pass shimsDir (or the resolved font-local shim path) into createLocalFontsPlugin(shimsDir) and guard with id.startsWith(shimsDir) / exact-path match. This eliminates the false-positive class entirely and keeps the two font plugins symmetric.
  2. Minimal: tighten the substring to something that can only be the shim, e.g. matching a path segment like /shims/font-local. rather than the bare font-local substring.

This isn't hypothetical-only: the whole point of the PR is that real packages wrap next/font/local, so the guard is now exercised against arbitrary package paths rather than just user source. Worth tightening in the same PR since it's the one introducing the exposure.

Non-blocking notes

  • The dep-optimizer caveat called out in the PR description (transform is bypassed unless the package is bundled via noExternal) is accurate. SSR/RSC environments set noExternal: true (index.ts:1792, 2020, 2045), so the App Router server path works as verified. A font package imported only from a "use client" component on the client environment could still be pre-bundled by the dep optimizer without this enforce: "pre" transform running — but that's a pre-existing limitation shared with google-fonts and not a regression here. Fine to track separately if it comes up.
  • Test coverage is good. Consider adding one negative case to lock in whichever guard fix you choose — e.g. a node_modules/font-local-something/dist/index.js package that should be transformed (option 1) — so a future loosening of the guard is caught.

Overall: the core change is right and safe to ship; I'd just tighten the shim guard before merge given it now sees all node_modules paths.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1780.

Summary

The fix is correct and well-targeted. Removing the node_modules exclusion makes vinext:local-fonts consistent with vinext:google-fonts (which never excluded node_modules) and matches Next.js's font loader behavior. Tests pass (37/37) and vp check is clean.

I flagged one correctness concern worth addressing before merge:

  • id.includes("font-local") shim guard (fonts.ts:1149) is now a broader false-positive surface. Before this PR it only ran against user source; now it runs against every node_modules file importing next/font/local. The loose substring match would silently skip any legitimate third-party package whose path contains font-local (e.g. font-local-loader, or fonts shipped under a font-local/ dir), reintroducing the exact bug being fixed. The sibling google-fonts plugin avoids this by taking shimsDir and using a precise id.startsWith(shimsDir) check — recommended to mirror that here.

Non-blocking notes:

  • The dep-optimizer caveat in the PR description is accurate; SSR/RSC noExternal: true makes the App Router path work as verified. Client-only "use client" font package imports remain a pre-existing shared limitation, not a regression.
  • Suggested adding a negative test (a non-shim font-local-named package that should transform) to lock in whichever guard fix is chosen.

github run

Now that node_modules is no longer excluded, the shim guard runs against
arbitrary third-party package paths. Replace the loose
`id.includes("font-local")` substring check with a precise
`id.startsWith(shimsDir)` prefix check, mirroring createGoogleFontsPlugin,
so legitimate packages whose path contains "font-local" (e.g.
font-local-loader) are still transformed instead of silently skipped.

Pass _shimsDir into createLocalFontsPlugin and add a regression test for a
third-party font-local-named package that must still be transformed.
@james-elicx

Copy link
Copy Markdown
Member Author

Addressed the shim-guard concern in d0a0517.

Replaced the loose id.includes("font-local") substring check with a precise prefix check, mirroring createGoogleFontsPlugin:

  • createLocalFontsPlugin now takes shimsDir (wired with _shimsDir at index.ts:3927) and guards with id.startsWith(shimsDir).
  • This skips only files under vinext's own shims directory, so legitimate third-party packages whose path contains font-local (e.g. font-local-loader, or fonts shipped under a font-local/ dir) are still transformed.

Added a regression test (transforms third-party packages whose path contains 'font-local') that builds a node_modules/font-local-loader/dist/index.js package and asserts it IS transformed, locking in against a future loosening of the guard. Updated the shim-skip test to use the real resolved shim path. All 38 font-local tests pass and vp check is clean.

Left the dep-optimizer / client-only "use client" caveat as a pre-existing shared limitation (not a regression) per the non-blocking note.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Re-review: shim-guard concern fully addressed ✅

Re-reviewed after d0a0517. The correctness concern from the prior review (loose id.includes("font-local") substring match becoming a false-positive surface once node_modules is no longer excluded) is properly resolved via the preferred option.

What was fixed correctly

  • Precise prefix guard. createLocalFontsPlugin now takes shimsDir and guards with id.startsWith(shimsDir) (fonts.ts:1161), replacing the bare font-local substring check. This eliminates the false-positive class entirely.
  • Full parity with google-fonts. The local-fonts plugin is now symmetric with createGoogleFontsPlugin — identical guard (id.startsWith(shimsDir), fonts.ts:728 vs new), identical filter shape (only the code string differs), same shimsDir source.
  • Wiring is correct. _shimsDir (index.ts:531) is path.resolve(__dirname, "shims") + "/" (trailing separator, so the prefix check can't partial-match a sibling dir), passed at index.ts:3927. The next/font/local alias resolves to path.join(shimsDir, "font-local") (index.ts:1393), so vinext's own shim module id is guaranteed to live under shimsDir and is correctly skipped.
  • Negative test added. transforms third-party packages whose path contains 'font-local' builds node_modules/font-local-loader/dist/index.js and asserts it IS transformed — locks in against a future loosening. The shim-skip test now uses the real resolved shim path rather than an arbitrary node_modules path, so it genuinely exercises the new guard.

Verification

  • vp test run tests/font-local-transform.test.ts → 38/38 pass.
  • Single call site (index.ts:3927); no stale zero-arg callers in tests or examples.

Non-blocking (unchanged from prior review)

  • The dep-optimizer / client-only "use client" caveat remains a pre-existing limitation shared with google-fonts, not a regression introduced here. Fine to track separately.

Core change is right, the guard is now tight and symmetric with the sibling plugin, and the regression coverage is solid. LGTM to ship.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Re-review posted to PR #1780.

Summary

The shim-guard concern from the prior review was fully addressed in commit d0a0517 using the preferred option:

  • Loose id.includes("font-local") substring check replaced with a precise id.startsWith(shimsDir) prefix check (fonts.ts:1161), eliminating the false-positive class where legitimate third-party packages (e.g. font-local-loader) would be silently skipped.
  • Full parity with createGoogleFontsPlugin — same guard, same shimsDir source, same filter shape.
  • Wiring verified correct: _shimsDir has a trailing separator (no partial-match), the next/font/local alias resolves under shimsDir so vinext's own shim is reliably skipped, single call site updated at index.ts:3927, no stale zero-arg callers.
  • Negative test added (font-local-loader package IS transformed) plus the shim-skip test now uses the real resolved shim path.
  • All 38 font-local tests pass.

No new issues found. The change is correct, tight, symmetric with the sibling plugin, and well-tested. LGTM to ship.

github run

@james-elicx
james-elicx merged commit 726452c into main Jun 5, 2026
40 checks passed
@james-elicx
james-elicx deleted the fix/local-font-node-modules branch June 5, 2026 23:59
@github-actions github-actions Bot mentioned this pull request Jun 6, 2026
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.

1 participant