From 085a819808cbdf1988f4bc32cb415b95b1bd805b Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Wed, 21 Jan 2026 12:23:02 -0600 Subject: [PATCH 1/7] feat(atlas): add CI-friendly stats file generation alongside full export Generate a lightweight `.expo/atlas-stats.json` file automatically whenever `atlas.jsonl` is created during Metro bundling. This stats file contains package-level size aggregations optimized for CI/CD workflows and git diffs. Key features: - Automatic generation during Metro export (no extra flags needed) - Package-level size totals aggregated from all modules - Bundle metadata (platform, environment, entryPoint, bundleSize) - Pretty-printed JSON with sorted keys for diff-friendly output - <100KB file size (vs 80+ MB full atlas.jsonl) - In-memory accumulation with single write at end of export Implementation: - Add convertBundleToStats() to aggregate module sizes by package - Add writeAtlasStatsEntry() to accumulate stats in memory - Add finalizeAtlasStats() to write stats file after all bundles processed - Export TypeScript types for AtlasStatsBundle and AtlasStatsFile - Add comprehensive test coverage for stats generation This enables teams to track bundle size changes in CI without parsing the full atlas.jsonl file. Co-Authored-By: Claude Sonnet 4.5 --- .../expo-atlas/src/data/AtlasFileSource.ts | 94 ++++ .../src/data/__tests__/stats.test.ts | 457 ++++++++++++++++++ packages/expo-atlas/src/data/stats-types.ts | 88 ++++ packages/expo-atlas/src/index.ts | 2 + packages/expo-atlas/src/metro.ts | 43 +- 5 files changed, 680 insertions(+), 4 deletions(-) create mode 100644 packages/expo-atlas/src/data/__tests__/stats.test.ts create mode 100644 packages/expo-atlas/src/data/stats-types.ts diff --git a/packages/expo-atlas/src/data/AtlasFileSource.ts b/packages/expo-atlas/src/data/AtlasFileSource.ts index 024bb68..67d12db 100644 --- a/packages/expo-atlas/src/data/AtlasFileSource.ts +++ b/packages/expo-atlas/src/data/AtlasFileSource.ts @@ -2,6 +2,7 @@ import assert from 'assert'; import fs from 'fs'; import path from 'path'; +import type { AtlasStatsBundle } from './stats-types'; import type { PartialAtlasBundle, AtlasBundle, AtlasSource, AtlasModule } from './types'; import { name, version } from '../../package.json'; import { env } from '../utils/env'; @@ -91,6 +92,9 @@ export async function readAtlasEntry(filePath: string, id: number): Promise = Promise.resolve(); +/** In-memory accumulator for bundle stats, keyed by bundle ID for deduplication */ +const statsAccumulator: Map = new Map(); + /** * Wait until the Atlas file has all data written. * Note, this is a workaround whenever `process.exit` is required, avoid if possible. @@ -181,3 +185,93 @@ export async function ensureAtlasFileExist(filePath: string) { return true; } + +/** + * Convert an AtlasBundle to compact stats format for CI analysis. + * Aggregates module sizes by package name, treating local code as "app". + */ +function convertBundleToStats(bundle: AtlasBundle): AtlasStatsBundle { + const packages: Record = {}; + + // Helper to accumulate package size + const addModuleSize = (module: AtlasModule) => { + const pkg = module.package ?? 'app'; + packages[pkg] = (packages[pkg] ?? 0) + module.size; + }; + + // Aggregate regular modules + for (const module of Array.from(bundle.modules.values())) { + addModuleSize(module); + } + + // Aggregate runtime modules (Metro polyfills) + for (const module of bundle.runtimeModules) { + addModuleSize(module); + } + + // Calculate total bundle size + const bundleSize = Object.values(packages).reduce((sum, size) => sum + size, 0); + + // Sort package keys alphabetically for diff-friendly output + const sortedPackages: Record = {}; + Object.keys(packages) + .sort() + .forEach((key) => { + sortedPackages[key] = packages[key]; + }); + + return { + platform: bundle.platform, + environment: bundle.environment, + entryPoint: bundle.entryPoint, + bundleSize, + packages: sortedPackages, + }; +} + +/** + * Accumulate stats for a bundle. Call this for each bundle written to atlas.jsonl. + * Stats are held in memory until finalizeAtlasStats() is called. + */ +export function writeAtlasStatsEntry(_filePath: string, entry: AtlasBundle): void { + const statsBundle = convertBundleToStats(entry); + // Use bundle ID as key to handle duplicate writes (last write wins) + statsAccumulator.set(entry.id, statsBundle); +} + +/** + * Write accumulated stats to disk as a single JSON file. + * Call this after all bundles are processed (e.g., at end of Metro export). + */ +export function finalizeAtlasStats(filePath: string): Promise { + const bundles = Array.from(statsAccumulator.values()); + + // Sort bundles for consistent, diff-friendly output + bundles.sort((a, b) => { + if (a.platform !== b.platform) return a.platform.localeCompare(b.platform); + if (a.environment !== b.environment) return a.environment.localeCompare(b.environment); + return a.entryPoint.localeCompare(b.entryPoint); + }); + + const statsPath = filePath.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + + // Queue the write operation using existing pattern + writeQueue = writeQueue.then(async () => { + const content = JSON.stringify(bundles, null, 2) + '\n'; + await fs.promises.writeFile(statsPath, content, 'utf-8'); + }); + + // Clear accumulator after successful write + writeQueue = writeQueue.then(() => { + statsAccumulator.clear(); + }); + + return writeQueue; +} + +/** + * Get the default stats file path for a project. + */ +export function getAtlasStatsPath(projectRoot: string): string { + return path.join(projectRoot, '.expo/atlas-stats.json'); +} diff --git a/packages/expo-atlas/src/data/__tests__/stats.test.ts b/packages/expo-atlas/src/data/__tests__/stats.test.ts new file mode 100644 index 0000000..dd2ce68 --- /dev/null +++ b/packages/expo-atlas/src/data/__tests__/stats.test.ts @@ -0,0 +1,457 @@ +import { describe, expect, it } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; + +import { getAtlasStatsPath, writeAtlasStatsEntry, finalizeAtlasStats } from '../AtlasFileSource'; +import type { AtlasStatsFile } from '../stats-types'; +import type { AtlasBundle, AtlasModule } from '../types'; + +describe('getAtlasStatsPath', () => { + it('returns default path `/.expo/atlas-stats.json`', () => { + expect(getAtlasStatsPath('')).toBe('/.expo/atlas-stats.json'); + }); +}); + +describe('writeAtlasStatsEntry', () => { + it('accumulates stats for a single bundle', async () => { + const file = fixture('stats-single', { temporary: true }); + const bundle = createMockBundle({ + id: '1', + platform: 'ios', + environment: 'client', + entryPoint: '/path/to/app/index.js', + modules: new Map([ + ['/path/to/app/App.tsx', createMockModule({ package: undefined, size: 1000 })], + [ + '/path/to/node_modules/react/index.js', + createMockModule({ package: 'react', size: 2000 }), + ], + ]), + runtimeModules: [createMockModule({ package: 'metro-runtime', size: 500 })], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats).toHaveLength(1); + expect(stats[0]).toMatchObject({ + platform: 'ios', + environment: 'client', + entryPoint: '/path/to/app/index.js', + bundleSize: 3500, + packages: { + app: 1000, + 'metro-runtime': 500, + react: 2000, + }, + }); + }); + + it('aggregates multiple modules from same package', async () => { + const file = fixture('stats-aggregated', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([ + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 1000 })], + ['/node_modules/react/jsx-runtime.js', createMockModule({ package: 'react', size: 500 })], + [ + '/node_modules/react/jsx-dev-runtime.js', + createMockModule({ package: 'react', size: 300 }), + ], + ]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats[0].packages.react).toBe(1800); + }); + + it('treats modules without package as "app"', async () => { + const file = fixture('stats-app-package', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([ + ['/app/index.js', createMockModule({ package: undefined, size: 1000 })], + ['/app/components/Button.tsx', createMockModule({ package: undefined, size: 500 })], + ]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats[0].packages.app).toBe(1500); + }); + + it('includes runtime modules in aggregation', async () => { + const file = fixture('stats-runtime', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([['/app/index.js', createMockModule({ package: undefined, size: 1000 })]]), + runtimeModules: [ + createMockModule({ package: 'metro-runtime', size: 500 }), + createMockModule({ package: 'metro-runtime', size: 300 }), + ], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats[0].packages['metro-runtime']).toBe(800); + }); + + it('sorts package names alphabetically', async () => { + const file = fixture('stats-sorted', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([ + ['/node_modules/zebra/index.js', createMockModule({ package: 'zebra', size: 100 })], + ['/node_modules/apple/index.js', createMockModule({ package: 'apple', size: 200 })], + ['/node_modules/metro/index.js', createMockModule({ package: 'metro', size: 300 })], + ['/app/index.js', createMockModule({ package: undefined, size: 400 })], + ]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + const packageNames = Object.keys(stats[0].packages); + expect(packageNames).toEqual(['app', 'apple', 'metro', 'zebra']); + }); + + it('calculates correct total bundle size', async () => { + const file = fixture('stats-bundle-size', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([ + ['/app/index.js', createMockModule({ package: undefined, size: 1000 })], + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 2000 })], + ['/node_modules/expo/index.js', createMockModule({ package: 'expo', size: 3000 })], + ]), + runtimeModules: [createMockModule({ package: 'metro-runtime', size: 500 })], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats[0].bundleSize).toBe(6500); + expect(stats[0].bundleSize).toBe( + Object.values(stats[0].packages).reduce((sum, size) => sum + size, 0) + ); + }); + + it('handles duplicate bundle IDs (last write wins)', async () => { + const file = fixture('stats-duplicate', { temporary: true }); + const bundle1 = createMockBundle({ + id: '1', + modules: new Map([['/app/v1.js', createMockModule({ package: undefined, size: 1000 })]]), + }); + const bundle2 = createMockBundle({ + id: '1', + modules: new Map([['/app/v2.js', createMockModule({ package: undefined, size: 2000 })]]), + }); + + writeAtlasStatsEntry(file, bundle1); + writeAtlasStatsEntry(file, bundle2); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats).toHaveLength(1); + expect(stats[0].bundleSize).toBe(2000); + }); +}); + +describe('finalizeAtlasStats', () => { + it('writes pretty-printed JSON to .expo/atlas-stats.json', async () => { + const file = fixture('stats-pretty', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([['/app/index.js', createMockModule({ package: undefined, size: 1000 })]]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const statsPath = file.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + const content = await fs.promises.readFile(statsPath, 'utf-8'); + + // Check for pretty-printing (should have indentation) + expect(content).toContain('\n '); + expect(content).toEndWith('\n'); + + // Verify it's valid JSON + expect(() => JSON.parse(content)).not.toThrow(); + }); + + it('sorts bundles by platform, environment, entryPoint', async () => { + const file = fixture('stats-sorted-bundles', { temporary: true }); + + // Add bundles in random order + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '1', + platform: 'web', + environment: 'client', + entryPoint: '/app/web.js', + }) + ); + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '2', + platform: 'ios', + environment: 'node', + entryPoint: '/app/server.js', + }) + ); + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '3', + platform: 'ios', + environment: 'client', + entryPoint: '/app/main.js', + }) + ); + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '4', + platform: 'android', + environment: 'client', + entryPoint: '/app/android.js', + }) + ); + + await finalizeAtlasStats(file); + const stats = await readStatsFile(file); + + // Should be sorted by platform first + expect(stats[0].platform).toBe('android'); + expect(stats[1].platform).toBe('ios'); + expect(stats[2].platform).toBe('ios'); + expect(stats[3].platform).toBe('web'); + + // Within same platform, sorted by environment + expect(stats[1].environment).toBe('client'); + expect(stats[2].environment).toBe('node'); + }); + + it('handles empty accumulator gracefully (writes [])', async () => { + const file = fixture('stats-empty', { temporary: true }); + + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats).toEqual([]); + }); + + it('handles empty bundle (no modules)', async () => { + const file = fixture('stats-no-modules', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map(), + runtimeModules: [], + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats[0]).toMatchObject({ + bundleSize: 0, + packages: {}, + }); + }); + + it('handles special characters in package names', async () => { + const file = fixture('stats-special-chars', { temporary: true }); + const bundle = createMockBundle({ + modules: new Map([ + [ + '/node_modules/@company/ui-lib/index.js', + createMockModule({ package: '@company/ui-lib', size: 1000 }), + ], + [ + '/node_modules/@babel/runtime/helpers/index.js', + createMockModule({ package: '@babel/runtime', size: 500 }), + ], + ]), + }); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats[0].packages['@company/ui-lib']).toBe(1000); + expect(stats[0].packages['@babel/runtime']).toBe(500); + }); + + it('produces diff-friendly output (stable ordering)', async () => { + const file = fixture('stats-diff-friendly', { temporary: true }); + const bundle = createMockBundle({ + id: '1', + modules: new Map([ + ['/app/index.js', createMockModule({ package: undefined, size: 1000 })], + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 2000 })], + ]), + }); + + // Write twice to verify stable output + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + const statsPath = file.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + const content1 = await fs.promises.readFile(statsPath, 'utf-8'); + + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + const content2 = await fs.promises.readFile(statsPath, 'utf-8'); + + expect(content1).toBe(content2); + }); +}); + +describe('integration', () => { + it('writes multiple bundles and produces correct stats file', async () => { + const file = fixture('stats-integration', { temporary: true }); + + // Create iOS client bundle + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '1', + platform: 'ios', + environment: 'client', + entryPoint: '/app/ios/index.js', + modules: new Map([ + ['/app/App.tsx', createMockModule({ package: undefined, size: 5000 })], + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 85000 })], + [ + '/node_modules/react-native/index.js', + createMockModule({ package: 'react-native', size: 450000 }), + ], + ]), + }) + ); + + // Create Android client bundle + writeAtlasStatsEntry( + file, + createMockBundle({ + id: '2', + platform: 'android', + environment: 'client', + entryPoint: '/app/android/index.js', + modules: new Map([ + ['/app/App.tsx', createMockModule({ package: undefined, size: 5000 })], + ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 85000 })], + [ + '/node_modules/react-native/index.js', + createMockModule({ package: 'react-native', size: 460000 }), + ], + ]), + }) + ); + + await finalizeAtlasStats(file); + + const stats = await readStatsFile(file); + expect(stats).toHaveLength(2); + + // Android should come first (alphabetical) + expect(stats[0].platform).toBe('android'); + expect(stats[0].bundleSize).toBe(550000); + expect(stats[0].packages).toMatchObject({ + app: 5000, + react: 85000, + 'react-native': 460000, + }); + + // iOS should come second + expect(stats[1].platform).toBe('ios'); + expect(stats[1].bundleSize).toBe(540000); + expect(stats[1].packages).toMatchObject({ + app: 5000, + react: 85000, + 'react-native': 450000, + }); + }); + + it('file size stays reasonable for realistic bundle', async () => { + const file = fixture('stats-size-check', { temporary: true }); + const modules = new Map(); + + // Create ~500 packages (realistic max) + for (let i = 0; i < 500; i++) { + modules.set( + `/node_modules/package-${i}/index.js`, + createMockModule({ package: `package-${i}`, size: 10000 }) + ); + } + + const bundle = createMockBundle({ modules }); + writeAtlasStatsEntry(file, bundle); + await finalizeAtlasStats(file); + + const statsPath = file.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + const stats = await fs.promises.stat(statsPath); + + // Should be under 100KB for 500 packages + expect(stats.size).toBeLessThan(100 * 1024); + }); +}); + +// Helper functions + +function createMockBundle(overrides: Partial = {}): AtlasBundle { + return { + id: '1', + platform: 'ios', + environment: 'client', + projectRoot: '/path/to/project', + sharedRoot: '/path/to/project', + entryPoint: '/path/to/project/index.js', + runtimeModules: [], + modules: new Map(), + transformOptions: {}, + serializeOptions: {}, + ...overrides, + }; +} + +function createMockModule(overrides: Partial = {}): AtlasModule { + return { + id: Math.random(), + absolutePath: '/path/to/module.js', + relativePath: 'module.js', + size: 1000, + imports: [], + importedBy: [], + ...overrides, + }; +} + +async function readStatsFile(atlasPath: string): Promise { + const statsPath = atlasPath.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + const content = await fs.promises.readFile(statsPath, 'utf-8'); + return JSON.parse(content); +} + +/** + * Get the file path to a fixture, by name. + * This automatically adds the required `.jsonl` or `.temp.jsonl` extension. + * Use `temporary: true` to keep it out of the repository, and reset the content automatically. + */ +function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { + const file = temporary + ? path.join(__dirname, 'fixtures/atlas', `${name}.temp.jsonl`) + : path.join(__dirname, 'fixtures/atlas', `${name}.jsonl`); + + fs.mkdirSync(path.dirname(file), { recursive: true }); + + if (temporary) { + fs.writeFileSync(file, ''); + } + + return file; +} diff --git a/packages/expo-atlas/src/data/stats-types.ts b/packages/expo-atlas/src/data/stats-types.ts new file mode 100644 index 0000000..7e9fab7 --- /dev/null +++ b/packages/expo-atlas/src/data/stats-types.ts @@ -0,0 +1,88 @@ +/** + * Type definitions for the Atlas stats export format. + * + * The stats file (`.expo/atlas-stats.json`) is a lightweight CI-friendly version of + * the full atlas.jsonl output. It contains package-level size aggregations optimized + * for diffing and scripting in CI/CD workflows. + * + * @example + * ```json + * [ + * { + * "platform": "ios", + * "environment": "client", + * "entryPoint": "/path/to/app/index.js", + * "bundleSize": 2458624, + * "packages": { + * "react": 85234, + * "react-native": 456789, + * "expo": 123456, + * "@company/ui-lib": 234567, + * "app": 987654 + * } + * } + * ] + * ``` + */ + +/** + * Root type for the atlas-stats.json file. + * Contains an array of bundle stats, one per bundle in the export. + */ +export type AtlasStatsFile = AtlasStatsBundle[]; + +/** + * Stats for a single bundle, containing package-level size aggregations. + * + * Each bundle in the export (e.g., iOS, Android, different environments) + * gets its own stats entry with metadata and package size totals. + */ +export type AtlasStatsBundle = { + /** + * Target platform for this bundle. + * Matches the platform specified in Metro's serialization options. + */ + platform: 'ios' | 'android' | 'web' | 'unknown'; + + /** + * Runtime environment for this bundle. + * - "client": Standard React Native client bundle + * - "node": Server-side bundle for Node.js environments + * - "react-server": React Server Components bundle + * - "dom": DOM-based web bundle + */ + environment: 'client' | 'node' | 'react-server' | 'dom'; + + /** + * Absolute path to the bundle's entry point file. + * This is the starting file from which Metro builds the dependency graph. + */ + entryPoint: string; + + /** + * Total size of the entire bundle in bytes (transformed output). + * This equals the sum of all package sizes. + */ + bundleSize: number; + + /** + * Package-level size aggregations. + * + * Maps each NPM package (or "app" for local code) to its total size in bytes. + * Sizes represent transformed/bundled output after Metro processing. + * + * Package names are sorted alphabetically for diff-friendly output. + * + * @example + * ```json + * { + * "@babel/runtime": 12345, + * "app": 987654, + * "expo": 123456, + * "react": 85234, + * "react-native": 456789 + * } + * ``` + */ + packages: Record; +}; diff --git a/packages/expo-atlas/src/index.ts b/packages/expo-atlas/src/index.ts index 5a1f1ac..ef88311 100644 --- a/packages/expo-atlas/src/index.ts +++ b/packages/expo-atlas/src/index.ts @@ -1,6 +1,7 @@ import './utils/global'; export type * from './data/types'; +export type * from './data/stats-types'; export { MetroGraphSource } from './data/MetroGraphSource'; export { AtlasFileSource, @@ -9,6 +10,7 @@ export { validateAtlasFile, getAtlasMetdata, getAtlasPath, + getAtlasStatsPath, } from './data/AtlasFileSource'; export { AtlasError, AtlasValidationError } from './utils/errors'; diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index 44e5687..b9be25c 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -4,7 +4,10 @@ import { createAtlasFile, ensureAtlasFileExist, getAtlasPath, + getAtlasStatsPath, writeAtlasEntry, + writeAtlasStatsEntry, + finalizeAtlasStats, } from './data/AtlasFileSource'; import { convertGraph, convertMetroConfig } from './data/MetroGraphSource'; @@ -47,11 +50,19 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => { + // Convert once, use twice (optimization) + const atlasBundle = convertGraph({ + projectRoot, + entryPoint, + preModules, + graph, + serializeOptions, + metroConfig, + }); + // Note(cedric): we don't have to await this, it has a built-in write queue - writeAtlasEntry( - atlasFile, - convertGraph({ projectRoot, entryPoint, preModules, graph, serializeOptions, metroConfig }) - ); + writeAtlasEntry(atlasFile, atlasBundle); + writeAtlasStatsEntry(atlasFile, atlasBundle); return originalSerializer(entryPoint, preModules, graph, serializeOptions); }; @@ -68,3 +79,27 @@ export async function resetExpoAtlasFile(projectRoot: string) { await createAtlasFile(filePath); return filePath; } + +/** + * Finalize and write the atlas-stats.json file. + * Call this after all Metro bundles are exported. + * + * @example + * ```ts + * import { resetExpoAtlasFile, finalizeExpoAtlasStats } from 'expo-atlas/metro'; + * + * // At start of export + * await resetExpoAtlasFile(projectRoot); + * + * // ... Metro bundling happens ... + * + * // After export completes + * const statsPath = await finalizeExpoAtlasStats(projectRoot); + * console.log(`Stats written to: ${statsPath}`); + * ``` + */ +export async function finalizeExpoAtlasStats(projectRoot: string): Promise { + const atlasPath = getAtlasPath(projectRoot); + await finalizeAtlasStats(atlasPath); + return getAtlasStatsPath(projectRoot); +} From 0bf51c78d7525e58fce1e8b8cd661a3c4ea99207 Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Wed, 21 Jan 2026 15:41:45 -0600 Subject: [PATCH 2/7] feat(atlas): add stats-only mode with file-level bundle analysis - Add EXPO_ATLAS_STATS_ONLY env var to skip atlas.jsonl generation - Break down stats into packages, files, and assets sections - Add auto-finalization on process exit for stats.json - Reduce disk I/O by ~92MB per export in stats-only mode --- packages/expo-atlas/package.json | 2 +- .../expo-atlas/src/data/AtlasFileSource.ts | 108 +++++++++++++++--- packages/expo-atlas/src/data/stats-types.ts | 56 ++++++++- packages/expo-atlas/src/metro.ts | 37 +++++- packages/expo-atlas/src/utils/env.ts | 3 + 5 files changed, 179 insertions(+), 27 deletions(-) diff --git a/packages/expo-atlas/package.json b/packages/expo-atlas/package.json index aa997ce..21a6d84 100644 --- a/packages/expo-atlas/package.json +++ b/packages/expo-atlas/package.json @@ -1,7 +1,7 @@ { "sideEffects": false, "name": "expo-atlas", - "version": "0.4.3", + "version": "0.4.5", "description": "Visualize React Native bundles to understand and optimize your app", "keywords": [ "react-native", diff --git a/packages/expo-atlas/src/data/AtlasFileSource.ts b/packages/expo-atlas/src/data/AtlasFileSource.ts index 67d12db..85935ee 100644 --- a/packages/expo-atlas/src/data/AtlasFileSource.ts +++ b/packages/expo-atlas/src/data/AtlasFileSource.ts @@ -94,6 +94,8 @@ let writeQueue: Promise = Promise.resolve(); /** In-memory accumulator for bundle stats, keyed by bundle ID for deduplication */ const statsAccumulator: Map = new Map(); +let statsFilePath: string | null = null; +let finalizationScheduled = false; /** * Wait until the Atlas file has all data written. @@ -186,57 +188,128 @@ export async function ensureAtlasFileExist(filePath: string) { return true; } +/** + * Asset file extensions to separate from app files + */ +const ASSET_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'svg', + 'webp', + 'bmp', + 'ttf', + 'otf', + 'woff', + 'woff2', + 'mp4', + 'webm', + 'mp3', + 'wav', + 'json', // For data assets +]); + +/** + * Check if a file path is an asset based on extension + */ +function isAsset(filePath: string): boolean { + const ext = filePath.split('.').pop()?.toLowerCase(); + return ext ? ASSET_EXTENSIONS.has(ext) : false; +} + /** * Convert an AtlasBundle to compact stats format for CI analysis. - * Aggregates module sizes by package name, treating local code as "app". + * Separates packages, app files, and assets for detailed analysis. */ function convertBundleToStats(bundle: AtlasBundle): AtlasStatsBundle { const packages: Record = {}; + const files: Record = {}; + const assets: Record = {}; - // Helper to accumulate package size + // Helper to categorize and store module size const addModuleSize = (module: AtlasModule) => { - const pkg = module.package ?? 'app'; - packages[pkg] = (packages[pkg] ?? 0) + module.size; + if (module.package) { + // External package + packages[module.package] = (packages[module.package] ?? 0) + module.size; + } else { + // App code - check if it's an asset or source file + const relativePath = module.relativePath; + if (isAsset(relativePath)) { + assets[relativePath] = module.size; + } else { + files[relativePath] = module.size; + } + } }; - // Aggregate regular modules + // Process regular modules for (const module of Array.from(bundle.modules.values())) { addModuleSize(module); } - // Aggregate runtime modules (Metro polyfills) + // Process runtime modules (Metro polyfills) for (const module of bundle.runtimeModules) { addModuleSize(module); } // Calculate total bundle size - const bundleSize = Object.values(packages).reduce((sum, size) => sum + size, 0); - - // Sort package keys alphabetically for diff-friendly output - const sortedPackages: Record = {}; - Object.keys(packages) - .sort() - .forEach((key) => { - sortedPackages[key] = packages[key]; - }); + const bundleSize = + Object.values(packages).reduce((sum, size) => sum + size, 0) + + Object.values(files).reduce((sum, size) => sum + size, 0) + + Object.values(assets).reduce((sum, size) => sum + size, 0); + + // Sort all keys alphabetically for diff-friendly output + const sortRecord = (record: Record): Record => { + const sorted: Record = {}; + Object.keys(record) + .sort() + .forEach((key) => { + sorted[key] = record[key]; + }); + return sorted; + }; return { platform: bundle.platform, environment: bundle.environment, entryPoint: bundle.entryPoint, bundleSize, - packages: sortedPackages, + packages: sortRecord(packages), + files: sortRecord(files), + assets: sortRecord(assets), }; } /** * Accumulate stats for a bundle. Call this for each bundle written to atlas.jsonl. * Stats are held in memory until finalizeAtlasStats() is called. + * Auto-finalizes on process exit if not manually finalized. */ -export function writeAtlasStatsEntry(_filePath: string, entry: AtlasBundle): void { +export function writeAtlasStatsEntry(filePath: string, entry: AtlasBundle): void { const statsBundle = convertBundleToStats(entry); // Use bundle ID as key to handle duplicate writes (last write wins) statsAccumulator.set(entry.id, statsBundle); + + // Store file path for auto-finalization + statsFilePath = filePath; + + // Schedule auto-finalization on process exit (once) + if (!finalizationScheduled && statsAccumulator.size > 0) { + finalizationScheduled = true; + + // Use beforeExit which allows async operations + process.once('beforeExit', async () => { + if (statsAccumulator.size > 0 && statsFilePath) { + try { + await finalizeAtlasStats(statsFilePath); + } catch (error) { + // Silently fail to avoid breaking the build + console.error('Failed to finalize atlas stats:', error); + } + } + }); + } } /** @@ -264,6 +337,7 @@ export function finalizeAtlasStats(filePath: string): Promise { // Clear accumulator after successful write writeQueue = writeQueue.then(() => { statsAccumulator.clear(); + finalizationScheduled = false; // Reset flag after finalization }); return writeQueue; diff --git a/packages/expo-atlas/src/data/stats-types.ts b/packages/expo-atlas/src/data/stats-types.ts index 7e9fab7..f42cc34 100644 --- a/packages/expo-atlas/src/data/stats-types.ts +++ b/packages/expo-atlas/src/data/stats-types.ts @@ -17,8 +17,16 @@ * "react": 85234, * "react-native": 456789, * "expo": 123456, - * "@company/ui-lib": 234567, - * "app": 987654 + * "@company/ui-lib": 234567 + * }, + * "files": { + * "app/components/Button.tsx": 2345, + * "app/screens/HomeScreen.tsx": 5678, + * "app/utils/helpers.ts": 1234 + * }, + * "assets": { + * "assets/images/logo.png": 12345, + * "assets/fonts/custom.ttf": 23456 * } * } * ] @@ -61,14 +69,14 @@ export type AtlasStatsBundle = { /** * Total size of the entire bundle in bytes (transformed output). - * This equals the sum of all package sizes. + * This equals the sum of all package sizes, files, and assets. */ bundleSize: number; /** - * Package-level size aggregations. + * Package-level size aggregations for external dependencies. * - * Maps each NPM package (or "app" for local code) to its total size in bytes. + * Maps each NPM package to its total size in bytes. * Sizes represent transformed/bundled output after Metro processing. * * Package names are sorted alphabetically for diff-friendly output. @@ -77,7 +85,6 @@ export type AtlasStatsBundle = { * ```json * { * "@babel/runtime": 12345, - * "app": 987654, * "expo": 123456, * "react": 85234, * "react-native": 456789 @@ -85,4 +92,41 @@ export type AtlasStatsBundle = { * ``` */ packages: Record; + + /** + * Individual app source files with their sizes. + * + * Maps relative file paths (from project root) to their size in bytes. + * Only includes JavaScript/TypeScript source files from your app code. + * + * Paths are sorted alphabetically for diff-friendly output. + * + * @example + * ```json + * { + * "app/components/Button.tsx": 2345, + * "app/screens/HomeScreen.tsx": 5678, + * "app/utils/helpers.ts": 1234 + * } + * ``` + */ + files: Record; + + /** + * Asset files (images, fonts, etc.) with their sizes. + * + * Maps relative asset paths to their size in bytes. + * Includes common asset types: png, jpg, gif, svg, ttf, otf, woff, etc. + * + * Paths are sorted alphabetically for diff-friendly output. + * + * @example + * ```json + * { + * "assets/images/logo.png": 12345, + * "assets/fonts/custom-font.ttf": 23456 + * } + * ``` + */ + assets: Record; }; diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index b9be25c..1ae5c84 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -10,12 +10,20 @@ import { finalizeAtlasStats, } from './data/AtlasFileSource'; import { convertGraph, convertMetroConfig } from './data/MetroGraphSource'; +import { env } from './utils/env'; export { waitUntilAtlasFileReady } from './data/AtlasFileSource'; type ExpoAtlasOptions = Partial<{ /** The output of the atlas file, defaults to `.expo/atlas.json` */ atlasFile: string; + /** + * Only generate the lightweight atlas-stats.json file, skip the full atlas.jsonl. + * Reduces disk I/O and saves ~90MB per export. + * Can also be enabled via EXPO_ATLAS_STATS_ONLY=true environment variable. + * @default false + */ + statsOnly: boolean; }>; /** @@ -33,6 +41,15 @@ type ExpoAtlasOptions = Partial<{ * * module.exports = withExpoAtlas(config) * ``` + * + * @example Stats-only mode (skip 90MB atlas.jsonl file): + * ```js + * module.exports = withExpoAtlas(config, { statsOnly: true }) + * ``` + * Or via environment variable: + * ```bash + * EXPO_ATLAS_STATS_ONLY=true npx expo export + * ``` */ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = {}) { const projectRoot = config.projectRoot; @@ -42,11 +59,16 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { throw new Error('No "projectRoot" configured in Metro config.'); } + // Check both option and env var (option takes precedence) + const statsOnly = options?.statsOnly ?? env.EXPO_ATLAS_STATS_ONLY; const atlasFile = options?.atlasFile ?? getAtlasPath(projectRoot); const metroConfig = convertMetroConfig(config); - // Note(cedric): we don't have to await this, Metro would never bundle before this is finishes - ensureAtlasFileExist(atlasFile); + // Only create the full atlas.jsonl file if not in stats-only mode + if (!statsOnly) { + // Note(cedric): we don't have to await this, Metro would never bundle before this is finishes + ensureAtlasFileExist(atlasFile); + } // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => { @@ -61,7 +83,9 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { }); // Note(cedric): we don't have to await this, it has a built-in write queue - writeAtlasEntry(atlasFile, atlasBundle); + if (!statsOnly) { + writeAtlasEntry(atlasFile, atlasBundle); + } writeAtlasStatsEntry(atlasFile, atlasBundle); return originalSerializer(entryPoint, preModules, graph, serializeOptions); @@ -73,8 +97,15 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { /** * Fully reset, or recreate, the Expo Atlas file containing all Metro information. * This method should only be called once per exporting session, to avoid overwriting data with mutliple Metro instances. + * + * In stats-only mode (EXPO_ATLAS_STATS_ONLY=true), this skips creating the full atlas.jsonl file. */ export async function resetExpoAtlasFile(projectRoot: string) { + // Skip creating the atlas.jsonl file in stats-only mode + if (env.EXPO_ATLAS_STATS_ONLY) { + return null; + } + const filePath = getAtlasPath(projectRoot); await createAtlasFile(filePath); return filePath; diff --git a/packages/expo-atlas/src/utils/env.ts b/packages/expo-atlas/src/utils/env.ts index 5c09474..decce36 100644 --- a/packages/expo-atlas/src/utils/env.ts +++ b/packages/expo-atlas/src/utils/env.ts @@ -7,4 +7,7 @@ export const env = { get EXPO_ATLAS_NO_VALIDATION() { return boolish('EXPO_ATLAS_NO_VALIDATION', false); }, + get EXPO_ATLAS_STATS_ONLY() { + return boolish('EXPO_ATLAS_STATS_ONLY', false); + }, }; From 78e804a612bc1fbf6f5f6aeb57e5e65b51e115b8 Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Wed, 21 Jan 2026 16:26:18 -0600 Subject: [PATCH 3/7] fix(atlas): ensure .expo directory exists in stats-only mode Modified ensureAtlasFileExist to accept statsOnly parameter and create the directory without creating atlas.jsonl file. This fixes ENOENT errors when finalizing stats in CI environments where .expo directory doesn't exist. - Add statsOnly parameter to ensureAtlasFileExist function - Always call ensureAtlasFileExist in metro.ts, passing statsOnly flag - In stats-only mode, only create directory without atlas.jsonl validation --- packages/expo-atlas/package.json | 2 +- .../expo-atlas/src/data/AtlasFileSource.ts | 14 +++++++++++-- packages/expo-atlas/src/metro.ts | 20 +++++++++++-------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/expo-atlas/package.json b/packages/expo-atlas/package.json index 21a6d84..9f1d515 100644 --- a/packages/expo-atlas/package.json +++ b/packages/expo-atlas/package.json @@ -1,7 +1,7 @@ { "sideEffects": false, "name": "expo-atlas", - "version": "0.4.5", + "version": "0.4.4", "description": "Visualize React Native bundles to understand and optimize your app", "keywords": [ "react-native", diff --git a/packages/expo-atlas/src/data/AtlasFileSource.ts b/packages/expo-atlas/src/data/AtlasFileSource.ts index 85935ee..9be3ca2 100644 --- a/packages/expo-atlas/src/data/AtlasFileSource.ts +++ b/packages/expo-atlas/src/data/AtlasFileSource.ts @@ -172,8 +172,19 @@ export async function createAtlasFile(filePath: string) { /** * Create the Atlas file if it doesn't exist, or recreate it if it's incompatible. + * In stats-only mode, only creates the directory without creating the atlas.jsonl file. + * + * @param filePath - Path to the atlas.jsonl file + * @param statsOnly - If true, only ensure directory exists (skip atlas.jsonl creation) */ -export async function ensureAtlasFileExist(filePath: string) { +export async function ensureAtlasFileExist(filePath: string, statsOnly: boolean = false) { + // In stats-only mode, just ensure the directory exists + if (statsOnly) { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + return true; + } + + // Full mode: validate and create atlas.jsonl if needed try { await validateAtlasFile(filePath); } catch (error: any) { @@ -207,7 +218,6 @@ const ASSET_EXTENSIONS = new Set([ 'webm', 'mp3', 'wav', - 'json', // For data assets ]); /** diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index 1ae5c84..ba83d07 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -64,11 +64,9 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { const atlasFile = options?.atlasFile ?? getAtlasPath(projectRoot); const metroConfig = convertMetroConfig(config); - // Only create the full atlas.jsonl file if not in stats-only mode - if (!statsOnly) { - // Note(cedric): we don't have to await this, Metro would never bundle before this is finishes - ensureAtlasFileExist(atlasFile); - } + // Ensure directory exists; in stats-only mode, skip creating atlas.jsonl + // Note(cedric): we don't have to await this, Metro would never bundle before this finishes + ensureAtlasFileExist(atlasFile, statsOnly); // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => { @@ -98,11 +96,17 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { * Fully reset, or recreate, the Expo Atlas file containing all Metro information. * This method should only be called once per exporting session, to avoid overwriting data with mutliple Metro instances. * - * In stats-only mode (EXPO_ATLAS_STATS_ONLY=true), this skips creating the full atlas.jsonl file. + * In stats-only mode, this skips creating the full atlas.jsonl file. + * + * @param projectRoot - The root directory of the project + * @param statsOnly - Only generate stats file, skip full atlas.jsonl. Defaults to EXPO_ATLAS_STATS_ONLY env var. */ -export async function resetExpoAtlasFile(projectRoot: string) { +export async function resetExpoAtlasFile( + projectRoot: string, + statsOnly: boolean = env.EXPO_ATLAS_STATS_ONLY +) { // Skip creating the atlas.jsonl file in stats-only mode - if (env.EXPO_ATLAS_STATS_ONLY) { + if (statsOnly) { return null; } From d086ed43e3c4d68107bd42965d97de1f506dc690 Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Thu, 22 Jan 2026 15:20:27 -0600 Subject: [PATCH 4/7] chore: revert package version --- packages/expo-atlas/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expo-atlas/package.json b/packages/expo-atlas/package.json index 9f1d515..aa997ce 100644 --- a/packages/expo-atlas/package.json +++ b/packages/expo-atlas/package.json @@ -1,7 +1,7 @@ { "sideEffects": false, "name": "expo-atlas", - "version": "0.4.4", + "version": "0.4.3", "description": "Visualize React Native bundles to understand and optimize your app", "keywords": [ "react-native", From 0bb7b6976cc8507e9af2db8a911b86187ddde142 Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Thu, 22 Jan 2026 15:51:33 -0600 Subject: [PATCH 5/7] refactor(atlas): separate directory creation from file validation - Extract ensureExpoDirExists to handle .expo directory creation independently - Simplify ensureAtlasFileExist to only handle atlas.jsonl validation/creation - Fix tests to expect app code in files instead of packages.app - Remove 4 redundant test cases - Remove disk I/O metrics from documentation comments --- .../expo-atlas/src/data/AtlasFileSource.ts | 20 +- .../src/data/__tests__/stats.test.ts | 197 ++++++------------ packages/expo-atlas/src/index.ts | 1 + packages/expo-atlas/src/metro.ts | 10 +- 4 files changed, 85 insertions(+), 143 deletions(-) diff --git a/packages/expo-atlas/src/data/AtlasFileSource.ts b/packages/expo-atlas/src/data/AtlasFileSource.ts index 9be3ca2..075ac40 100644 --- a/packages/expo-atlas/src/data/AtlasFileSource.ts +++ b/packages/expo-atlas/src/data/AtlasFileSource.ts @@ -170,21 +170,21 @@ export async function createAtlasFile(filePath: string) { await appendJsonLine(filePath, getAtlasMetdata()); } +/** + * Ensure the .expo directory exists for Atlas files. + * + * @param projectRoot - The root directory of the project + */ +export async function ensureExpoDirExists(projectRoot: string) { + await fs.promises.mkdir(path.join(projectRoot, '.expo'), { recursive: true }); +} + /** * Create the Atlas file if it doesn't exist, or recreate it if it's incompatible. - * In stats-only mode, only creates the directory without creating the atlas.jsonl file. * * @param filePath - Path to the atlas.jsonl file - * @param statsOnly - If true, only ensure directory exists (skip atlas.jsonl creation) */ -export async function ensureAtlasFileExist(filePath: string, statsOnly: boolean = false) { - // In stats-only mode, just ensure the directory exists - if (statsOnly) { - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - return true; - } - - // Full mode: validate and create atlas.jsonl if needed +export async function ensureAtlasFileExist(filePath: string) { try { await validateAtlasFile(filePath); } catch (error: any) { diff --git a/packages/expo-atlas/src/data/__tests__/stats.test.ts b/packages/expo-atlas/src/data/__tests__/stats.test.ts index dd2ce68..296f467 100644 --- a/packages/expo-atlas/src/data/__tests__/stats.test.ts +++ b/packages/expo-atlas/src/data/__tests__/stats.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'bun:test'; import fs from 'fs'; import path from 'path'; -import { getAtlasStatsPath, writeAtlasStatsEntry, finalizeAtlasStats } from '../AtlasFileSource'; +import { + getAtlasPath, + getAtlasStatsPath, + writeAtlasStatsEntry, + finalizeAtlasStats, +} from '../AtlasFileSource'; import type { AtlasStatsFile } from '../stats-types'; import type { AtlasBundle, AtlasModule } from '../types'; @@ -14,14 +19,18 @@ describe('getAtlasStatsPath', () => { describe('writeAtlasStatsEntry', () => { it('accumulates stats for a single bundle', async () => { - const file = fixture('stats-single', { temporary: true }); + const projectRoot = createTestProject('stats-single'); + const file = getAtlasPath(projectRoot); const bundle = createMockBundle({ id: '1', platform: 'ios', environment: 'client', entryPoint: '/path/to/app/index.js', modules: new Map([ - ['/path/to/app/App.tsx', createMockModule({ package: undefined, size: 1000 })], + [ + '/path/to/app/App.tsx', + createMockModule({ package: undefined, relativePath: 'App.tsx', size: 1000 }), + ], [ '/path/to/node_modules/react/index.js', createMockModule({ package: 'react', size: 2000 }), @@ -33,7 +42,7 @@ describe('writeAtlasStatsEntry', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats).toHaveLength(1); expect(stats[0]).toMatchObject({ platform: 'ios', @@ -41,15 +50,18 @@ describe('writeAtlasStatsEntry', () => { entryPoint: '/path/to/app/index.js', bundleSize: 3500, packages: { - app: 1000, 'metro-runtime': 500, react: 2000, }, + files: { + 'App.tsx': 1000, + }, }); }); it('aggregates multiple modules from same package', async () => { - const file = fixture('stats-aggregated', { temporary: true }); + const projectRoot = createTestProject('stats-aggregated'); + const file = getAtlasPath(projectRoot); const bundle = createMockBundle({ modules: new Map([ ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 1000 })], @@ -64,28 +76,13 @@ describe('writeAtlasStatsEntry', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats[0].packages.react).toBe(1800); }); - it('treats modules without package as "app"', async () => { - const file = fixture('stats-app-package', { temporary: true }); - const bundle = createMockBundle({ - modules: new Map([ - ['/app/index.js', createMockModule({ package: undefined, size: 1000 })], - ['/app/components/Button.tsx', createMockModule({ package: undefined, size: 500 })], - ]), - }); - - writeAtlasStatsEntry(file, bundle); - await finalizeAtlasStats(file); - - const stats = await readStatsFile(file); - expect(stats[0].packages.app).toBe(1500); - }); - it('includes runtime modules in aggregation', async () => { - const file = fixture('stats-runtime', { temporary: true }); + const projectRoot = createTestProject('stats-runtime'); + const file = getAtlasPath(projectRoot); const bundle = createMockBundle({ modules: new Map([['/app/index.js', createMockModule({ package: undefined, size: 1000 })]]), runtimeModules: [ @@ -97,12 +94,13 @@ describe('writeAtlasStatsEntry', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats[0].packages['metro-runtime']).toBe(800); }); it('sorts package names alphabetically', async () => { - const file = fixture('stats-sorted', { temporary: true }); + const projectRoot = createTestProject('stats-sorted'); + const file = getAtlasPath(projectRoot); const bundle = createMockBundle({ modules: new Map([ ['/node_modules/zebra/index.js', createMockModule({ package: 'zebra', size: 100 })], @@ -115,34 +113,14 @@ describe('writeAtlasStatsEntry', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); const packageNames = Object.keys(stats[0].packages); - expect(packageNames).toEqual(['app', 'apple', 'metro', 'zebra']); - }); - - it('calculates correct total bundle size', async () => { - const file = fixture('stats-bundle-size', { temporary: true }); - const bundle = createMockBundle({ - modules: new Map([ - ['/app/index.js', createMockModule({ package: undefined, size: 1000 })], - ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 2000 })], - ['/node_modules/expo/index.js', createMockModule({ package: 'expo', size: 3000 })], - ]), - runtimeModules: [createMockModule({ package: 'metro-runtime', size: 500 })], - }); - - writeAtlasStatsEntry(file, bundle); - await finalizeAtlasStats(file); - - const stats = await readStatsFile(file); - expect(stats[0].bundleSize).toBe(6500); - expect(stats[0].bundleSize).toBe( - Object.values(stats[0].packages).reduce((sum, size) => sum + size, 0) - ); + expect(packageNames).toEqual(['apple', 'metro', 'zebra']); }); it('handles duplicate bundle IDs (last write wins)', async () => { - const file = fixture('stats-duplicate', { temporary: true }); + const projectRoot = createTestProject('stats-duplicate'); + const file = getAtlasPath(projectRoot); const bundle1 = createMockBundle({ id: '1', modules: new Map([['/app/v1.js', createMockModule({ package: undefined, size: 1000 })]]), @@ -156,7 +134,7 @@ describe('writeAtlasStatsEntry', () => { writeAtlasStatsEntry(file, bundle2); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats).toHaveLength(1); expect(stats[0].bundleSize).toBe(2000); }); @@ -164,7 +142,8 @@ describe('writeAtlasStatsEntry', () => { describe('finalizeAtlasStats', () => { it('writes pretty-printed JSON to .expo/atlas-stats.json', async () => { - const file = fixture('stats-pretty', { temporary: true }); + const projectRoot = createTestProject('stats-pretty'); + const file = getAtlasPath(projectRoot); const bundle = createMockBundle({ modules: new Map([['/app/index.js', createMockModule({ package: undefined, size: 1000 })]]), }); @@ -172,7 +151,7 @@ describe('finalizeAtlasStats', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const statsPath = file.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + const statsPath = getAtlasStatsPath(projectRoot); const content = await fs.promises.readFile(statsPath, 'utf-8'); // Check for pretty-printing (should have indentation) @@ -184,7 +163,8 @@ describe('finalizeAtlasStats', () => { }); it('sorts bundles by platform, environment, entryPoint', async () => { - const file = fixture('stats-sorted-bundles', { temporary: true }); + const projectRoot = createTestProject('stats-sorted-bundles'); + const file = getAtlasPath(projectRoot); // Add bundles in random order writeAtlasStatsEntry( @@ -225,7 +205,7 @@ describe('finalizeAtlasStats', () => { ); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); // Should be sorted by platform first expect(stats[0].platform).toBe('android'); @@ -239,16 +219,18 @@ describe('finalizeAtlasStats', () => { }); it('handles empty accumulator gracefully (writes [])', async () => { - const file = fixture('stats-empty', { temporary: true }); + const projectRoot = createTestProject('stats-empty'); + const file = getAtlasPath(projectRoot); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats).toEqual([]); }); it('handles empty bundle (no modules)', async () => { - const file = fixture('stats-no-modules', { temporary: true }); + const projectRoot = createTestProject('stats-no-modules'); + const file = getAtlasPath(projectRoot); const bundle = createMockBundle({ modules: new Map(), runtimeModules: [], @@ -257,63 +239,19 @@ describe('finalizeAtlasStats', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats[0]).toMatchObject({ bundleSize: 0, packages: {}, }); }); - it('handles special characters in package names', async () => { - const file = fixture('stats-special-chars', { temporary: true }); - const bundle = createMockBundle({ - modules: new Map([ - [ - '/node_modules/@company/ui-lib/index.js', - createMockModule({ package: '@company/ui-lib', size: 1000 }), - ], - [ - '/node_modules/@babel/runtime/helpers/index.js', - createMockModule({ package: '@babel/runtime', size: 500 }), - ], - ]), - }); - - writeAtlasStatsEntry(file, bundle); - await finalizeAtlasStats(file); - - const stats = await readStatsFile(file); - expect(stats[0].packages['@company/ui-lib']).toBe(1000); - expect(stats[0].packages['@babel/runtime']).toBe(500); - }); - - it('produces diff-friendly output (stable ordering)', async () => { - const file = fixture('stats-diff-friendly', { temporary: true }); - const bundle = createMockBundle({ - id: '1', - modules: new Map([ - ['/app/index.js', createMockModule({ package: undefined, size: 1000 })], - ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 2000 })], - ]), - }); - - // Write twice to verify stable output - writeAtlasStatsEntry(file, bundle); - await finalizeAtlasStats(file); - const statsPath = file.replace(/atlas\.jsonl$/, 'atlas-stats.json'); - const content1 = await fs.promises.readFile(statsPath, 'utf-8'); - - writeAtlasStatsEntry(file, bundle); - await finalizeAtlasStats(file); - const content2 = await fs.promises.readFile(statsPath, 'utf-8'); - - expect(content1).toBe(content2); - }); }); describe('integration', () => { it('writes multiple bundles and produces correct stats file', async () => { - const file = fixture('stats-integration', { temporary: true }); + const projectRoot = createTestProject('stats-integration'); + const file = getAtlasPath(projectRoot); // Create iOS client bundle writeAtlasStatsEntry( @@ -324,7 +262,10 @@ describe('integration', () => { environment: 'client', entryPoint: '/app/ios/index.js', modules: new Map([ - ['/app/App.tsx', createMockModule({ package: undefined, size: 5000 })], + [ + '/app/App.tsx', + createMockModule({ package: undefined, relativePath: 'App.tsx', size: 5000 }), + ], ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 85000 })], [ '/node_modules/react-native/index.js', @@ -343,7 +284,10 @@ describe('integration', () => { environment: 'client', entryPoint: '/app/android/index.js', modules: new Map([ - ['/app/App.tsx', createMockModule({ package: undefined, size: 5000 })], + [ + '/app/App.tsx', + createMockModule({ package: undefined, relativePath: 'App.tsx', size: 5000 }), + ], ['/node_modules/react/index.js', createMockModule({ package: 'react', size: 85000 })], [ '/node_modules/react-native/index.js', @@ -355,30 +299,35 @@ describe('integration', () => { await finalizeAtlasStats(file); - const stats = await readStatsFile(file); + const stats = await readStatsFile(projectRoot); expect(stats).toHaveLength(2); // Android should come first (alphabetical) expect(stats[0].platform).toBe('android'); expect(stats[0].bundleSize).toBe(550000); expect(stats[0].packages).toMatchObject({ - app: 5000, react: 85000, 'react-native': 460000, }); + expect(stats[0].files).toMatchObject({ + 'App.tsx': 5000, + }); // iOS should come second expect(stats[1].platform).toBe('ios'); expect(stats[1].bundleSize).toBe(540000); expect(stats[1].packages).toMatchObject({ - app: 5000, react: 85000, 'react-native': 450000, }); + expect(stats[1].files).toMatchObject({ + 'App.tsx': 5000, + }); }); it('file size stays reasonable for realistic bundle', async () => { - const file = fixture('stats-size-check', { temporary: true }); + const projectRoot = createTestProject('stats-size-check'); + const file = getAtlasPath(projectRoot); const modules = new Map(); // Create ~500 packages (realistic max) @@ -393,7 +342,7 @@ describe('integration', () => { writeAtlasStatsEntry(file, bundle); await finalizeAtlasStats(file); - const statsPath = file.replace(/atlas\.jsonl$/, 'atlas-stats.json'); + const statsPath = getAtlasStatsPath(projectRoot); const stats = await fs.promises.stat(statsPath); // Should be under 100KB for 500 packages @@ -431,27 +380,17 @@ function createMockModule(overrides: Partial = {}): AtlasModule { }; } -async function readStatsFile(atlasPath: string): Promise { - const statsPath = atlasPath.replace(/atlas\.jsonl$/, 'atlas-stats.json'); +async function readStatsFile(projectRoot: string): Promise { + const statsPath = getAtlasStatsPath(projectRoot); const content = await fs.promises.readFile(statsPath, 'utf-8'); return JSON.parse(content); } /** - * Get the file path to a fixture, by name. - * This automatically adds the required `.jsonl` or `.temp.jsonl` extension. - * Use `temporary: true` to keep it out of the repository, and reset the content automatically. + * Create a temporary project root for testing */ -function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { - const file = temporary - ? path.join(__dirname, 'fixtures/atlas', `${name}.temp.jsonl`) - : path.join(__dirname, 'fixtures/atlas', `${name}.jsonl`); - - fs.mkdirSync(path.dirname(file), { recursive: true }); - - if (temporary) { - fs.writeFileSync(file, ''); - } - - return file; +function createTestProject(name: string): string { + const projectRoot = path.join(__dirname, '__temp__', name); + fs.mkdirSync(path.join(projectRoot, '.expo'), { recursive: true }); + return projectRoot; } diff --git a/packages/expo-atlas/src/index.ts b/packages/expo-atlas/src/index.ts index ef88311..4cc57f1 100644 --- a/packages/expo-atlas/src/index.ts +++ b/packages/expo-atlas/src/index.ts @@ -6,6 +6,7 @@ export { MetroGraphSource } from './data/MetroGraphSource'; export { AtlasFileSource, createAtlasFile, + ensureExpoDirExists, ensureAtlasFileExist, validateAtlasFile, getAtlasMetdata, diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index ba83d07..1eda7b4 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -2,6 +2,7 @@ import { type MetroConfig } from 'metro-config'; import { createAtlasFile, + ensureExpoDirExists, ensureAtlasFileExist, getAtlasPath, getAtlasStatsPath, @@ -19,7 +20,6 @@ type ExpoAtlasOptions = Partial<{ atlasFile: string; /** * Only generate the lightweight atlas-stats.json file, skip the full atlas.jsonl. - * Reduces disk I/O and saves ~90MB per export. * Can also be enabled via EXPO_ATLAS_STATS_ONLY=true environment variable. * @default false */ @@ -42,7 +42,7 @@ type ExpoAtlasOptions = Partial<{ * module.exports = withExpoAtlas(config) * ``` * - * @example Stats-only mode (skip 90MB atlas.jsonl file): + * @example Stats-only mode: * ```js * module.exports = withExpoAtlas(config, { statsOnly: true }) * ``` @@ -64,9 +64,11 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { const atlasFile = options?.atlasFile ?? getAtlasPath(projectRoot); const metroConfig = convertMetroConfig(config); - // Ensure directory exists; in stats-only mode, skip creating atlas.jsonl // Note(cedric): we don't have to await this, Metro would never bundle before this finishes - ensureAtlasFileExist(atlasFile, statsOnly); + ensureExpoDirExists(projectRoot); + if (!statsOnly) { + ensureAtlasFileExist(atlasFile); + } // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => { From 9cc935ce564aa84f72a7e9d6f071e01525917955 Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Thu, 22 Jan 2026 16:11:01 -0600 Subject: [PATCH 6/7] chore: some final polish --- packages/expo-atlas/src/index.ts | 4 ++-- packages/expo-atlas/src/metro.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/expo-atlas/src/index.ts b/packages/expo-atlas/src/index.ts index 4cc57f1..eb944d7 100644 --- a/packages/expo-atlas/src/index.ts +++ b/packages/expo-atlas/src/index.ts @@ -6,12 +6,12 @@ export { MetroGraphSource } from './data/MetroGraphSource'; export { AtlasFileSource, createAtlasFile, - ensureExpoDirExists, ensureAtlasFileExist, - validateAtlasFile, + ensureExpoDirExists, getAtlasMetdata, getAtlasPath, getAtlasStatsPath, + validateAtlasFile, } from './data/AtlasFileSource'; export { AtlasError, AtlasValidationError } from './utils/errors'; diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index 1eda7b4..5f93867 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -2,13 +2,13 @@ import { type MetroConfig } from 'metro-config'; import { createAtlasFile, - ensureExpoDirExists, ensureAtlasFileExist, + ensureExpoDirExists, + finalizeAtlasStats, getAtlasPath, getAtlasStatsPath, writeAtlasEntry, writeAtlasStatsEntry, - finalizeAtlasStats, } from './data/AtlasFileSource'; import { convertGraph, convertMetroConfig } from './data/MetroGraphSource'; import { env } from './utils/env'; @@ -72,7 +72,6 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, serializeOptions) => { - // Convert once, use twice (optimization) const atlasBundle = convertGraph({ projectRoot, entryPoint, From 07d93dbb3e693a0942f54eae7ab994490af32e83 Mon Sep 17 00:00:00 2001 From: Jason Gaare Date: Thu, 22 Jan 2026 16:32:38 -0600 Subject: [PATCH 7/7] chore(atlas): revert resetExpoAtlastFile to original function --- packages/expo-atlas/src/metro.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/expo-atlas/src/metro.ts b/packages/expo-atlas/src/metro.ts index 5f93867..9d457cc 100644 --- a/packages/expo-atlas/src/metro.ts +++ b/packages/expo-atlas/src/metro.ts @@ -97,20 +97,9 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { * Fully reset, or recreate, the Expo Atlas file containing all Metro information. * This method should only be called once per exporting session, to avoid overwriting data with mutliple Metro instances. * - * In stats-only mode, this skips creating the full atlas.jsonl file. - * * @param projectRoot - The root directory of the project - * @param statsOnly - Only generate stats file, skip full atlas.jsonl. Defaults to EXPO_ATLAS_STATS_ONLY env var. */ -export async function resetExpoAtlasFile( - projectRoot: string, - statsOnly: boolean = env.EXPO_ATLAS_STATS_ONLY -) { - // Skip creating the atlas.jsonl file in stats-only mode - if (statsOnly) { - return null; - } - +export async function resetExpoAtlasFile(projectRoot: string) { const filePath = getAtlasPath(projectRoot); await createAtlasFile(filePath); return filePath;