Skip to content

Commit 338f2cd

Browse files
authored
chore: add bundle-size and eager-graph budget checks (#17603)
## Changes Ports the bundle-size and eager-graph CI checks from `posthog/posthog` to this repo. Because posthog.com is Gatsby + webpack (not esbuild), the eager-graph check is reimplemented against a webpack module graph rather than copied. **Eager-graph check** — catches regressions that total-size can't see (a fake-lazy `require()` moves no bytes but makes them eager on every page load): - `gatsby/emitWebpackGraphPlugin.js` — env-gated (`EMIT_WEBPACK_STATS=true`) webpack plugin, wired into `gatsby-node.ts` `onCreateWebpackConfig` only at the `build-javascript` stage so it never touches local dev or normal builds. At `emit` it reads webpack's own static/dynamic split (`module.dependencies` vs `module.blocks`) and writes a compact module graph. - `scripts/bundle/check-eager-graph.mjs` — walks the static-import closure from Gatsby's always-loaded `app` entrypoint (the per-page shell — the closest analog to a SPA shell root) and budgets its source-byte size. Same `--report-only` / `--assert-report` shape as the upstream check. **Bundle-size check** — `bundle-size-report.mjs` (gzip of `public/**/*.js`, content-hash-stripped names) + `build-bundle-comment.mjs` (sticky PR comment with deltas vs master). **CI wiring** — both run off the **existing deploy-preview build**, so they add **no extra build to a PR**. Deltas come from a master baseline cached by a new `bundle-baseline.yml` workflow (push-to-master, path-filtered to bundle-relevant files so content/docs commits don't trigger a build). ### Rollout The eager-graph budget ships **report-only** (never fails CI). The real master eager size can only be measured by the first CI build, after which: confirm the entrypoint is named `app` (the comment lists the known entrypoints otherwise), set `budgetBytes` in `check-eager-graph.mjs`, and drop `--report-only` to enforce. The closure-walker is unit-tested without needing a build (`pnpm test:bundle` — verifies dynamic-only modules are excluded from the eager closure, budget comparison, forbidden-module detection, and missing-entrypoint handling). ## Checklist - [x] Words are spelled using American English - [x] Eager-graph closure walker is unit-tested (`pnpm test:bundle`) - [ ] No content/page changes — bundle/CI tooling only ## 🤖 Agent context Continuation of the posthog/posthog bundle-splitting work (memory issue #32479) applied to posthog.com. The port is not a copy: posthog/posthog reads an esbuild metafile's per-import `kind`; posthog.com is Gatsby/webpack with no metafile, page-split, and ~45min cold builds with no `node_modules` checked out locally — so the graph emission and real numbers can only be exercised in CI, which is why the budget rolls out report-only first.
1 parent fc77ac5 commit 338f2cd

11 files changed

Lines changed: 702 additions & 0 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Bundle baseline (master)
2+
3+
# Produces the master baseline that deploy-preview.yml diffs each PR against. Builds the
4+
# site the same way the preview build does (GATSBY_MINIMAL + EMIT_WEBPACK_STATS) and caches
5+
# the resulting reports. Path-filtered so content/docs-only commits don't trigger a build —
6+
# only changes that can move the JS bundle do.
7+
8+
on:
9+
push:
10+
branches:
11+
- master
12+
paths:
13+
- 'src/**'
14+
- 'gatsby-*'
15+
- 'gatsby/**'
16+
- 'scripts/bundle/**'
17+
- 'package.json'
18+
- 'pnpm-lock.yaml'
19+
- '.github/workflows/bundle-baseline.yml'
20+
21+
concurrency:
22+
group: bundle-baseline
23+
cancel-in-progress: true
24+
25+
env:
26+
NODE_OPTIONS: '--max_old_space_size=12288'
27+
28+
jobs:
29+
baseline:
30+
name: Build & cache baseline
31+
runs-on: depot-ubuntu-latest-8
32+
timeout-minutes: 45
33+
permissions:
34+
contents: read
35+
36+
steps:
37+
- name: Checkout
38+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
39+
40+
- name: Set up pnpm
41+
uses: pnpm/action-setup@c5ba7f7862a0f64c1b1a05fbac13e0b8e86ba08c # v4
42+
43+
- name: Set up Node.js
44+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
45+
with:
46+
node-version: '22'
47+
cache: 'pnpm'
48+
49+
- name: Restore Cloudinary metadata cache
50+
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
51+
with:
52+
path: .cloudinary-resources.json
53+
key: cloudinary-${{ github.run_id }}
54+
restore-keys: |
55+
cloudinary-
56+
57+
- name: Install dependencies
58+
run: pnpm install --frozen-lockfile
59+
60+
- name: Build site
61+
run: pnpm build
62+
env:
63+
GATSBY_MINIMAL: 'true'
64+
EMIT_WEBPACK_STATS: 'true'
65+
CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }}
66+
CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }}
67+
68+
- name: Measure bundle size and eager graph
69+
run: |
70+
node scripts/bundle/bundle-size-report.mjs
71+
node scripts/bundle/check-eager-graph.mjs --report-only
72+
mkdir -p bundle-report/baseline
73+
cp bundle-report/eager-graph-report.json bundle-report/bundle-size-report.json bundle-report/baseline/
74+
75+
- name: Save baseline to cache
76+
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
77+
with:
78+
path: bundle-report/baseline
79+
key: bundle-baseline-${{ github.sha }}

