Skip to content

Commit

Permalink
Add experimental image post-processing (vercel#15875)
Browse files Browse the repository at this point in the history
This PR adds a second experimental post-processing step for the framework introduced by @prateekbh in vercel#14746. The image post-processing step scans the rendered document for the first few images and uses a simple heuristic to determine if the images should be automatically preloaded.

Analysis of quite a few production Next apps has shown that a lot of sites are taking a substantial hit to their [LCP](https://web.dev/lcp/) score because an image that's part of the "hero" element on the page is not preloaded and is getting downloaded with lower priority than the JavaScript bundles. This post-processor should automatically fix that for a lot of sites, without causing any real performance effects in cases where it fails to identify the hero image.

This feature is behind an experimental flag, and will be subject to quite a bit of experimentation and tweaking before it's ready to be made a default setting.
  • Loading branch information
atcastle authored Aug 5, 2020
1 parent abf6e74 commit b6060fa
Show file tree
Hide file tree
Showing 14 changed files with 278 additions and 1 deletion.
4 changes: 3 additions & 1 deletion packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import WebpackConformancePlugin, {
ReactSyncScriptsConformanceCheck,
} from './webpack/plugins/webpack-conformance-plugin'
import { WellKnownErrorsPlugin } from './webpack/plugins/wellknown-errors-plugin'

type ExcludesFalse = <T>(x: T | false) => x is T

const isWebpack5 = parseInt(webpack.version!) === 5
Expand Down Expand Up @@ -923,6 +922,9 @@ export default async function getBaseWebpackConfig(
'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify(
config.experimental.optimizeFonts
),
'process.env.__NEXT_OPTIMIZE_IMAGES': JSON.stringify(
config.experimental.optimizeImages
),
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
config.experimental.scrollRestoration
),
Expand Down
1 change: 1 addition & 0 deletions packages/next/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ export default async function exportApp(
buildExport: options.buildExport,
serverless: isTargetLikeServerless(nextConfig.target),
optimizeFonts: nextConfig.experimental.optimizeFonts,
optimizeImages: nextConfig.experimental.optimizeImages,
})

