diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 7f7665ed44f81..0915309912eeb 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -52,7 +52,7 @@ import WebpackConformancePlugin, { ReactSyncScriptsConformanceCheck, } from './webpack/plugins/webpack-conformance-plugin' import { WellKnownErrorsPlugin } from './webpack/plugins/wellknown-errors-plugin' - +import FontStylesheetGatheringPlugin from './webpack/plugins/font-stylesheet-gathering-plugin' type ExcludesFalse = (x: T | false) => x is T const isWebpack5 = parseInt(webpack.version!) === 5 @@ -862,6 +862,9 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_REACT_MODE': JSON.stringify( config.experimental.reactMode ), + 'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify( + config.experimental.optimizeFonts + ), 'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify( config.experimental.scrollRestoration ), @@ -967,6 +970,10 @@ export default async function getBaseWebpackConfig( inputChunkName.replace(/\.js$/, '.module.js'), }) })(), + config.experimental.optimizeFonts && + !dev && + isServer && + new FontStylesheetGatheringPlugin(), config.experimental.conformance && !isWebpack5 && !dev && diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 400bf0304bf88..aecb0f05ec45a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -6,6 +6,7 @@ import { loader } from 'webpack' import { API_ROUTE } from '../../../lib/constants' import { BUILD_MANIFEST, + FONT_MANIFEST, REACT_LOADABLE_MANIFEST, ROUTES_MANIFEST, } from '../../../next-server/lib/constants' @@ -58,6 +59,10 @@ const nextServerlessLoader: loader.Loader = function () { '/' ) const routesManifest = join(distDir, ROUTES_MANIFEST).replace(/\\/g, '/') + const fontManifest = join(distDir, 'serverless', FONT_MANIFEST).replace( + /\\/g, + '/' + ) const escapedBuildId = escapeRegexp(buildId) const pageIsDynamicRoute = isDynamicRoute(page) @@ -266,7 +271,7 @@ const nextServerlessLoader: loader.Loader = function () { } const {parse} = require('url') const {parse: parseQs} = require('querystring') - const {renderToHTML} = require('next/dist/next-server/server/render'); + const { renderToHTML } = require('next/dist/next-server/server/render'); const { tryGetPreviewData } = require('next/dist/next-server/server/api-utils'); const {sendPayload} = require('next/dist/next-server/server/send-payload'); const buildManifest = require('${buildManifest}'); @@ -274,6 +279,7 @@ const nextServerlessLoader: loader.Loader = function () { const Document = require('${absoluteDocumentPath}').default; const Error = require('${absoluteErrorPath}').default; const App = require('${absoluteAppPath}').default; + ${dynamicRouteImports} ${rewriteImports} @@ -418,6 +424,11 @@ const nextServerlessLoader: loader.Loader = function () { const previewData = tryGetPreviewData(req, res, options.previewProps) const isPreviewMode = previewData !== false + if (process.env.__NEXT_OPTIMIZE_FONTS) { + renderOpts.optimizeFonts = true + renderOpts.fontManifest = require('${fontManifest}') + process.env['__NEXT_OPTIMIZE_FONT'+'S'] = true + } let result = await renderToHTML(req, res, "${page}", Object.assign({}, getStaticProps ? { ...(parsedUrl.query.amp ? { amp: '1' } : {}) } : parsedUrl.query, nowParams ? nowParams : params, _params, isFallback ? { __nextFallback: 'true' } : {}), renderOpts) if (!renderMode) { diff --git a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts new file mode 100644 index 0000000000000..3e3efba8d2bfb --- /dev/null +++ b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts @@ -0,0 +1,141 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { NodePath } from 'ast-types/lib/node-path' +import { compilation as CompilationType, Compiler } from 'webpack' +import { namedTypes } from 'ast-types' +import { RawSource } from 'webpack-sources' +import { + getFontDefinitionFromNetwork, + FontManifest, +} from '../../../next-server/server/font-utils' +// @ts-ignore +import BasicEvaluatedExpression from 'webpack/lib/BasicEvaluatedExpression' +import { OPTIMIZED_FONT_PROVIDERS } from '../../../next-server/lib/constants' + +interface VisitorMap { + [key: string]: (path: NodePath) => void +} + +export default class FontStylesheetGatheringPlugin { + compiler?: Compiler + gatheredStylesheets: Array = [] + + private parserHandler = ( + factory: CompilationType.NormalModuleFactory + ): void => { + const JS_TYPES = ['auto', 'esm', 'dynamic'] + // Do an extra walk per module and add interested visitors to the walk. + for (const type of JS_TYPES) { + factory.hooks.parser + .for('javascript/' + type) + .tap(this.constructor.name, (parser: any) => { + /** + * Webpack fun facts: + * `parser.hooks.call.for` cannot catch calls for user defined identifiers like `__jsx` + * it can only detect calls for native objects like `window`, `this`, `eval` etc. + * In order to be able to catch calls of variables like `__jsx`, first we need to catch them as + * Identifier and then return `BasicEvaluatedExpression` whose `id` and `type` webpack matches to + * invoke hook for call. + * See: https://github.com/webpack/webpack/blob/webpack-4/lib/Parser.js#L1931-L1932. + */ + parser.hooks.evaluate + .for('Identifier') + .tap(this.constructor.name, (node: namedTypes.Identifier) => { + // We will only optimize fonts from first party code. + if (parser?.state?.module?.resource.includes('node_modules')) { + return + } + return node.name === '__jsx' + ? new BasicEvaluatedExpression() + //@ts-ignore + .setRange(node.range) + .setExpression(node) + .setIdentifier('__jsx') + : undefined + }) + + parser.hooks.call + .for('__jsx') + .tap(this.constructor.name, (node: namedTypes.CallExpression) => { + if (node.arguments.length !== 2) { + // A font link tag has only two arguments rel=stylesheet and href='...' + return + } + if (!isNodeCreatingLinkElement(node)) { + return + } + + // node.arguments[0] is the name of the tag and [1] are the props. + const propsNode = node.arguments[1] as namedTypes.ObjectExpression + const props: { [key: string]: string } = {} + propsNode.properties.forEach((prop) => { + if (prop.type !== 'Property') { + return + } + if ( + prop.key.type === 'Identifier' && + prop.value.type === 'Literal' + ) { + props[prop.key.name] = prop.value.value as string + } + }) + if ( + !props.rel || + props.rel !== 'stylesheet' || + !props.href || + !OPTIMIZED_FONT_PROVIDERS.some((url) => + props.href.startsWith(url) + ) + ) { + return false + } + + this.gatheredStylesheets.push(props.href) + }) + }) + } + } + + public apply(compiler: Compiler) { + this.compiler = compiler + compiler.hooks.normalModuleFactory.tap( + this.constructor.name, + this.parserHandler + ) + compiler.hooks.make.tapAsync(this.constructor.name, (compilation, cb) => { + compilation.hooks.finishModules.tapAsync( + this.constructor.name, + async (_: any, modulesFinished: Function) => { + const fontDefinitionPromises = this.gatheredStylesheets.map((url) => + getFontDefinitionFromNetwork(url) + ) + let manifestContent: FontManifest = [] + + for (let promiseIndex in fontDefinitionPromises) { + manifestContent.push({ + url: this.gatheredStylesheets[promiseIndex], + content: await fontDefinitionPromises[promiseIndex], + }) + } + compilation.assets['font-manifest.json'] = new RawSource( + JSON.stringify(manifestContent, null, ' ') + ) + modulesFinished() + } + ) + cb() + }) + } +} + +function isNodeCreatingLinkElement(node: namedTypes.CallExpression) { + const callee = node.callee as namedTypes.Identifier + if (callee.type !== 'Identifier') { + return false + } + const componentNode = node.arguments[0] as namedTypes.Literal + if (componentNode.type !== 'Literal') { + return false + } + // Next has pragma: __jsx. + return callee.name === '__jsx' && componentNode.value === 'link' +} diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 23d40b0ba0bb7..93cbac6986dfd 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -388,6 +388,7 @@ export default async function exportApp( subFolders, buildExport: options.buildExport, serverless: isTargetLikeServerless(nextConfig.target), + optimizeFonts: nextConfig.experimental.optimizeFonts, }) for (const validation of result.ampValidations || []) { diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index f8159e3b44717..5c600e79b0b20 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -13,6 +13,8 @@ import 'next/dist/next-server/server/node-polyfill-fetch' import { IncomingMessage, ServerResponse } from 'http' import { ComponentType } from 'react' import { GetStaticProps } from '../types' +import { requireFontManifest } from '../next-server/server/require' +import { FontManifest } from '../next-server/server/font-utils' const envConfig = require('../next-server/lib/runtime-config') @@ -44,6 +46,7 @@ interface ExportPageInput { serverRuntimeConfig: string subFolders: string serverless: boolean + optimizeFonts: boolean } interface ExportPageResults { @@ -60,6 +63,8 @@ interface RenderOpts { ampSkipValidation?: boolean hybridAmp?: boolean inAmpMode?: boolean + optimizeFonts?: boolean + fontManifest?: FontManifest } type ComponentModule = ComponentType<{}> & { @@ -78,6 +83,7 @@ export default async function exportPage({ serverRuntimeConfig, subFolders, serverless, + optimizeFonts, }: ExportPageInput): Promise { let results: ExportPageResults = { ampValidations: [], @@ -211,7 +217,14 @@ export default async function exportPage({ req, res, 'export', - { ampPath }, + { + ampPath, + /// @ts-ignore + optimizeFonts, + fontManifest: optimizeFonts + ? requireFontManifest(distDir, serverless) + : null, + }, // @ts-ignore params ) @@ -246,7 +259,25 @@ export default async function exportPage({ html = components.Component queryWithAutoExportWarn() } else { - curRenderOpts = { ...components, ...renderOpts, ampPath, params } + /** + * This sets environment variable to be used at the time of static export by head.tsx. + * Using this from process.env allows targetting both serverless and SSR by calling + * `process.env.__NEXT_OPTIMIZE_FONTS`. + * TODO(prateekbh@): Remove this when experimental.optimizeFonts are being clened up. + */ + if (optimizeFonts) { + process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true) + } + curRenderOpts = { + ...components, + ...renderOpts, + ampPath, + params, + optimizeFonts, + fontManifest: optimizeFonts + ? requireFontManifest(distDir, serverless) + : null, + } // @ts-ignore html = await renderMethod(req, res, page, query, curRenderOpts) } diff --git a/packages/next/next-server/lib/constants.ts b/packages/next/next-server/lib/constants.ts index 86cbd01ba1367..665fc9b24f4ac 100644 --- a/packages/next/next-server/lib/constants.ts +++ b/packages/next/next-server/lib/constants.ts @@ -9,6 +9,7 @@ export const EXPORT_DETAIL = 'export-detail.json' export const PRERENDER_MANIFEST = 'prerender-manifest.json' export const ROUTES_MANIFEST = 'routes-manifest.json' export const REACT_LOADABLE_MANIFEST = 'react-loadable-manifest.json' +export const FONT_MANIFEST = 'font-manifest.json' export const SERVER_DIRECTORY = 'server' export const SERVERLESS_DIRECTORY = 'serverless' export const CONFIG_FILE = 'next.config.js' @@ -33,3 +34,4 @@ export const TEMPORARY_REDIRECT_STATUS = 307 export const PERMANENT_REDIRECT_STATUS = 308 export const STATIC_PROPS_ID = '__N_SSG' export const SERVER_PROPS_ID = '__N_SSP' +export const OPTIMIZED_FONT_PROVIDERS = ['https://fonts.googleapis.com/css'] diff --git a/packages/next/next-server/lib/head.tsx b/packages/next/next-server/lib/head.tsx index 4b03b2fc05847..0161711ccbc6b 100644 --- a/packages/next/next-server/lib/head.tsx +++ b/packages/next/next-server/lib/head.tsx @@ -136,6 +136,21 @@ function reduceComponents( .reverse() .map((c: React.ReactElement, i: number) => { const key = c.key || i + if (process.env.__NEXT_OPTIMIZE_FONTS) { + if ( + c.type === 'link' && + c.props['href'] && + // TODO(prateekbh@): Replace this with const from `constants` when the tree shaking works. + ['https://fonts.googleapis.com/css'].some((url) => + c.props['href'].startsWith(url) + ) + ) { + const newProps = { ...(c.props || {}) } + newProps['data-href'] = newProps['href'] + newProps['href'] = undefined + return React.cloneElement(c, newProps) + } + } return React.cloneElement(c, { key }) }) } diff --git a/packages/next/next-server/lib/post-process.ts b/packages/next/next-server/lib/post-process.ts new file mode 100644 index 0000000000000..3c45f2383a3c3 --- /dev/null +++ b/packages/next/next-server/lib/post-process.ts @@ -0,0 +1,161 @@ +import { parse, HTMLElement } from 'node-html-parser' +import { OPTIMIZED_FONT_PROVIDERS } from './constants' + +const MIDDLEWARE_TIME_BUDGET = 10 + +type postProcessOptions = { + optimizeFonts: boolean +} + +type renderOptions = { + getFontDefinition?: (url: string) => string +} + +type postProcessData = { + preloads: { + images: Array + } +} + +interface PostProcessMiddleware { + inspect: ( + originalDom: HTMLElement, + data: postProcessData, + options: renderOptions + ) => void + mutate: ( + markup: string, + data: postProcessData, + options: renderOptions + ) => Promise +} + +type middlewareSignature = { + name: string + middleware: PostProcessMiddleware + condition: ((options: postProcessOptions) => boolean) | null +} + +const middlewareRegistry: Array = [] + +function registerPostProcessor( + name: string, + middleware: PostProcessMiddleware, + condition?: (options: postProcessOptions) => boolean +) { + middlewareRegistry.push({ name, middleware, condition: condition || null }) +} + +async function processHTML( + html: string, + data: renderOptions, + options: postProcessOptions +): Promise { + // Don't parse unless there's at least one processor middleware + if (!middlewareRegistry[0]) { + return html + } + const postProcessData: postProcessData = { + preloads: { + images: [], + }, + } + const root: HTMLElement = parse(html) + let document = html + // Calls the middleware, with some instrumentation and logging + async function callMiddleWare( + middleware: PostProcessMiddleware, + name: string + ) { + let timer = Date.now() + middleware.inspect(root, postProcessData, data) + const inspectTime = Date.now() - timer + document = await middleware.mutate(document, postProcessData, data) + timer = Date.now() - timer + if (timer > MIDDLEWARE_TIME_BUDGET) { + console.warn( + `The postprocess middleware "${name}" took ${timer}ms(${inspectTime}, ${ + timer - inspectTime + }) to complete. This is longer than the ${MIDDLEWARE_TIME_BUDGET} limit.` + ) + } + return + } + + for (let i = 0; i < middlewareRegistry.length; i++) { + let middleware = middlewareRegistry[i] + if (!middleware.condition || middleware.condition(options)) { + await callMiddleWare( + middlewareRegistry[i].middleware, + middlewareRegistry[i].name + ) + } + } + + return document +} + +class FontOptimizerMiddleware implements PostProcessMiddleware { + fontDefinitions: Array = [] + inspect( + originalDom: HTMLElement, + _data: postProcessData, + options: renderOptions + ) { + if (!options.getFontDefinition) { + return + } + // collecting all the requested font definitions + originalDom + .querySelectorAll('link') + .filter( + (tag: HTMLElement) => + tag.getAttribute('rel') === 'stylesheet' && + tag.hasAttribute('data-href') && + OPTIMIZED_FONT_PROVIDERS.some((url) => + tag.getAttribute('data-href').startsWith(url) + ) + ) + .forEach((element: HTMLElement) => { + const url = element.getAttribute('data-href') + this.fontDefinitions.push(url) + }) + } + mutate = async ( + markup: string, + _data: postProcessData, + options: renderOptions + ) => { + let result = markup + if (!options.getFontDefinition) { + return markup + } + for (const key in this.fontDefinitions) { + const url = this.fontDefinitions[key] + if (result.indexOf(`` + ) + } + return result + } +} + +// Initialization +registerPostProcessor( + 'Inline-Fonts', + new FontOptimizerMiddleware(), + // Using process.env because passing Experimental flag through loader is not possible. + // @ts-ignore + (options) => options.optimizeFonts || process.env.__NEXT_OPTIMIZE_FONTS +) + +export default processHTML diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 005295dc092dc..eed6e1ea2d8ee 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -52,6 +52,7 @@ const defaultConfig: { [key: string]: any } = { workerThreads: false, pageEnv: false, productionBrowserSourceMaps: false, + optimizeFonts: false, scrollRestoration: false, }, future: { diff --git a/packages/next/next-server/server/font-utils.ts b/packages/next/next-server/server/font-utils.ts new file mode 100644 index 0000000000000..6120ff10be27b --- /dev/null +++ b/packages/next/next-server/server/font-utils.ts @@ -0,0 +1,59 @@ +const https = require('https') + +const CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36' +const IE_UA = 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko' + +export type FontManifest = Array<{ + url: string + content: string +}> + +function getFontForUA(url: string, UA: string): Promise { + return new Promise((resolve) => { + let rawData: any = '' + https.get( + url, + { + headers: { + 'user-agent': UA, + }, + }, + (res: any) => { + res.on('data', (chunk: any) => { + rawData += chunk + }) + res.on('end', () => { + resolve(rawData.toString('utf8')) + }) + } + ) + }) +} + +export async function getFontDefinitionFromNetwork( + url: string +): Promise { + let result = '' + /** + * The order of IE -> Chrome is important, other wise chrome starts loading woff1. + * CSS cascading 🤷‍♂️. + */ + result += await getFontForUA(url, IE_UA) + result += await getFontForUA(url, CHROME_UA) + return result +} + +export function getFontDefinitionFromManifest( + url: string, + manifest: FontManifest +): string { + return ( + manifest.find((font) => { + if (font && font.url === url) { + return true + } + return false + })?.content || '' + ) +} diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index ce5005bd6c1d7..3ec96ccf5f1bb 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -43,7 +43,7 @@ import { recursiveReadDirSync } from './lib/recursive-readdir-sync' import { loadComponents, LoadComponentsReturnType } from './load-components' import { normalizePagePath } from './normalize-page-path' import { RenderOpts, RenderOptsPartial, renderToHTML } from './render' -import { getPagePath } from './require' +import { getPagePath, requireFontManifest } from './require' import Router, { DynamicRoutes, PageChecker, @@ -63,6 +63,7 @@ import './node-polyfill-fetch' import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' import { removePathTrailingSlash } from '../../client/normalize-trailing-slash' import getRouteFromAssetPath from '../lib/router/utils/get-route-from-asset-path' +import { FontManifest } from './font-utils' const getCustomRouteMatcher = pathMatch(true) @@ -119,6 +120,8 @@ export default class Server { customServer?: boolean ampOptimizerConfig?: { [key: string]: any } basePath: string + optimizeFonts: boolean + fontManifest: FontManifest } private compression?: Middleware private onErrorMiddleware?: ({ err }: { err: Error }) => Promise @@ -165,6 +168,10 @@ export default class Server { customServer: customServer === true ? true : undefined, ampOptimizerConfig: this.nextConfig.experimental.amp?.optimizer, basePath: this.nextConfig.basePath, + optimizeFonts: this.nextConfig.experimental.optimizeFonts, + fontManifest: this.nextConfig.experimental.optimizeFonts + ? requireFontManifest(this.distDir, this._isLikeServerless) + : null, } // Only the `publicRuntimeConfig` key is exposed to the client side @@ -219,6 +226,16 @@ export default class Server { ), flushToDisk: this.nextConfig.experimental.sprFlushToDisk, }) + + /** + * This sets environment variable to be used at the time of SSR by head.tsx. + * Using this from process.env allows targetting both serverless and SSR by calling + * `process.env.__NEXT_OPTIMIZE_FONTS`. + * TODO(prateekbh@): Remove this when experimental.optimizeFonts are being clened up. + */ + if (this.renderOpts.optimizeFonts) { + process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true) + } } protected currentPhase(): string { @@ -1044,7 +1061,10 @@ export default class Server { renderResult = await (components.Component as any).renderReqToHTML( req, res, - 'passthrough' + 'passthrough', + { + fontManifest: this.renderOpts.fontManifest, + } ) html = renderResult.html diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index ae89757548525..61debbceb48bb 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -45,6 +45,8 @@ import { tryGetPreviewData, __ApiPreviewProps } from './api-utils' import { getPageFiles } from './get-page-files' import { LoadComponentsReturnType, ManifestItem } from './load-components' import optimizeAmp from './optimize-amp' +import postProcess from '../lib/post-process' +import { FontManifest, getFontDefinitionFromManifest } from './font-utils' function noRouter() { const message = @@ -143,6 +145,8 @@ export type RenderOptsPartial = { previewProps: __ApiPreviewProps basePath: string unstable_runtimeJS?: false + optimizeFonts: boolean + fontManifest?: FontManifest } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial @@ -269,6 +273,7 @@ export async function renderToHTML( pageConfig = {}, Component, buildManifest, + fontManifest, reactLoadableManifest, ErrorDebug, getStaticProps, @@ -280,6 +285,13 @@ export async function renderToHTML( basePath, } = renderOpts + const getFontDefinition = (url: string): string => { + if (fontManifest) { + return getFontDefinitionFromManifest(url, fontManifest) + } + return '' + } + const callMiddleware = async (method: string, args: any[], props = false) => { let results: any = props ? {} : [] @@ -781,6 +793,16 @@ export async function renderToHTML( } } + html = await postProcess( + html, + { + getFontDefinition, + }, + { + optimizeFonts: renderOpts.optimizeFonts, + } + ) + if (inAmpMode || hybridAmp) { // fix & being escaped for amphtml rel link html = html.replace(/&amp=1/g, '&=1') diff --git a/packages/next/next-server/server/require.ts b/packages/next/next-server/server/require.ts index 5e59f15d0167e..28fca96a2cbc0 100644 --- a/packages/next/next-server/server/require.ts +++ b/packages/next/next-server/server/require.ts @@ -4,6 +4,7 @@ import { PAGES_MANIFEST, SERVER_DIRECTORY, SERVERLESS_DIRECTORY, + FONT_MANIFEST, } from '../lib/constants' import { normalizePagePath, denormalizePagePath } from './normalize-page-path' import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' @@ -54,3 +55,12 @@ export function requirePage( } return require(pagePath) } + +export function requireFontManifest(distDir: string, serverless: boolean) { + const serverBuildPath = join( + distDir, + serverless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY + ) + const fontManifest = require(join(serverBuildPath, FONT_MANIFEST)) + return fontManifest +} diff --git a/packages/next/package.json b/packages/next/package.json index 32d06ddea12ab..e0f2f6c23be09 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -78,6 +78,7 @@ "@babel/types": "7.9.6", "@next/react-dev-overlay": "9.5.0", "@next/react-refresh-utils": "9.5.0", + "ast-types": "0.13.2", "babel-plugin-syntax-jsx": "6.18.0", "babel-plugin-transform-define": "2.0.0", "babel-plugin-transform-react-remove-prop-types": "0.4.24", @@ -93,6 +94,7 @@ "mkdirp": "0.5.3", "native-url": "0.3.4", "neo-async": "2.6.1", + "node-html-parser": "^1.2.19", "pnp-webpack-plugin": "1.6.4", "postcss": "7.0.32", "prop-types": "15.7.2", @@ -153,7 +155,6 @@ "@zeit/ncc": "0.22.0", "amphtml-validator": "1.0.31", "arg": "4.1.0", - "ast-types": "0.13.2", "async-retry": "1.2.3", "async-sema": "3.0.0", "babel-loader": "8.1.0", diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index dfb3f391b9974..f2016166521c1 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -1,7 +1,10 @@ import PropTypes from 'prop-types' -import React, { useContext, Component } from 'react' +import React, { useContext, Component, ReactNode } from 'react' import flush from 'styled-jsx/server' -import { AMP_RENDER_TARGET } from '../next-server/lib/constants' +import { + AMP_RENDER_TARGET, + OPTIMIZED_FONT_PROVIDERS, +} from '../next-server/lib/constants' import { DocumentContext as DocumentComponentContext } from '../next-server/lib/document-context' import { DocumentContext, @@ -236,6 +239,24 @@ export class Head extends Component< )) } + makeStylesheetInert(node: ReactNode): ReactNode { + return React.Children.map(node, (c: any) => { + if ( + c.type === 'link' && + c.props['href'] && + OPTIMIZED_FONT_PROVIDERS.some((url) => c.props['href'].startsWith(url)) + ) { + const newProps = { ...(c.props || {}) } + newProps['data-href'] = newProps['href'] + newProps['href'] = undefined + return React.cloneElement(c, newProps) + } else if (c.props && c.props['children']) { + c.props['children'] = this.makeStylesheetInert(c.props['children']) + } + return c + }) + } + render() { const { styles, @@ -278,6 +299,10 @@ export class Head extends Component< ) } + if (process.env.__NEXT_OPTIMIZE_FONTS) { + children = this.makeStylesheetInert(children) + } + let hasAmphtmlRel = false let hasCanonicalRel = false @@ -285,7 +310,6 @@ export class Head extends Component< head = React.Children.map(head || [], (child) => { if (!child) return child const { type, props } = child - if (inAmpMode) { let badProp: string = '' @@ -435,7 +459,9 @@ export class Head extends Component< href={canonicalBase + getAmpPath(ampPath, dangerousAsPath)} /> )} - {this.getCssLinks()} + {process.env.__NEXT_OPTIMIZE_FONTS + ? this.makeStylesheetInert(this.getCssLinks()) + : this.getCssLinks()} {!disableRuntimeJS && this.getPreloadDynamicChunks()} {!disableRuntimeJS && this.getPreloadMainLinks()} {this.context._documentProps.isDevelopment && ( diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index fb864ecf43991..2b4f1738042b6 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -98,7 +98,7 @@ describe('Build Output', () => { expect(parseFloat(indexFirstLoad) - 60).toBeLessThanOrEqual(0) expect(indexFirstLoad.endsWith('kB')).toBe(true) - expect(parseFloat(err404Size) - 3.4).toBeLessThanOrEqual(0) + expect(parseFloat(err404Size) - 3.6).toBeLessThanOrEqual(0) expect(err404Size.endsWith('kB')).toBe(true) expect(parseFloat(err404FirstLoad) - 63).toBeLessThanOrEqual(0) diff --git a/test/integration/font-optimization/pages/_document.js b/test/integration/font-optimization/pages/_document.js new file mode 100644 index 0000000000000..59d8fe75cd4c4 --- /dev/null +++ b/test/integration/font-optimization/pages/_document.js @@ -0,0 +1,30 @@ +import * as React from 'react' +/// @ts-ignore +import Document, { Main, NextScript, Head } from 'next/document' + +export default class MyDocument extends Document { + constructor(props) { + super(props) + const { __NEXT_DATA__, ids } = props + if (ids) { + __NEXT_DATA__.ids = ids + } + } + + render() { + return ( + + + + + +
+ + + + ) + } +} diff --git a/test/integration/font-optimization/pages/index.js b/test/integration/font-optimization/pages/index.js new file mode 100644 index 0000000000000..39cf2d1f7b376 --- /dev/null +++ b/test/integration/font-optimization/pages/index.js @@ -0,0 +1,7 @@ +import React from 'react' + +const Page = () => { + return
Hi!
+} + +export default Page diff --git a/test/integration/font-optimization/pages/stars.js b/test/integration/font-optimization/pages/stars.js new file mode 100644 index 0000000000000..d38e96c4ea4f7 --- /dev/null +++ b/test/integration/font-optimization/pages/stars.js @@ -0,0 +1,26 @@ +import Head from 'next/head' + +function Home({ stars }) { + return ( +
+ + Create Next App + + + + +
+
Next stars: {stars}
+
+
+ ) +} + +Home.getInitialProps = async () => { + return { stars: Math.random() * 1000 } +} + +export default Home diff --git a/test/integration/font-optimization/pages/static-head.js b/test/integration/font-optimization/pages/static-head.js new file mode 100644 index 0000000000000..f56ef406776c3 --- /dev/null +++ b/test/integration/font-optimization/pages/static-head.js @@ -0,0 +1,18 @@ +import React from 'react' +import Head from 'next/head' + +const Page = () => { + return ( + <> + + + +
Hi!
+ + ) +} + +export default Page diff --git a/test/integration/font-optimization/server.js b/test/integration/font-optimization/server.js new file mode 100644 index 0000000000000..6a98fa3d30806 --- /dev/null +++ b/test/integration/font-optimization/server.js @@ -0,0 +1,111 @@ +const http = require('http') +const url = require('url') +const fs = require('fs') +const path = require('path') +const server = http.createServer(async (req, res) => { + let { pathname } = url.parse(req.url) + pathname = pathname.replace(/\/$/, '') + let isDataReq = false + if (pathname.startsWith('/_next/data')) { + isDataReq = true + pathname = pathname + .replace(`/_next/data/${process.env.BUILD_ID}/`, '/') + .replace(/\.json$/, '') + } + console.log('serving', pathname) + + if (pathname === '/favicon.ico') { + res.statusCode = 404 + return res.end() + } + + if (pathname.startsWith('/_next/static/')) { + res.write( + fs.readFileSync( + path.join( + __dirname, + './.next/static/', + decodeURI(pathname.slice('/_next/static/'.length)) + ), + 'utf8' + ) + ) + return res.end() + } else { + const ext = isDataReq ? 'json' : 'html' + if ( + fs.existsSync( + path.join(__dirname, `./.next/serverless/pages${pathname}.${ext}`) + ) + ) { + res.write( + fs.readFileSync( + path.join(__dirname, `./.next/serverless/pages${pathname}.${ext}`), + 'utf8' + ) + ) + return res.end() + } + + let re + try { + re = require(`./.next/serverless/pages${pathname}`) + } catch { + const d = decodeURI(pathname) + if ( + fs.existsSync( + path.join(__dirname, `./.next/serverless/pages${d}.${ext}`) + ) + ) { + res.write( + fs.readFileSync( + path.join(__dirname, `./.next/serverless/pages${d}.${ext}`), + 'utf8' + ) + ) + return res.end() + } + + const routesManifest = require('./.next/routes-manifest.json') + const { dynamicRoutes } = routesManifest + dynamicRoutes.some(({ page, regex }) => { + if (new RegExp(regex).test(pathname)) { + if ( + fs.existsSync( + path.join(__dirname, `./.next/serverless/pages${page}.${ext}`) + ) + ) { + res.write( + fs.readFileSync( + path.join(__dirname, `./.next/serverless/pages${page}.${ext}`), + 'utf8' + ) + ) + res.end() + return true + } + + re = require(`./.next/serverless/pages${page}`) + return true + } + return false + }) + } + if (!res.finished) { + try { + return await (typeof re.render === 'function' + ? re.render(req, res) + : re.default(req, res)) + } catch (e) { + console.log('FAIL_FUNCTION', e) + res.statusCode = 500 + res.write('FAIL_FUNCTION') + res.end() + } + } + } +}) + +server.listen(process.env.PORT, () => { + console.log('ready on', process.env.PORT) +}) diff --git a/test/integration/font-optimization/test/index.test.js b/test/integration/font-optimization/test/index.test.js new file mode 100644 index 0000000000000..49d66ee869f88 --- /dev/null +++ b/test/integration/font-optimization/test/index.test.js @@ -0,0 +1,129 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { + killApp, + findPort, + nextStart, + nextBuild, + renderViaHTTP, + initNextServerScript, +} from 'next-test-utils' +import fs from 'fs-extra' + +jest.setTimeout(1000 * 30) + +const appDir = join(__dirname, '../') +const nextConfig = join(appDir, 'next.config.js') +let builtServerPagesDir +let builtPage +let appPort +let app + +const fsExists = (file) => + fs + .access(file) + .then(() => true) + .catch(() => false) + +async function getBuildId() { + return fs.readFile(join(appDir, '.next', 'BUILD_ID'), 'utf8') +} + +const startServerlessEmulator = async (dir, port) => { + const scriptPath = join(dir, 'server.js') + const env = Object.assign( + {}, + { ...process.env }, + { PORT: port, BUILD_ID: await getBuildId() } + ) + return initNextServerScript(scriptPath, /ready on/i, env) +} + +function runTests() { + it('should inline the google fonts for static pages', async () => { + const html = await renderViaHTTP(appPort, '/index') + expect(await fsExists(builtPage('font-manifest.json'))).toBe(true) + expect(html).toContain( + '' + ) + expect(html).toMatch( + /