.github/workflows/deploy-preview.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,57 @@ jobs:
8080
run: pnpm build
8181
env:
8282
GATSBY_MINIMAL: 'true'
83+
# Emit the static/dynamic module graph so the eager-graph check below can
84+
# measure the app shell. Reuses this single PR build — no second build.
85+
EMIT_WEBPACK_STATS: 'true'
8386
CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }}
8487
CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }}
8588

89+
# Bundle + eager-graph report. Runs off the build above, so it adds no build to
90+
# the PR. The master baseline (for deltas) is produced by bundle-baseline.yml and
91+
# restored from cache here; absent a baseline the comment shows absolute sizes.
92+
- name: Restore bundle baseline from master
93+
if: success()
94+
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
95+
with:
96+
path: bundle-report/baseline
97+
key: bundle-baseline-${{ github.event.pull_request.base.sha }}
98+
restore-keys: |
99+
bundle-baseline-
100+
101+
- name: Measure bundle size and eager graph
102+
if: success()
103+
run: |
104+
node scripts/bundle/bundle-size-report.mjs
105+
node scripts/bundle/check-eager-graph.mjs --report-only
106+
node scripts/bundle/build-bundle-comment.mjs
107+
108+
- name: Upload bundle report
109+
if: success()
110+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
111+
with:
112+
name: bundle-report
113+
path: bundle-report/
114+
retention-days: 7
115+
116+
- name: Find bundle report comment
117+
if: success()
118+
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
119+
id: find-bundle-comment
120+
with:
121+
issue-number: ${{ github.event.pull_request.number }}
122+
comment-author: 'github-actions[bot]'
123+
body-includes: '<!-- bundle-size-report -->'
124+
125+
- name: Post bundle report comment
126+
if: success()
127+
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
128+
with:
129+
issue-number: ${{ github.event.pull_request.number }}
130+
comment-id: ${{ steps.find-bundle-comment.outputs.comment-id }}
131+
edit-mode: replace
132+
body-path: bundle-report/comment.md
133+
86134
- name: Deploy to Cloudflare Pages
87135
id: deploy
88136
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ node_modules
77
/public
88
.DS_Store
99

10+
# Bundle / eager-graph check artifacts (generated by the EMIT_WEBPACK_STATS build)
11+
/bundle-report
12+
1013
.vscode
1114
.idea
1215

gatsby-node.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ export const onCreateWebpackConfig: GatsbyNode['onCreateWebpackConfig'] = ({ sta
120120
},
121121
},
122122
})
123+
124+
if (stage === 'build-javascript' && process.env.EMIT_WEBPACK_STATS === 'true') {
125+
const { EmitWebpackGraphPlugin } = require('./gatsby/emitWebpackGraphPlugin')
126+
actions.setWebpackConfig({
127+
plugins: [new EmitWebpackGraphPlugin(path.resolve(__dirname, 'bundle-report', 'webpack-graph.json'))],
128+
})
129+
}
123130
}
124131

