diff --git a/README.md b/README.md index eb7003d..fbcbe6b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Expo Atlas -Inspect the bundle stats from Metro. +Inspect bundle contents, on module level, from Metro. > [!Warning] > This project is highly experimental and will likely not work for your project. diff --git a/package.json b/package.json index 80649b4..4bd8f0b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "sideEffects": false, "name": "expo-atlas", "version": "0.0.18-preview.2", - "description": "Inspect bundle stats from Metro", + "description": "Inspect bundle contents, on module level, from Metro", "keywords": [ "expo", "atlas", diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 6bbc380..fa5d361 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -29,7 +29,7 @@ if (args['--version']) { if (args['--help']) { printLines([ chalk.bold('Usage'), - ` ${chalk.dim('$')} expo-atlas ${chalk.dim('[statsFile]')}`, + ` ${chalk.dim('$')} expo-atlas ${chalk.dim('[atlas file]')}`, '', chalk.bold('Options'), ` --port${chalk.dim(', -p')} Port to listen on`, @@ -52,7 +52,7 @@ async function run() { printLines([ `Expo Atlas is ready on: ${chalk.underline(href)}`, - ` ${chalk.dim(`Using: ${options.statsFile}`)}`, + ` ${chalk.dim(`Using: ${options.atlasFile}`)}`, ]); if (options.browserOpen) { @@ -72,10 +72,10 @@ run().catch((error) => { throw error; } - if (error.code === 'STATS_FILE_INCOMPATIBLE') { - const statsFile = path.relative(process.cwd(), error.statsFile); - console.error('Stats file is incompatible with this version, use this instead:'); - console.error(` npx expo-atlas@${error.incompatibleVersion} ${statsFile}`); + if (error.code === 'ATLAS_FILE_INCOMPATIBLE') { + const atlasFile = path.relative(process.cwd(), error.filePath); + console.error('Atlas file is incompatible with this version, use this instead:'); + console.error(` npx expo-atlas@${error.incompatibleVersion} ${atlasFile}`); } else { console.error(`${error.message} (${error.code})`); } diff --git a/src/cli/createServer.ts b/src/cli/createServer.ts index d75a6f7..743f271 100644 --- a/src/cli/createServer.ts +++ b/src/cli/createServer.ts @@ -2,13 +2,13 @@ import compression from 'compression'; import express from 'express'; import { type Options } from './resolveOptions'; -import { StatsFileSource } from '../data/StatsFileSource'; +import { AtlasFileSource } from '../data/AtlasFileSource'; import { createAtlasMiddleware } from '../utils/middleware'; export function createServer(options: Options) { process.env.NODE_ENV = 'production'; - const source = new StatsFileSource(options.statsFile); + const source = new AtlasFileSource(options.atlasFile); const middleware = createAtlasMiddleware(source); const baseUrl = '/_expo/atlas'; // Keep in sync with webui `app.json` `baseUrl` diff --git a/src/cli/resolveOptions.ts b/src/cli/resolveOptions.ts index 45528a7..7f894a6 100644 --- a/src/cli/resolveOptions.ts +++ b/src/cli/resolveOptions.ts @@ -2,20 +2,20 @@ import freeport from 'freeport-async'; import path from 'path'; import { type Input } from './bin'; -import { getStatsPath, validateStatsFile } from '../utils/stats'; +import { getAtlasPath, validateAtlasFile } from '../data/AtlasFileSource'; export type Options = Awaited>; export async function resolveOptions(input: Input) { - const statsFile = await resolveStatsFile(input); + const atlasFile = await resolveAtlasFile(input); const port = await resolvePort(input); - return { statsFile, port, browserOpen: input['--no-open'] !== true }; + return { atlasFile, port, browserOpen: input['--no-open'] !== true }; } -async function resolveStatsFile(input: Input) { - const statsFile = input._[0] ?? getStatsPath(process.cwd()); - await validateStatsFile(statsFile); - return path.resolve(statsFile); +async function resolveAtlasFile(input: Input) { + const atlasFile = input._[0] ?? getAtlasPath(process.cwd()); + await validateAtlasFile(atlasFile); + return path.resolve(atlasFile); } async function resolvePort(input: Pick) { diff --git a/src/data/AtlasFileSource.ts b/src/data/AtlasFileSource.ts new file mode 100644 index 0000000..505d586 --- /dev/null +++ b/src/data/AtlasFileSource.ts @@ -0,0 +1,128 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; + +import type { PartialAtlasEntry, AtlasEntry, AtlasSource } from './types'; +import { name, version } from '../../package.json'; +import { env } from '../utils/env'; +import { AtlasValidationError } from '../utils/errors'; +import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../utils/jsonl'; + +export type AtlasMetadata = { name: string; version: string }; + +export class AtlasFileSource implements AtlasSource { + constructor(public readonly filePath: string) { + // + } + + listEntries() { + return listAtlasEntries(this.filePath); + } + + getEntry(id: string) { + const numeric = parseInt(id, 10); + assert(!Number.isNaN(numeric) && numeric > 1, `Invalid entry ID: ${id}`); + return readAtlasEntry(this.filePath, Number(id)); + } +} + +/** + * List all entries without parsing the data. + * This only reads the bundle name, and adds a line number as ID. + */ +export async function listAtlasEntries(filePath: string) { + const bundlePattern = /^\["([^"]+)","([^"]+)","([^"]+)/; + const entries: PartialAtlasEntry[] = []; + + await forEachJsonLines(filePath, (contents, line) => { + // Skip the metadata line + if (line === 1) return; + + const [_, platform, projectRoot, entryPoint] = contents.match(bundlePattern) ?? []; + if (platform && projectRoot && entryPoint) { + entries.push({ + id: String(line), + platform: platform as any, + projectRoot, + entryPoint, + }); + } + }); + + return entries; +} + +/** + * Get the entry by id or line number, and parse the data. + */ +export async function readAtlasEntry(filePath: string, id: number): Promise { + const atlasEntry = await parseJsonLine(filePath, id); + return { + id: String(id), + platform: atlasEntry[0], + projectRoot: atlasEntry[1], + entryPoint: atlasEntry[2], + runtimeModules: atlasEntry[3], + modules: new Map(atlasEntry[4].map((module) => [module.path, module])), + transformOptions: atlasEntry[5], + serializeOptions: atlasEntry[6], + }; +} + +/** Simple promise to avoid mixing appended data */ +let writeQueue: Promise = Promise.resolve(); + +/** + * Add a new entry to the file. + * This is appended on a new line, so we can load the selectively. + */ +export function writeAtlasEntry(filePath: string, entry: AtlasEntry) { + const line = [ + entry.platform, + entry.projectRoot, + entry.entryPoint, + entry.runtimeModules, + Array.from(entry.modules.values()), + entry.transformOptions, + entry.serializeOptions, + ]; + + return (writeQueue = writeQueue.then(() => appendJsonLine(filePath, line))); +} + +/** The default location of the metro file */ +export function getAtlasPath(projectRoot: string) { + return path.join(projectRoot, '.expo/atlas.jsonl'); +} + +/** The information to validate if a file is compatible with this library version */ +export function getAtlasMetdata(): AtlasMetadata { + return { name, version }; +} + +/** Validate if the file is compatible with this library version */ +export async function validateAtlasFile(filePath: string, metadata = getAtlasMetdata()) { + if (!fs.existsSync(filePath)) { + throw new AtlasValidationError('ATLAS_FILE_NOT_FOUND', filePath); + } + + if (env.EXPO_ATLAS_NO_VALIDATION) { + return; + } + + const data = await parseJsonLine(filePath, 1); + + if (data.name !== metadata.name || data.version !== metadata.version) { + throw new AtlasValidationError('ATLAS_FILE_INCOMPATIBLE', filePath, data.version); + } +} + +/** + * Create or overwrite the file with basic metadata. + * This metdata is used by the API to determine version compatibility. + */ +export async function createAtlasFile(filePath: string) { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.rm(filePath, { force: true }); + await appendJsonLine(filePath, getAtlasMetdata()); +} diff --git a/src/data/MetroGraphSource.ts b/src/data/MetroGraphSource.ts index 544a902..e2880e6 100644 --- a/src/data/MetroGraphSource.ts +++ b/src/data/MetroGraphSource.ts @@ -1,14 +1,14 @@ import type metro from 'metro'; import path from 'path'; -import type { StatsEntry, StatsModule, StatsSource } from './types'; +import type { AtlasEntry, AtlasModule, AtlasSource } from './types'; import { bufferIsUtf8 } from '../utils/buffer'; import { getPackageNameFromPath } from '../utils/package'; type MetroGraph = metro.Graph | metro.ReadOnlyGraph; type MetroModule = metro.Module; -type ConvertGraphToStatsOptions = { +type ConvertGraphToAtlasOptions = { projectRoot: string; entryPoint: string; preModules: Readonly; @@ -20,9 +20,9 @@ type ConvertGraphToStatsOptions = { }; }; -export class MetroGraphSource implements StatsSource { - /** All known stats entries, stored by ID */ - protected entries: Map = new Map(); +export class MetroGraphSource implements AtlasSource { + /** All known entries, stored by ID */ + protected entries: Map = new Map(); listEntries() { return Array.from(this.entries.values()).map((entry) => ({ @@ -36,24 +36,24 @@ export class MetroGraphSource implements StatsSource { getEntry(id: string) { const entry = this.entries.get(id); if (!entry) { - throw new Error(`Stats entry "${id}" not found.`); + throw new Error(`Entry "${id}" not found.`); } return entry; } /** * Event handler when a new graph instance is ready to serialize. - * This converts all relevant data stored in the graph to stats objects. + * This converts all relevant data stored in the graph to objects. */ - onSerializeGraph(options: ConvertGraphToStatsOptions) { + onSerializeGraph(options: ConvertGraphToAtlasOptions) { const entry = convertGraph(options); this.entries.set(entry.id, entry); return entry; } } -/** Convert a Metro graph instance to a JSON-serializable stats entry */ -export function convertGraph(options: ConvertGraphToStatsOptions): StatsEntry { +/** Convert a Metro graph instance to a JSON-serializable entry */ +export function convertGraph(options: ConvertGraphToAtlasOptions): AtlasEntry { const serializeOptions = convertSerializeOptions(options); const transformOptions = convertTransformOptions(options); const platform = @@ -75,9 +75,9 @@ export function convertGraph(options: ConvertGraphToStatsOptions): StatsEntry { /** Find and collect all dependnecies related to the entrypoint within the graph */ export function collectEntryPointModules( - options: Pick + options: Pick ) { - const modules = new Map(); + const modules = new Map(); function discover(modulePath: string) { const module = options.graph.dependencies.get(modulePath); @@ -92,11 +92,11 @@ export function collectEntryPointModules( return modules; } -/** Convert a Metro module to a JSON-serializable stats module */ +/** Convert a Metro module to a JSON-serializable Atlas module */ export function convertModule( - options: Pick, + options: Pick, module: MetroModule -): StatsModule { +): AtlasModule { return { path: module.path, package: getPackageNameFromPath(module.path), @@ -118,7 +118,7 @@ export function convertModule( * If a file is an asset, it returns `[binary file]` instead. */ function getModuleSourceContent( - options: Pick, + options: Pick, module: MetroModule ) { const fileExtension = path.extname(module.path).replace('.', ''); @@ -144,16 +144,16 @@ function getModuleSourceContent( /** Convert Metro transform options to a JSON-serializable object */ export function convertTransformOptions( - options: Pick -): StatsEntry['transformOptions'] { + options: Pick +): AtlasEntry['transformOptions'] { return options.graph.transformOptions ?? {}; } /** Convert Metro serialize options to a JSON-serializable object */ export function convertSerializeOptions( - options: Pick -): StatsEntry['serializeOptions'] { - const serializeOptions: StatsEntry['serializeOptions'] = { ...options.options }; + options: Pick +): AtlasEntry['serializeOptions'] { + const serializeOptions: AtlasEntry['serializeOptions'] = { ...options.options }; // Delete all filters delete serializeOptions['processModuleFilter']; diff --git a/src/data/StatsFileSource.ts b/src/data/StatsFileSource.ts deleted file mode 100644 index a003a48..0000000 --- a/src/data/StatsFileSource.ts +++ /dev/null @@ -1,84 +0,0 @@ -import assert from 'assert'; - -import type { PartialStatsEntry, StatsEntry, StatsSource } from './types'; -import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../utils/jsonl'; - -export class StatsFileSource implements StatsSource { - constructor(public readonly statsPath: string) { - // - } - - listEntries() { - return listStatsEntries(this.statsPath); - } - - getEntry(id: string) { - const numeric = parseInt(id, 10); - assert(!Number.isNaN(numeric) && numeric > 1, `Invalid stats entry ID: ${id}`); - return readStatsEntry(this.statsPath, Number(id)); - } -} - -/** - * List all stats entries without parsing the data. - * This only reads the bundle name, and adds a line number as ID. - */ -export async function listStatsEntries(statsPath: string) { - const bundlePattern = /^\["([^"]+)","([^"]+)","([^"]+)/; - const entries: PartialStatsEntry[] = []; - - await forEachJsonLines(statsPath, (contents, line) => { - // Skip the stats metadata line - if (line === 1) return; - - const [_, platform, projectRoot, entryPoint] = contents.match(bundlePattern) ?? []; - if (platform && projectRoot && entryPoint) { - entries.push({ - id: String(line), - platform: platform as any, - projectRoot, - entryPoint, - }); - } - }); - - return entries; -} - -/** - * Get the stats entry by id or line number, and parse the data. - */ -export async function readStatsEntry(statsPath: string, id: number): Promise { - const statsEntry = await parseJsonLine(statsPath, id); - return { - id: String(id), - platform: statsEntry[0], - projectRoot: statsEntry[1], - entryPoint: statsEntry[2], - runtimeModules: statsEntry[3], - modules: new Map(statsEntry[4].map((module) => [module.path, module])), - transformOptions: statsEntry[5], - serializeOptions: statsEntry[6], - }; -} - -/** Simple promise to avoid mixing appended data */ -let writeStatsQueue: Promise = Promise.resolve(); - -/** - * Add a new stats entry to the stats file. - * This is appended on a new line, so we can load the stats selectively. - */ -export function writeStatsEntry(statsPath: string, stats: StatsEntry) { - const entry = [ - stats.platform, - stats.projectRoot, - stats.entryPoint, - stats.runtimeModules, - Array.from(stats.modules.values()), - stats.transformOptions, - stats.serializeOptions, - ]; - - return (writeStatsQueue = writeStatsQueue.then(() => appendJsonLine(statsPath, entry))); -} diff --git a/src/utils/__tests__/stats.test.ts b/src/data/__tests__/atlas.test.ts similarity index 65% rename from src/utils/__tests__/stats.test.ts rename to src/data/__tests__/atlas.test.ts index 31b6ba4..81756dc 100644 --- a/src/utils/__tests__/stats.test.ts +++ b/src/data/__tests__/atlas.test.ts @@ -3,73 +3,78 @@ import fs from 'fs'; import path from 'path'; import { name, version } from '../../../package.json'; -import { AtlasValidationError } from '../errors'; -import { getStatsPath, getStatsMetdata, createStatsFile, validateStatsFile } from '../stats'; - -describe('getStatsPath', () => { +import { AtlasValidationError } from '../../utils/errors'; +import { + getAtlasPath, + getAtlasMetdata, + createAtlasFile, + validateAtlasFile, +} from '../AtlasFileSource'; + +describe('getAtlasPath', () => { it('returns default path `/.expo/atlas.jsonl`', () => { - expect(getStatsPath('')).toBe('/.expo/atlas.jsonl'); + expect(getAtlasPath('')).toBe('/.expo/atlas.jsonl'); }); }); -describe('getStatsMetadata', () => { +describe('getAtlasMetdata', () => { it('returns package name and version', () => { - expect(getStatsMetdata()).toMatchObject({ name, version }); + expect(getAtlasMetdata()).toMatchObject({ name, version }); }); }); -describe('createStatsFile', () => { - it('creates a stats file with the correct metadata', async () => { +describe('createAtlasFile', () => { + it('creates a file with the correct metadata', async () => { const file = fixture('create-metadata', { temporary: true }); - await createStatsFile(file); + await createAtlasFile(file); await expect(fs.promises.readFile(file, 'utf8')).resolves.toBe( JSON.stringify({ name, version }) + '\n' ); }); - it('overwrites invalid stats file', async () => { + it('overwrites invalid file', async () => { const file = fixture('create-invalid', { temporary: true }); await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); - await createStatsFile(file); + await createAtlasFile(file); await expect(fs.promises.readFile(file, 'utf8')).resolves.toBe( JSON.stringify({ name, version }) + '\n' ); }); - it('reuses valid stats file', async () => { + it('reuses valid file', async () => { const file = fixture('create-valid', { temporary: true }); await fs.promises.writeFile(file, JSON.stringify({ name, version }) + '\n'); - await createStatsFile(file); + await createAtlasFile(file); await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe( JSON.stringify({ name, version }) + '\n' ); }); }); -describe('validateStatsFile', () => { - it('passes for valid stats file', async () => { +describe('validateAtlasFile', () => { + it('passes for valid file', async () => { const file = fixture('validate-valid', { temporary: true }); - await createStatsFile(file); - await expect(validateStatsFile(file)).resolves.pass(); + await createAtlasFile(file); + await expect(validateAtlasFile(file)).resolves.pass(); }); - it('fails for non-existing stats file', async () => { - await expect(validateStatsFile('./this-file-does-not-exists')).rejects.toThrow( + it('fails for non-existing file', async () => { + await expect(validateAtlasFile('./this-file-does-not-exists')).rejects.toThrow( AtlasValidationError ); }); - it('fails for invalid stats file', async () => { + it('fails for invalid file', async () => { const file = fixture('validate-invalid', { temporary: true }); await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); - await expect(validateStatsFile(file)).rejects.toThrow(AtlasValidationError); + await expect(validateAtlasFile(file)).rejects.toThrow(AtlasValidationError); }); - it('skips validation when EXPO_ATLAS_NO_STATS_VALIDATION is true-ish', async () => { - using _env = env('EXPO_ATLAS_NO_STATS_VALIDATION', 'true'); + it('skips validation when EXPO_ATLAS_NO_VALIDATION is true-ish', async () => { + using _env = env('EXPO_ATLAS_NO_VALIDATION', 'true'); const file = fixture('validate-skip-invalid', { temporary: true }); await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); - await expect(validateStatsFile(file)).resolves.pass(); + await expect(validateAtlasFile(file)).resolves.pass(); }); }); @@ -80,8 +85,8 @@ describe('validateStatsFile', () => { */ function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { const file = temporary - ? path.join(__dirname, 'fixtures/stats', `${name}.temp.jsonl`) - : path.join(__dirname, 'fixtures/stats', `${name}.jsonl`); + ? path.join(__dirname, 'fixtures/atlas', `${name}.temp.jsonl`) + : path.join(__dirname, 'fixtures/atlas', `${name}.jsonl`); fs.mkdirSync(path.dirname(file), { recursive: true }); diff --git a/src/data/types.ts b/src/data/types.ts index 1d63bc7..7df8dae 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -1,16 +1,16 @@ import type { MixedOutput } from 'metro'; -export interface StatsSource { - /** List all available stats entries */ - listEntries(): PartialStatsEntry[] | Promise; - /** Load the full stats entry, by reference */ - getEntry(ref: string): StatsEntry | Promise; +export interface AtlasSource { + /** List all available entries */ + listEntries(): PartialAtlasEntry[] | Promise; + /** Load the full entry, by reference */ + getEntry(ref: string): AtlasEntry | Promise; } -export type PartialStatsEntry = Pick; +export type PartialAtlasEntry = Pick; -export type StatsEntry = { - /** The unique reference or ID to this stats entry */ +export type AtlasEntry = { + /** The unique reference or ID to this entry */ id: string; /** The platform for which the bundle was created */ platform: 'android' | 'ios' | 'web'; @@ -19,16 +19,16 @@ export type StatsEntry = { /** The absolute path to the entry point used when creating the bundle */ entryPoint: string; /** All known modules that are prepended for the runtime itself */ - runtimeModules: StatsModule[]; + runtimeModules: AtlasModule[]; /** All known modules imported within the bundle, stored by absolute path */ - modules: Map; + modules: Map; /** The sarialization options used for this bundle */ serializeOptions?: Record; /** The transformation options used for this bundle */ transformOptions?: Record; }; -export type StatsModule = { +export type AtlasModule = { /** The absoluate path of this module */ path: string; /** The name of the package this module belongs to, if from an external package */ diff --git a/src/index.ts b/src/index.ts index 00357b0..45d57b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,13 @@ import './utils/global'; export type * from './data/types'; export { MetroGraphSource } from './data/MetroGraphSource'; -export { StatsFileSource } from './data/StatsFileSource'; +export { + AtlasFileSource, + createAtlasFile, + validateAtlasFile, + getAtlasMetdata, + getAtlasPath, +} from './data/AtlasFileSource'; export { AtlasError, AtlasValidationError } from './utils/errors'; export { createAtlasMiddleware } from './utils/middleware'; -export { createStatsFile, validateStatsFile, getStatsMetdata, getStatsPath } from './utils/stats'; diff --git a/src/metro.ts b/src/metro.ts index b4eea86..d3cd288 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -1,12 +1,11 @@ import { type MetroConfig } from 'metro-config'; +import { createAtlasFile, getAtlasPath, writeAtlasEntry } from './data/AtlasFileSource'; import { convertGraph } from './data/MetroGraphSource'; -import { writeStatsEntry } from './data/StatsFileSource'; -import { createStatsFile, getStatsPath } from './utils/stats'; type ExpoAtlasOptions = Partial<{ - /** The output of the stats file, defaults to `.expo/stats.json` */ - statsFile: string; + /** The output of the atlas file, defaults to `.expo/atlas.json` */ + atlasFile: string; }>; /** @@ -33,20 +32,20 @@ export function withExpoAtlas(config: MetroConfig, options: ExpoAtlasOptions = { throw new Error('No "projectRoot" configured in Metro config.'); } - const statsFile = options?.statsFile ?? getStatsPath(projectRoot); + const atlasFile = options?.atlasFile ?? getAtlasPath(projectRoot); const extensions = { source: config.resolver?.sourceExts, asset: config.resolver?.assetExts, }; // Note(cedric): we don't have to await this, Metro would never bundle before this is finisheds - createStatsFile(statsFile); + createAtlasFile(atlasFile); // @ts-expect-error config.serializer.customSerializer = (entryPoint, preModules, graph, options) => { // Note(cedric): we don't have to await this, it has a built-in write queue - writeStatsEntry( - statsFile, + writeAtlasEntry( + atlasFile, convertGraph({ projectRoot, entryPoint, preModules, graph, options, extensions }) ); diff --git a/src/utils/env.ts b/src/utils/env.ts index f22a8aa..5c09474 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -1,10 +1,10 @@ import { boolish } from 'getenv'; export const env = { - get EXPO_ATLAS_DEBUG() { - return boolish('EXPO_ATLAS_DEBUG', false); + get EXPO_DEBUG() { + return boolish('EXPO_DEBUG', false); }, - get EXPO_ATLAS_NO_STATS_VALIDATION() { - return boolish('EXPO_ATLAS_NO_STATS_VALIDATION', false); + get EXPO_ATLAS_NO_VALIDATION() { + return boolish('EXPO_ATLAS_NO_VALIDATION', false); }, }; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 2c9c1c0..f1cd219 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -15,15 +15,15 @@ export class AtlasError extends Error { export class AtlasValidationError extends AtlasError { constructor( - code: 'STATS_FILE_NOT_FOUND' | 'STATS_FILE_INCOMPATIBLE', - public readonly statsFile: string, + code: 'ATLAS_FILE_NOT_FOUND' | 'ATLAS_FILE_INCOMPATIBLE', + public readonly filePath: string, public readonly incompatibleVersion?: string ) { super( code, - code === 'STATS_FILE_NOT_FOUND' - ? `Stats file not found: ${statsFile}` - : `Stats file is incompatible with this version.` + code === 'ATLAS_FILE_NOT_FOUND' + ? `Atlas file not found: ${filePath}` + : `Atlas file is incompatible with this version.` ); } } diff --git a/src/utils/global.ts b/src/utils/global.ts index 3e09141..ac3939b 100644 --- a/src/utils/global.ts +++ b/src/utils/global.ts @@ -1,9 +1,9 @@ -import { StatsSource } from '../data/types'; +import { AtlasSource } from '../data/types'; declare global { /** * The globally initialized data source for Atlas. * This is set in a global to access the data from the bundled webui API routes. */ - var EXPO_ATLAS_SOURCE: StatsSource; // eslint-disable-line no-var + var EXPO_ATLAS_SOURCE: AtlasSource; // eslint-disable-line no-var } diff --git a/src/utils/middleware.ts b/src/utils/middleware.ts index 219f659..f23f72d 100644 --- a/src/utils/middleware.ts +++ b/src/utils/middleware.ts @@ -5,7 +5,7 @@ import path from 'path'; import serveStaticHandler from 'serve-static'; import { env } from './env'; -import { type StatsSource } from '../data/types'; +import { type AtlasSource } from '../data/types'; const WEBUI_ROOT = path.resolve(__dirname, '../../../webui'); @@ -14,8 +14,8 @@ const SERVER_BUILD_DIR = path.join(WEBUI_ROOT, 'dist/server'); /** * Initialize Expo Atlas to gather statistics from Metro during development. - * This function creates a connect middleware to serve the webui and the stats API. - * It's designed to use any `StatsSource` implementation and passes it to the webui. + * This function creates a connect middleware to serve the webui and the Atlas API. + * It's designed to use any `AtlasSource` implementation and passes it to the webui. * * @example ```js * import { createAtlasMiddleware, MetroGraphSource } from 'expo-atlas/middleware'; @@ -27,12 +27,12 @@ const SERVER_BUILD_DIR = path.join(WEBUI_ROOT, 'dist/server'); * app.use('/_expo/atlas', middleware); * ``` */ -export function createAtlasMiddleware(source: StatsSource) { +export function createAtlasMiddleware(source: AtlasSource) { global.EXPO_ATLAS_SOURCE = source; const middleware = connect(); - if (env.EXPO_ATLAS_DEBUG) { + if (env.EXPO_DEBUG) { middleware.use(morgan('tiny')); } diff --git a/src/utils/stats.ts b/src/utils/stats.ts deleted file mode 100644 index fd746f6..0000000 --- a/src/utils/stats.ts +++ /dev/null @@ -1,46 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { appendJsonLine, parseJsonLine } from './jsonl'; -import { name, version } from '../../package.json'; -import { env } from '../utils/env'; -import { AtlasValidationError } from '../utils/errors'; - -export type StatsMetadata = { name: string; version: string }; - -/** The default location of the metro stats file */ -export function getStatsPath(projectRoot: string) { - return path.join(projectRoot, '.expo/atlas.jsonl'); -} - -/** The information to validate if a stats file is compatible with this library version */ -export function getStatsMetdata(): StatsMetadata { - return { name, version }; -} - -/** Validate if the stats file is compatible with this library version */ -export async function validateStatsFile(statsFile: string, metadata = getStatsMetdata()) { - if (!fs.existsSync(statsFile)) { - throw new AtlasValidationError('STATS_FILE_NOT_FOUND', statsFile); - } - - if (env.EXPO_ATLAS_NO_STATS_VALIDATION) { - return; - } - - const data = await parseJsonLine(statsFile, 1); - - if (data.name !== metadata.name || data.version !== metadata.version) { - throw new AtlasValidationError('STATS_FILE_INCOMPATIBLE', statsFile, data.version); - } -} - -/** - * Create or overwrite the stats file with basic metadata. - * This metdata is used by the API to determine version compatibility. - */ -export async function createStatsFile(filePath: string) { - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await fs.promises.rm(filePath, { force: true }); - await appendJsonLine(filePath, getStatsMetdata()); -} diff --git a/webui/metro.config.js b/webui/metro.config.js index 4900a27..6200769 100644 --- a/webui/metro.config.js +++ b/webui/metro.config.js @@ -17,10 +17,10 @@ config.cacheStores = [ // Initialize the Expo Atlas global data source in development if (process.env.NODE_ENV === 'development') { - const { StatsFileSource } = require('../build/src/data/StatsFileSource'); - const statsFile = path.resolve(__dirname, './fixture/atlas-tabs-50.jsonl'); + const { AtlasFileSource } = require('../build/src/data/AtlasFileSource'); + const filePath = path.resolve(__dirname, './fixture/atlas-tabs-50.jsonl'); - global.EXPO_ATLAS_SOURCE = new StatsFileSource(statsFile); + global.EXPO_ATLAS_SOURCE = new AtlasFileSource(filePath); } module.exports = config; diff --git a/webui/src/app/stats/[entry]/folders/[path].tsx b/webui/src/app/(atlas)/[entry]/folders/[path].tsx similarity index 71% rename from webui/src/app/stats/[entry]/folders/[path].tsx rename to webui/src/app/(atlas)/[entry]/folders/[path].tsx index 4dcc0aa..79eb525 100644 --- a/webui/src/app/stats/[entry]/folders/[path].tsx +++ b/webui/src/app/(atlas)/[entry]/folders/[path].tsx @@ -1,20 +1,20 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useLocalSearchParams } from 'expo-router'; -import type { ModuleGraphResponse } from '~/app/api/stats/[entry]/modules/graph+api'; +import type { ModuleGraphResponse } from '~/app/--/entries/[entry]/modules/graph+api'; import { BundleGraph } from '~/components/BundleGraph'; -import { Page, PageHeader, PageTitle } from '~/components/Page'; -import { StatsModuleFilter } from '~/components/forms/StatsModuleFilter'; -import { useStatsEntry } from '~/providers/stats'; +import { Page, PageContent, PageHeader, PageTitle } from '~/components/Page'; +import { ModuleFiltersForm } from '~/components/forms/ModuleFilter'; +import { useEntry } from '~/providers/entries'; import { Tag } from '~/ui/Tag'; import { fetchApi } from '~/utils/api'; +import { relativeEntryPath } from '~/utils/entry'; import { type ModuleFilters, useModuleFilters, moduleFiltersToParams } from '~/utils/filters'; import { formatFileSize } from '~/utils/formatString'; -import { relativeEntryPath } from '~/utils/stats'; export default function FolderPage() { const { path: absolutePath } = useLocalSearchParams<{ path: string }>(); - const { entry } = useStatsEntry(); + const { entry } = useEntry(); const { filters, filtersEnabled } = useModuleFilters(); const modules = useModuleGraphDataInFolder(entry.id, absolutePath!, filters); const treeHasData = !!modules.data?.data?.children?.length; @@ -29,21 +29,23 @@ export default function FolderPage() { {!!modules.data && } - + {modules.isError ? ( -
- Could not load the graph, try reloading this page -
+ + Try restarting Expo Atlas. If this error keeps happening, open a bug report. + ) : treeHasData ? ( ) : ( !modules.isPending && ( -
- {!filtersEnabled - ? 'No data available' - : 'No data available, try resetting the filters'} -
+ +

+ {filtersEnabled + ? 'Try adjusting or clearing the filters' + : 'Try another bundle entry'} +

+
) )} @@ -87,8 +89,8 @@ function useModuleGraphDataInFolder(entryId: string, path: string, filters: Modu ModuleFilters | undefined, ]; const url = filters - ? `/api/stats/${entry}/modules/graph?path=${encodeURIComponent(path)}&${moduleFiltersToParams(filters)}` - : `/api/stats/${entry}/modules/graph?path=${encodeURIComponent(path)}`; + ? `/entries/${entry}/modules/graph?path=${encodeURIComponent(path)}&${moduleFiltersToParams(filters)}` + : `/entries/${entry}/modules/graph?path=${encodeURIComponent(path)}`; return fetchApi(url) .then((res) => (res.ok ? res : Promise.reject(res))) diff --git a/webui/src/app/stats/[entry]/index.tsx b/webui/src/app/(atlas)/[entry]/index.tsx similarity index 68% rename from webui/src/app/stats/[entry]/index.tsx rename to webui/src/app/(atlas)/[entry]/index.tsx index 7f2a2f7..6171116 100644 --- a/webui/src/app/stats/[entry]/index.tsx +++ b/webui/src/app/(atlas)/[entry]/index.tsx @@ -1,18 +1,18 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import type { ModuleGraphResponse } from '~/app/api/stats/[entry]/modules/graph+api'; +import type { ModuleGraphResponse } from '~/app/--/entries/[entry]/modules/graph+api'; import { BundleGraph } from '~/components/BundleGraph'; -import { Page, PageHeader, PageTitle } from '~/components/Page'; -import { StatsModuleFilter } from '~/components/forms/StatsModuleFilter'; -import { useStatsEntry } from '~/providers/stats'; +import { Page, PageContent, PageHeader, PageTitle } from '~/components/Page'; +import { ModuleFiltersForm } from '~/components/forms/ModuleFilter'; +import { useEntry } from '~/providers/entries'; import { Spinner } from '~/ui/Spinner'; import { Tag } from '~/ui/Tag'; import { fetchApi } from '~/utils/api'; import { type ModuleFilters, moduleFiltersToParams, useModuleFilters } from '~/utils/filters'; import { formatFileSize } from '~/utils/formatString'; -export default function StatsPage() { - const { entry } = useStatsEntry(); +export default function BundlePage() { + const { entry } = useEntry(); const { filters, filtersEnabled } = useModuleFilters(); const modules = useModuleGraphData(entry.id, filters); const treeHasData = !!modules.data?.data?.children?.length; @@ -25,18 +25,26 @@ export default function StatsPage() {

Bundle

{!!modules.data && } - + - {(modules.isPending && !modules.isPlaceholderData) || modules.isError ? ( -
- {modules.isError ? 'Could not load the graph, try reloading this page' : } -
+ {modules.isPending && !modules.isPlaceholderData ? ( + + + + ) : modules.isError ? ( + +

Try restarting Expo Atlas. If this error keeps happening, open a bug report.

+
) : treeHasData ? ( ) : ( -
- {!filtersEnabled ? 'No data available' : 'No data available, try resetting the filters'} -
+ +

+ {filtersEnabled + ? 'Try adjusting or clearing the filters' + : 'Try another bundle entry'} +

+
)} @@ -73,8 +81,8 @@ function useModuleGraphData(entryId: string, filters: ModuleFilters) { queryFn: ({ queryKey }) => { const [_key, entry, filters] = queryKey as [string, string, ModuleFilters | undefined]; const url = filters - ? `/api/stats/${entry}/modules/graph?${moduleFiltersToParams(filters)}` - : `/api/stats/${entry}/modules/graph`; + ? `/entries/${entry}/modules/graph?${moduleFiltersToParams(filters)}` + : `/entries/${entry}/modules/graph`; return fetchApi(url) .then((res) => (res.ok ? res : Promise.reject(res))) diff --git a/webui/src/app/stats/[entry]/modules/[path].tsx b/webui/src/app/(atlas)/[entry]/modules/[path].tsx similarity index 89% rename from webui/src/app/stats/[entry]/modules/[path].tsx rename to webui/src/app/(atlas)/[entry]/modules/[path].tsx index 2755d6c..4105bce 100644 --- a/webui/src/app/stats/[entry]/modules/[path].tsx +++ b/webui/src/app/(atlas)/[entry]/modules/[path].tsx @@ -2,17 +2,17 @@ import { useQuery } from '@tanstack/react-query'; import { Link, useLocalSearchParams } from 'expo-router'; import { Page, PageHeader, PageTitle } from '~/components/Page'; -import { useStatsEntry } from '~/providers/stats'; +import { useEntry } from '~/providers/entries'; import { CodeBlock, CodeBlockSectionWithPrettier, guessLanguageFromPath } from '~/ui/CodeBlock'; import { Skeleton } from '~/ui/Skeleton'; import { Tag } from '~/ui/Tag'; import { fetchApi } from '~/utils/api'; +import { relativeEntryPath } from '~/utils/entry'; import { formatFileSize } from '~/utils/formatString'; -import { relativeEntryPath } from '~/utils/stats'; -import { type PartialStatsEntry, type StatsModule } from '~core/data/types'; +import { type PartialAtlasEntry, type AtlasModule } from '~core/data/types'; export default function ModulePage() { - const { entry } = useStatsEntry(); + const { entry } = useEntry(); const { path: absolutePath } = useLocalSearchParams<{ path: string }>(); const module = useModuleData(entry.id, absolutePath!); @@ -52,7 +52,7 @@ export default function ModulePage() { @@ -84,8 +84,8 @@ function ModuleSummary({ module, platform, }: { - module: StatsModule; - platform?: PartialStatsEntry['platform']; + module: AtlasModule; + platform?: PartialAtlasEntry['platform']; }) { return (
@@ -108,18 +108,19 @@ function ModuleSummary({ ); } -function getModuleType(module: StatsModule) { +function getModuleType(module: AtlasModule) { const type = module.path.includes('?ctx=') ? 'require.context' : 'file'; return module.package ? `package ${type}` : type; } /** Load the module data from API, by path reference only */ function useModuleData(entryId: string, path: string) { - return useQuery({ + return useQuery({ + refetchOnWindowFocus: false, queryKey: [`module`, entryId, path], queryFn: async ({ queryKey }) => { const [_key, entry, path] = queryKey as [string, string, string]; - return fetchApi(`/api/stats/${entry}/modules`, { + return fetchApi(`/entries/${entry}/modules`, { method: 'POST', body: JSON.stringify({ path }), }) diff --git a/webui/src/app/api/stats/[entry]/index+api.ts b/webui/src/app/--/entries/[entry]/index+api.ts similarity index 100% rename from webui/src/app/api/stats/[entry]/index+api.ts rename to webui/src/app/--/entries/[entry]/index+api.ts diff --git a/webui/src/app/api/stats/[entry]/modules/graph+api.ts b/webui/src/app/--/entries/[entry]/modules/graph+api.ts similarity index 89% rename from webui/src/app/api/stats/[entry]/modules/graph+api.ts rename to webui/src/app/--/entries/[entry]/modules/graph+api.ts index 9feaf39..49cd6cc 100644 --- a/webui/src/app/api/stats/[entry]/modules/graph+api.ts +++ b/webui/src/app/--/entries/[entry]/modules/graph+api.ts @@ -1,7 +1,7 @@ import { getSource } from '~/utils/atlas'; import { filterModules, moduleFiltersFromParams } from '~/utils/filters'; import { type TreemapNode, createModuleTree, finalizeModuleTree } from '~/utils/treemap'; -import type { StatsEntry } from '~core/data/types'; +import type { AtlasEntry } from '~core/data/types'; export type ModuleGraphResponse = { data: TreemapNode; @@ -17,7 +17,7 @@ export type ModuleGraphResponse = { }; export async function GET(request: Request, params: Record<'entry', string>) { - let entry: StatsEntry; + let entry: AtlasEntry; try { entry = await getSource().getEntry(params.entry); @@ -33,10 +33,8 @@ export async function GET(request: Request, params: Record<'entry', string>) { rootPath: query.get('path') || undefined, }); - const tree = createModuleTree(filteredModules); - const response: ModuleGraphResponse = { - data: finalizeModuleTree(tree), + data: finalizeModuleTree(createModuleTree(filteredModules)), entry: { platform: entry.platform as any, moduleSize: allModules.reduce((size, module) => size + module.size, 0), diff --git a/webui/src/app/api/stats/[entry]/modules/index+api.ts b/webui/src/app/--/entries/[entry]/modules/index+api.ts similarity index 92% rename from webui/src/app/api/stats/[entry]/modules/index+api.ts rename to webui/src/app/--/entries/[entry]/modules/index+api.ts index ee72aa5..49411ab 100644 --- a/webui/src/app/api/stats/[entry]/modules/index+api.ts +++ b/webui/src/app/--/entries/[entry]/modules/index+api.ts @@ -1,9 +1,9 @@ import { getSource } from '~/utils/atlas'; import { filterModules, moduleFiltersFromParams } from '~/utils/filters'; -import { type StatsEntry, type StatsModule } from '~core/data/types'; +import { type AtlasEntry, type AtlasModule } from '~core/data/types'; -/** The partial module data, when listing all available modules from a stats entry */ -export type PartialModule = Omit; +/** The partial module data, when listing all available modules from an entry */ +export type PartialModule = Omit; export type ModuleListResponse = { data: PartialModule[]; @@ -20,7 +20,7 @@ export type ModuleListResponse = { /** Get all modules as simple list */ export async function GET(request: Request, params: Record<'entry', string>) { - let entry: StatsEntry; + let entry: AtlasEntry; try { entry = await getSource().getEntry(params.entry); @@ -69,7 +69,7 @@ export async function POST(request: Request, params: Record<'entry', string>) { ); } - let entry: StatsEntry; + let entry: AtlasEntry; try { entry = await getSource().getEntry(params.entry); diff --git a/webui/src/app/api/stats/index+api.ts b/webui/src/app/--/entries/index+api.ts similarity index 100% rename from webui/src/app/api/stats/index+api.ts rename to webui/src/app/--/entries/index+api.ts diff --git a/webui/src/app/_layout.tsx b/webui/src/app/_layout.tsx index b4789e7..30a6670 100644 --- a/webui/src/app/_layout.tsx +++ b/webui/src/app/_layout.tsx @@ -1,7 +1,7 @@ import { Slot } from 'expo-router'; +import { EntryProvider } from '~/providers/entries'; import { QueryProvider } from '~/providers/query'; -import { StatsEntryProvider } from '~/providers/stats'; import { ThemeProvider } from '~/providers/theme'; // Import the Expo-required radix styles @@ -32,9 +32,9 @@ export default function RootLayout() { return ( - + - + ); diff --git a/webui/src/app/index.tsx b/webui/src/app/index.tsx index 2050ff8..7ed9f9d 100644 --- a/webui/src/app/index.tsx +++ b/webui/src/app/index.tsx @@ -1,9 +1,9 @@ import { Redirect } from 'expo-router'; -import { useStatsEntry } from '~/providers/stats'; +import { useEntry } from '~/providers/entries'; export default function HomeScreen() { - const { entry } = useStatsEntry(); + const { entry } = useEntry(); - return ; + return ; } diff --git a/webui/src/components/BundleGraph.tsx b/webui/src/components/BundleGraph.tsx index e75050c..d753c3b 100644 --- a/webui/src/components/BundleGraph.tsx +++ b/webui/src/components/BundleGraph.tsx @@ -10,13 +10,13 @@ import { useMemo } from 'react'; import { formatFileSize } from '~/utils/formatString'; import type { TreemapNode } from '~/utils/treemap'; -import type { PartialStatsEntry } from '~core/data/types'; +import type { PartialAtlasEntry } from '~core/data/types'; // Register used echarts components, to avoid loading unused code echarts.use([TooltipComponent, TreemapChart, CanvasRenderer]); type BundleGraphProps = { - entry: PartialStatsEntry; + entry: PartialAtlasEntry; graph: TreemapNode; }; @@ -51,8 +51,8 @@ export function BundleGraph(props: BundleGraphProps) { if (event.event.altKey || event.event.ctrlKey || event.event.metaKey) { router.push({ pathname: data.children?.length - ? '/stats/[entry]/folders/[path]' - : '/stats/[entry]/modules/[path]', + ? '/(atlas)/[entry]/folders/[path]' + : '/(atlas)/[entry]/modules/[path]', params: { entry: props.entry.id, path: data.value === 100 ? data.name : data.modulePath, diff --git a/webui/src/components/Page.tsx b/webui/src/components/Page.tsx index cd990cf..e1c1023 100644 --- a/webui/src/components/Page.tsx +++ b/webui/src/components/Page.tsx @@ -1,8 +1,8 @@ import { cva, type VariantProps } from 'class-variance-authority'; import cn from 'classnames'; -import { forwardRef, type HTMLAttributes } from 'react'; +import { forwardRef, type PropsWithChildren, type HTMLAttributes } from 'react'; -import { StatsEntrySelect } from '~/components/forms/StatsEntrySelect'; +import { EntrySelectForm } from '~/components/forms/EntrySelect'; import { LayoutContent, LayoutNavigation } from '~/ui/Layout'; export { LayoutHeader as PageHeader, LayoutTitle as PageTitle } from '~/ui/Layout'; @@ -27,7 +27,7 @@ export const Page = forwardRef( return (
- + {children}
@@ -37,7 +37,7 @@ export const Page = forwardRef( return (
- + {children}
@@ -45,3 +45,16 @@ export const Page = forwardRef( } ); Page.displayName = 'Page'; + +type PageContentProps = PropsWithChildren & { + title?: string; +}; + +export function PageContent({ title, children }: PageContentProps) { + return ( +
+ {!!title &&

{title}

} + {children} +
+ ); +} diff --git a/webui/src/components/forms/StatsEntrySelect.tsx b/webui/src/components/forms/EntrySelect.tsx similarity index 92% rename from webui/src/components/forms/StatsEntrySelect.tsx rename to webui/src/components/forms/EntrySelect.tsx index 09c3327..e9445ad 100644 --- a/webui/src/components/forms/StatsEntrySelect.tsx +++ b/webui/src/components/forms/EntrySelect.tsx @@ -6,14 +6,14 @@ import ChevronDownIcon from 'lucide-react/dist/esm/icons/chevron-down'; // @ts-expect-error import ChevronUpIcon from 'lucide-react/dist/esm/icons/chevron-up'; -import { useStatsEntry } from '~/providers/stats'; +import { useEntry } from '~/providers/entries'; import { Button } from '~/ui/Button'; import { Tag } from '~/ui/Tag'; -import { relativeEntryPath } from '~/utils/stats'; +import { relativeEntryPath } from '~/utils/entry'; -export function StatsEntrySelect() { +export function EntrySelectForm() { const router = useRouter(); - const { entry, entries } = useStatsEntry(); + const { entry, entries } = useEntry(); return ( router.setParams({ entry })}> diff --git a/webui/src/components/forms/StatsModuleFilter.tsx b/webui/src/components/forms/ModuleFilter.tsx similarity index 97% rename from webui/src/components/forms/StatsModuleFilter.tsx rename to webui/src/components/forms/ModuleFilter.tsx index 60ce905..d2d9334 100644 --- a/webui/src/components/forms/StatsModuleFilter.tsx +++ b/webui/src/components/forms/ModuleFilter.tsx @@ -16,11 +16,11 @@ import { import { debounce } from '~/utils/debounce'; import { useModuleFilters } from '~/utils/filters'; -type StatsModuleFilterProps = { +type ModuleFiltersFormProps = { disableNodeModules?: boolean; }; -export function StatsModuleFilter(props: StatsModuleFilterProps) { +export function ModuleFiltersForm(props: ModuleFiltersFormProps) { const router = useRouter(); const { filters, filtersEnabled } = useModuleFilters(); diff --git a/webui/src/providers/entries.tsx b/webui/src/providers/entries.tsx new file mode 100644 index 0000000..d2e7a6f --- /dev/null +++ b/webui/src/providers/entries.tsx @@ -0,0 +1,73 @@ +import { useQuery } from '@tanstack/react-query'; +import { useLocalSearchParams } from 'expo-router'; +import { type PropsWithChildren, createContext, useContext, useMemo } from 'react'; + +import { PageContent } from '~/components/Page'; +import { Spinner } from '~/ui/Spinner'; +import { fetchApi } from '~/utils/api'; +import { type PartialAtlasEntry } from '~core/data/types'; + +type EntryContext = { + entries: NonNullable['data']>; +}; + +export const entryContext = createContext({ + entries: [], +}); + +export const useEntry = () => { + const { entries } = useContext(entryContext); + const { entry: entryId } = useLocalSearchParams<{ entry?: string }>(); + const entry = useMemo( + () => entries.find((entry) => entry.id === entryId) || entries[0], + [entries, entryId] + ); + + return { entry, entries }; +}; + +export function EntryProvider({ children }: PropsWithChildren) { + const entries = useEntryData(); + + if (entries.data?.length) { + return ( + + {children} + + ); + } + + // TODO: add better UX for loading + if (entries.isFetching && !entries.data?.length) { + return ( + + + + ); + } + + // TODO: add better UX for empty state + if (entries.isFetched && !entries.data?.length) { + return ( + +

Open your app in the browser, or device, to collect data.

+
+ ); + } + + // TODO: add better UX for error state + return ( + +

Try restarting Expo Atlas. If this error keeps happening, open a bug report.

+
+ ); +} + +/** Load all available entries from API */ +function useEntryData() { + return useQuery({ + refetchOnWindowFocus: false, + queryKey: ['entries'], + queryFn: () => fetchApi('/entries').then((res) => res.json()), + }); +} diff --git a/webui/src/providers/stats.tsx b/webui/src/providers/stats.tsx deleted file mode 100644 index e38cdbd..0000000 --- a/webui/src/providers/stats.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useLocalSearchParams } from 'expo-router'; -import { type PropsWithChildren, createContext, useContext, useMemo } from 'react'; - -import { Spinner } from '~/ui/Spinner'; -import { fetchApi } from '~/utils/api'; -import { type PartialStatsEntry } from '~core/data/types'; - -type StatsEntryContext = { - entries: NonNullable['data']>; -}; - -export const statsEntryContext = createContext({ - entries: [], -}); - -export const useStatsEntry = () => { - const { entries } = useContext(statsEntryContext); - const { entry: entryId } = useLocalSearchParams<{ entry?: string }>(); - const entry = useMemo( - () => entries.find((entry) => entry.id === entryId) || entries[0], - [entries, entryId] - ); - - return { entry, entries }; -}; - -export function StatsEntryProvider({ children }: PropsWithChildren) { - const entries = useStatsEntriesData(); - - if (entries.data?.length) { - return ( - - {children} - - ); - } - - // TODO: add better UX for loading - if (entries.isFetching && !entries.data?.length) { - return ( -
- -
- ); - } - - // TODO: add better UX for empty state - if (entries.isFetched && !entries.data?.length) { - return ( -
-

No stats found.

-

Open your app in the browser, or device, to collect the stats.

-
- ); - } - - // TODO: add better UX for error state - return ( -
-

No stats source.

-

Try restarting Expo Atlas. If this error keeps happening, open a bug report.

-
- ); -} - -/** Load all available stats entries from API */ -function useStatsEntriesData() { - return useQuery({ - queryKey: ['stats-entries'], - queryFn: () => fetchApi('/api/stats').then((res) => res.json()), - }); -} diff --git a/webui/src/utils/api.ts b/webui/src/utils/api.ts index fb7a6f4..c3bee85 100644 --- a/webui/src/utils/api.ts +++ b/webui/src/utils/api.ts @@ -4,7 +4,7 @@ * * @see https://docs.expo.dev/versions/latest/config/app/#baseurl */ -const baseUrl = __DEV__ ? '' : '/_expo/atlas'; +const baseUrl = __DEV__ ? '/--' : '/_expo/atlas/--'; /** * Fetch data from the API routes, adding the `baseUrl` to all requests. diff --git a/webui/src/utils/stats.ts b/webui/src/utils/entry.ts similarity index 65% rename from webui/src/utils/stats.ts rename to webui/src/utils/entry.ts index d9ef516..d023a75 100644 --- a/webui/src/utils/stats.ts +++ b/webui/src/utils/entry.ts @@ -1,9 +1,9 @@ -import { PartialStatsEntry } from '~core/data/types'; +import { PartialAtlasEntry } from '~core/data/types'; /** * Translate an absolute path to a relative path, based on the entry's project root. * This is a simple replace operation. */ -export function relativeEntryPath(entry: Pick, path: string) { +export function relativeEntryPath(entry: Pick, path: string) { return path.replace(entry.projectRoot + '/', ''); } diff --git a/webui/src/utils/filters.ts b/webui/src/utils/filters.ts index 048580a..5c050e9 100644 --- a/webui/src/utils/filters.ts +++ b/webui/src/utils/filters.ts @@ -2,7 +2,7 @@ import { useGlobalSearchParams } from 'expo-router'; import path from 'path'; import picomatch from 'picomatch'; -import { type StatsModule } from '~core/data/types'; +import { type AtlasModule } from '~core/data/types'; export type ModuleFilters = { /** Only match the project code, or all code including (external) packages */ @@ -63,7 +63,7 @@ export function useModuleFilters() { /** Filter the modules based on the filters, and an optional (root) path. */ export function filterModules( - modules: StatsModule[], + modules: AtlasModule[], options: { projectRoot: string; filters: ModuleFilters; diff --git a/webui/src/utils/treemap.ts b/webui/src/utils/treemap.ts index d4f9115..a053396 100644 --- a/webui/src/utils/treemap.ts +++ b/webui/src/utils/treemap.ts @@ -1,4 +1,4 @@ -import type { StatsModule } from '~core/data/types'; +import type { AtlasModule } from '~core/data/types'; export type TreemapNode = { /** The current path of the node */ @@ -52,7 +52,7 @@ export function finalizeModuleTree(node: TreemapNode): TreemapNode { * Create a nested treemap from the list of modules. * This will group the modules by `module.path` segments, and add metadata to each node. */ -export function createModuleTree(modules: StatsModule[]): TreemapNode { +export function createModuleTree(modules: AtlasModule[]): TreemapNode { const totalSize = modules.reduce((total, module) => total + module.size, 0); const map: TreemapNode = { name: '/', // This is the root, so no prefix