Skip to content

Commit

Permalink
@next/font/google fetch error dev (#42637)
Browse files Browse the repository at this point in the history
If `@next/font/google` fails to fetch the font in DEV, return the
fallback font instead of throwing so you still can run the app. In build
it still throws.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
Hannes Bornö committed Nov 8, 2022
1 parent 85b200a commit 0696920
Show file tree
Hide file tree
Showing 12 changed files with 316 additions and 148 deletions.
217 changes: 133 additions & 84 deletions packages/font/src/google/loader.ts
Expand Up @@ -34,7 +34,9 @@ const downloadGoogleFonts: FontLoader = async ({
data,
config,
emitFontFile,
isDev,
isServer,
loaderContext,
}) => {
const subsets = config?.subsets || []

Expand Down Expand Up @@ -69,80 +71,7 @@ const downloadGoogleFonts: FontLoader = async ({
)
const url = getUrl(fontFamily, fontAxes, display)

let cachedCssRequest = cssCache.get(url)
const fontFaceDeclarations =
cachedCssRequest ?? (await fetchCSSFromGoogleFonts(url, fontFamily))
if (!cachedCssRequest) {
cssCache.set(url, fontFaceDeclarations)
} else {
cssCache.delete(url)
}

// Find font files to download
const fontFiles: Array<{
googleFontFileUrl: string
preloadFontFile: boolean
}> = []
let currentSubset = ''
for (const line of fontFaceDeclarations.split('\n')) {
// Each @font-face has the subset above it in a comment
const newSubset = /\/\* (.+?) \*\//.exec(line)?.[1]
if (newSubset) {
currentSubset = newSubset
} else {
const googleFontFileUrl = /src: url\((.+?)\)/.exec(line)?.[1]
if (
googleFontFileUrl &&
!fontFiles.some(
(foundFile) => foundFile.googleFontFileUrl === googleFontFileUrl
)
) {
fontFiles.push({
googleFontFileUrl,
preloadFontFile:
!!preload && (callSubsets ?? subsets).includes(currentSubset),
})
}
}
}

// Download font files
const downloadedFiles = await Promise.all(
fontFiles.map(async ({ googleFontFileUrl, preloadFontFile }) => {
let cachedFontRequest = fontCache.get(googleFontFileUrl)
const fontFileBuffer =
cachedFontRequest ?? (await fetchFontFile(googleFontFileUrl))
if (!cachedFontRequest) {
fontCache.set(googleFontFileUrl, fontFileBuffer)
} else {
fontCache.delete(googleFontFileUrl)
}

const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(googleFontFileUrl)![1]
// Emit font file to .next/static/fonts
const selfHostedFileUrl = emitFontFile(
fontFileBuffer,
ext,
preloadFontFile
)

return {
googleFontFileUrl,
selfHostedFileUrl,
}
})
)

// Replace @font-face sources with self-hosted files
let updatedCssResponse = fontFaceDeclarations
for (const { googleFontFileUrl, selfHostedFileUrl } of downloadedFiles) {
updatedCssResponse = updatedCssResponse.replace(
new RegExp(escapeStringRegexp(googleFontFileUrl), 'g'),
selfHostedFileUrl
)
}

// Add fallback font
// Find fallback font metrics
let adjustFontFallbackMetrics: AdjustFontFallback | undefined
if (adjustFontFallback) {
try {
Expand All @@ -164,16 +93,136 @@ const downloadGoogleFonts: FontLoader = async ({
}
}

return {
css: updatedCssResponse,
fallbackFonts: fallback,
weight:
weights.length === 1 && weights[0] !== 'variable'
? weights[0]
: undefined,
style: styles.length === 1 ? styles[0] : undefined,
variable,
adjustFontFallback: adjustFontFallbackMetrics,
try {
const hasCachedCSS = cssCache.has(url)
const fontFaceDeclarations = hasCachedCSS
? cssCache.get(url)
: await fetchCSSFromGoogleFonts(url, fontFamily).catch(() => null)
if (!hasCachedCSS) {
cssCache.set(url, fontFaceDeclarations)
} else {
cssCache.delete(url)
}
if (fontFaceDeclarations === null) {
throw new Error(`Failed to fetch \`${fontFamily}\` from Google Fonts.`)
}

// Find font files to download
const fontFiles: Array<{
googleFontFileUrl: string
preloadFontFile: boolean
}> = []
let currentSubset = ''
for (const line of fontFaceDeclarations.split('\n')) {
// Each @font-face has the subset above it in a comment
const newSubset = /\/\* (.+?) \*\//.exec(line)?.[1]
if (newSubset) {
currentSubset = newSubset
} else {
const googleFontFileUrl = /src: url\((.+?)\)/.exec(line)?.[1]
if (
googleFontFileUrl &&
!fontFiles.some(
(foundFile) => foundFile.googleFontFileUrl === googleFontFileUrl
)
) {
fontFiles.push({
googleFontFileUrl,
preloadFontFile:
!!preload && (callSubsets ?? subsets).includes(currentSubset),
})
}
}
}

// Download font files
const downloadedFiles = await Promise.all(
fontFiles.map(async ({ googleFontFileUrl, preloadFontFile }) => {
const hasCachedFont = fontCache.has(googleFontFileUrl)
const fontFileBuffer = hasCachedFont
? fontCache.get(googleFontFileUrl)
: await fetchFontFile(googleFontFileUrl).catch(() => null)
if (!hasCachedFont) {
fontCache.set(googleFontFileUrl, fontFileBuffer)
} else {
fontCache.delete(googleFontFileUrl)
}
if (fontFileBuffer === null) {
throw new Error(
`Failed to fetch \`${fontFamily}\` from Google Fonts.`
)
}

const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(googleFontFileUrl)![1]
// Emit font file to .next/static/fonts
const selfHostedFileUrl = emitFontFile(
fontFileBuffer,
ext,
preloadFontFile
)

return {
googleFontFileUrl,
selfHostedFileUrl,
}
})
)

// Replace @font-face sources with self-hosted files
let updatedCssResponse = fontFaceDeclarations
for (const { googleFontFileUrl, selfHostedFileUrl } of downloadedFiles) {
updatedCssResponse = updatedCssResponse.replace(
new RegExp(escapeStringRegexp(googleFontFileUrl), 'g'),
selfHostedFileUrl
)
}

return {
css: updatedCssResponse,
fallbackFonts: fallback,
weight:
weights.length === 1 && weights[0] !== 'variable'
? weights[0]
: undefined,
style: styles.length === 1 ? styles[0] : undefined,
variable,
adjustFontFallback: adjustFontFallbackMetrics,
}
} catch (err) {
loaderContext.cacheable(false)
if (isDev) {
if (isServer) {
Log.error(
`Failed to download \`${fontFamily}\` from Google Fonts. Using fallback font instead.`
)
}

// In dev we should return the fallback font instead of throwing an error
let css = `@font-face {
font-family: '${fontFamily} Fallback';
src: local("${adjustFontFallbackMetrics?.fallbackFont ?? 'Arial'}");`
if (adjustFontFallbackMetrics) {
css += `
ascent-override:${adjustFontFallbackMetrics.ascentOverride};
descent-override:${adjustFontFallbackMetrics.descentOverride};
line-gap-override:${adjustFontFallbackMetrics.lineGapOverride};
size-adjust:${adjustFontFallbackMetrics.sizeAdjust};`
}
css += '\n}'

return {
css,
fallbackFonts: fallback,
weight:
weights.length === 1 && weights[0] !== 'variable'
? weights[0]
: undefined,
style: styles.length === 1 ? styles[0] : undefined,
variable,
}
} else {
throw err
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/font/src/local/loader.ts
Expand Up @@ -57,7 +57,7 @@ const fetchFonts: FontLoader = async ({
data,
emitFontFile,
resolve,
fs,
loaderContext,
}) => {
const {
src,
Expand All @@ -74,7 +74,7 @@ const fetchFonts: FontLoader = async ({
const fontFiles = await Promise.all(
src.map(async ({ path, style, weight, ext, format }) => {
const resolved = await resolve(path)
const fileBuffer = await promisify(fs.readFile)(resolved)
const fileBuffer = await promisify(loaderContext.fs.readFile)(resolved)
const fontUrl = emitFontFile(fileBuffer, ext, preload)

let fontMetadata: any
Expand Down
Expand Up @@ -59,6 +59,7 @@ export function getFontLoader(
loaders.push({
loader: 'next-font-loader',
options: {
isDev: ctx.isDevelopment,
isServer: ctx.isServer,
assetPrefix: ctx.assetPrefix,
fontLoaderOptions,
Expand Down
Expand Up @@ -13,6 +13,7 @@ export default async function nextFontLoader(this: any) {
return fontLoaderSpan.traceAsyncFn(async () => {
const callback = this.async()
const {
isDev,
isServer,
assetPrefix,
fontLoaderOptions,
Expand Down Expand Up @@ -79,8 +80,9 @@ export default async function nextFontLoader(this: any) {
),
src.startsWith('.') ? src : `./${src}`
),
fs: this.fs,
isDev,
isServer,
loaderContext: this,
})

const { postcss } = await getPostcss()
Expand Down
Expand Up @@ -51,7 +51,7 @@ const postcssFontLoaderPlugn = ({
}

if (!fontFamily) {
throw new Error('Font loaders must have exactly one font family')
throw new Error("Font loaders must return one or more @font-face's")
}

// Add fallback font with override values
Expand Down
3 changes: 2 additions & 1 deletion packages/next/font/index.d.ts
Expand Up @@ -19,8 +19,9 @@ export type FontLoader = (options: {
config: any
emitFontFile: (content: Buffer, ext: string, preload: boolean) => string
resolve: (src: string) => string
fs: any
isDev: boolean
isServer: boolean
loaderContext: any
}) => Promise<{
css: string
fallbackFonts?: string[]
Expand Down
76 changes: 76 additions & 0 deletions test/e2e/next-font/google-fetch-error.test.ts
@@ -0,0 +1,76 @@
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { join } from 'path'
import webdriver from 'next-webdriver'

const mockedGoogleFontResponses = require.resolve(
'./google-font-mocked-responses.js'
)

describe('@next/font/google fetch error', () => {
const isDev = (global as any).isNextDev
let next: NextInstance

if ((global as any).isNextDeploy) {
it('should skip next deploy for now', () => {})
return
}

beforeAll(async () => {
next = await createNext({
files: {
pages: new FileRef(join(__dirname, 'google-fetch-error/pages')),
'next.config.js': new FileRef(
join(__dirname, 'google-fetch-error/next.config.js')
),
},
dependencies: {
'@next/font': 'canary',
},
env: {
NEXT_FONT_GOOGLE_MOCKED_RESPONSES: mockedGoogleFontResponses,
},
skipStart: true,
})
})
afterAll(() => next.destroy())

if (isDev) {
it('should use a fallback font in dev', async () => {
await next.start()
const outputIndex = next.cliOutput.length
const browser = await webdriver(next.url, '/')

const ascentOverride = await browser.eval(
'Array.from(document.fonts.values()).find(font => font.family.includes("Inter_Fallback")).ascentOverride'
)
expect(ascentOverride).toBe('90%')

const descentOverride = await browser.eval(
'Array.from(document.fonts.values()).find(font => font.family.includes("Inter_Fallback")).descentOverride'
)
expect(descentOverride).toBe('22.43%')

const lineGapOverride = await browser.eval(
'Array.from(document.fonts.values()).find(font => font.family.includes("Inter_Fallback")).lineGapOverride'
)
expect(lineGapOverride).toBe('0%')

const sizeAdjust = await browser.eval(
'Array.from(document.fonts.values()).find(font => font.family.includes("Inter_Fallback")).sizeAdjust'
)
expect(sizeAdjust).toBe('107.64%')

expect(next.cliOutput.slice(outputIndex)).toInclude(
'Failed to download `Inter` from Google Fonts. Using fallback font instead.'
)
})
} else {
it('should error when not in dev', async () => {
await expect(next.start()).rejects.toThrow('next build failed')
expect(next.cliOutput).toInclude(
'Failed to fetch `Inter` from Google Fonts.'
)
})
}
})
9 changes: 9 additions & 0 deletions test/e2e/next-font/google-fetch-error/next.config.js
@@ -0,0 +1,9 @@
module.exports = {
experimental: {
fontLoaders: [
{
loader: '@next/font/google',
},
],
},
}

0 comments on commit 0696920

Please sign in to comment.