125132
exports.createPages = async ({ actions }) => {

gatsby/emitWebpackGraphPlugin.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
// Emits a compact static/dynamic module graph for the eager-graph budget check
5+
// (scripts/bundle/check-eager-graph.mjs). Only wired into the production
6+
// `build-javascript` stage, and only when EMIT_WEBPACK_STATS=true, so it never slows
7+
// local dev or normal CI builds.
8+
//
9+
// webpack 5 exposes the distinction we need directly on each module:
10+
// - module.dependencies -> synchronous (STATIC) edges
11+
// - module.blocks -> AsyncDependenciesBlock[], the dynamic import() edges
12+
// We resolve each dependency to its target module via compilation.moduleGraph and key
13+
// everything by a dense integer index (module.identifier() strings are unique but huge).
14+
class EmitWebpackGraphPlugin {
15+
constructor(outputPath) {
16+
this.outputPath = outputPath
17+
}
18+
19+
apply(compiler) {
20+
compiler.hooks.emit.tapAsync('EmitWebpackGraphPlugin', (compilation, callback) => {
21+
try {
22+
this.writeGraph(compilation)
23+
} catch (error) {
24+
compilation.warnings.push(
25+
new Error(`EmitWebpackGraphPlugin: failed to write webpack graph: ${error.stack || error}`)
26+
)
27+
}
28+
callback()
29+
})
30+
}
31+
32+
writeGraph(compilation) {
33+
const { moduleGraph, chunkGraph } = compilation
34+
const moduleList = [...compilation.modules]
35+
const indexOf = new Map()
36+
moduleList.forEach((module, i) => indexOf.set(module, i))
37+
38+
const sizeOf = (module) => {
39+
try {
40+
return module.size() || 0
41+
} catch {
42+
return 0
43+
}
44+
}
45+
46+
const collectBlockTargets = (block, into) => {
47+
for (const dependency of block.dependencies || []) {
48+
const target = moduleGraph.getModule(dependency)
49+
const targetIndex = target && indexOf.get(target)
50+
if (targetIndex !== undefined) {
51+
into.add(targetIndex)
52+
}
53+
}
54+
for (const nested of block.blocks || []) {
55+
collectBlockTargets(nested, into)
56+
}
57+
}
58+
59+
const modules = {}
60+
moduleList.forEach((module, i) => {
61+
const staticTargets = new Set()
62+
for (const dependency of module.dependencies || []) {
63+
const target = moduleGraph.getModule(dependency)
64+
const targetIndex = target && indexOf.get(target)
65+
if (targetIndex !== undefined && targetIndex !== i) {
66+
staticTargets.add(targetIndex)
67+
}
68+
}
69+
const dynamicTargets = new Set()
70+
for (const block of module.blocks || []) {
71+
collectBlockTargets(block, dynamicTargets)
72+
}
73+
modules[i] = {
74+
name: module.readableIdentifier(compilation.requestShortener),
75+
size: sizeOf(module),
76+
static: [...staticTargets],
77+
dynamic: [...dynamicTargets],
78+
}
79+
})
80+
81+
const entrypoints = {}
82+
for (const [name, entrypoint] of compilation.entrypoints) {
83+
const chunk = entrypoint.getEntrypointChunk()
84+
const roots = []
85+
for (const module of chunkGraph.getChunkEntryModulesIterable(chunk)) {
86+
const moduleIndex = indexOf.get(module)
87+
if (moduleIndex !== undefined) {
88+
roots.push(moduleIndex)
89+
}
90+
}
91+
entrypoints[name] = { roots }
92+
}
93+
94+
fs.mkdirSync(path.dirname(this.outputPath), { recursive: true })
95+
fs.writeFileSync(this.outputPath, JSON.stringify({ entrypoints, modules }))
96+
}
97+
}
98+
99+
module.exports = { EmitWebpackGraphPlugin }

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"typegen": "kea-typegen write .",
2828
"update-sprite": "svg-sprite -s --symbol-dest src/components/productFeature/images/icons --symbol-sprite sprited-icons.svg src/components/productFeature/images/icons/*.svg",
2929
"test-redirects": "jest scripts",
30+
"test:bundle": "node --test scripts/bundle/check-eager-graph.test.mjs",
3031
"check-links-post-build": "node scripts/check-links-post-build.js",
3132
"storybook": "start-storybook -s ./static -p 6006",
3233
"build-storybook": "build-storybook",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"entrypoints": {
3+
"app": { "roots": ["a"] },
4+
"polyfill": { "roots": ["p"] }
5+
},
6+
"modules": {
7+
"a": { "name": "./.cache/production-app.js", "size": 100, "static": ["b", "c"], "dynamic": ["e"] },
8+
"b": { "name": "./src/components/Shell.tsx", "size": 200, "static": ["d"], "dynamic": [] },
9+
"c": { "name": "./src/components/Header.tsx", "size": 50, "static": [], "dynamic": [] },
10+
"d": { "name": "../node_modules/heavy/index.js", "size": 1000, "static": [], "dynamic": [] },
11+
"e": { "name": "./src/templates/lazy-page.tsx", "size": 5000, "static": ["f"], "dynamic": [] },
12+
"f": { "name": "../node_modules/monaco-editor/editor.js", "size": 9000, "static": [], "dynamic": [] },
13+
"p": { "name": "./.cache/polyfill.js", "size": 30, "static": [], "dynamic": [] }
14+
}
15+
}

0 commit comments

Comments
 (0)