bundle size analyzer
This commit is contained in:
@@ -26,7 +26,7 @@ env:
|
||||
|
||||
jobs:
|
||||
# Populate this from main so every PR can restore the same trusted baseline.
|
||||
webpack-analyzer-base:
|
||||
bundle-analyzer-base:
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
permissions:
|
||||
@@ -43,28 +43,29 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
- name: ⬇️ Get base bundle size from cache
|
||||
id: get-base-size
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ github.sha }}
|
||||
path: base-bundle-size.txt
|
||||
key: base-bundle-size-main-${{ github.sha }}
|
||||
|
||||
- name: 🔦 Generate stats file for base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
- name: 🔦 Build and measure base bundle
|
||||
if: ${{ !steps.get-base-size.outputs.cache-hit }}
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
pnpm generate-webpack-stats-file
|
||||
pnpm build-web
|
||||
node scripts/measure-web-bundle.js > base-bundle-size.txt
|
||||
|
||||
- name: ⬆️ Save base stats to cache
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
- name: ⬆️ Save base bundle size to cache
|
||||
if: ${{ !steps.get-base-size.outputs.cache-hit }}
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ github.sha }}
|
||||
path: base-bundle-size.txt
|
||||
key: base-bundle-size-main-${{ github.sha }}
|
||||
|
||||
webpack-analyzer:
|
||||
bundle-analyzer:
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
permissions:
|
||||
@@ -109,41 +110,66 @@ jobs:
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
|
||||
- name: 🔦 Generate stats file for PR
|
||||
- name: 🔦 Build and measure PR bundle
|
||||
run: |
|
||||
pnpm generate-webpack-stats-file
|
||||
mv stats.json ../stats-new.json
|
||||
pnpm build-web
|
||||
node scripts/measure-web-bundle.js > ../pr-bundle-size.txt
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
- name: ⬇️ Get base bundle size from cache
|
||||
id: get-base-size
|
||||
# Restore-only prevents PR-scoped fallback builds from creating caches.
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
|
||||
path: base-bundle-size.txt
|
||||
key: base-bundle-size-main-${{ steps.base-commit.outputs.base-commit }}
|
||||
|
||||
- name: ⏪ Restore to base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
if: ${{ !steps.get-base-size.outputs.cache-hit }}
|
||||
env:
|
||||
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
|
||||
run: |
|
||||
git reset "$BASE_COMMIT"
|
||||
git restore .
|
||||
# Drop the PR-side export so the measure script's output-dir
|
||||
# auto-detection can't pick it up: a webpack base exports to
|
||||
# web-build/ while the PR's Metro build left dist/ behind.
|
||||
rm -rf dist web-build
|
||||
|
||||
- name: 🔦 Generate stats file from base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
- name: 🔦 Build and measure base bundle
|
||||
if: ${{ !steps.get-base-size.outputs.cache-hit }}
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
pnpm generate-webpack-stats-file
|
||||
pnpm build-web
|
||||
node scripts/measure-web-bundle.js --allow-missing > base-bundle-size.txt
|
||||
|
||||
- name: % Get diff
|
||||
id: get-diff
|
||||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||||
with:
|
||||
base_path: "stats.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("node:fs")
|
||||
const base = Number(fs.readFileSync("base-bundle-size.txt", "utf8").trim())
|
||||
const pr = Number(fs.readFileSync("../pr-bundle-size.txt", "utf8").trim())
|
||||
if (!Number.isFinite(base) || !Number.isFinite(pr) || base <= 0) {
|
||||
console.error(`Bad measurements: base=${base} pr=${pr}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const fmt = bytes => {
|
||||
const abs = Math.abs(bytes)
|
||||
if (abs >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
if (abs >= 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
const diff = pr - base
|
||||
const out = [
|
||||
`base_file_string=${fmt(base)}`,
|
||||
`pr_file_string=${fmt(pr)}`,
|
||||
`diff_file_string=${diff > 0 ? "+" : ""}${fmt(diff)}`,
|
||||
`percent=${((diff / base) * 100).toFixed(2)}`,
|
||||
].join("\n")
|
||||
console.log(out)
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, out + "\n")
|
||||
'
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
|
||||
+3
-2
@@ -93,8 +93,8 @@
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "expo export --dump-sourcemap && pnpm upload-native-sourcemaps",
|
||||
"upload-native-sourcemaps": "pnpm exec sentry-expo-upload-sourcemaps dist",
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
||||
"generate-web-stats": "EXPO_ATLAS=1 pnpm build-web",
|
||||
"open-analyzer": "npx expo-atlas .expo/atlas.jsonl",
|
||||
"icons:optimize": "svgo -f ./assets/icons",
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
@@ -170,6 +170,7 @@
|
||||
"expo-age-range": "57.0.2",
|
||||
"expo-application": "~57.0.2",
|
||||
"expo-asset": "~57.0.7",
|
||||
"expo-atlas": "^0.4.3",
|
||||
"expo-blur": "~57.0.2",
|
||||
"expo-build-properties": "~57.0.7",
|
||||
"expo-camera": "~57.0.3",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Measures the initial payload of the exported web bundle.
|
||||
*
|
||||
* Reads index.html from the export directory, collects every local resource
|
||||
* referenced by <script src> / <link href> tags, sums their sizes plus the
|
||||
* size of index.html itself, and prints the total in bytes to stdout.
|
||||
* Per-file details go to stderr, so callers can do:
|
||||
*
|
||||
* SIZE=$(node scripts/measure-web-bundle.js --exclude '\.ico$|\.woff2?$')
|
||||
*
|
||||
* Lazily-loaded chunks (locale messages, hls, etc.) are not referenced from
|
||||
* index.html and therefore do not count toward the total.
|
||||
*
|
||||
* The export directory is auto-detected: Metro exports to dist/, the old
|
||||
* webpack config exported to web-build/. This lets CI compare a Metro-built
|
||||
* PR against a webpack-built base commit with the same command; pass --dir
|
||||
* to override.
|
||||
*/
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.join(__dirname, '..')
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'Usage: node scripts/measure-web-bundle.js [--dir <path>] [--exclude <regex>]... [--allow-missing]',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let dirArg = null
|
||||
let allowMissing = false
|
||||
const excludes = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--dir' && argv[i + 1]) {
|
||||
dirArg = argv[++i]
|
||||
} else if (argv[i] === '--exclude' && argv[i + 1]) {
|
||||
excludes.push(argv[++i])
|
||||
} else if (argv[i] === '--allow-missing') {
|
||||
allowMissing = true
|
||||
} else {
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = dirArg
|
||||
? [path.resolve(dirArg)]
|
||||
: [path.join(projectRoot, 'dist'), path.join(projectRoot, 'web-build')]
|
||||
const outDir = candidates.find(dir => fs.existsSync(dir))
|
||||
if (!outDir) {
|
||||
console.error(
|
||||
`Web build output not found (looked for ${candidates.join(', ')}). ` +
|
||||
'Run pnpm build-web first.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const indexPath = path.join(outDir, 'index.html')
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.error(`${indexPath} not found. Run pnpm build-web first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const excludeRes = excludes.map(pattern => new RegExp(pattern))
|
||||
const html = fs.readFileSync(indexPath, 'utf8')
|
||||
|
||||
/*
|
||||
* Every fetched-on-load resource is referenced via a src/href attribute on a
|
||||
* <script> or <link> tag. Inline url(...) references (e.g. the italic font in
|
||||
* the splash CSS) are fetched on demand, so they are intentionally skipped.
|
||||
*/
|
||||
const urls = new Set()
|
||||
for (const match of html.matchAll(
|
||||
/<(?:script|link)\b[^>]*?\b(?:src|href)="([^"]+)"/g,
|
||||
)) {
|
||||
urls.add(match[1])
|
||||
}
|
||||
|
||||
/*
|
||||
* URLs are root-relative as served in production, where the export lives
|
||||
* under the app.config.js baseUrl ('/static'). On disk both '/static/foo'
|
||||
* and '/foo' resolve to '<outDir>/foo', so try the path with and without
|
||||
* the baseUrl prefix.
|
||||
*/
|
||||
function resolveLocal(url) {
|
||||
const rel = url.replace(/^\//, '')
|
||||
for (const candidate of [rel, rel.replace(/^static\//, '')]) {
|
||||
const abs = path.join(outDir, candidate)
|
||||
if (fs.existsSync(abs)) return {rel: candidate, abs}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let total = fs.statSync(indexPath).size
|
||||
let count = 1
|
||||
console.error(`${String(total).padStart(12)} index.html`)
|
||||
|
||||
for (const url of [...urls].sort()) {
|
||||
if (/^(?:https?:)?\/\//.test(url) || url.startsWith('data:')) continue
|
||||
const resolved = resolveLocal(url)
|
||||
if (!resolved) {
|
||||
/*
|
||||
* By default fail loudly: a missing referenced asset means the layout
|
||||
* changed and the measurement would silently undercount. --allow-missing
|
||||
* downgrades this to a warning for outputs that intentionally reference
|
||||
* resources served from elsewhere.
|
||||
*/
|
||||
console.error(`Referenced resource not found in ${outDir}: ${url}`)
|
||||
if (!allowMissing) process.exit(1)
|
||||
continue
|
||||
}
|
||||
if (excludeRes.some(re => re.test(resolved.rel))) continue
|
||||
const {size} = fs.statSync(resolved.abs)
|
||||
total += size
|
||||
count++
|
||||
console.error(`${String(size).padStart(12)} ${resolved.rel}`)
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
// Fail loudly: index.html referencing no assets means the build output
|
||||
// layout changed and a near-0 measurement would corrupt the CI size diff.
|
||||
console.error(`No resources measured from ${indexPath}; check the build.`)
|
||||
if (!allowMissing) process.exit(1)
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Measured index.html + ${count - 1} referenced assets in ${outDir}: ${(total / 1024).toFixed(2)} KB`,
|
||||
)
|
||||
console.log(total)
|
||||
@@ -85,8 +85,29 @@ if (!interRegular || !interItalic) {
|
||||
)
|
||||
}
|
||||
|
||||
const interRegularPath = `{{ staticCDNHost }}/static/assets/assets/fonts/inter/${interRegular}`
|
||||
const interItalicPath = `{{ staticCDNHost }}/static/assets/assets/fonts/inter/${interItalic}`
|
||||
|
||||
const interFontPath = "/assets/assets/fonts/inter/";
|
||||
/*
|
||||
* The static index.html references the fonts by their unhashed names, but
|
||||
* Metro emits them content-hashed. Rewrite dist/index.html in place so the
|
||||
* font preload and @font-face URLs resolve when serving dist/ directly.
|
||||
*/
|
||||
const fixedIndexHtml = indexHtml
|
||||
.replaceAll(
|
||||
'/assets/assets/fonts/inter/InterVariable.woff2',
|
||||
`${interFontPath}${interRegular}`,
|
||||
)
|
||||
.replaceAll(
|
||||
'/assets/assets/fonts/inter/InterVariable-Italic.woff2',
|
||||
`${interFontPath}${interItalic}`,
|
||||
)
|
||||
if (fixedIndexHtml !== indexHtml) {
|
||||
console.log('Rewriting font paths in dist/index.html to hashed filenames')
|
||||
fs.writeFileSync(path.join(distDir, 'index.html'), fixedIndexHtml)
|
||||
}
|
||||
|
||||
const interRegularPath = `{{ staticCDNHost }}/static${interFontPath}${interRegular}`
|
||||
const interItalicPath = `{{ staticCDNHost }}/static${interFontPath}${interItalic}`
|
||||
|
||||
const fontsHtml = `<link rel="preload" as="font" type="font/woff2" href="${interRegularPath}" crossorigin>
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user