Skip to content

Commit

Permalink
App font file preload (#41158)
Browse files Browse the repository at this point in the history
Finds the font files connected to font loader modules and adds them to
the font loader manifest. They're then collected and rendered similarly
to CSS links in app-render.
  • Loading branch information
Hannes Bornö committed Oct 6, 2022
1 parent 27fe5c8 commit dd3e005
Show file tree
Hide file tree
Showing 17 changed files with 220 additions and 20 deletions.
7 changes: 5 additions & 2 deletions packages/next/build/webpack-config.ts
Expand Up @@ -1964,8 +1964,11 @@ export default async function getBaseWebpackConfig(
!!config.experimental.sri?.algorithm &&
new SubresourceIntegrityPlugin(config.experimental.sri.algorithm),
isClient &&
config.experimental.fontLoaders &&
new FontLoaderManifestPlugin(),
fontLoaderTargets &&
new FontLoaderManifestPlugin({
appDirEnabled: !!config.experimental.appDir,
fontLoaderTargets,
}),
!dev &&
isClient &&
new (require('./webpack/plugins/telemetry-plugin').TelemetryPlugin)(
Expand Down
49 changes: 49 additions & 0 deletions packages/next/build/webpack/plugins/font-loader-manifest-plugin.ts
Expand Up @@ -6,13 +6,41 @@ export type FontLoaderManifest = {
pages: {
[path: string]: string[]
}
app: {
[moduleRequest: string]: string[]
}
}
const PLUGIN_NAME = 'FontLoaderManifestPlugin'

// Creates a manifest of all fonts that should be preloaded given a route
export class FontLoaderManifestPlugin {
private appDirEnabled: boolean
private fontLoaderTargets: string[]

constructor(options: {
appDirEnabled: boolean
fontLoaderTargets: string[]
}) {
this.appDirEnabled = options.appDirEnabled
this.fontLoaderTargets = options.fontLoaderTargets
}

apply(compiler: webpack.Compiler) {
compiler.hooks.make.tap(PLUGIN_NAME, (compilation) => {
let fontLoaderModules: webpack.Module[]

// Get all font loader modules
if (this.appDirEnabled) {
compilation.hooks.finishModules.tap(PLUGIN_NAME, (modules) => {
const modulesArr = Array.from(modules)
fontLoaderModules = modulesArr.filter((mod: any) =>
this.fontLoaderTargets.some((fontLoaderTarget) =>
mod.userRequest?.startsWith(`${fontLoaderTarget}?`)
)
)
})
}

compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
Expand All @@ -21,6 +49,27 @@ export class FontLoaderManifestPlugin {
(assets: any) => {
const fontLoaderManifest: FontLoaderManifest = {
pages: {},
app: {},
}

if (this.appDirEnabled) {
for (const mod of fontLoaderModules) {
const modAssets = Object.keys(mod.buildInfo.assets)
const fontFiles: string[] = modAssets.filter((file: string) =>
/\.(woff|woff2|eot|ttf|otf)$/.test(file)
)

// Font files ending with .p.(woff|woff2|eot|ttf|otf) are preloaded
const preloadedFontFiles: string[] = fontFiles.filter(
(file: string) => /\.p.(woff|woff2|eot|ttf|otf)$/.test(file)
)

// Create an entry for the request even if no files should preload. If that's the case a preconnect tag is added.
if (fontFiles.length > 0) {
fontLoaderManifest.app[(mod as any).userRequest] =
preloadedFontFiles
}
}
}

for (const entrypoint of compilation.entrypoints.values()) {
Expand Down
56 changes: 56 additions & 0 deletions packages/next/server/app-render.tsx
@@ -1,6 +1,7 @@
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http'
import type { LoadComponentsReturnType } from './load-components'
import type { ServerRuntime } from '../types'
import type { FontLoaderManifest } from '../build/webpack/plugins/font-loader-manifest-plugin'

// TODO-APP: change to React.use once it becomes stable
// @ts-ignore
Expand Down Expand Up @@ -139,6 +140,7 @@ export type RenderOptsPartial = {
runtime?: ServerRuntime
serverComponents?: boolean
assetPrefix?: string
fontLoaderManifest?: FontLoaderManifest
}

export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
Expand Down Expand Up @@ -532,6 +534,40 @@ function getCssInlinedLinkTags(
return [...chunks]
}

/**
* Get inline <link rel="preload" as="font"> tags based on server CSS manifest and font loader manifest. Only used when rendering to HTML.
*/
function getPreloadedFontFilesInlineLinkTags(
serverComponentManifest: FlightManifest,
serverCSSManifest: FlightCSSManifest,
fontLoaderManifest: FontLoaderManifest | undefined,
filePath?: string
): string[] {
if (!fontLoaderManifest || !filePath) {
return []
}
const layoutOrPageCss =
serverCSSManifest[filePath] ||
serverComponentManifest.__client_css_manifest__?.[filePath]

if (!layoutOrPageCss) {
return []
}

const fontFiles = new Set<string>()

for (const css of layoutOrPageCss) {
const preloadedFontFiles = fontLoaderManifest.app[css]
if (preloadedFontFiles) {
for (const fontFile of preloadedFontFiles) {
fontFiles.add(fontFile)
}
}
}

return [...fontFiles]
}

function getScriptNonceFromHeader(cspHeaderValue: string): string | undefined {
const directives = cspHeaderValue
// Directives are split by ';'.
Expand Down Expand Up @@ -686,6 +722,7 @@ export async function renderToHTMLOrFlight(
supportsDynamicHTML,
ComponentMod,
dev,
fontLoaderManifest,
} = renderOpts

patchFetch(ComponentMod)
Expand Down Expand Up @@ -896,6 +933,12 @@ export async function renderToHTMLOrFlight(
layoutOrPagePath
)
: []
const preloadedFontFiles = getPreloadedFontFilesInlineLinkTags(
serverComponentManifest,
serverCSSManifest!,
fontLoaderManifest,
layoutOrPagePath
)
const Template = template
? await interopDefault(template())
: React.Fragment
Expand Down Expand Up @@ -1074,6 +1117,19 @@ export async function renderToHTMLOrFlight(

return (
<>
{preloadedFontFiles.map((fontFile) => {
const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(fontFile)![1]
return (
<link
key={fontFile}
rel="preload"
href={`/_next/${fontFile}`}
as="font"
type={`font/${ext}`}
crossOrigin="anonymous"
/>
)
})}
{stylesheets
? stylesheets.map((href) => (
<link
Expand Down
66 changes: 66 additions & 0 deletions test/e2e/app-dir/next-font.test.ts
Expand Up @@ -230,4 +230,70 @@ describe('app dir next-font', () => {
).toBe('normal')
})
})

describe('preload', () => {
it('should preload correctly with server components', async () => {
const html = await renderViaHTTP(next.url, '/')
const $ = cheerio.load(html)

// Preconnect
expect($('link[rel="preconnect"]').length).toBe(0)

expect($('link[as="font"]').length).toBe(3)
expect($('link[as="font"]').get(0).attribs).toEqual({
as: 'font',
crossorigin: '',
href: '/_next/static/media/e9b9dc0d8ba35f48.p.woff2',
rel: 'preload',
type: 'font/woff2',
})
expect($('link[as="font"]').get(1).attribs).toEqual({
as: 'font',
crossorigin: '',
href: '/_next/static/media/b2104791981359ae.p.woff2',
rel: 'preload',
type: 'font/woff2',
})
expect($('link[as="font"]').get(2).attribs).toEqual({
as: 'font',
crossorigin: '',
href: '/_next/static/media/b61859a50be14c53.p.woff2',
rel: 'preload',
type: 'font/woff2',
})
})

it('should preload correctly with client components', async () => {
const html = await renderViaHTTP(next.url, '/client')
const $ = cheerio.load(html)

// Preconnect
expect($('link[rel="preconnect"]').length).toBe(0)

expect($('link[as="font"]').length).toBe(3)
// From root layout
expect($('link[as="font"]').get(0).attribs).toEqual({
as: 'font',
crossorigin: '',
href: '/_next/static/media/e9b9dc0d8ba35f48.p.woff2',
rel: 'preload',
type: 'font/woff2',
})

expect($('link[as="font"]').get(1).attribs).toEqual({
as: 'font',
crossorigin: '',
href: '/_next/static/media/e1053f04babc7571.p.woff2',
rel: 'preload',
type: 'font/woff2',
})
expect($('link[as="font"]').get(2).attribs).toEqual({
as: 'font',
crossorigin: '',
href: '/_next/static/media/feab2c68f2a8e9a4.p.woff2',
rel: 'preload',
type: 'font/woff2',
})
})
})
})
2 changes: 1 addition & 1 deletion test/e2e/app-dir/next-font/app/Comp.js
@@ -1,4 +1,4 @@
import { font3 } from '../fonts/fonts'
import font3 from '../fonts/font3'

export default function Component() {
return (
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/next-font/app/client/Comp.js
@@ -1,5 +1,5 @@
'client'
import { font6 } from '../../fonts/fonts'
import font6 from '../../fonts/font6'

export default function Component() {
return (
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/next-font/app/client/layout.js
@@ -1,5 +1,5 @@
'client'
import { font4 } from '../../fonts/fonts'
import font4 from '../../fonts/font4'

export default function Root({ children }) {
return (
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/next-font/app/client/page.js
@@ -1,6 +1,6 @@
'client'
import Comp from './Comp'
import { font5 } from '../../fonts/fonts'
import font5 from '../../fonts/font5'

export default function HomePage() {
return (
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/next-font/app/layout.js
@@ -1,4 +1,4 @@
import { font1 } from '../fonts/fonts'
import font1 from '../fonts/font1'

export default function Root({ children }) {
return (
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/next-font/app/page.js
@@ -1,5 +1,5 @@
import Comp from './Comp'
import { font2 } from '../fonts/fonts'
import font2 from '../fonts/font2'

export default function HomePage() {
return (
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/app-dir/next-font/fonts/font1.js
@@ -0,0 +1,5 @@
import localFont from '@next/font/local'

const font1 = localFont({ src: './font1.woff2', variable: '--font-1' })

export default font1
5 changes: 5 additions & 0 deletions test/e2e/app-dir/next-font/fonts/font2.js
@@ -0,0 +1,5 @@
import localFont from '@next/font/local'

export const font2 = localFont({ src: './font2.woff2', variable: '--font-2' })

export default font2
9 changes: 9 additions & 0 deletions test/e2e/app-dir/next-font/fonts/font3.js
@@ -0,0 +1,9 @@
import localFont from '@next/font/local'

export const font3 = localFont({
src: './font3.woff2',
weight: '900',
style: 'italic',
})

export default font3
5 changes: 5 additions & 0 deletions test/e2e/app-dir/next-font/fonts/font4.js
@@ -0,0 +1,5 @@
import localFont from '@next/font/local'

export const font4 = localFont({ src: './font4.woff2', weight: '100' })

export default font4
9 changes: 9 additions & 0 deletions test/e2e/app-dir/next-font/fonts/font5.js
@@ -0,0 +1,9 @@
import localFont from '@next/font/local'

export const font5 = localFont({
src: './font5.woff2',
style: 'italic',
preload: false,
})

export default font5
5 changes: 5 additions & 0 deletions test/e2e/app-dir/next-font/fonts/font6.js
@@ -0,0 +1,5 @@
import localFont from '@next/font/local'

export const font6 = localFont({ src: './font6.woff2' })

export default font6
12 changes: 0 additions & 12 deletions test/e2e/app-dir/next-font/fonts/fonts.js

This file was deleted.

0 comments on commit dd3e005

Please sign in to comment.