nextjs-bundle-analysis
Version:
Analyzes each PR's impact on your next.js app's bundle size and displays it using a comment
349 lines (307 loc) • 13.9 kB
JavaScript
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
const originalFilesize = require('filesize')
const numberToWords = require('number-to-words')
const fs = require('fs')
const path = require('path')
const { getBuildOutputDirectory, getOptions } = require('./utils')
// Override default filesize options to display a non-breakable space as a spacer.
const filesize = (bytes, options) => {
return originalFilesize(bytes, {
spacer: ' ',
...options,
})
}
// Pull options from `package.json`
const options = getOptions()
const BUDGET = options.budget
const BUDGET_PERCENT_INCREASE_RED = options.budgetPercentIncreaseRed
// this must be explicitly set to false not to render
const SHOW_DETAILS =
options.showDetails === undefined ? true : options.showDetails
const BUILD_OUTPUT_DIRECTORY = getBuildOutputDirectory(options)
const PACKAGE_NAME = options.name
const SKIP_COMMENT_IF_EMPTY = options.skipCommentIfEmpty
// import the current and base branch bundle stats
const currentBundle = require(path.join(
process.cwd(),
BUILD_OUTPUT_DIRECTORY,
'analyze/__bundle_analysis.json'
))
const baseBundle = require(path.join(
process.cwd(),
BUILD_OUTPUT_DIRECTORY,
'analyze/base/bundle/__bundle_analysis.json'
))
// kick it off
let output = `## 📦 Next.js Bundle Analysis for ${PACKAGE_NAME}
This analysis was generated by the [Next.js Bundle Analysis action](https://github.com/hashicorp/nextjs-bundle-analysis). 🤖
`
// pull the global bundle out, we handle this separately
const globalBundleCurrent = currentBundle.__global
const globalBundleBase = baseBundle.__global
delete currentBundle.__global
delete baseBundle.__global
// calculate the difference between the current bundle and the base branch's
let globalBundleChanges = false
const globalGzipDiff = globalBundleCurrent.gzip - globalBundleBase.gzip
// only report a global bundle size change if we don't have a minimum change
// threshold configured, or if the change is greater than the threshold
if (
globalGzipDiff !== 0 &&
(!('minimumChangeThreshold' in options) ||
Math.abs(globalGzipDiff) > options.minimumChangeThreshold)
) {
globalBundleChanges = {
page: 'global',
raw: globalBundleCurrent.raw,
gzip: globalBundleCurrent.gzip,
gzipDiff: globalGzipDiff,
increase: Math.sign(globalGzipDiff) > 0,
}
}
// now we're going to go through each of the pages in the current bundle and
// run analysis on each one.
const changedPages = []
const newPages = []
for (let page in currentBundle) {
const currentStats = currentBundle[page]
const baseStats = baseBundle[page]
// if the page does't appear in the base bundle, it is a new page, we can
// push this directly to its own category. we also don't compare it to anything
// because its a new page.
if (!baseStats) {
newPages.push({ page, ...currentStats })
} else if (currentStats.gzip !== baseStats.gzip) {
// otherwise, we run a comparsion between the current page and base branch page
// we push these to their own category for rendering later
const rawDiff = currentStats.raw - baseStats.raw
const gzipDiff = currentStats.gzip - baseStats.gzip
const increase = !!Math.sign(gzipDiff)
// only report a page size change if we don't have a minimum change
// threshold configured, or if the change is greater than the threshold
if (
!('minimumChangeThreshold' in options) ||
Math.abs(gzipDiff) > options.minimumChangeThreshold
) {
changedPages.push({ page, ...currentStats, rawDiff, gzipDiff, increase })
}
}
}
// with our data in hand, we now get to a bunch of output formatting.
// we start with any changes to the global bundle.
if (globalBundleChanges) {
// start with the headline, which will render differently depending on whether
// there was an increase of decrease.
output += `### ${
globalBundleChanges.increase ? '⚠️' : '🎉'
} Global Bundle Size ${
globalBundleChanges.increase ? 'Increased' : 'Decreased'
}
`
// this is where we actually generate the table including the changes.
output += markdownTable(globalBundleChanges)
// and we end with some extra details further explaining the data above
if (SHOW_DETAILS) {
output += `\n<details>
<summary>Details</summary>
<p>The <strong>global bundle</strong> is the javascript bundle that loads alongside every page. It is in its own category because its impact is much higher - an increase to its size means that every page on your website loads slower, and a decrease means every page loads faster.</p>
<p>Any third party scripts you have added directly to your app using the <code><script></code> tag are not accounted for in this analysis</p>
<p>If you want further insight into what is behind the changes, give <a href='https://www.npmjs.com/package/@next/bundle-analyzer'>@next/bundle-analyzer</a> a try!</p>
</details>\n\n`
}
}
// next up is the newly added pages
if (newPages.length) {
// this might seem like too much, but I feel like this type of small detail really
// matters <3
const plural = newPages.length > 1 ? 's' : ''
output += `### New Page${plural} Added
The following page${plural} ${
plural === 's' ? 'were' : 'was'
} added to the bundle from the code in this PR:
`
// as before, run the data in as a table
output += markdownTable(newPages, globalBundleCurrent) + '\n'
// there is no "details" section here, didnt't seem necessary. i'm open to one being
// added though!
}
// finally, we run through the pages that existed in the base branch, still exist in the
// current branch, and have changed size.
if (changedPages.length) {
// same flow here as the others:
// - headline that adjusts wording based on number of changes
// - table containing all the resources and info
// - details section
const plural = changedPages.length > 1 ? 's' : ''
output += `### ${titleCase(
numberToWords.toWords(changedPages.length)
)} Page${plural} Changed Size
The following page${plural} changed size from the code in this PR compared to its base branch:
`
output += markdownTable(changedPages, globalBundleCurrent, globalBundleBase)
// this details section is a bit more responsive, it will render slightly different
// details depending on whether a budget is being used, since the information presented
// is quite different.
if (SHOW_DETAILS) {
output += `\n<details>
<summary>Details</summary>
<p>Only the gzipped size is provided here based on <a href='https://twitter.com/slightlylate/status/1412851269211811845'>an expert tip</a>.</p>
<p><strong>First Load</strong> is the size of the global bundle plus the bundle for the individual page. If a user were to show up to your website and land on a given page, the first load size represents the amount of javascript that user would need to download. If <code>next/link</code> is used, subsequent page loads would only need to download that page's bundle (the number in the "Size" column), since the global bundle has already been downloaded.</p>
<p>Any third party scripts you have added directly to your app using the <code><script></code> tag are not accounted for in this analysis</p>
${
BUDGET && globalBundleCurrent
? `<p>The "Budget %" column shows what percentage of your performance budget the <strong>First Load</strong> total takes up. For example, if your budget was 100kb, and a given page's first load size was 10kb, it would be 10% of your budget. You can also see how much this has increased or decreased compared to the base branch of your PR. If this percentage has increased by ${BUDGET_PERCENT_INCREASE_RED}% or more, there will be a red status indicator applied, indicating that special attention should be given to this. If you see "+/- <0.01%" it means that there was a change in bundle size, but it is a trivial enough amount that it can be ignored.</p>`
: `<p>Next to the size is how much the size has increased or decreased compared with the base branch of this PR. If this percentage has increased by ${BUDGET_PERCENT_INCREASE_RED}% or more, there will be a red status indicator applied, indicating that special attention should be given to this.`
}
</details>\n`
}
}
// and finally, if there are no changes at all, we try to be clear about that
const hasNoChanges =
!newPages.length && !changedPages.length && !globalBundleChanges
if (hasNoChanges) {
output += 'This PR introduced no changes to the JavaScript bundle! 🙌'
}
// we add this tag so that our action can be able to easily and consistently find the
// right comment to edit as more commits are pushed.
output += `<!-- __NEXTJS_BUNDLE_${PACKAGE_NAME} -->`
// however, if ignoreIfEmpty is true, set output to an empty string
if (hasNoChanges && SKIP_COMMENT_IF_EMPTY) {
output = ''
}
// log the output, mostly for testing and debugging. this will show up in the
// github actions console.
console.log(output)
// and to cap it off, we write the output to a file which is later read in as comment
// contents by the actions workflow.
fs.writeFileSync(
path.join(
process.cwd(),
BUILD_OUTPUT_DIRECTORY,
'analyze/__bundle_analysis_comment.txt'
),
output.trim()
)
// Util Functions
// this is where the vast majority of the complexity lives, its a single function
// that renders a markdown table displaying a wide range of bundle size data in a
// wide variety of different ways. this could potentially be improved by splitting it
// up into several different functions for rendering the different tables we produce
// (new pages, changed pages, global bundle)
function markdownTable(_data, globalBundleCurrent, globalBundleBase) {
const data = [].concat(_data)
// the table renders different depending on whether the budget option is enabled
// and also some tables do not run budget diffs (new, global)
const showBudget = globalBundleCurrent && BUDGET
const showBudgetDiff = BUDGET && !!globalBundleBase
// first we set up the table headers
return `Page | Size (compressed) | ${
globalBundleCurrent ? `First Load |` : ''
}${showBudget ? ` % of Budget (\`${filesize(BUDGET)}\`) |` : ''}
|---|---|${globalBundleCurrent ? '---|' : ''}${showBudget ? '---|' : ''}
${data
.map((d) => {
// next, we go through each item in the bundle data that was passed in and render
// a row for it. a couple calculations are run upfront to make rendering easier.
const firstLoadSize = globalBundleCurrent
? d.gzip + globalBundleCurrent.gzip
: 0
const budgetPercentage = showBudget
? ((firstLoadSize / BUDGET) * 100).toFixed(2)
: 0
const previousBudgetPercentage =
globalBundleBase && d.gzipDiff
? (
((globalBundleCurrent.gzip + d.gzip + d.gzipDiff) / BUDGET) *
100
).toFixed(2)
: 0
const budgetChange = previousBudgetPercentage
? (previousBudgetPercentage - budgetPercentage).toFixed(2)
: 0
return (
`| \`${d.page}\`` +
renderSize(d, showBudgetDiff) +
renderFirstLoad(globalBundleCurrent, firstLoadSize) +
renderBudgetPercentage(
showBudget,
budgetPercentage,
previousBudgetPercentage,
budgetChange
) +
' |\n'
)
})
.join('')}`
}
// as long as global bundle is passed, render the first load size, which is the global
// bundle plus the size of the current page, representing the total JS required
// in order to land on that page.
function renderFirstLoad(globalBundleCurrent, firstLoadSize) {
if (!globalBundleCurrent) return ''
return ` | ${filesize(firstLoadSize)}`
}
// renders the bundle size of the current page. if there is a diff from the base branch
// size of the page, also displays the size difference, unless there is a budget set and
// the budget has a diff from the base branch, in which case the diff is not rendered.
function renderSize(d, showBudgetDiff) {
const gzd = d.gzipDiff
const percentChange = (gzd / d.gzip) * 100
return ` | \`${filesize(d.gzip)}\`${
gzd && !showBudgetDiff
? ` _(${renderStatusIndicator(percentChange)}${filesize(gzd)})_`
: ''
}`
}
// renders the percentage of the budget taken up by the current page's first load js
// for changed pages, also renders the percent change compared to the base branch size
function renderBudgetPercentage(
showBudget,
budgetPercentage,
previousBudgetPercentage,
budgetChange
) {
if (!showBudget) return ''
// we round to 2 decimal places for number values, if there was a change smaller than that
// its displayed as "+/- <0.01%", signaling that it's not a consequential change, but it
// still is a change technically, so we still show it.
const budgetChangeText = ` _(${renderStatusIndicator(budgetChange)}${
budgetChange < 0.01 && budgetChange > -0.01
? '+/- <0.01%'
: budgetChange + '%'
})_`
// only render the budget change for changed pages (indicated by previousBudgetPercentage
// being passed in)
return ` | ${budgetPercentage}%${
previousBudgetPercentage ? budgetChangeText : ''
}`
}
// given a percentage that a metric has changed, renders a colored status indicator
// this makes it easier to call attention to things that need attention
//
// in general:
// - yellow means "keep an eye on this"
// - red means "this is a problem"
// - green means "this is a win"
function renderStatusIndicator(percentageChange) {
let res = ''
if (percentageChange > 0 && percentageChange < BUDGET_PERCENT_INCREASE_RED) {
res += '🟡 +'
} else if (percentageChange >= BUDGET_PERCENT_INCREASE_RED) {
res += '🔴 +'
} else if (percentageChange < 0.01 && percentageChange > -0.01) {
res += ''
} else {
res += '🟢 '
}
return res
}
function titleCase(str) {
return str.replace(/\w\S*/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
})
}