for (const validation of result.ampValidations || []) {
Expand Down
9 changes: 9 additions & 0 deletions packages/next/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ interface ExportPageInput {
subFolders: string
serverless: boolean
optimizeFonts: boolean
optimizeImages: boolean
}

interface ExportPageResults {
Expand All @@ -64,6 +65,7 @@ interface RenderOpts {
hybridAmp?: boolean
inAmpMode?: boolean
optimizeFonts?: boolean
optimizeImages?: boolean
fontManifest?: FontManifest
}

Expand All @@ -84,6 +86,7 @@ export default async function exportPage({
subFolders,
serverless,
optimizeFonts,
optimizeImages,
}: ExportPageInput): Promise<ExportPageResults> {
let results: ExportPageResults = {
ampValidations: [],
Expand Down Expand Up @@ -221,6 +224,8 @@ export default async function exportPage({
ampPath,
/// @ts-ignore
optimizeFonts,
/// @ts-ignore
optimizeImages,
fontManifest: optimizeFonts
? requireFontManifest(distDir, serverless)
: null,
Expand Down Expand Up @@ -268,12 +273,16 @@ export default async function exportPage({
if (optimizeFonts) {
process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true)
}
if (optimizeImages) {
process.env.__NEXT_OPTIMIZE_IMAGES = JSON.stringify(true)
}
curRenderOpts = {
...components,
...renderOpts,
ampPath,
params,
optimizeFonts,
optimizeImages,
fontManifest: optimizeFonts
? requireFontManifest(distDir, serverless)
: null,
Expand Down
87 changes: 87 additions & 0 deletions packages/next/next-server/lib/post-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { parse, HTMLElement } from 'node-html-parser'
import { OPTIMIZED_FONT_PROVIDERS } from './constants'

const MIDDLEWARE_TIME_BUDGET = 10
const MAXIMUM_IMAGE_PRELOADS = 2
const IMAGE_PRELOAD_SIZE_THRESHOLD = 2500

type postProcessOptions = {
optimizeFonts: boolean
optimizeImages: boolean
}

type renderOptions = {
Expand Down Expand Up @@ -149,6 +152,83 @@ class FontOptimizerMiddleware implements PostProcessMiddleware {
}
}

class ImageOptimizerMiddleware implements PostProcessMiddleware {
inspect(originalDom: HTMLElement, _data: postProcessData) {
const imgElements = originalDom.querySelectorAll('img')
let eligibleImages: Array<HTMLElement> = []
for (let i = 0; i < imgElements.length; i++) {
if (isImgEligible(imgElements[i])) {
eligibleImages.push(imgElements[i])
}
if (eligibleImages.length >= MAXIMUM_IMAGE_PRELOADS) {
break
}
}
_data.preloads.images = eligibleImages.map((el) => el.getAttribute('src'))
}
mutate = async (markup: string, _data: postProcessData) => {
let result = markup
let imagePreloadTags = _data.preloads.images
.filter((imgHref) => !preloadTagAlreadyExists(markup, imgHref))
.reduce(
(acc, imgHref) => acc + `<link rel="preload" href="${imgHref}"/>`,
''
)
return result.replace(
/<link rel="preload"/,
`${imagePreloadTags}<link rel="preload"`
)
}
}

function isImgEligible(imgElement: HTMLElement): boolean {
return (
imgElement.hasAttribute('src') &&
imageIsNotTooSmall(imgElement) &&
imageIsNotHidden(imgElement)
)
}

function preloadTagAlreadyExists(html: string, href: string) {
const regex = new RegExp(`<link[^>]*href[^>]*${href}`)
return html.match(regex)
}

function imageIsNotTooSmall(imgElement: HTMLElement): boolean {
// Skip images without both height and width--we don't know enough to say if
// they are too small
if (
!(imgElement.hasAttribute('height') && imgElement.hasAttribute('width'))
) {
return true
}
try {
if (
parseInt(imgElement.getAttribute('height')) *
parseInt(imgElement.getAttribute('width')) <=
IMAGE_PRELOAD_SIZE_THRESHOLD
) {
return false
}
} catch (err) {
return true
}
return true
}

// Traverse up the dom from each image to see if it or any of it's
// ancestors have the hidden attribute.
function imageIsNotHidden(imgElement: HTMLElement): boolean {
let activeElement = imgElement
while (activeElement.parentNode) {
if (activeElement.hasAttribute('hidden')) {
return false
}
activeElement = activeElement.parentNode as HTMLElement
}
return true
}

// Initialization
registerPostProcessor(
'Inline-Fonts',
Expand All @@ -158,4 +238,11 @@ registerPostProcessor(
(options) => options.optimizeFonts || process.env.__NEXT_OPTIMIZE_FONTS
)

registerPostProcessor(
'Preload Images',
new ImageOptimizerMiddleware(),
// @ts-ignore
(options) => options.optimizeImages || process.env.__NEXT_OPTIMIZE_IMAGES
)

export default processHTML
1 change: 1 addition & 0 deletions packages/next/next-server/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const defaultConfig: { [key: string]: any } = {
pageEnv: false,
productionBrowserSourceMaps: false,
optimizeFonts: false,
optimizeImages: false,
scrollRestoration: false,
},
future: {
Expand Down
5 changes: 5 additions & 0 deletions packages/next/next-server/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default class Server {
basePath: string
optimizeFonts: boolean
fontManifest: FontManifest
optimizeImages: boolean
}
private compression?: Middleware
private onErrorMiddleware?: ({ err }: { err: Error }) => Promise<void>
Expand Down Expand Up @@ -172,6 +173,7 @@ export default class Server {
fontManifest: this.nextConfig.experimental.optimizeFonts
? requireFontManifest(this.distDir, this._isLikeServerless)
: null,
optimizeImages: this.nextConfig.experimental.optimizeImages,
}

// Only the `publicRuntimeConfig` key is exposed to the client side
Expand Down Expand Up @@ -236,6 +238,9 @@ export default class Server {
if (this.renderOpts.optimizeFonts) {
process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true)
}
if (this.renderOpts.optimizeImages) {
process.env.__NEXT_OPTIMIZE_IMAGES = JSON.stringify(true)
}
}

protected currentPhase(): string {
Expand Down
2 changes: 2 additions & 0 deletions packages/next/next-server/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export type RenderOptsPartial = {
unstable_runtimeJS?: false
optimizeFonts: boolean
fontManifest?: FontManifest
optimizeImages: boolean
devOnlyCacheBusterQueryString?: string
}

Expand Down Expand Up @@ -813,6 +814,7 @@ export async function renderToHTML(
},
{
optimizeFonts: renderOpts.optimizeFonts,
optimizeImages: renderOpts.optimizeImages,
}
)

Expand Down
1 change: 1 addition & 0 deletions test/integration/font-optimization/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { target: 'serverless', experimental: { optimizeFonts: true } }
4 changes: 4 additions & 0 deletions test/integration/image-optimization/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
target: 'serverless',
experimental: { optimizeImages: true },
}
24 changes: 24 additions & 0 deletions test/integration/image-optimization/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'

const Page = () => {
return (
<div>
<link rel="preload" href="already-preloaded.jpg" />
<img src="already-preloaded.jpg" />
<img src="tiny-image.jpg" width="20" height="20" />
<img src="hidden-image-1.jpg" hidden />
<div hidden>
<img src="hidden-image-2.jpg" />
</div>
<img src="main-image-1.jpg" />
<div>
<img src="main-image-2.jpg" />
</div>
<img src="main-image-3.jpg" />
<img src="main-image-4.jpg" />
<img src="main-image-5.jpg" />
</div>
)
}

export default Page
29 changes: 29 additions & 0 deletions test/integration/image-optimization/pages/stars.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
function Home({ stars }) {
return (
<div className="container">
<main>
<div>
<link rel="preload" href="already-preloaded.jpg" />
<img src="already-preloaded.jpg" />
<img src="tiny-image.jpg" width="20" height="20" />
<img src="hidden-image-1.jpg" hidden />
<div hidden>
<img src="hidden-image-2.jpg" />
</div>
<img src="main-image-1.jpg" />
<img src="main-image-2.jpg" />
<img src="main-image-3.jpg" />
<img src="main-image-4.jpg" />
<img src="main-image-5.jpg" />
</div>
<div>Next stars: {stars}</div>
</main>
</div>
)
}

Home.getInitialProps = async () => {
return { stars: Math.random() * 1000 }
}

export default Home
18 changes: 18 additions & 0 deletions test/integration/image-optimization/pages/static-head.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react'
import Head from 'next/head'

const Page = () => {
return (
<>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Modak"
rel="stylesheet"
/>
</Head>
<div>Hi!</div>
</>
)
}

export default Page
Loading

0 comments on commit b6060fa

Please sign in to comment.