diff --git a/.bundle-size-skip-branch b/.bundle-size-skip-branch new file mode 100644 index 0000000000..f8c2fd02f1 --- /dev/null +++ b/.bundle-size-skip-branch @@ -0,0 +1,6 @@ +# This file allows skipping the bundle size CI check for a specific branch. +# When a branch name in this file matches the PR branch, the size check is skipped. +# +# Usage: Run `bin/skip-bundle-size-check` to set the current branch, then commit and push. +# +# This is useful when you have an intentional size increase that exceeds the 0.5KB threshold. diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 0000000000..3562fc73bd --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,115 @@ +name: Bundle Size + +on: + pull_request: + paths: + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.size-limit.json' + - '.github/workflows/bundle-size.yml' + - '.bundle-size-skip-branch' + +jobs: + check-skip: + runs-on: ubuntu-22.04 + outputs: + skip: ${{ steps.skip-check.outputs.skip }} + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Check if branch should skip size check + id: skip-check + env: + BRANCH: ${{ github.head_ref }} + run: | + SKIP_FILE=".bundle-size-skip-branch" + SKIP_BRANCH=$(grep -v '^[[:space:]]*#' "$SKIP_FILE" 2>/dev/null | grep -v '^[[:space:]]*$' | tr -d '[:space:]' || echo "") + if [ "$SKIP_BRANCH" = "$BRANCH" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::notice::Branch '$BRANCH' is set to skip size check" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + size: + needs: check-skip + if: needs.check-skip.outputs.skip != 'true' + runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write + env: + CI_JOB_NUMBER: 1 + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + # 1. Get PR's size-limit config first (base branch may not have it) + - name: Checkout PR branch for config + uses: actions/checkout@v4 + + - name: Save size-limit config + run: cp .size-limit.json /tmp/size-limit-config.json + + # 2. Get base branch sizes + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - name: Copy size-limit config to base branch + run: cp /tmp/size-limit-config.json .size-limit.json + + - name: Install base dependencies + run: pnpm install --frozen-lockfile + + - name: Build base branch + run: pnpm run build + + - name: Verify build artifacts + run: | + missing=0 + for pkg in react-on-rails react-on-rails-pro react-on-rails-pro-node-renderer; do + if ! ls packages/$pkg/lib/*.js >/dev/null 2>&1; then + echo "::error::Missing build artifacts in packages/$pkg/lib/" + missing=1 + fi + done + if [ $missing -eq 1 ]; then + exit 1 + fi + echo "All build artifacts verified" + + - name: Measure base branch sizes + run: | + pnpm exec size-limit --json > /tmp/base-sizes.json + echo "Base branch sizes:" + cat /tmp/base-sizes.json + + # 3. Checkout PR and set dynamic limits + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Install PR dependencies + run: pnpm install --frozen-lockfile + + - name: Set dynamic limits (base + 0.5KB) + run: node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + + # 4. Run the action with dynamic limits + - name: Check bundle size + uses: andresz1/size-limit-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + package_manager: pnpm + build_script: build + skip_step: install diff --git a/CHANGELOG.md b/CHANGELOG.md index 9349e3a348..49aa694af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Changes since the last non-beta release. #### Added +- **Bundle Size CI Monitoring**: Added automated bundle size tracking to CI using size-limit. Compares PR bundle sizes against the base branch and fails if any package increases by more than 0.5KB. Includes local comparison tool (`bin/compare-bundle-sizes`) and bypass mechanism (`bin/skip-bundle-size-check`) for intentional size increases. [PR 2149](https://github.com/shakacode/react_on_rails/pull/2149) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + - **Service Dependency Checking for bin/dev**: Added optional `.dev-services.yml` configuration to validate required external services (Redis, PostgreSQL, Elasticsearch, etc.) are running before `bin/dev` starts the development server. Provides clear error messages with start commands and install hints when services are missing. Zero impact if not configured - backwards compatible with all existing installations. [PR 2098](https://github.com/shakacode/react_on_rails/pull/2098) by [justin808](https://github.com/justin808). #### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2df5d7178e..0eb799e57f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -305,6 +305,69 @@ Run only ESLint: pnpm run lint ``` +### Bundle Size Checks + +React on Rails monitors bundle sizes in CI to prevent unexpected size increases. The CI compares your PR's bundle sizes against the base branch and fails if any package increases by more than 0.5KB. + +#### Running Locally + +Check current bundle sizes: + +```sh +pnpm run size +``` + +Get JSON output for programmatic use: + +```sh +pnpm run size:json +``` + +Compare your branch against the base branch: + +```sh +bin/compare-bundle-sizes +``` + +This script automatically: + +1. Stashes any uncommitted changes +2. Checks out and builds the base branch (default: `master`) +3. Checks out and builds your current branch +4. Compares the sizes and shows a detailed report + +Options: + +```sh +bin/compare-bundle-sizes main # Compare against 'main' instead of 'master' +bin/compare-bundle-sizes --hierarchical # Group results by package +``` + +#### Bypassing the Check + +If your PR intentionally increases bundle size (e.g., adding a new feature), you can skip the bundle size check: + +```sh +# Run from your PR branch +bin/skip-bundle-size-check +git add .bundle-size-skip-branch +git commit -m "Skip bundle size check for intentional size increase" +git push +``` + +This sets your branch to skip the size check. The skip only applies to the specific branch name written to `.bundle-size-skip-branch`. + +**Important**: Only skip the check when the size increase is justified. Document why the increase is acceptable in your PR description. + +#### What Gets Measured + +The CI measures sizes for: + +- **react-on-rails**: Raw, gzip, and brotli compressed sizes +- **react-on-rails-pro**: Raw, gzip, and brotli compressed sizes +- **react-on-rails-pro-node-renderer**: Raw, gzip, and brotli compressed sizes +- **Webpack bundled imports**: Client-side bundle sizes when importing via webpack + ### Starting the Dummy App To run the dummy app, it's **CRITICAL** to not just run `rails s`. You have to run `foreman start` with one of the Procfiles. If you don't do this, then `webpack` will not generate a new bundle, and you will be seriously confused when you change JavaScript and the app does not change. If you change the Webpack configs, then you need to restart Foreman. If you change the JS code for react-on-rails, you need to run `pnpm run build` in the project root. diff --git a/bin/compare-bundle-sizes b/bin/compare-bundle-sizes new file mode 100755 index 0000000000..09fb90d296 --- /dev/null +++ b/bin/compare-bundle-sizes @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Compare bundle sizes between current branch and a base branch +# +# Usage: +# bin/compare-bundle-sizes [base-branch] +# +# Arguments: +# base-branch The branch to compare against (default: master) +# +# Examples: +# bin/compare-bundle-sizes # Compare against master +# bin/compare-bundle-sizes develop # Compare against develop +# bin/compare-bundle-sizes feature/some-branch + +set -e + +BASE_BRANCH="${1:-master}" +CURRENT_BRANCH=$(git branch --show-current) +STASHED=false + +# Colors for output +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +cleanup() { + echo -e "\n${BLUE}Cleaning up...${NC}" + git checkout "$CURRENT_BRANCH" --quiet 2>/dev/null || true + if [ "$STASHED" = true ]; then + git stash pop --quiet 2>/dev/null || true + fi +} + +trap cleanup EXIT + +echo -e "${BLUE}📦 Bundle Size Comparison${NC}" +echo -e " Current branch: ${YELLOW}$CURRENT_BRANCH${NC}" +echo -e " Base branch: ${YELLOW}$BASE_BRANCH${NC}" +echo "" + +# Check for uncommitted changes +if ! git diff --quiet || ! git diff --cached --quiet; then + echo -e "${YELLOW}Stashing uncommitted changes...${NC}" + git stash push -m "compare-bundle-sizes temp stash" --quiet + STASHED=true +fi + +# Get base branch sizes +echo -e "${BLUE}Building base branch ($BASE_BRANCH)...${NC}" +git fetch origin "$BASE_BRANCH" --quiet 2>/dev/null || true +git checkout "$BASE_BRANCH" --quiet 2>/dev/null || git checkout "origin/$BASE_BRANCH" --quiet +pnpm install --frozen-lockfile 2>&1 | grep -v "^$" | head -5 || true +pnpm run build 2>&1 | grep -v "^$" | tail -3 || true + +echo -e "${BLUE}Measuring base branch sizes...${NC}" +pnpm exec size-limit --json > /tmp/base-sizes.json + +# Get current branch sizes +echo -e "${BLUE}Building current branch ($CURRENT_BRANCH)...${NC}" +git checkout "$CURRENT_BRANCH" --quiet +pnpm install --frozen-lockfile 2>&1 | grep -v "^$" | head -5 || true +pnpm run build 2>&1 | grep -v "^$" | tail -3 || true + +echo -e "${BLUE}Measuring current branch sizes...${NC}" +pnpm exec size-limit --json > /tmp/current-sizes.json + +# Compare sizes using the bundle-size script +node scripts/bundle-size.mjs compare --base /tmp/base-sizes.json --current /tmp/current-sizes.json diff --git a/bin/skip-bundle-size-check b/bin/skip-bundle-size-check new file mode 100755 index 0000000000..8c0045b0a7 --- /dev/null +++ b/bin/skip-bundle-size-check @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Skip bundle size check for current branch +# +# Usage: +# bin/skip-bundle-size-check +# + +set -e + +SKIP_FILE=".bundle-size-skip-branch" +BRANCH=$(git branch --show-current) + +if [ -z "$BRANCH" ]; then + echo "Error: Not on a branch (detached HEAD?)" + exit 1 +fi + +# Write comment header and branch name +cat > "$SKIP_FILE" << EOF +# This file allows skipping the bundle size CI check for a specific branch. +# When a branch name in this file matches the PR branch, the size check is skipped. +# +# Usage: Run \`bin/skip-bundle-size-check\` to set the current branch, then commit and push. +# +# This is useful when you have an intentional size increase that exceeds the 0.5KB threshold. +$BRANCH +EOF +echo "Set '$BRANCH' as the branch to skip bundle size check" +echo "" +echo "Next steps:" +echo " git add $SKIP_FILE" +echo " git commit -m 'Skip bundle size check for $BRANCH'" +echo " git push" diff --git a/package.json b/package.json index 519f049274..26d94f213f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@types/react-dom": "^18.3.5", "@types/turbolinks": "^5.2.2", "create-react-class": "^15.7.0", - "globals": "^16.2.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", "eslint-config-shakacode": "^19.0.0", @@ -47,6 +46,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-testing-library": "^7.1.1", + "globals": "^16.2.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", diff --git a/scripts/bundle-size.mjs b/scripts/bundle-size.mjs new file mode 100644 index 0000000000..e1a429e8a8 --- /dev/null +++ b/scripts/bundle-size.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Bundle Size Utilities + * + * Commands: + * set-limits - Update .size-limit.json with dynamic limits (base + threshold) + * compare - Compare two size measurements and print a report + * + * Usage: + * node scripts/bundle-size.mjs set-limits --base [--config ] [--threshold ] + * node scripts/bundle-size.mjs compare --base --current [--threshold ] + * + * Examples: + * node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + * node scripts/bundle-size.mjs compare --base /tmp/base-sizes.json --current /tmp/current-sizes.json + */ + +import fs from 'fs'; + +// Default threshold: 0.5 KB (512 bytes) +// Intentionally strict to catch any bundle size changes early. +// For intentional size increases, use bin/skip-bundle-size-check to bypass the CI check. +const DEFAULT_THRESHOLD = 512; +const DEFAULT_CONFIG = '.size-limit.json'; + +// ANSI color codes +const colors = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m', +}; + +/** + * Format bytes to human-readable string + */ +function formatSize(bytes) { + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(2)} kB`; + } + return `${bytes} B`; +} + +/** + * Format size difference with percentage + */ +function formatDiff(diff, percent) { + if (diff === 0) return '0%'; + const sign = diff > 0 ? '+' : ''; + return `${sign}${formatSize(Math.abs(diff))} (${sign}${percent.toFixed(2)}%)`; +} + +/** + * Parse command line arguments + */ +function parseArgs(args) { + const parsed = { _: [] }; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + parsed[key] = next; + i += 1; + } else { + parsed[key] = true; + } + } else { + parsed._.push(arg); + } + } + return parsed; +} + +/** + * Read and parse JSON file + */ +function readJsonFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); +} + +/** + * Try to read and parse JSON file, exit on error + */ +// eslint-disable-next-line consistent-return +function readJsonFileOrExit(filePath) { + try { + return readJsonFile(filePath); + } catch (error) { + console.error(`${colors.red}Error reading ${filePath}: ${error.message}${colors.reset}`); + process.exit(1); + } +} + +/** + * Command: set-limits + * Updates .size-limit.json with dynamic limits based on base sizes + */ +function setLimits(options) { + const basePath = options.base; + const configPath = options.config || DEFAULT_CONFIG; + const threshold = parseInt(options.threshold, 10) || DEFAULT_THRESHOLD; + + if (!basePath) { + console.error(`${colors.red}Error: --base is required${colors.reset}`); + process.exit(1); + } + + const baseSizes = readJsonFileOrExit(basePath); + const config = readJsonFileOrExit(configPath); + + console.log(`${colors.blue}Setting dynamic limits (base + ${formatSize(threshold)}):${colors.reset}\n`); + + const updatedConfig = config.map((entry) => { + const baseEntry = baseSizes.find((b) => b.name === entry.name); + if (baseEntry) { + const limit = baseEntry.size + threshold; + console.log(`${entry.name}:`); + console.log(` base size: ${formatSize(baseEntry.size)}`); + console.log(` limit: ${formatSize(limit)}\n`); + return { ...entry, limit: `${limit} B` }; + } + console.log(`${colors.yellow}${entry.name}: No base entry found, keeping original limit${colors.reset}`); + return entry; + }); + + fs.writeFileSync(configPath, `${JSON.stringify(updatedConfig, null, 2)}\n`); + console.log(`${colors.green}Updated ${configPath}${colors.reset}`); +} + +/** + * Get diff color based on threshold + */ +function getDiffColor(diff, threshold) { + if (diff > threshold) return colors.red; + if (diff > 0) return colors.yellow; + return colors.green; +} + +/** + * Print a single result row + */ +function printResultRow(result, maxNameLen, threshold) { + const status = result.exceeded + ? `${colors.red}❌ EXCEEDED${colors.reset}` + : `${colors.green}✅ OK${colors.reset}`; + + const diffColor = getDiffColor(result.diff, threshold); + const diffStr = `${diffColor}${formatDiff(result.diff, result.percent)}${colors.reset}`; + + const namePart = result.name.padEnd(maxNameLen + 2); + const basePart = formatSize(result.baseSize).padStart(12); + const currentPart = formatSize(result.currentSize).padStart(12); + const diffPart = diffStr.padStart(20 + 9); + + console.log(`${namePart}${basePart}${currentPart}${diffPart} ${status}`); +} + +/** + * Command: compare + * Compares two size measurements and prints a report + */ +function compare(options) { + const basePath = options.base; + const currentPath = options.current; + const threshold = parseInt(options.threshold, 10) || DEFAULT_THRESHOLD; + const json = options.json === true || options.json === 'true'; + + if (!basePath || !currentPath) { + console.error(`${colors.red}Error: --base and --current are required${colors.reset}`); + process.exit(1); + } + + const baseSizes = readJsonFileOrExit(basePath); + const currentSizes = readJsonFileOrExit(currentPath); + + const results = currentSizes.map((current) => { + const base = baseSizes.find((b) => b.name === current.name) || { size: 0 }; + const diff = current.size - base.size; + const percent = base.size > 0 ? (diff / base.size) * 100 : 0; + const exceeded = diff > threshold; + + return { + name: current.name, + baseSize: base.size, + currentSize: current.size, + diff, + percent, + exceeded, + }; + }); + + const hasExceeded = results.some((r) => r.exceeded); + + if (json) { + // JSON output for programmatic use + console.log( + JSON.stringify( + { + threshold, + hasExceeded, + results: results.map((r) => ({ + name: r.name, + baseSize: r.baseSize, + currentSize: r.currentSize, + diff: r.diff, + percentChange: r.percent, + exceeded: r.exceeded, + })), + }, + null, + 2, + ), + ); + } else { + // Pretty table output + const maxNameLen = Math.max(...results.map((r) => r.name.length)); + const separator = '━'.repeat(76); + const thinSeparator = '─'.repeat(maxNameLen + 2 + 12 + 12 + 20 + 12); + + console.log(''); + console.log(`${colors.blue}${separator}${colors.reset}`); + console.log(`${colors.blue}Bundle Size Report${colors.reset}`); + console.log(`${colors.blue}${separator}${colors.reset}`); + console.log(''); + + const header = `${'Package'.padEnd(maxNameLen + 2)}${'Base'.padStart(12)}${'Current'.padStart( + 12, + )}${'Diff'.padStart(20)} Status`; + console.log(header); + console.log(thinSeparator); + + results.forEach((r) => printResultRow(r, maxNameLen, threshold)); + + console.log(''); + console.log(thinSeparator); + console.log(`Threshold: ${formatSize(threshold)} (base + ${formatSize(threshold)})`); + console.log(''); + + if (hasExceeded) { + console.log(`${colors.red}❌ Some packages exceeded the size threshold!${colors.reset}`); + } else { + console.log(`${colors.green}✅ All packages within threshold.${colors.reset}`); + } + } + + if (hasExceeded) { + process.exit(1); + } +} + +/** + * Print usage help + */ +function printHelp() { + console.log(` +${colors.blue}Bundle Size Utilities${colors.reset} + +${colors.yellow}Commands:${colors.reset} + set-limits Update .size-limit.json with dynamic limits + compare Compare two size measurements and print report + +${colors.yellow}Usage:${colors.reset} + node scripts/bundle-size.mjs set-limits --base [options] + node scripts/bundle-size.mjs compare --base --current [options] + +${colors.yellow}Options for set-limits:${colors.reset} + --base Path to base sizes JSON (required) + --config Path to .size-limit.json (default: .size-limit.json) + --threshold Size threshold in bytes (default: 512) + +${colors.yellow}Options for compare:${colors.reset} + --base Path to base sizes JSON (required) + --current Path to current sizes JSON (required) + --threshold Size threshold in bytes (default: 512) + --json Output results as JSON + +${colors.yellow}Examples:${colors.reset} + # Set dynamic limits from base sizes + node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + + # Compare sizes with custom threshold (1KB) + node scripts/bundle-size.mjs compare --base base.json --current current.json --threshold 1024 + + # Get comparison as JSON + node scripts/bundle-size.mjs compare --base base.json --current current.json --json +`); +} + +// Main +const args = parseArgs(process.argv.slice(2)); +const command = args._[0]; + +switch (command) { + case 'set-limits': + setLimits(args); + break; + case 'compare': + compare(args); + break; + case 'help': + case '--help': + case '-h': + printHelp(); + break; + default: + if (command) { + console.error(`${colors.red}Unknown command: ${command}${colors.reset}\n`); + } + printHelp(); + process.exit(command ? 1 : 0); +}