ci: analyze bundle size (#13071)

This adds a new `analyze` step to our CI that analyzes the bundle size
for our `payload`, `@payloadcms/ui`, `@payloadcms/next` and
`@payloadcms/richtext-lexical` packages.

It does so using a new `build:bundle-for-analysis` script that packages
can add if the normal build step does not output an esbuild-bundled
version suitable for analyzing. For example, `ui` already runs esbuild,
but we run it again using `build:bundle-for-analysis` because we do not
want to split the bundle.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210692087147570
This commit is contained in:
Alessio Gravili
2025-07-07 13:00:02 -07:00
committed by GitHub
parent f4f13a26c7
commit af9837de44
13 changed files with 139 additions and 26 deletions

View File

@@ -721,3 +721,37 @@ jobs:
- run: | - run: |
echo github.ref: ${{ github.ref }} echo github.ref: ${{ github.ref }}
echo isV3: ${{ github.ref == 'refs/heads/main' }} echo isV3: ${{ github.ref == 'refs/heads/main' }}
analyze:
runs-on: ubuntu-latest
needs: [changes, build]
timeout-minutes: 5
permissions:
contents: read # for checkout repository
actions: read # for fetching base branch bundle stats
pull-requests: write # for comments
steps:
- uses: actions/checkout@v4
- name: Node setup
uses: ./.github/actions/setup
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
pnpm-run-install: false
pnpm-restore-cache: false # Full build is restored below
pnpm-install-cache-key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Restore build
uses: actions/cache@v4
with:
path: ./*
key: ${{ github.sha }}-${{ github.run_number }}
- run: pnpm run build:bundle-for-analysis # Esbuild packages that haven't already been built in the build step for the purpose of analyzing bundle size
env:
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
- name: Analyze esbuild bundle size
uses: exoego/esbuild-bundle-analyzer@v1
with:
metafiles: 'packages/payload/meta_index.json,packages/payload/meta_shared.json,packages/ui/meta_client.json,packages/ui/meta_shared.json,packages/next/meta_index.json,packages/richtext-lexical/meta_client.json'

5
.gitignore vendored
View File

@@ -22,6 +22,11 @@ meta_server.json
meta_index.json meta_index.json
meta_shared.json meta_shared.json
packages/payload/esbuild
packages/ui/esbuild
packages/next/esbuild
packages/richtext-lexical/esbuild
.turbo .turbo
# Ignore test directory media folder/files # Ignore test directory media folder/files

View File

@@ -14,6 +14,7 @@
"build:all": "turbo build --filter \"!blank\" --filter \"!website\"", "build:all": "turbo build --filter \"!blank\" --filter \"!website\"",
"build:app": "next build", "build:app": "next build",
"build:app:analyze": "cross-env ANALYZE=true next build", "build:app:analyze": "cross-env ANALYZE=true next build",
"build:bundle-for-analysis": "turbo run build:bundle-for-analysis",
"build:clean": "pnpm clean:build", "build:clean": "pnpm clean:build",
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\"", "build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\"",
"build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force", "build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force",

32
packages/next/bundle.js Normal file
View File

@@ -0,0 +1,32 @@
import * as esbuild from 'esbuild'
import fs from 'fs'
import { sassPlugin } from 'esbuild-sass-plugin'
import path from 'path'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const directoryArg = process.argv[2] || 'dist'
async function build() {
const resultIndex = await esbuild.build({
entryPoints: ['dist/esbuildEntry.js'],
bundle: true,
platform: 'node',
format: 'esm',
outfile: `${directoryArg}/index.js`,
splitting: false,
external: ['@payloadcms/ui', 'payload', '@payloadcms/translations', '@payloadcms/graphql'],
minify: true,
metafile: true,
tsconfig: path.resolve(dirname, './tsconfig.json'),
// plugins: [commonjs()],
sourcemap: true,
plugins: [sassPlugin({ css: 'external' })],
})
console.log('payload server bundled successfully')
fs.writeFileSync('meta_index.json', JSON.stringify(resultIndex.metafile))
}
await build()

View File

@@ -12,6 +12,18 @@ export const index = [
}, },
}, },
}, },
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
projectService: {
// See comment in packages/eslint-config/index.mjs
allowDefaultProject: ['bundleScss.js', 'bundle.js', 'babel.config.cjs'],
},
},
},
},
] ]
export default index export default index

View File

@@ -74,6 +74,7 @@
"scripts": { "scripts": {
"build": "pnpm build:reactcompiler", "build": "pnpm build:reactcompiler",
"build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist", "build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist",
"build:bundle-for-analysis": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && node ./bundle.js esbuild",
"build:cjs": "swc ./src/withPayload.js -o ./dist/cjs/withPayload.cjs --config-file .swcrc-cjs --strip-leading-paths", "build:cjs": "swc ./src/withPayload.js -o ./dist/cjs/withPayload.cjs --config-file .swcrc-cjs --strip-leading-paths",
"build:esbuild": "node bundleScss.js", "build:esbuild": "node bundleScss.js",
"build:reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:types && pnpm build:esbuild && pnpm build:cjs", "build:reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:types && pnpm build:esbuild && pnpm build:cjs",

View File

@@ -5,13 +5,16 @@ import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
const directoryArg = process.argv[2] || 'dist'
async function build() { async function build() {
const resultIndex = await esbuild.build({ const resultIndex = await esbuild.build({
entryPoints: ['src/index.ts'], entryPoints: ['dist/index.js'],
bundle: true, bundle: true,
platform: 'node', platform: 'node',
format: 'esm', format: 'esm',
outfile: 'dist/index.js', outfile: `${directoryArg}/index.js`,
splitting: false, splitting: false,
external: [ external: [
'lodash', 'lodash',
@@ -33,11 +36,11 @@ async function build() {
console.log('payload server bundled successfully') console.log('payload server bundled successfully')
const resultShared = await esbuild.build({ const resultShared = await esbuild.build({
entryPoints: ['src/exports/shared.ts'], entryPoints: ['dist/exports/shared.js'],
bundle: true, bundle: true,
platform: 'node', platform: 'node',
format: 'esm', format: 'esm',
outfile: 'dist/exports/shared.js', outfile: `${directoryArg}/exports/shared.js`,
splitting: false, splitting: false,
external: [ external: [
'lodash', 'lodash',

View File

@@ -71,8 +71,8 @@
"bin.js" "bin.js"
], ],
"scripts": { "scripts": {
"build": "rimraf .dist && rimraf tsconfig.tsbuildinfo && pnpm copyfiles && pnpm build:types && pnpm build:swc && pnpm build:esbuild", "build": "rimraf .dist && rimraf tsconfig.tsbuildinfo && pnpm copyfiles && pnpm build:types && pnpm build:swc && echo skipping esbuild",
"build:esbuild": "echo skipping esbuild", "build:bundle-for-analysis": "node ./bundle.js esbuild",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist", "build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf -g {dist,*.tsbuildinfo}", "clean": "rimraf -g {dist,*.tsbuildinfo}",

View File

@@ -6,6 +6,10 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
import { sassPlugin } from 'esbuild-sass-plugin' import { sassPlugin } from 'esbuild-sass-plugin'
const directoryArg = process.argv[2] || 'dist'
const shouldSplit = process.argv.includes('--no-split') ? false : true
const removeCSSImports = { const removeCSSImports = {
name: 'remove-css-imports', name: 'remove-css-imports',
setup(build) { setup(build) {
@@ -19,31 +23,34 @@ const removeCSSImports = {
} }
async function build() { async function build() {
//create empty directoryArg/exports/client_optimized dir
await fs.promises.mkdir(`${directoryArg}/exports/client_optimized`, { recursive: true })
// Bundle only the .scss files into a single css file // Bundle only the .scss files into a single css file
await esbuild.build({ await esbuild.build({
entryPoints: ['src/exports/cssEntry.ts'], entryPoints: ['src/exports/cssEntry.ts'],
bundle: true, bundle: true,
minify: true, minify: true,
outdir: 'dist/bundled_scss', outdir: `${directoryArg}/bundled_scss`,
loader: { '.svg': 'dataurl' }, loader: { '.svg': 'dataurl' },
packages: 'external', packages: 'external',
//external: ['*.svg'], //external: ['*.svg'],
plugins: [sassPlugin({ css: 'external' })], plugins: [sassPlugin({ css: 'external' })],
}) })
//create empty dist/exports/client_optimized dir
fs.mkdirSync('dist/exports/client_optimized')
try { try {
fs.renameSync('dist/bundled_scss/cssEntry.css', 'dist/field/bundled.css') await fs.promises.rename(`${directoryArg}/bundled_scss/cssEntry.css`, `dist/field/bundled.css`)
fs.copyFileSync('dist/field/bundled.css', 'dist/exports/client_optimized/bundled.css') fs.copyFileSync(
fs.rmSync('dist/bundled_scss', { recursive: true }) `dist/field/bundled.css`,
`${directoryArg}/exports/client_optimized/bundled.css`,
)
fs.rmSync(`${directoryArg}/bundled_scss`, { recursive: true })
} catch (err) { } catch (err) {
console.error(`Error while renaming index.css: ${err}`) console.error(`Error while renaming index.css: ${err}`)
throw err throw err
} }
console.log('dist/field/bundled.css bundled successfully') console.log(`${directoryArg}/field/bundled.css bundled successfully`)
// Bundle `client.ts` // Bundle `client.ts`
const resultClient = await esbuild.build({ const resultClient = await esbuild.build({
@@ -51,10 +58,10 @@ async function build() {
bundle: true, bundle: true,
platform: 'browser', platform: 'browser',
format: 'esm', format: 'esm',
outdir: 'dist/exports/client_optimized', outdir: `${directoryArg}/exports/client_optimized`,
//outfile: 'index.js', //outfile: 'index.js',
// IMPORTANT: splitting the client bundle means that the `use client` directive will be lost for every chunk // IMPORTANT: splitting the client bundle means that the `use client` directive will be lost for every chunk
splitting: true, splitting: shouldSplit,
external: [ external: [
'*.scss', '*.scss',
'*.css', '*.css',

View File

@@ -337,12 +337,14 @@
"scripts": { "scripts": {
"build": "pnpm build:reactcompiler", "build": "pnpm build:reactcompiler",
"build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist", "build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist",
"build:bundle-for-analysis": "rm -rf dist esbuild && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:esbuild esbuild --no-split",
"build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build", "build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build",
"build:esbuild": "node bundle.js && rm -rf dist/exports/client && mv dist/exports/client_optimized dist/exports/client", "build:esbuild": "node bundle.js",
"build:reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:esbuild && pnpm build:types", "build:esbuild:postprocess": "rm -rf dist/exports/client && mv dist/exports/client_optimized dist/exports/client",
"build:reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:esbuild && pnpm build:esbuild:postprocess && pnpm build:types",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist", "build:types": "tsc --emitDeclarationOnly --outDir dist",
"build:without_reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm copyfiles && pnpm build:types && pnpm build:swc && pnpm build:esbuild && rm -rf dist/exports/client && mv dist/exports/client_unoptimized dist/exports/client", "build:without_reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm copyfiles && pnpm build:types && pnpm build:swc && pnpm build:esbuild && pnpm build:esbuild:postproces && rm -rf dist/exports/client && mv dist/exports/client_unoptimized dist/exports/client",
"clean": "rimraf -g {dist,*.tsbuildinfo}", "clean": "rimraf -g {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .", "lint": "eslint .",

View File

@@ -7,6 +7,10 @@ const dirname = path.dirname(filename)
import { sassPlugin } from 'esbuild-sass-plugin' import { sassPlugin } from 'esbuild-sass-plugin'
import { commonjs } from '@hyrious/esbuild-plugin-commonjs' import { commonjs } from '@hyrious/esbuild-plugin-commonjs'
const directoryArg = process.argv[2] || 'dist'
const shouldSplit = process.argv.includes('--no-split') ? false : true
const removeCSSImports = { const removeCSSImports = {
name: 'remove-css-imports', name: 'remove-css-imports',
setup(build) { setup(build) {
@@ -62,6 +66,11 @@ const useClientPlugin = {
} }
async function build() { async function build() {
// Create directoryArg if it doesn't exist
if (!fs.existsSync(directoryArg)) {
await fs.promises.mkdir(directoryArg, { recursive: true })
}
// Bundle only the .scss files into a single css file // Bundle only the .scss files into a single css file
await esbuild.build({ await esbuild.build({
entryPoints: ['src/exports/client/index.ts'], entryPoints: ['src/exports/client/index.ts'],
@@ -73,7 +82,7 @@ async function build() {
}) })
try { try {
fs.renameSync('dist-styles/index.css', 'dist/styles.css') fs.renameSync('dist-styles/index.css', `${directoryArg}/styles.css`)
fs.rmdirSync('dist-styles', { recursive: true }) fs.rmdirSync('dist-styles', { recursive: true })
} catch (err) { } catch (err) {
console.error(`Error while renaming index.css and dist-styles: ${err}`) console.error(`Error while renaming index.css and dist-styles: ${err}`)
@@ -87,10 +96,10 @@ async function build() {
bundle: true, bundle: true,
platform: 'browser', platform: 'browser',
format: 'esm', format: 'esm',
outdir: 'dist/exports/client_optimized', outdir: `${directoryArg}/exports/client_optimized`,
//outfile: 'index.js', //outfile: 'index.js',
// IMPORTANT: splitting the client bundle means that the `use client` directive will be lost for every chunk // IMPORTANT: splitting the client bundle means that the `use client` directive will be lost for every chunk
splitting: true, splitting: shouldSplit,
write: true, // required for useClientPlugin write: true, // required for useClientPlugin
banner: { banner: {
js: `// Workaround for react-datepicker and other cjs dependencies potentially inserting require("react") statements js: `// Workaround for react-datepicker and other cjs dependencies potentially inserting require("react") statements
@@ -142,11 +151,11 @@ function require(m) {
console.log('client.ts bundled successfully') console.log('client.ts bundled successfully')
const resultShared = await esbuild.build({ const resultShared = await esbuild.build({
entryPoints: ['src/exports/shared/index.ts'], entryPoints: ['dist/exports/shared/index.js'],
bundle: true, bundle: true,
platform: 'node', platform: 'node',
format: 'esm', format: 'esm',
outdir: 'dist/exports/shared', outdir: `${directoryArg}/exports/shared_optimized`,
//outfile: 'index.js', //outfile: 'index.js',
// IMPORTANT: splitting the client bundle means that the `use client` directive will be lost for every chunk // IMPORTANT: splitting the client bundle means that the `use client` directive will be lost for every chunk
splitting: false, splitting: false,

View File

@@ -121,8 +121,10 @@
"scripts": { "scripts": {
"build": "pnpm build:reactcompiler", "build": "pnpm build:reactcompiler",
"build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist", "build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist",
"build:esbuild": "node bundle.js && rm -rf dist/exports/client && mv dist/exports/client_optimized dist/exports/client", "build:bundle-for-analysis": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:esbuild esbuild --no-split",
"build:reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:esbuild && pnpm build:types", "build:esbuild": "node bundle.js",
"build:esbuild:postprocess": "rm -rf dist/exports/client && mv dist/exports/client_optimized dist/exports/client && rm -rf dist/exports/shared && mv dist/exports/shared_optimized dist/exports/shared",
"build:reactcompiler": "rm -rf dist esbuild && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:babel && pnpm copyfiles && pnpm build:esbuild && pnpm build:esbuild:postprocess && pnpm build:types",
"build:remove-artifact": "rm dist/prod/index.js", "build:remove-artifact": "rm dist/prod/index.js",
"build:swc": "swc ./src -d dist --config-file .swcrc --strip-leading-paths", "build:swc": "swc ./src -d dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist", "build:types": "tsc --emitDeclarationOnly --outDir dist",

View File

@@ -10,6 +10,11 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": ["./dist/**"] "outputs": ["./dist/**"]
}, },
"build:bundle-for-analysis": {
"cache": true,
"dependsOn": ["^build:bundle-for-analysis"],
"outputs": ["./esbuild/**"]
},
"dev": { "dev": {
"cache": false "cache": false
}, },