Skip to content

fix(next/font/google): use real axis range, validate options at build time (#885)#893

Merged
james-elicx merged 4 commits intocloudflare:mainfrom
NathanDrake2406:nathan/fix-font-google-narrow-axis
Apr 27, 2026
Merged

fix(next/font/google): use real axis range, validate options at build time (#885)#893
james-elicx merged 4 commits intocloudflare:mainfrom
NathanDrake2406:nathan/fix-font-google-narrow-axis

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 25, 2026

What this changes

Wires the metadata-driven Google Fonts pipeline from #892 into the two consumers that previously hardcoded :wght@100..900. Fixes issue #885 and related Google Fonts URL bugs.

File Change
shims/font-google-base.ts Rewrite buildGoogleFontsUrl on top of the pure URL builder. No metadata in the shim, so the Worker bundle stays small. Empty options and weight: 'variable' no longer emit invalid fallback axes.
plugins/fonts.ts Replace inline URL construction with validateGoogleFontOptions + getFontAxes + buildGoogleFontsUrl. Surface validation errors and HTTP 4xx/5xx as build errors, with bounded Google response bodies.
build/google-fonts/validate.ts Report non-array axes values before font capability checks so object-form migration errors are clearer.
examples/app-router-nitro Add the required subsets option to its Geist_Mono call so the example still builds under the stricter validator.
tests/* Add regression coverage for narrow axes, italic-only URLs, weight: 'variable' fallback behavior, plugin-level axes, validation errors, HTTP error truncation, and network-error fallback.

Why

Issue #885: vinext hardcoded :wght@100..900 in two URL builders, so fonts whose wght axis is narrower (Sen 400..800, Anton 400) returned HTTP 400 from Google and rendered as sans-serif fallback. Investigation found related cases broken by the same code path: italic-only style requests dropped italic in dev fallback, the documented axes option was ignored by the plugin path, weight: 'variable' could produce a rejected dev fallback URL, and unknown families silently produced URLs that Google rejected at request time.

Approach

  1. shim: wrap the pure URL builder. Empty options now emit no axis segment; italic-only requests now keep the ital axis; unresolved weight: 'variable' is dropped in the dev fallback because the shim intentionally has no metadata.
  2. plugin: validate at transform time, fetch with the metadata-driven URL, distinguish HTTP errors from network errors. HTTP 4xx/5xx surfaces as a build error with the URL and a bounded response body; network errors still fall through silently for offline dev.
  3. tests/examples: lock in the reported regression and review feedback, including the Nitro example build, exact italic axis values, valid axes: ['opsz'] through the plugin pipeline, and clearer object-form axes errors.

Validation

  • vp test run tests/font-google.test.ts tests/google-fonts/build-url.test.ts tests/google-fonts/validate.test.ts: 120 tests pass
  • vp check examples/app-router-nitro/app/layout.tsx packages/vinext/src/plugins/fonts.ts packages/vinext/src/shims/font-google-base.ts packages/vinext/src/build/google-fonts/validate.ts tests/font-google.test.ts tests/google-fonts/validate.test.ts: clean
  • vp run --filter vinext-app-router-nitro build: pass

Risks / release notes

  • fontCache is keyed by URL. Existing on-disk caches under .vinext/fonts/<hash>/ are stale for affected fonts; one refetch per font on next build.
  • Validator is now strict about subsets when preload is enabled, matching Next.js. User code that relied on vinext's pre-fix permissiveness needs subsets: [...] or preload: false; in-tree examples now satisfy this.
  • Italic-only dev fallback requests now emit only ital=1 instead of accidentally also shipping the regular face. This matches Next.js and the intent of style: ['italic'], but it is user-visible for code that relied on the old side effect.
  • Shim's dev fallback emits no axis segment for empty options or unresolved weight: 'variable', so dev mode under dynamic options returns Google's default static face instead of a full variable range. Production uses the metadata-aware path and is unaffected.
  • The "auto-disables preload" test in port Next.js Google Fonts metadata + URL pipeline (PR 1 of 2 for #885) #892 pins on Playwrite AR Guides (empty subsets in current upstream metadata). If a future canary refresh adds a subset to that family the fixture needs to change.

Closes #885.

Copilot AI review requested due to automatic review settings April 25, 2026 09:28
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 25, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@893

commit: 50e0b79

@NathanDrake2406 NathanDrake2406 marked this pull request as draft April 25, 2026 09:29
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Replace the inline URL builder in shims/font-google-base.ts with a thin
wrapper around the pure buildGoogleFontsUrl helper from PR 1
(packages/vinext/src/build/google-fonts/build-url.ts). The shim still
takes a FontOptions object and still returns a CSS URL, so external
callers and the existing 84 unit tests are unaffected.

Two visible behaviour changes:

1. Empty options no longer emit `:wght@100..900`. They emit no axis
   segment at all, which makes Google return the default static face
   (HTTP 200) for every font, including narrow-axis families like Sen
   that previously got HTTP 400. This is the dev fallback only; the
   build plugin always pre-resolves the real axis range from metadata
   in production.

2. Italic-only requests no longer drop the ital axis. Pre-fix the outer
   guard `weights.length > 0 || styles.length > 0` entered the block
   but the inner branch only handled the wght path, so a call like
   `Inter({ style: ['italic'] })` produced `family=Inter&display=swap`
   with no italic anywhere in the URL. The shim now derives an ital
   array from style and falls back to weight '400' when ital is set
   without an explicit weight, so Google has somewhere to attach the
   ital axis in the URL.

The shim deliberately does not import font-data.json or validate.ts: a
388 KB metadata file in the Worker bundle would dwarf the rest of the
shim, and the production path already has the metadata-aware variant.

Refs cloudflare#885.
…ld errors

Replace the inline URL builder in plugins/fonts.ts with the pipeline added
in PR 1: validateGoogleFontOptions parses the call's options against the
bundled font metadata, getFontAxes resolves the actual `wght` range for
variable fonts, and buildGoogleFontsUrl emits the URL.

Three behaviour changes:

1. Variable fonts called with no `weight` now request the font's real
   axis range (Sen 400..800, Inter 100..900) instead of a hardcoded
   100..900. This is the user-visible fix for issue cloudflare#885: Sen returns
   HTTP 200 with all five face files instead of HTTP 400.

2. Validation errors surface as build errors with the source file path
   attached. Unknown family, missing weight on a static font (Anton,
   Archivo Black, etc.), unknown weight, mixing 'variable' with explicit
   weights, axes on non-variable fonts, invalid display values, and
   missing/unknown subsets are now all caught at transform time. Pre-fix
   these silently produced URLs that Google rejected at request time.

3. HTTP non-2xx responses from Google are now distinguished from network
   errors. A 4xx/5xx is wrapped in GoogleFontsHttpError and surfaced
   with the failing URL plus the response body, because pre-fix the
   silent `catch { return; }` skipped self-hosting and let the runtime
   CDN path retry the same broken URL in the browser. Network errors
   (offline dev) still fall through silently to keep `vp dev` usable
   without a connection.

The fontCache key is the URL, so existing on-disk caches under
`.vinext/fonts/<hash>/` will be stale for affected fonts after this
change. One refetch per font on the next build.

Refs cloudflare#885.
… validator throws

Five new tests in tests/font-google.test.ts plus subset additions to
existing fixtures whose Inter() / Roboto() calls were missing them.

Shim regressions:
- buildGoogleFontsUrl with empty options must not emit `:wght@100..900`
- buildGoogleFontsUrl with style-only options must keep the ital axis
  visible in the URL

Plugin regressions:
- Sen() with no weight must fetch `:wght@400..800`, not `:wght@100..900`
- unknown families throw a build error with the file path attached
- non-variable fonts called without an explicit weight throw at build
  time instead of silently emitting a broken URL
- HTTP 4xx/5xx from Google surfaces as a build error containing the URL
  and the response body
- network errors (DNS, ECONNREFUSED, AbortError) still fall through
  silently so offline dev keeps working

The existing plugin transform tests passed `Inter({ weight: '400' })`
without subsets; the validator now treats that as a missing-subsets
error (matches Next.js behaviour), so those fixtures gain
`subsets: ['latin']`. The two nested-brace regression tests still
exercise the parser's balanced-brace logic but use a benign
`_placeholder` field instead of `axes: { wght: 400 }`, which is
semantically invalid for Inter under the new validator.

Refs cloudflare#885.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/fix-font-google-narrow-axis branch from 5d82825 to 5f68e02 Compare April 26, 2026 16:15
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 26, 2026 16:15
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix — wiring the metadata-driven pipeline into the plugin is the right shape, the error layering (HTTP error vs network error) is clean, and the regression tests target the actual reported bug paths.

A few things to address before merge:

1. Real (non-test) example will break the build. The PR description says "All in-tree examples already pass subsets explicitly," but examples/app-router-nitro/app/layout.tsx calls Geist_Mono({ variable: "--font-mono" }) with no subsets and no preload: false. Under the new validator that throws Preload is enabled but no subsets were specified for font \Geist Mono``. Anyone who runs this example after the upgrade hits a hard build error. Either fix the example in this PR or call it out in the risks section.

2. Italic-only test is too weak to catch the bug it documents. expect(url).toContain("ital") passes for family=Inter:ital,wght@0,400 too (i.e. ital=0, no italic served). Worth pinning the exact axis values so a future regression on the dev fallback doesn't sneak past.

3. Behavioral change for italic-only worth flagging in release notes. Pre-port code emitted both ital=0 and ital=1 for italic-only requests; the new code emits only ital=1. This matches Next.js (and is the documented intent of style: ['italic']), but it is user-visible and not the same set of glyphs they're getting today.

4. Lost coverage for the documented axes option. The two existing brace-balancing tests previously used axes: { wght: 400 } as the nested-object payload. Renaming to _placeholder keeps the brace coverage but removes any end-to-end exercise of the axes option through the plugin → validator → URL builder path. The unit tests under tests/google-fonts/ cover the helpers, but a single plugin-level test that actually validates and emits a URL with a real axes: ['opsz'] (or similar) for a font that has non-wght variable axes would close the gap.

5. Minor — axes: { wght: 400 } (object form) is now an opaque programmer error. It's valid syntax, parses cleanly, but the validator throws Invalid axes value for font \X`, expected an array of axesfrom insidegetFontAxesonly whenweights[0] === 'variable'. For non-variable callers (the most common case), it throws Axes can only be defined for variable fonts.which is misleading when the real problem is the shape, not the font. Not a blocker, but a user upgrading from old vinext code may hit this and the message could be more explicit about "expectedstring[]`".

All of (2)–(5) are polish; (1) is the only one I'd consider a hard blocker since it ships broken-out-of-the-box example code.

Comment thread tests/font-google.test.ts Outdated
// dropped. Italic-only must now leave a visible ital axis in the URL.
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
const url = buildGoogleFontsUrl("Inter", { style: ["italic"] });
expect(url).toContain("ital");
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.

This assertion is too loose — :ital,wght@0,400 (ital=0, the regular face) would also satisfy toContain("ital"), so the test would still pass even if the bug it documents (italic dropped, regular served) regressed in the dev fallback. Tighten it to assert the actual axis values:

Suggested change
expect(url).toContain("ital");
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
const url = buildGoogleFontsUrl("Inter", { style: ["italic"] });
const decoded = decodeURIComponent(url);
// Italic-only must produce ital=1 (italic), not ital=0 (regular).
expect(decoded).toContain(":ital,wght@1,400");
expect(decoded).not.toContain("ital,wght@0,");

Comment thread tests/font-google.test.ts
const code = [
`import { Inter } from 'next/font/google';`,
`const inter = Inter({ subsets: ["latin"], axes: { wght: 400 } });`,
`const inter = Inter({ subsets: ["latin"], _placeholder: { wght: 400 } });`,
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.

Renaming axes_placeholder here keeps the brace-balancing coverage but quietly removes the only end-to-end test that exercised a real axes: ... option through the plugin → validator → URL builder path. Consider adding a separate test that uses a valid axes: ['opsz'] (or another non-wght variable axis) on a variable font that has it, asserting the URL contains the expected variable-axis segment. That keeps the brace-nesting coverage you have here and also locks in the documented axes option behavior.

// with the file path attached so Vite reports the offending
// call site instead of a generic plugin error.
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[vinext:google-fonts] ${id}: ${message}`);
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.

Two small ergonomic improvements worth considering:

  1. The [vinext:google-fonts] ${id}: ${message} prefix is good, but Vite already prefixes plugin errors with the plugin name. You'll end up with something like [plugin vinext:google-fonts] [vinext:google-fonts] /app/layout.tsx: Unknown font.... Consider dropping the manual prefix or using this.error(...) inside the transform hook so Vite formats the location automatically.

  2. For users upgrading old code that passed axes: { wght: 400 } (object form rather than string[]), the resulting error is Axes can only be defined for variable fonts. for non-variable fonts (misleading) or Invalid axes value for font X, expected an array of axes for variable fonts (better, but only after the variable check passes). A pre-flight check on the option shape would give a clearer migration message — not blocking, but would smooth the upgrade for anyone who copy-pasted from old Next.js examples that occasionally show the object form.

Comment thread packages/vinext/src/plugins/fonts.ts Outdated
// through to a CDN URL that ships the same bad request
// to the browser.
throw new Error(
`[vinext:google-fonts] ${id}: Google Fonts returned HTTP ${err.status} for ${err.url}.\n${err.responseBody.trim() || "(empty response body)"}`,
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.

Including err.responseBody directly in the thrown message is helpful for the URL+axis case (Google's CSS comment is short and informative), but for some failure modes Google returns an HTML error page that is several KB. Consider truncating the body to a sane length (e.g. 500 chars) to avoid hostile noise in the build output:

Suggested change
`[vinext:google-fonts] ${id}: Google Fonts returned HTTP ${err.status} for ${err.url}.\n${err.responseBody.trim() || "(empty response body)"}`,
const body = err.responseBody.trim();
const truncated = body.length > 500 ? `${body.slice(0, 500)}\n... (truncated ${body.length - 500} bytes)` : body;
throw new Error(
`[vinext:google-fonts] ${id}: Google Fonts returned HTTP ${err.status} for ${err.url}.\n${truncated || "(empty response body)"}`,
);

// The plugin's metadata-aware path covers the variable-font case in
// production.
const wght = weights.length > 0 ? weights : ital ? ["400"] : undefined;

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.

Edge case worth a comment or guard: if a caller in dev passes weight: 'variable' (the same sentinel the plugin uses), this code emits :wght@variable which Google rejects with HTTP 400 — same failure mode as the bug being fixed, just on a different code path. Pre-port code had the same behavior so it isn't a regression, but since this whole rewrite is about not producing URLs Google rejects, it would be worth either:

  1. Special-casing weights = ['variable'] to drop the wght axis entirely (matches "empty options" behavior), or
  2. Adding a one-line comment that this case is intentionally unhandled in the dev fallback because the shim has no metadata.

The production path covers it correctly via getFontAxes, so this is purely about the dev experience.

const wght = weights.length > 0 ? weights : ital ? ["400"] : undefined;

return buildUrlFromAxes(family, { wght, ital }, options.display ?? "swap");
}
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.

Behavioral note for the changelog: pre-port code emitted both ital=0 and ital=1 for italic-only requests (so the regular face also shipped); the new code emits only ital=1. This matches Next.js and is the documented intent of style: ['italic'], but it is a user-visible diff — anyone relying on the side effect of also getting the regular face will see different rendering. Worth calling out in release notes.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

Review posted successfully. Summary of what I flagged:

Hard blocker:

  1. examples/app-router-nitro/app/layout.tsx will fail to build — the PR description claims all in-tree examples already pass subsets explicitly, but Geist_Mono({ variable: "--font-mono" }) doesn't, and the new validator throws on that.

Polish / non-blocking:
2. The italic-only test (expect(url).toContain("ital")) is too loose — would pass for ital=0,wght@400 too, missing the regression it documents.
3. Behavioral change for italic-only (pre-port emitted both ital=0 and ital=1, new code emits only ital=1) deserves a release-note callout.
4. Renaming axes: { wght: 400 }_placeholder in the brace-balancing tests removes the only end-to-end exercise of the axes option through the plugin.
5. Suggested truncating the response body in the HTTP error path (Google can return multi-KB HTML pages).
6. Suggested either special-casing weight: 'variable' in the dev-fallback shim or commenting why it's unhandled.

Review URL: #893 (review)

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the address google font review feedback commit (50e0b79). The previous bonk review's hard blocker and all polish items have been addressed:

  • (blocker) Nitro example: Geist_Mono({ ..., subsets: ["latin"] }) — fixed.
  • (polish) italic-only test assertion: now pins :ital,wght@1,400 and negates ital,wght@0, — fixed.
  • (polish) lost axes end-to-end coverage: new test self-hosts variable font axes through the plugin pipeline exercises axes: ['opsz'] on Roboto_Flex through validate → getFontAxes → buildGoogleFontsUrl, asserting Roboto+Flex:opsz,wght@8..144,100..1000. The expected key/value ordering matches the URL builder's lowercase-first sort and the variant-value sort, so this is a real exercise of the documented axes option, not just brace-balancing — fixed.
  • (polish) HTTP error body truncation: extracted into formatGoogleFontsErrorBody with a 500-char cap and an (empty response body) sentinel — fixed.
  • (polish) weight: 'variable' dev fallback: shim now drops the unresolved sentinel (normalizedWeights = weights.length === 1 && weights[0] === 'variable' ? [] : weights) instead of emitting :wght@variable. Covered by the new dev-fallback test — fixed.
  • (extra) clearer axes shape error: validator now rejects non-array axes before the variable-font check, with a separate test in tests/google-fonts/validate.test.ts. Earlier this surfaced as the misleading Axes can only be defined for variable fonts. for non-variable callers — fixed.

The behavioural changes (italic-only no longer ships ital=0, dev fallback emits no axis segment for empty options, stricter subsets validation) are listed in the PR description's risks section.

Verified the math in the HTTP-error test: '/* axis range out of bounds */' + 'x'.repeat(600) is 629 chars, no whitespace to trim, so truncatedLength = 629 - 500 = 129 matches the regex.

One small non-blocking nit remaining; see inline comment. LGTM otherwise.

// through to a CDN URL that ships the same bad request
// to the browser.
throw new Error(
`[vinext:google-fonts] ${id}: Google Fonts returned HTTP ${err.status} for ${err.url}.\n${formatGoogleFontsErrorBody(err.responseBody)}`,
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.

Non-blocking nit, carry-over from the prior review: the manual [vinext:google-fonts] ${id}: prefix duplicates Vite's own plugin-error formatting, so the user sees [plugin vinext:google-fonts] [vinext:google-fonts] /app/layout.tsx: .... Using this.error({ message, id }) from inside the transform hook would let Vite format the location and avoid the double prefix. Fine to leave for a follow-up.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 27, 2026

Review posted at #893 (review).

Summary: The latest commit (50e0b79) addresses all of the previous review's blocker (Nitro example missing subsets) and polish items (italic-only assertion strength, lost axes end-to-end coverage, HTTP body truncation, weight: 'variable' dev fallback). Verified the new axes: ['opsz'] test's expected URL Roboto+Flex:opsz,wght@8..144,100..1000 matches the builder's lowercase-first axis sort, and the HTTP-error truncation math (629 - 500 = 129) is correct. Only one small non-blocking nit remains: the manual [vinext:google-fonts] prefix duplicates Vite's plugin-error formatting; suggested using this.error() in a follow-up.

github run

@james-elicx james-elicx merged commit 696948a into cloudflare:main Apr 27, 2026
24 checks passed
@NathanDrake2406 NathanDrake2406 deleted the nathan/fix-font-google-narrow-axis branch April 27, 2026 06:55
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.

next/font/google shim hardcodes :wght@100..900 when no weight is specified, causing HTTP 400 from Google Fonts for fonts with a narrower weight range

3 participants