Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App font file preload #41158

Merged
merged 8 commits into from Oct 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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.