Skip to content

Commit 4ceabab

Browse files
committed
initial commit
0 parents  commit 4ceabab

13 files changed

Lines changed: 5788 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.DS_Store
2+
node_modules
3+
.next

.prettierrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"semi": false,
3+
"singleQuote": true
4+
}

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Next.js Bundle Analysis Github Action
2+
3+
Analyzes each PR's impact on your next.js app's bundle size and displays it using a comment. Optionally supports performance budgets.
4+
5+
![screenshot of bundle analysis comment](https://p176.p0.n0.cdn.getcloudapp.com/items/BluKP76d/2b51f74a-9c0f-481f-b76a-9b36cf37d369.png?v=ddd23d0d9ee1ee9ad40487d181ed917f)
6+
7+
### Usage
8+
9+
> **NOTE**: Due to github actions' lack of support for more complex actions, the experience of getting this set up is unusual in that it requires a generation script which copies most of the logic into your project directly. As soon as github adds support for the [features](https://github.com/actions/runner/pull/1144) [needed](https://github.com/actions/runner/pull/1144#discussion_r651087316) to properly package up this action, we'll put out an update that removes the extra boilerplate and makes usage much simpler. Until then, we all have no choice but to endure this unusual setup process.
10+
11+
```sh
12+
$ npx nextjs-bundle-analysis generate
13+
```

__tests__/__fixtures__/package-lock.json

Lines changed: 2076 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "bundle-analysis-test-fixture",
3+
"version": "1.0.0",
4+
"description": "its a test",
5+
"main": "index.js",
6+
"author": "",
7+
"license": "GPL-2.0",
8+
"dependencies": {
9+
"next": "^11.0.1",
10+
"react": "^17.0.2",
11+
"react-dom": "^17.0.2"
12+
},
13+
"scripts": {
14+
"build": "next build"
15+
},
16+
"nextBundleAnalysis": {
17+
"budget": 358400,
18+
"budgetPercentIncreaseRed": 20
19+
}
20+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function IndexPage() {
2+
return <p>hello world</p>
3+
}

__tests__/index.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const path = require('path')
2+
const fs = require('fs')
3+
const { execSync } = require('child_process')
4+
const rimraf = require('rimraf')
5+
const { afterAll, beforeAll, expect } = require('@jest/globals')
6+
const mkdirp = require('mkdirp')
7+
8+
const fixturesPath = path.join(__dirname, '__fixtures__')
9+
10+
beforeAll(() => {
11+
process.chdir(fixturesPath)
12+
execSync('npm install')
13+
execSync('npm run build')
14+
})
15+
16+
afterAll(() => {
17+
rimraf.sync(path.join(fixturesPath, '.next'))
18+
})
19+
20+
test('sort of integration', () => {
21+
// make sure the 'report' command works
22+
execSync('node ../../report.js')
23+
const bundleAnalysis = fs.readFileSync(
24+
path.join(process.cwd(), '.next/analyze/__bundle_analysis.json'),
25+
'utf8'
26+
)
27+
expect(bundleAnalysis.length).toBeGreaterThan(1)
28+
29+
// create a fake artifact download - in the real world this would pull from
30+
// github as part of the action flow
31+
mkdirp.sync(path.join(process.cwd(), '.next/analyze/base/bundle'))
32+
fs.writeFileSync(
33+
path.join(
34+
process.cwd(),
35+
'.next/analyze/base/bundle/__bundle_analysis.json'
36+
),
37+
bundleAnalysis
38+
)
39+
40+
// make sure the 'compare' command works
41+
execSync('node ../../compare.js')
42+
const comment = fs.readFileSync(
43+
path.join(process.cwd(), '.next/analyze/__bundle_analysis_comment.txt'),
44+
'utf8'
45+
)
46+
expect(comment).toMatch(/no changes to the javascript bundle/)
47+
})

compare.js

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#!/usr/bin/env node
2+
3+
const filesize = require('filesize')
4+
const numberToWords = require('number-to-words')
5+
const fs = require('fs')
6+
const path = require('path')
7+
8+
// import the current and base branch bundle stats
9+
const currentBundle = require(path.join(
10+
process.cwd(),
11+
'.next/analyze/__bundle_analysis.json'
12+
))
13+
const baseBundle = require(path.join(
14+
process.cwd(),
15+
'.next/analyze/base/bundle/__bundle_analysis.json'
16+
))
17+
18+
// Pull options from `package.json`
19+
const options = require(path.join(
20+
process.cwd(),
21+
'package.json'
22+
)).nextBundleAnalysis
23+
24+
const BUDGET = options.budget
25+
const BUDGET_PERCENT_INCREASE_RED = options.budgetPercentIncreaseRed
26+
27+
// kick it off
28+
let output = `## 📦 Next.js Bundle Analysis
29+
30+
This analysis was generated by the [next.js bundle analysis action](#) 🤖
31+
32+
`
33+
34+
// pull the global bundle out, we handle this separately
35+
const globalBundleCurrent = currentBundle.__global
36+
const globalBundleBase = baseBundle.__global
37+
delete currentBundle.__global
38+
delete baseBundle.__global
39+
40+
// calculate the difference between the current bundle and the base branch's
41+
const globalBundleChanges =
42+
globalBundleCurrent.gzip !== globalBundleBase.gzip
43+
? {
44+
page: 'global',
45+
raw: globalBundleCurrent.raw,
46+
gzip: globalBundleCurrent.gzip,
47+
gzipDiff: globalBundleCurrent.gzip - globalBundleBase.gzip,
48+
increase:
49+
Math.sign(globalBundleCurrent.gzip - globalBundleBase.gzip) > 0,
50+
}
51+
: false
52+
53+
// now we're going to go through each of the pages in the current bundle and
54+
// run analysis on each one.
55+
const changedPages = []
56+
const newPages = []
57+
58+
for (let page in currentBundle) {
59+
const currentStats = currentBundle[page]
60+
const baseStats = baseBundle[page]
61+
62+
// if the page does't appear in the base bundle, it is a new page, we can
63+
// push this directly to its own category. we also don't compare it to anything
64+
// because its a new page.
65+
if (!baseStats) {
66+
newPages.push({ page, ...currentStats })
67+
} else if (currentStats.gzip !== baseStats.gzip) {
68+
// otherwise, we run a comparsion between the current page and base branch page
69+
// we push these to their own category for rendering later
70+
const rawDiff = currentStats.raw - baseStats.raw
71+
const gzipDiff = currentStats.gzip - baseStats.gzip
72+
const increase = !!Math.sign(gzipDiff)
73+
changedPages.push({ page, ...currentStats, rawDiff, gzipDiff, increase })
74+
}
75+
}
76+
77+
// with our data in hand, we now get to a bunch of output formatting.
78+
// we start with any changes to the global bundle.
79+
if (globalBundleChanges) {
80+
// start with the headline, which will render differently depending on whether
81+
// there was an increase of decrease.
82+
output += `### ${
83+
globalBundleChanges.increase ? '⚠️' : '🎉'
84+
} Global Bundle Size ${
85+
globalBundleChanges.increase ? 'Increased' : 'Decreased'
86+
}
87+
88+
`
89+
// this is where we actually generate the table including the changes.
90+
output += markdownTable(globalBundleChanges)
91+
92+
// and we end with some extra details further explaining the data above
93+
output += `\n<details>
94+
<summary>Details</summary>
95+
<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>
96+
<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>
97+
</details>\n\n`
98+
}
99+
100+
// next up is the newly added pages
101+
if (newPages.length) {
102+
// this might seem like too much, but I feel like this type of small detail really
103+
// matters <3
104+
const plural = newPages.length > 1 ? 's' : ''
105+
output += `### New Page${plural} Added
106+
107+
The following page${plural} ${
108+
plural === 's' ? 'were' : 'was'
109+
} added to the bundle from the code in this PR:
110+
111+
`
112+
// as before, run the data in as a table
113+
output += markdownTable(newPages, globalBundleCurrent) + '\n'
114+
115+
// there is no "details" section here, didnt't seem necessary. i'm open to one being
116+
// added though!
117+
}
118+
119+
// finally, we run through the pages that existed in the base branch, still exist in the
120+
// current branch, and have changed size.
121+
if (changedPages.length) {
122+
// same flow here as the others:
123+
// - headline that adjusts wording based on number of changes
124+
// - table containing all the resources and info
125+
// - details section
126+
const plural = changedPages.length > 1 ? 's' : ''
127+
output += `### ${titleCase(
128+
numberToWords.toWords(changedPages.length)
129+
)} Page${plural} Changed Size
130+
131+
The following page${plural} changed size from the code in this PR compared to its base branch:
132+
133+
`
134+
output += markdownTable(changedPages, globalBundleCurrent, globalBundleBase)
135+
136+
// this details section is a bit more responsive, it will render slightly different
137+
// details depending on whether a budget is being used, since the information presented
138+
// is quite different.
139+
output += `\n<details>
140+
<summary>Details</summary>
141+
<p>Only the gzipped size is provided here based on <a href='https://twitter.com/slightlylate/status/1412851269211811845'>an expert tip</a>.</p>
142+
<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>
143+
${
144+
BUDGET && globalBundleCurrent
145+
? `<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>`
146+
: `<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.`
147+
}
148+
</details>\n`
149+
}
150+
151+
// and finally, if there are no changes at all, we try to be clear about that
152+
if (!newPages.length && !changedPages.length && !globalBundleChanges) {
153+
output += 'This PR introduced no changes to the javascript bundle 🙌'
154+
}
155+
156+
// we add this tag so that our action can be able to easily and consistently find the
157+
// right comment to edit as more commits are pushed.
158+
output += '<!-- __NEXTJS_BUNDLE -->'
159+
160+
// log the output, mostly for testing and debugging. this will show up in the
161+
// github actions console.
162+
console.log(output)
163+
164+
// and to cap it off, we write the output to a file which is later read in as comment
165+
// contents by the actions workflow.
166+
fs.writeFileSync(
167+
path.join(process.cwd(), '.next/analyze/__bundle_analysis_comment.txt'),
168+
output.trim()
169+
)
170+
171+
// Util Functions
172+
173+
// this is where the vast majority of the complexity lives, its a single function
174+
// that renders a markdown table displaying a wide range of bundle size data in a
175+
// wide variety of different ways. this could potentially be improved by splitting it
176+
// up into several different functions for rendering the different tables we produce
177+
// (new pages, changed pages, global bundle)
178+
function markdownTable(_data, globalBundleCurrent, globalBundleBase) {
179+
const data = [].concat(_data)
180+
// the table renders different depending on whether the budget option is enabled
181+
// and also some tables do not run budget diffs (new, global)
182+
const showBudget = globalBundleCurrent && BUDGET
183+
const showBudgetDiff = BUDGET && !!globalBundleBase
184+
185+
// first we set up the table headers
186+
return `Page | Size (compressed) | ${
187+
globalBundleCurrent ? `First Load |` : ''
188+
}${showBudgetDiff ? ` % of Budget (\`${filesize(BUDGET)}\`) |` : ''}
189+
|---|---|${globalBundleCurrent ? '---|' : ''}${showBudget ? '---|' : ''}
190+
${data
191+
.map((d) => {
192+
// next, we go through each item in the bundle data that was passed in and render
193+
// a row for it. a couple calculations are run upfront to make rendering easier.
194+
const firstLoadSize = globalBundleCurrent
195+
? d.gzip + globalBundleCurrent.gzip
196+
: 0
197+
198+
const budgetPercentage = showBudget
199+
? ((firstLoadSize / BUDGET) * 100).toFixed(2)
200+
: 0
201+
const previousBudgetPercentage =
202+
globalBundleBase && d.gzipDiff
203+
? (
204+
((globalBundleCurrent.gzip + d.gzip + d.gzipDiff) / BUDGET) *
205+
100
206+
).toFixed(2)
207+
: 0
208+
const budgetChange = previousBudgetPercentage
209+
? (previousBudgetPercentage - budgetPercentage).toFixed(2)
210+
: 0
211+
212+
return (
213+
`| \`${d.page}\`` +
214+
renderSize(d, showBudgetDiff) +
215+
renderFirstLoad(globalBundleCurrent, firstLoadSize) +
216+
renderBudgetPercentage(
217+
showBudget,
218+
budgetPercentage,
219+
previousBudgetPercentage,
220+
budgetChange
221+
) +
222+
' |\n'
223+
)
224+
})
225+
.join('')}`
226+
}
227+
228+
// as long as global bundle is passed, render the first load size, which is the global
229+
// bundle plus the size of the current page, representing the total JS required
230+
// in order to land on that page.
231+
function renderFirstLoad(globalBundleCurrent, firstLoadSize) {
232+
if (!globalBundleCurrent) return ''
233+
return ` | ${filesize(firstLoadSize)}`
234+
}
235+
236+
// renders the bundle size of the current page. if there is a diff from the base branch
237+
// size of the page, also displays the size difference, unless there is a budget set and
238+
// the budget has a diff from the base branch, in which case the diff is not rendered.
239+
function renderSize(d, showBudgetDiff) {
240+
const gzd = d.gzipDiff
241+
const percentChange = (gzd / d.gzip) * 100
242+
return ` | \`${filesize(d.gzip)}\`${
243+
gzd && !showBudgetDiff
244+
? ` _(${renderStatusIndicator(percentChange)}${filesize(gzd)})_`
245+
: ''
246+
}`
247+
}
248+
249+
// renders the percentage of the budget taken up by the current page's first load js
250+
// for changed pages, also renders the percent change compared to the base branch size
251+
function renderBudgetPercentage(
252+
showBudget,
253+
budgetPercentage,
254+
previousBudgetPercentage,
255+
budgetChange
256+
) {
257+
if (!showBudget) return ''
258+
259+
// we round to 2 decimal places for number values, if there was a change smaller than that
260+
// its displayed as "+/- <0.01%", signaling that it's not a consequential change, but it
261+
// still is a change technically, so we still show it.
262+
const budgetChangeText = ` _(${renderStatusIndicator(budgetChange)}${
263+
budgetChange < 0.01 && budgetChange > -0.01
264+
? '+/- <0.01%'
265+
: budgetChange + '%'
266+
})_`
267+
268+
// only render the budget change for changed pages (indicated by previousBudgetPercentage
269+
// being passed in)
270+
return ` | ${budgetPercentage}%${
271+
previousBudgetPercentage ? budgetChangeText : ''
272+
}`
273+
}
274+
275+
// given a percentage that a metric has changed, renders a colored status indicator
276+
// this makes it easier to call attention to things that need attention
277+
//
278+
// in general:
279+
// - yellow means "keep an eye on this"
280+
// - red means "this is a problem"
281+
// - green means "this is a win"
282+
function renderStatusIndicator(percentageChange) {
283+
let res = ''
284+
if (percentageChange > 0 && percentageChange < BUDGET_PERCENT_INCREASE_RED) {
285+
res += '🟡 +'
286+
} else if (percentageChange >= BUDGET_PERCENT_INCREASE_RED) {
287+
res += '🔴 +'
288+
} else if (percentageChange < 0.01 && percentageChange > -0.01) {
289+
res += ''
290+
} else {
291+
res += '🟢 '
292+
}
293+
return res
294+
}
295+
296+
function titleCase(str) {
297+
return str.replace(/\w\S*/g, (txt) => {
298+
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
299+
})
300+
}

generate.js

Whitespace-only changes.

0 commit comments

Comments
 (0)