From 6975da35ca1c3c543336dbaeced97a2f45ee99c3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 14 Jul 2026 11:11:39 +0200 Subject: [PATCH] feat: persist Metro transform and file-map caches under .harness Introduce @react-native-harness/cache as the single owner of .harness/cache layout and warmth checks. Metro's transform cache moves to .harness/cache/metro and its file-map (haste) cache, previously left in $TMPDIR, is now pinned to .harness/cache/metro-file-map. Both are enabled by default via a new `cache` config option group; `unstable__enableMetroCache` is kept as a deprecated alias for one release. The GitHub Action's cache step now covers both directories under a bumped cache key. --- .gitignore | 1 - .nx/version-plans/persist-metro-cache.md | 5 + action.yml | 10 +- actions/shared/index.cjs | 21 ++- packages/bundler-metro/package.json | 1 + .../bundler-metro/src/__tests__/paths.test.ts | 55 +------- .../src/__tests__/withRnHarness.test.ts | 119 +++++++++++++++++ packages/bundler-metro/src/index.ts | 1 - packages/bundler-metro/src/metro-cache.ts | 27 ++-- packages/bundler-metro/src/paths.ts | 22 --- packages/bundler-metro/src/withRnHarness.ts | 40 +++++- packages/bundler-metro/tsconfig.json | 3 + packages/bundler-metro/tsconfig.lib.json | 3 + packages/cache/README.md | 26 ++++ packages/cache/eslint.config.mjs | 20 +++ packages/cache/package.json | 22 +++ packages/cache/src/__tests__/boundary.test.ts | 95 +++++++++++++ packages/cache/src/__tests__/index.test.ts | 54 ++++++++ packages/cache/src/__tests__/paths.test.ts | 18 +++ packages/cache/src/__tests__/warmth.test.ts | 47 +++++++ packages/cache/src/index.ts | 42 ++++++ packages/cache/src/paths.ts | 43 ++++++ packages/cache/src/warmth.ts | 59 ++++++++ packages/cache/tsconfig.json | 13 ++ packages/cache/tsconfig.lib.json | 18 +++ packages/cache/vite.config.ts | 18 +++ .../resolveMetroCacheEnabled.test.ts | 126 ++++++++++++++++++ packages/config/src/index.ts | 1 + packages/config/src/types.ts | 66 ++++++++- packages/github-action/README.md | 2 +- packages/github-action/src/action.yml | 10 +- packages/jest/package.json | 1 + .../jest/src/__tests__/harness-cache.test.ts | 91 +++++++++++++ packages/jest/src/harness-session.ts | 41 +++++- packages/jest/tsconfig.json | 3 + packages/jest/tsconfig.lib.json | 3 + pnpm-lock.yaml | 15 +++ tsconfig.json | 3 + .../docs/getting-started/configuration.mdx | 5 +- website/src/docs/guides/ci-cd.md | 4 +- 40 files changed, 1035 insertions(+), 119 deletions(-) create mode 100644 .nx/version-plans/persist-metro-cache.md create mode 100644 packages/cache/README.md create mode 100644 packages/cache/eslint.config.mjs create mode 100644 packages/cache/package.json create mode 100644 packages/cache/src/__tests__/boundary.test.ts create mode 100644 packages/cache/src/__tests__/index.test.ts create mode 100644 packages/cache/src/__tests__/paths.test.ts create mode 100644 packages/cache/src/__tests__/warmth.test.ts create mode 100644 packages/cache/src/index.ts create mode 100644 packages/cache/src/paths.ts create mode 100644 packages/cache/src/warmth.ts create mode 100644 packages/cache/tsconfig.json create mode 100644 packages/cache/tsconfig.lib.json create mode 100644 packages/cache/vite.config.ts create mode 100644 packages/config/src/__tests__/resolveMetroCacheEnabled.test.ts create mode 100644 packages/jest/src/__tests__/harness-cache.test.ts diff --git a/.gitignore b/.gitignore index a3957e87..d4eef0ab 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,3 @@ npm-debug.* *.mobileprovision *.orig.* web-build/ -cache/ diff --git a/.nx/version-plans/persist-metro-cache.md b/.nx/version-plans/persist-metro-cache.md new file mode 100644 index 00000000..3493d15f --- /dev/null +++ b/.nx/version-plans/persist-metro-cache.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +Metro's transform and file-map caches now persist under `.harness/cache/metro` and `.harness/cache/metro-file-map` in your project root, enabled by default, so repeated Metro runs and CI jobs reuse work instead of rebuilding from scratch. Configure this with the new `cache.metro` and `cache.version` options; the previous `unstable__enableMetroCache` flag still works but is deprecated in favor of `cache.metro`. The official GitHub Action restores and saves the new cache paths automatically. diff --git a/action.yml b/action.yml index f9098959..09cf8899 100644 --- a/action.yml +++ b/action.yml @@ -64,13 +64,15 @@ runs: echo "Please provide the path to the built app (.apk for Android, .app for iOS)" exit 1 fi - - name: Metro bundler cache (.harness/metro-cache) + - name: Metro bundler cache (.harness/cache/metro) uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: ${{ steps.load-config.outputs.projectRoot }}/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} + path: | + ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/metro + ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/metro-file-map + key: ${{ runner.os }}-metro-cache-v2-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} restore-keys: | - ${{ runner.os }}-metro-cache- + ${{ runner.os }}-metro-cache-v2- - name: Restore Harness cache id: cache-harness-restore if: fromJson(steps.load-config.outputs.config).platformId == 'ios' diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index e05c4ad4..861e89fa 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4426,6 +4426,7 @@ var pluginsLogger = logger.child("plugins"); // ../config/dist/types.js var DEFAULT_METRO_PORT = 8081; +var configLogger = logger.child("config"); var RunnerSchema = external_exports.object({ name: external_exports.string().min(1, "Runner name is required").regex(/^[a-zA-Z0-9._-]+$/, "Runner name can only contain alphanumeric characters, dots, underscores, and hyphens"), config: external_exports.record(external_exports.any()), @@ -4449,9 +4450,23 @@ var ConfigSchema = external_exports.object({ bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4), maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), eagerPrewarm: external_exports.boolean().optional().default(true).describe("Start building the Metro bundle while the platform (emulator, simulator, or browser) is still booting, so the first bundle is ready sooner. Disable to defer the first bundle build until app startup."), - resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), - unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), - unstable__enableMetroCache: external_exports.boolean().optional().default(false), + resetEnvironmentBetweenTestFiles: external_exports.union([external_exports.boolean(), external_exports.enum(["process", "runtime"])]).optional().default(true).describe("Controls how the environment is reset between test files. `true` (default) and `'process'` kill and cold-restart the app process. `'runtime'` reloads the JS runtime in place (DevSettings.reload() / window.location.reload()), which is cheaper but escalates to a process restart if the reload fails or the app does not reconnect in time. `false` disables resetting the environment between test files entirely."), + skipAlreadyIncludedModules: external_exports.boolean().optional().describe('Skip re-sending modules already served in the main app bundle when Metro serves a per-test-file bundle. Defaults to true; set to false as an escape hatch if it causes issues. Left undefined (rather than defaulted here) so `resolveSkipAlreadyIncludedModules` can tell "not set" apart from "explicitly set" when reconciling with the deprecated `unstable__skipAlreadyIncludedModules` alias.'), + // Deprecated alias for `skipAlreadyIncludedModules` -- see + // `resolveSkipAlreadyIncludedModules`. Left without a `.default()` for the + // same reason: a default value would be indistinguishable from the user + // never having set it, which would break alias-vs-explicit-flag precedence. + unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().describe("Deprecated. Use `skipAlreadyIncludedModules` instead."), + cache: external_exports.object({ + metro: external_exports.boolean().optional().describe('Enable persistent Metro transform and file-map caching under the .harness cache directory. Defaults to true; set to false to always start with a cold Metro cache. Left undefined (rather than defaulted here) so `resolveMetroCacheEnabled` can tell "not set" apart from "explicitly set" when reconciling with the deprecated `unstable__enableMetroCache` alias.'), + version: external_exports.string().optional().describe("User-controlled salt folded into the Metro cacheVersion. Bump it to force-invalidate previously cached transforms.") + }).optional(), + // Deprecated alias for `cache.metro` -- see `resolveMetroCacheEnabled`. + // Left without a `.default()` for the same reason as + // `unstable__skipAlreadyIncludedModules`: a default value would be + // indistinguishable from the user never having set it, which would break + // alias-vs-explicit-flag precedence. + unstable__enableMetroCache: external_exports.boolean().optional().describe("Deprecated. Use `cache.metro` instead."), permissions: external_exports.boolean().optional().default(false).describe("Enable platform-specific permission prompt automation. When false, Harness does not start permission-handling helpers such as the iOS XCTest agent."), detectNativeCrashes: external_exports.boolean().optional().default(true), crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500), diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index adc1b124..f207ca55 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -18,6 +18,7 @@ "dependencies": { "@react-native/metro-config": "*", "@react-native-harness/babel-preset": "workspace:*", + "@react-native-harness/cache": "workspace:*", "@react-native-harness/tools": "workspace:*", "@react-native-harness/config": "workspace:*", "@react-native-harness/runtime": "workspace:*", diff --git a/packages/bundler-metro/src/__tests__/paths.test.ts b/packages/bundler-metro/src/__tests__/paths.test.ts index 80300e29..3e7539b4 100644 --- a/packages/bundler-metro/src/__tests__/paths.test.ts +++ b/packages/bundler-metro/src/__tests__/paths.test.ts @@ -1,33 +1,10 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - getHarnessManifestPath, - getHarnessMetroCachePath, - getHarnessRootPath, - isMetroCacheReusable, -} from '../paths.js'; - -const tempDirs: string[] = []; - -const createTempProjectRoot = (): string => { - const tempDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'rn-harness-bundler-metro-') - ); - tempDirs.push(tempDir); - return tempDir; -}; - -afterEach(() => { - for (const tempDir of tempDirs.splice(0)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -}); +import { describe, expect, it } from 'vitest'; +import { getHarnessManifestPath, getHarnessRootPath } from '../paths.js'; describe('bundler metro paths', () => { it('resolves the harness root under the project root', () => { - const projectRoot = createTempProjectRoot(); + const projectRoot = '/tmp/some-project'; expect(getHarnessRootPath(projectRoot)).toBe( path.join(projectRoot, '.harness') @@ -35,31 +12,5 @@ describe('bundler metro paths', () => { expect(getHarnessManifestPath(projectRoot)).toBe( path.join(projectRoot, '.harness', 'manifest.js') ); - expect(getHarnessMetroCachePath(projectRoot)).toBe( - path.join(projectRoot, '.harness', 'metro-cache') - ); - }); - - it('returns false when the metro cache directory is missing', () => { - const projectRoot = createTempProjectRoot(); - - expect(isMetroCacheReusable(projectRoot)).toBe(false); - }); - - it('returns false when the metro cache directory is empty', () => { - const projectRoot = createTempProjectRoot(); - fs.mkdirSync(getHarnessMetroCachePath(projectRoot), { recursive: true }); - - expect(isMetroCacheReusable(projectRoot)).toBe(false); - }); - - it('returns true when the metro cache directory contains entries', () => { - const projectRoot = createTempProjectRoot(); - const metroCachePath = getHarnessMetroCachePath(projectRoot); - - fs.mkdirSync(metroCachePath, { recursive: true }); - fs.writeFileSync(path.join(metroCachePath, 'entry'), 'cached'); - - expect(isMetroCacheReusable(projectRoot)).toBe(true); }); }); diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 64cd2cb0..ed1aebaf 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -1,9 +1,14 @@ import os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; +import { getConfig } from '@react-native-harness/config'; type MinimalMetroConfig = { projectRoot: string; maxWorkers?: number; + cacheVersion?: string; + cacheStores?: unknown; + fileMapCacheDirectory?: string; + hasteMapCacheDirectory?: string; serializer?: { isThirdPartyModule?: (module: { path: string }) => boolean; }; @@ -14,10 +19,27 @@ type MinimalMetroConfig = { }; }; +const { ensureDomainDirectories } = vi.hoisted(() => ({ + ensureDomainDirectories: vi.fn(), +})); + vi.mock('@react-native-harness/config', async (importOriginal) => ({ ...(await importOriginal()), getConfig: vi.fn(async () => ({ config: {}, + projectRoot: '/tmp/app', + })), +})); + +vi.mock('@react-native-harness/cache', () => ({ + createHarnessCache: vi.fn((options: { projectRoot: string }) => ({ + paths: { + root: `${options.projectRoot}/.harness/cache`, + metroTransform: `${options.projectRoot}/.harness/cache/metro`, + metroFileMap: `${options.projectRoot}/.harness/cache/metro-file-map`, + }, + isWarm: vi.fn(() => false), + ensureDomainDirectories, })), })); @@ -128,4 +150,101 @@ describe('withRnHarness', () => { ); expect(config.maxWorkers).toBeLessThan(64); }); + + it('enables the persistent Metro cache by default', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + const { getHarnessCacheStores } = await import('../metro-cache.js'); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + serializer: {}, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(getHarnessCacheStores).toHaveBeenCalledWith( + '/tmp/app/.harness/cache/metro', + ); + expect(config.cacheStores).toBe( + vi.mocked(getHarnessCacheStores).mock.results.at(-1)?.value, + ); + expect(config.fileMapCacheDirectory).toBe( + '/tmp/app/.harness/cache/metro-file-map', + ); + expect(ensureDomainDirectories).toHaveBeenCalledWith('metro'); + expect(config.cacheVersion).toMatch(/^react-native-harness:\d+\.\d+\.\d+/); + }); + + it('respects a user-configured file map cache directory', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + fileMapCacheDirectory: '/custom/file-map', + serializer: {}, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.fileMapCacheDirectory).toBe('/custom/file-map'); + }); + + it('respects the legacy haste map cache directory', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + hasteMapCacheDirectory: '/custom/haste-map', + serializer: {}, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.fileMapCacheDirectory).toBeUndefined(); + expect(config.hasteMapCacheDirectory).toBe('/custom/haste-map'); + }); + + it('leaves Metro cache defaults untouched when caching is disabled', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + vi.mocked(getConfig).mockResolvedValueOnce({ + config: { cache: { metro: false } }, + projectRoot: '/tmp/app', + } as Awaited>); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + serializer: {}, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.cacheStores).toBeUndefined(); + expect(config.fileMapCacheDirectory).toBeUndefined(); + }); + + it('folds the user cache version salt into the Metro cacheVersion', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + vi.mocked(getConfig).mockResolvedValueOnce({ + config: { cache: { version: 'my-salt' } }, + projectRoot: '/tmp/app', + } as Awaited>); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + serializer: {}, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.cacheVersion).toMatch( + /^react-native-harness:\d+\.\d+\.\d+.*:my-salt$/, + ); + }); }); diff --git a/packages/bundler-metro/src/index.ts b/packages/bundler-metro/src/index.ts index 6ad72eeb..de75865b 100644 --- a/packages/bundler-metro/src/index.ts +++ b/packages/bundler-metro/src/index.ts @@ -7,7 +7,6 @@ export type { PrewarmState, } from './types.js'; export type { Reporter, ReportableEvent } from './reporter.js'; -export { isMetroCacheReusable } from './paths.js'; export { StartupStallError, type StartupStallCode, diff --git a/packages/bundler-metro/src/metro-cache.ts b/packages/bundler-metro/src/metro-cache.ts index e625004d..5af44f37 100644 --- a/packages/bundler-metro/src/metro-cache.ts +++ b/packages/bundler-metro/src/metro-cache.ts @@ -1,21 +1,16 @@ -import fs from 'node:fs'; import type { CacheStoresConfigT } from 'metro-config'; import { CacheStore, MetroCache } from 'metro-cache'; import type { MixedOutput, TransformResult } from 'metro'; -import { getHarnessMetroCachePath } from './paths.js'; -export const getHarnessCacheStores = (): (( - metroCache: MetroCache -) => CacheStoresConfigT) => { - return ({ FileStore }) => { - const cacheRoot = getHarnessMetroCachePath(); - - fs.mkdirSync(cacheRoot, { recursive: true }); - - return [ - new FileStore({ root: cacheRoot }) as CacheStore< - TransformResult - >, - ]; - }; +// Directory creation is handled by the cache package's +// ensureDomainDirectories; FileStore also self-heals a missing root by +// re-creating directories on ENOENT during writes. +export const getHarnessCacheStores = ( + cacheRoot: string +): ((metroCache: MetroCache) => CacheStoresConfigT) => { + return ({ FileStore }) => [ + new FileStore({ root: cacheRoot }) as CacheStore< + TransformResult + >, + ]; }; diff --git a/packages/bundler-metro/src/paths.ts b/packages/bundler-metro/src/paths.ts index 7317da78..6ebbb4b5 100644 --- a/packages/bundler-metro/src/paths.ts +++ b/packages/bundler-metro/src/paths.ts @@ -1,32 +1,10 @@ -import fs from 'node:fs'; import path from 'node:path'; const HARNESS_DIRNAME = '.harness'; const MANIFEST_FILENAME = 'manifest.js'; -const METRO_CACHE_DIRNAME = 'metro-cache'; export const getHarnessRootPath = (projectRoot = process.cwd()): string => path.resolve(projectRoot, HARNESS_DIRNAME); export const getHarnessManifestPath = (projectRoot = process.cwd()): string => path.join(getHarnessRootPath(projectRoot), MANIFEST_FILENAME); - -export const getHarnessMetroCachePath = ( - projectRoot = process.cwd() -): string => path.join(getHarnessRootPath(projectRoot), METRO_CACHE_DIRNAME); - -export const isMetroCacheReusable = (projectRoot = process.cwd()): boolean => { - const metroCachePath = getHarnessMetroCachePath(projectRoot); - - try { - const stat = fs.statSync(metroCachePath); - - if (!stat.isDirectory()) { - return false; - } - - return fs.readdirSync(metroCachePath).length > 0; - } catch { - return false; - } -}; diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 439814e2..b023e9be 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -1,9 +1,12 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import type { MetroConfig } from 'metro-config'; +import { createHarnessCache } from '@react-native-harness/cache'; import { getConfig, + resolveMetroCacheEnabled, resolveSkipAlreadyIncludedModules, + type Config, } from '@react-native-harness/config'; import { logger } from '@react-native-harness/tools'; import { getHarnessBabelTransformerPath } from './babel-transformer.js'; @@ -20,6 +23,15 @@ const metroWorkersLogger = logger.child('metro-workers'); const INTERNAL_CALLSITES_REGEX = /(^|[\\/])(node_modules[/\\]@react-native-harness)([\\/]|$)/; +const getHarnessCacheVersion = (harnessConfig: Config): string => { + const { version } = require('../package.json') as { version: string }; + const userSalt = harnessConfig.cache?.version; + + return userSalt + ? `react-native-harness:${version}:${userSalt}` + : `react-native-harness:${version}`; +}; + export const withRnHarness = ( config: T | Promise, isInvokedByHarness = false, @@ -30,7 +42,10 @@ export const withRnHarness = ( } const metroConfig = await config; - const { config: harnessConfig } = await getConfig(process.cwd()); + const { config: harnessConfig, projectRoot } = await getConfig( + process.cwd() + ); + const harnessCache = createHarnessCache({ projectRoot }); const harnessResolver = getHarnessResolver(metroConfig, harnessConfig); const harnessManifest = getHarnessManifest(harnessConfig); @@ -54,7 +69,7 @@ export const withRnHarness = ( const patchedConfig: MetroConfig = { ...metroConfig, - cacheVersion: 'react-native-harness', + cacheVersion: getHarnessCacheVersion(harnessConfig), maxWorkers: cappedMaxWorkers, server: { ...metroConfig.server, @@ -84,6 +99,7 @@ export const withRnHarness = ( ...metroConfig.resolver, blockList: undefined, resolveRequest: harnessResolver, + useWatchman: process.env.RN_HARNESS_DEBUG_USE_WATCHMAN !== '0', }, transformer: { ...metroConfig.transformer, @@ -111,9 +127,25 @@ export const withRnHarness = ( }, }; - if (harnessConfig.unstable__enableMetroCache) { + if (resolveMetroCacheEnabled(harnessConfig)) { + harnessCache.ensureDomainDirectories('metro'); + (patchedConfig.cacheStores as NotReadOnly) = - getHarnessCacheStores(); + getHarnessCacheStores(harnessCache.paths.metroTransform); + + // Without an explicit directory the file-map cache lands in $TMPDIR; + // pin it under the harness cache root unless the user already picked a + // location. + if ( + metroConfig.fileMapCacheDirectory === undefined && + metroConfig.hasteMapCacheDirectory === undefined + ) { + ( + patchedConfig as NotReadOnly< + Pick + > + ).fileMapCacheDirectory = harnessCache.paths.metroFileMap; + } } if (resolveSkipAlreadyIncludedModules(harnessConfig)) { diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 403a9dfe..5f3f6e8a 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -6,6 +6,9 @@ { "path": "../runtime" }, + { + "path": "../cache" + }, { "path": "../config" }, diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index 8f6398da..2eef96e4 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -15,6 +15,9 @@ { "path": "../runtime/tsconfig.lib.json" }, + { + "path": "../cache/tsconfig.lib.json" + }, { "path": "../config/tsconfig.lib.json" }, diff --git a/packages/cache/README.md b/packages/cache/README.md new file mode 100644 index 00000000..6c61e153 --- /dev/null +++ b/packages/cache/README.md @@ -0,0 +1,26 @@ +![harness-banner](https://react-native-harness.dev/harness-banner.jpg) + +### Cache Management for React Native Harness + +[![mit licence][license-badge]][license] +[![npm downloads][npm-downloads-badge]][npm-downloads] +[![Chat][chat-badge]][chat] +[![PRs Welcome][prs-welcome-badge]][prs-welcome] + +Owns the layout of `.harness/cache/` and exposes a narrow API for checking and preparing cache directories, so no other package needs to construct cache paths or hold cache-key logic. + +## Made with ❤️ at Callstack + +`@react-native-harness/cache` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/@react-native-harness/cache?style=for-the-badge +[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE +[npm-downloads-badge]: https://img.shields.io/npm/dm/@react-native-harness/cache?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/@react-native-harness/cache +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ../../CONTRIBUTING.md +[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge +[chat]: https://discord.gg/xgGt7KAjxv diff --git a/packages/cache/eslint.config.mjs b/packages/cache/eslint.config.mjs new file mode 100644 index 00000000..97a5fc1f --- /dev/null +++ b/packages/cache/eslint.config.mjs @@ -0,0 +1,20 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vite', 'vitest'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/cache/package.json b/packages/cache/package.json new file mode 100644 index 00000000..63040334 --- /dev/null +++ b/packages/cache/package.json @@ -0,0 +1,22 @@ +{ + "name": "@react-native-harness/cache", + "version": "1.3.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "@react-native-harness/tools": "workspace:*", + "tslib": "^2.3.0" + }, + "license": "MIT" +} diff --git a/packages/cache/src/__tests__/boundary.test.ts b/packages/cache/src/__tests__/boundary.test.ts new file mode 100644 index 00000000..e8779b54 --- /dev/null +++ b/packages/cache/src/__tests__/boundary.test.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// All `.harness/cache` layout knowledge must live in this package; other +// packages consume `HarnessCache.paths` instead of constructing cache paths +// themselves. This test scans the other packages' sources for violations. +// +// Allowlisted until phase 2 migrates them onto this package: +// - packages/tools/src/harness-artifacts.ts: getHarnessCacheRootPath, used by +// platform-ios for the XCTest agent cache. +const ALLOWLIST = new Set(['tools/src/harness-artifacts.ts']); + +const findRepoRoot = (start: string): string => { + let dir = start; + + while (!fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error('Could not find the repository root'); + } + dir = parent; + } + + return dir; +}; + +const collectSourceFiles = (dir: string, files: string[] = []): string[] => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if ( + entry.name === 'node_modules' || + entry.name === 'dist' || + entry.name === '__tests__' + ) { + continue; + } + collectSourceFiles(path.join(dir, entry.name), files); + } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { + files.push(path.join(dir, entry.name)); + } + } + + return files; +}; + +const constructsHarnessCachePath = (content: string): boolean => { + if (content.includes('.harness/cache')) { + return true; + } + + const hasHarnessDirLiteral = + content.includes("'.harness'") || content.includes('".harness"'); + // A quoted 'cache' followed by `,`, `)`, or `]` is a path segment being + // joined, as opposed to e.g. a property name in a type. + const hasCacheSegmentLiteral = /['"]cache['"]\s*[,)\]]/.test(content); + + return hasHarnessDirLiteral && hasCacheSegmentLiteral; +}; + +describe('cache path boundary', () => { + it('no package outside @react-native-harness/cache constructs .harness cache paths', () => { + const repoRoot = findRepoRoot(path.dirname(fileURLToPath(import.meta.url))); + const packagesRoot = path.join(repoRoot, 'packages'); + + const violations: string[] = []; + + for (const entry of fs.readdirSync(packagesRoot, { + withFileTypes: true, + })) { + if (!entry.isDirectory() || entry.name === 'cache') { + continue; + } + + const srcDir = path.join(packagesRoot, entry.name, 'src'); + if (!fs.existsSync(srcDir)) { + continue; + } + + for (const file of collectSourceFiles(srcDir)) { + const relativePath = path.relative(packagesRoot, file); + if (ALLOWLIST.has(relativePath)) { + continue; + } + + if (constructsHarnessCachePath(fs.readFileSync(file, 'utf8'))) { + violations.push(relativePath); + } + } + } + + expect(violations).toEqual([]); + }); +}); diff --git a/packages/cache/src/__tests__/index.test.ts b/packages/cache/src/__tests__/index.test.ts new file mode 100644 index 00000000..166091a7 --- /dev/null +++ b/packages/cache/src/__tests__/index.test.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHarnessCache } from '../index.js'; + +describe('createHarnessCache', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('exposes paths rooted at /.harness/cache', () => { + const cache = createHarnessCache({ projectRoot }); + + expect(cache.paths.root).toBe(path.join(projectRoot, '.harness', 'cache')); + }); + + it('reports metro as cold before directories exist', () => { + const cache = createHarnessCache({ projectRoot }); + + expect(cache.isWarm('metro')).toBe(false); + }); + + it('creates domain directories and reports warm once populated', () => { + const cache = createHarnessCache({ projectRoot }); + + cache.ensureDomainDirectories('metro'); + + expect(fs.existsSync(cache.paths.metroTransform)).toBe(true); + expect(fs.existsSync(cache.paths.metroFileMap)).toBe(true); + expect(cache.isWarm('metro')).toBe(false); + + fs.writeFileSync(path.join(cache.paths.metroTransform, 'entry'), 'data'); + expect(cache.isWarm('metro')).toBe(true); + }); + + it('swallows and logs errors instead of throwing when directories cannot be created', () => { + // Point the project root at a path that cannot be created (its parent is a file). + const blockedRoot = path.join(projectRoot, 'blocked-file', 'nested'); + fs.writeFileSync(path.join(projectRoot, 'blocked-file'), 'not a directory'); + + const cache = createHarnessCache({ projectRoot: blockedRoot }); + + expect(() => cache.ensureDomainDirectories('metro')).not.toThrow(); + expect(cache.isWarm('metro')).toBe(false); + }); +}); diff --git a/packages/cache/src/__tests__/paths.test.ts b/packages/cache/src/__tests__/paths.test.ts new file mode 100644 index 00000000..bd60602b --- /dev/null +++ b/packages/cache/src/__tests__/paths.test.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createHarnessCachePaths } from '../paths.js'; + +describe('createHarnessCachePaths', () => { + it('nests cache paths under /.harness/cache', () => { + const projectRoot = '/tmp/some-project'; + const paths = createHarnessCachePaths(projectRoot); + + expect(paths.root).toBe(path.join(projectRoot, '.harness', 'cache')); + expect(paths.metroTransform).toBe( + path.join(projectRoot, '.harness', 'cache', 'metro') + ); + expect(paths.metroFileMap).toBe( + path.join(projectRoot, '.harness', 'cache', 'metro-file-map') + ); + }); +}); diff --git a/packages/cache/src/__tests__/warmth.test.ts b/packages/cache/src/__tests__/warmth.test.ts new file mode 100644 index 00000000..80a5a4d9 --- /dev/null +++ b/packages/cache/src/__tests__/warmth.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createHarnessCachePaths } from '../paths.js'; +import { isDomainWarm } from '../warmth.js'; + +describe('isDomainWarm', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-cache-')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('is false when the domain directory does not exist', () => { + const paths = createHarnessCachePaths(projectRoot); + + expect(isDomainWarm('metro', paths)).toBe(false); + }); + + it('is false when the domain directory exists but is empty', () => { + const paths = createHarnessCachePaths(projectRoot); + fs.mkdirSync(paths.metroTransform, { recursive: true }); + + expect(isDomainWarm('metro', paths)).toBe(false); + }); + + it('is true when the domain directory exists and has entries', () => { + const paths = createHarnessCachePaths(projectRoot); + fs.mkdirSync(paths.metroTransform, { recursive: true }); + fs.writeFileSync(path.join(paths.metroTransform, 'entry'), 'data'); + + expect(isDomainWarm('metro', paths)).toBe(true); + }); + + it('is false when the path exists but is a file, not a directory', () => { + const paths = createHarnessCachePaths(projectRoot); + fs.mkdirSync(path.dirname(paths.metroTransform), { recursive: true }); + fs.writeFileSync(paths.metroTransform, 'not a directory'); + + expect(isDomainWarm('metro', paths)).toBe(false); + }); +}); diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts new file mode 100644 index 00000000..e511d23f --- /dev/null +++ b/packages/cache/src/index.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import { logger } from '@react-native-harness/tools'; +import { + createHarnessCachePaths, + getDomainDirectories, + type CacheDomainId, + type HarnessCachePaths, +} from './paths.js'; +import { isDomainWarm } from './warmth.js'; + +export type { CacheDomainId, HarnessCachePaths }; + +export interface HarnessCache { + readonly paths: HarnessCachePaths; + isWarm(domain: CacheDomainId): boolean; + ensureDomainDirectories(domain: CacheDomainId): void; +} + +const cacheLogger = logger.child('cache'); + +export const createHarnessCache = (options: { + projectRoot: string; +}): HarnessCache => { + const paths = createHarnessCachePaths(options.projectRoot); + + return { + paths, + isWarm: (domain) => isDomainWarm(domain, paths), + ensureDomainDirectories: (domain) => { + for (const directory of getDomainDirectories(domain, paths)) { + try { + fs.mkdirSync(directory, { recursive: true }); + } catch (error) { + cacheLogger.warn( + `Failed to create cache directory "${directory}". Continuing with a cold cache.`, + error + ); + } + } + }, + }; +}; diff --git a/packages/cache/src/paths.ts b/packages/cache/src/paths.ts new file mode 100644 index 00000000..e0efb102 --- /dev/null +++ b/packages/cache/src/paths.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; + +const HARNESS_DIRNAME = '.harness'; +const CACHE_DIRNAME = 'cache'; +const METRO_TRANSFORM_DIRNAME = 'metro'; +const METRO_FILE_MAP_DIRNAME = 'metro-file-map'; + +export type CacheDomainId = 'metro'; + +export interface HarnessCachePaths { + readonly root: string; + readonly metroTransform: string; + readonly metroFileMap: string; +} + +export const createHarnessCachePaths = ( + projectRoot: string +): HarnessCachePaths => { + const root = path.join(projectRoot, HARNESS_DIRNAME, CACHE_DIRNAME); + + return { + root, + metroTransform: path.join(root, METRO_TRANSFORM_DIRNAME), + metroFileMap: path.join(root, METRO_FILE_MAP_DIRNAME), + }; +}; + +/** + * Directories that make up a cache domain. `metro` covers both the transform + * cache and the file-map (haste) cache so `ensureDomainDirectories` prepares + * both ahead of a run. + */ +export const getDomainDirectories = ( + domain: CacheDomainId, + paths: HarnessCachePaths +): readonly string[] => { + switch (domain) { + case 'metro': + return [paths.metroTransform, paths.metroFileMap]; + default: + return []; + } +}; diff --git a/packages/cache/src/warmth.ts b/packages/cache/src/warmth.ts new file mode 100644 index 00000000..c246e1a1 --- /dev/null +++ b/packages/cache/src/warmth.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import { logger } from '@react-native-harness/tools'; +import type { CacheDomainId, HarnessCachePaths } from './paths.js'; + +const cacheLogger = logger.child('cache'); + +/** + * A domain is warm when its cache directory exists and is non-empty. Cache + * I/O must never fail a run, so any error while inspecting the filesystem is + * logged and treated as cold. + */ +export const isDomainWarm = ( + domain: CacheDomainId, + paths: HarnessCachePaths +): boolean => { + const directory = getWarmthDirectory(domain, paths); + + if (!directory) { + return false; + } + + try { + const stat = fs.statSync(directory); + + if (!stat.isDirectory()) { + return false; + } + + return fs.readdirSync(directory).length > 0; + } catch (error) { + if (isMissingPathError(error)) { + return false; + } + + cacheLogger.warn( + `Failed to check cache warmth for "${domain}" at "${directory}". Treating as cold.`, + error + ); + return false; + } +}; + +const getWarmthDirectory = ( + domain: CacheDomainId, + paths: HarnessCachePaths +): string | undefined => { + switch (domain) { + case 'metro': + return paths.metroTransform; + default: + return undefined; + } +}; + +const isMissingPathError = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT'; diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json new file mode 100644 index 00000000..a97d4269 --- /dev/null +++ b/packages/cache/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../tools" + }, + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/cache/tsconfig.lib.json b/packages/cache/tsconfig.lib.json new file mode 100644 index 00000000..44a1da23 --- /dev/null +++ b/packages/cache/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../tools/tsconfig.lib.json" + } + ] +} diff --git a/packages/cache/vite.config.ts b/packages/cache/vite.config.ts new file mode 100644 index 00000000..9c4dedfc --- /dev/null +++ b/packages/cache/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/cache', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/config/src/__tests__/resolveMetroCacheEnabled.test.ts b/packages/config/src/__tests__/resolveMetroCacheEnabled.test.ts new file mode 100644 index 00000000..140ff816 --- /dev/null +++ b/packages/config/src/__tests__/resolveMetroCacheEnabled.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConfigSchema, resolveMetroCacheEnabled } from '../types.js'; + +const baseConfig = { + entryPoint: './index.js', + appRegistryComponentName: 'App', + runners: [ + { + name: 'test-runner', + config: {}, + runner: 'test-runner', + platformId: 'test-platform', + }, + ], +}; + +describe('ConfigSchema cache group', () => { + it('accepts a config without the cache group', () => { + const result = ConfigSchema.parse(baseConfig); + + expect(result.cache).toBeUndefined(); + expect(result.unstable__enableMetroCache).toBeUndefined(); + }); + + it('accepts cache.metro and cache.version', () => { + const result = ConfigSchema.parse({ + ...baseConfig, + cache: { metro: false, version: 'v2' }, + }); + + expect(result.cache).toEqual({ metro: false, version: 'v2' }); + }); + + it('passes cache.version through untouched when metro is unset', () => { + const result = ConfigSchema.parse({ + ...baseConfig, + cache: { version: 'salt-1' }, + }); + + expect(result.cache?.metro).toBeUndefined(); + expect(result.cache?.version).toBe('salt-1'); + }); + + it('rejects a non-string cache.version', () => { + expect(() => + ConfigSchema.parse({ ...baseConfig, cache: { version: 2 } }) + ).toThrow(); + }); +}); + +describe('resolveMetroCacheEnabled', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to true when neither cache.metro nor the alias is set', () => { + expect( + resolveMetroCacheEnabled({ + cache: undefined, + unstable__enableMetroCache: undefined, + }) + ).toBe(true); + + expect( + resolveMetroCacheEnabled({ + cache: {}, + unstable__enableMetroCache: undefined, + }) + ).toBe(true); + }); + + it('uses the deprecated alias when only it is set', () => { + expect( + resolveMetroCacheEnabled({ + cache: undefined, + unstable__enableMetroCache: false, + }) + ).toBe(false); + + expect( + resolveMetroCacheEnabled({ + cache: undefined, + unstable__enableMetroCache: true, + }) + ).toBe(true); + }); + + it('logs a deprecation warning when the alias is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + resolveMetroCacheEnabled({ + cache: undefined, + unstable__enableMetroCache: true, + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain('unstable__enableMetroCache'); + }); + + it('does not log a deprecation warning when only cache.metro is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + resolveMetroCacheEnabled({ + cache: { metro: false }, + unstable__enableMetroCache: undefined, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('lets explicit cache.metro win over the alias in either direction', () => { + expect( + resolveMetroCacheEnabled({ + cache: { metro: false }, + unstable__enableMetroCache: true, + }) + ).toBe(false); + + expect( + resolveMetroCacheEnabled({ + cache: { metro: true }, + unstable__enableMetroCache: false, + }) + ).toBe(true); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index edaffdd0..522bb8fc 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -4,6 +4,7 @@ export { ConfigSchema, DEFAULT_METRO_PORT, isDiagnosticsEnabled, + resolveMetroCacheEnabled, resolveSkipAlreadyIncludedModules, } from './types.js'; export { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index f0bb43d3..3a4eb1d0 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -114,7 +114,36 @@ export const ConfigSchema = z .describe( 'Deprecated. Use `skipAlreadyIncludedModules` instead.' ), - unstable__enableMetroCache: z.boolean().optional().default(false), + cache: z + .object({ + metro: z + .boolean() + .optional() + .describe( + 'Enable persistent Metro transform and file-map caching under the .harness cache directory. ' + + 'Defaults to true; set to false to always start with a cold Metro cache. ' + + 'Left undefined (rather than defaulted here) so `resolveMetroCacheEnabled` can tell ' + + '"not set" apart from "explicitly set" when reconciling with the deprecated ' + + '`unstable__enableMetroCache` alias.' + ), + version: z + .string() + .optional() + .describe( + 'User-controlled salt folded into the Metro cacheVersion. ' + + 'Bump it to force-invalidate previously cached transforms.' + ), + }) + .optional(), + // Deprecated alias for `cache.metro` -- see `resolveMetroCacheEnabled`. + // Left without a `.default()` for the same reason as + // `unstable__skipAlreadyIncludedModules`: a default value would be + // indistinguishable from the user never having set it, which would break + // alias-vs-explicit-flag precedence. + unstable__enableMetroCache: z + .boolean() + .optional() + .describe('Deprecated. Use `cache.metro` instead.'), permissions: z .boolean() .optional() @@ -263,3 +292,38 @@ export const resolveSkipAlreadyIncludedModules = ( return true; }; + +/** + * Resolves whether persistent Metro caching is enabled, reconciling + * `cache.metro` with the deprecated `unstable__enableMetroCache` alias: + * + * - `cache.metro`, when explicitly set, always wins over the alias. + * - Otherwise, the alias's value is used (with a deprecation warning). + * - Otherwise, defaults to `true`. + * + * The default is applied here rather than in the zod schema so that "unset" + * and "explicitly set to the default value" remain distinguishable -- that + * distinction is what lets the deprecated alias keep working as an escape + * hatch (e.g. `unstable__enableMetroCache: false`) even though caching now + * defaults to on. + */ +export const resolveMetroCacheEnabled = ( + config: Pick +): boolean => { + if (config.unstable__enableMetroCache !== undefined) { + configLogger.warn( + '`unstable__enableMetroCache` is deprecated and will be removed in a future release. ' + + 'Use `cache.metro` instead.' + ); + } + + if (config.cache?.metro !== undefined) { + return config.cache.metro; + } + + if (config.unstable__enableMetroCache !== undefined) { + return config.unstable__enableMetroCache; + } + + return true; +}; diff --git a/packages/github-action/README.md b/packages/github-action/README.md index 30619425..b10b9f14 100644 --- a/packages/github-action/README.md +++ b/packages/github-action/README.md @@ -33,7 +33,7 @@ The action reads your `rn-harness.config.mjs` file, resolves the `runner` you pa - `preRunHook` (optional): Inline shell script run in `bash` immediately before Harness starts - `afterRunHook` (optional): Inline shell script run in `bash` immediately after Harness finishes and before artifact upload - Crash artifacts persisted to `.harness/crash-reports/` are uploaded automatically when present -- Metro cache persisted to `.harness/metro-cache/` is restored and saved automatically when present +- Metro cache persisted to `.harness/cache/metro/` and `.harness/cache/metro-file-map/` is restored and saved automatically when present ## Behavior diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index f9098959..09cf8899 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -64,13 +64,15 @@ runs: echo "Please provide the path to the built app (.apk for Android, .app for iOS)" exit 1 fi - - name: Metro bundler cache (.harness/metro-cache) + - name: Metro bundler cache (.harness/cache/metro) uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: ${{ steps.load-config.outputs.projectRoot }}/.harness/metro-cache - key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} + path: | + ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/metro + ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/metro-file-map + key: ${{ runner.os }}-metro-cache-v2-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} restore-keys: | - ${{ runner.os }}-metro-cache- + ${{ runner.os }}-metro-cache-v2- - name: Restore Harness cache id: cache-harness-restore if: fromJson(steps.load-config.outputs.config).platformId == 'ios' diff --git a/packages/jest/package.json b/packages/jest/package.json index e9958fbd..305ae20b 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -36,6 +36,7 @@ "@jest/test-result": "^30.2.0", "@react-native-harness/bridge": "workspace:*", "@react-native-harness/bundler-metro": "workspace:*", + "@react-native-harness/cache": "workspace:*", "@react-native-harness/config": "workspace:*", "@react-native-harness/plugins": "workspace:*", "@react-native-harness/platforms": "workspace:*", diff --git a/packages/jest/src/__tests__/harness-cache.test.ts b/packages/jest/src/__tests__/harness-cache.test.ts new file mode 100644 index 00000000..3772a1a1 --- /dev/null +++ b/packages/jest/src/__tests__/harness-cache.test.ts @@ -0,0 +1,91 @@ +import type { HarnessPlatform } from '@react-native-harness/platforms'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { maybeLogMetroCacheReused } from '../harness-session.js'; + +const { createHarnessCache, isWarm, logMetroCacheReused } = vi.hoisted(() => { + const isWarm = vi.fn(); + + return { + isWarm, + createHarnessCache: vi.fn((options: { projectRoot: string }) => ({ + paths: { + root: `${options.projectRoot}/.harness/cache`, + metroTransform: `${options.projectRoot}/.harness/cache/metro`, + metroFileMap: `${options.projectRoot}/.harness/cache/metro-file-map`, + }, + isWarm, + ensureDomainDirectories: vi.fn(), + })), + logMetroCacheReused: vi.fn(), + }; +}); + +vi.mock('@react-native-harness/cache', () => ({ + createHarnessCache, +})); + +vi.mock('../logs.js', async (importOriginal) => ({ + ...(await importOriginal()), + logMetroCacheReused, +})); + +const platform = { name: 'test-runner' } as HarnessPlatform; + +describe('maybeLogMetroCacheReused', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('logs the reuse message when caching is enabled and the cache is warm', () => { + isWarm.mockReturnValue(true); + + maybeLogMetroCacheReused({ + config: { cache: { metro: true } }, + projectRoot: '/tmp/app', + platform, + }); + + expect(createHarnessCache).toHaveBeenCalledWith({ + projectRoot: '/tmp/app', + }); + expect(isWarm).toHaveBeenCalledWith('metro'); + expect(logMetroCacheReused).toHaveBeenCalledWith(platform); + }); + + it('logs the reuse message with a warm cache when caching is on by default', () => { + isWarm.mockReturnValue(true); + + maybeLogMetroCacheReused({ + config: {}, + projectRoot: '/tmp/app', + platform, + }); + + expect(logMetroCacheReused).toHaveBeenCalledWith(platform); + }); + + it('does not log when caching is enabled but the cache is cold', () => { + isWarm.mockReturnValue(false); + + maybeLogMetroCacheReused({ + config: { cache: { metro: true } }, + projectRoot: '/tmp/app', + platform, + }); + + expect(logMetroCacheReused).not.toHaveBeenCalled(); + }); + + it('does not log when caching is disabled, regardless of cache state', () => { + isWarm.mockReturnValue(true); + + maybeLogMetroCacheReused({ + config: { cache: { metro: false } }, + projectRoot: '/tmp/app', + platform, + }); + + expect(createHarnessCache).not.toHaveBeenCalled(); + expect(logMetroCacheReused).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index f580aeee..083a4e4d 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -19,9 +19,9 @@ import { type HarnessPlatformInitOptions, type HarnessPlatformRunner, } from '@react-native-harness/platforms'; +import { createHarnessCache } from '@react-native-harness/cache'; import { getMetroInstance, - isMetroCacheReusable, waitForMetroBackedAppReady, type MetroInstance, type MetroWebSocketEndpoint, @@ -46,6 +46,7 @@ import { import { getConfig, isDiagnosticsEnabled, + resolveMetroCacheEnabled, type Config as HarnessConfig, ConfigSchema, } from '@react-native-harness/config'; @@ -399,13 +400,37 @@ const applyEnvVars = ( } }; +/** + * Logs the "Reusing Metro cache" message when persistent Metro caching is + * enabled and the cache is warm. `projectRoot` must be the config-resolved + * project root so the warmth check looks at the same directory Metro caches + * into. + */ +export const maybeLogMetroCacheReused = (options: { + config: Pick; + projectRoot: string; + platform: HarnessPlatform; +}): void => { + if (!resolveMetroCacheEnabled(options.config)) { + return; + } + + if ( + createHarnessCache({ projectRoot: options.projectRoot }).isWarm('metro') + ) { + logMetroCacheReused(options.platform); + } +}; + const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{ harnessConfig: HarnessConfig; platform: HarnessPlatform; projectRoot: string; + configProjectRoot: string; }> => { const projectRoot = globalConfig.rootDir; - const { config: rawConfig } = await getConfig(projectRoot); + const { config: rawConfig, projectRoot: configProjectRoot } = + await getConfig(projectRoot); const cliArgs = getAdditionalCliArgs(); let harnessConfig = cliArgs.metroPort != null @@ -434,7 +459,7 @@ const loadConfig = async (globalConfig: JestConfig.GlobalConfig): Promise<{ const platform = harnessConfig.runners.find((r) => r.name === selectedRunnerName); if (!platform) throw new RunnerNotFoundError(selectedRunnerName); - return { harnessConfig, platform, projectRoot }; + return { harnessConfig, platform, projectRoot, configProjectRoot }; }; // --------------------------------------------------------------------------- @@ -448,7 +473,7 @@ export const createHarnessSession = async ( preRunMessage.remove(process.stderr); const setupStartedAt = Date.now(); - const { harnessConfig, platform, projectRoot } = await loadConfig(globalConfig); + const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig); applyEnvVars(harnessConfig, globalConfig); const diagnostics = createDiagnostics({ @@ -529,9 +554,11 @@ export const createHarnessSession = async ( if (didFallback) logMetroPortFallback(initialMetroPort, runtimeConfig.metroPort); - if (runtimeConfig.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) { - logMetroCacheReused(platform); - } + maybeLogMetroCacheReused({ + config: runtimeConfig, + projectRoot: configProjectRoot, + platform, + }); const pluginAbortController = new AbortController(); const pluginManager = createHarnessPluginManager({ diff --git a/packages/jest/tsconfig.json b/packages/jest/tsconfig.json index d186efc1..c04ed7e2 100644 --- a/packages/jest/tsconfig.json +++ b/packages/jest/tsconfig.json @@ -6,6 +6,9 @@ { "path": "../tools" }, + { + "path": "../cache" + }, { "path": "../platforms" }, diff --git a/packages/jest/tsconfig.lib.json b/packages/jest/tsconfig.lib.json index 6e003874..f468351c 100644 --- a/packages/jest/tsconfig.lib.json +++ b/packages/jest/tsconfig.lib.json @@ -15,6 +15,9 @@ { "path": "../tools/tsconfig.lib.json" }, + { + "path": "../cache/tsconfig.lib.json" + }, { "path": "../platforms/tsconfig.lib.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cacf6e37..239dda3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: '@react-native-harness/babel-preset': specifier: workspace:* version: link:../babel-preset + '@react-native-harness/cache': + specifier: workspace:* + version: link:../cache '@react-native-harness/config': specifier: workspace:* version: link:../config @@ -301,6 +304,15 @@ importers: specifier: '*' version: 0.83.3 + packages/cache: + dependencies: + '@react-native-harness/tools': + specifier: workspace:* + version: link:../tools + tslib: + specifier: ^2.3.0 + version: 2.8.1 + packages/cli: dependencies: '@react-native-harness/bridge': @@ -381,6 +393,9 @@ importers: '@react-native-harness/bundler-metro': specifier: workspace:* version: link:../bundler-metro + '@react-native-harness/cache': + specifier: workspace:* + version: link:../cache '@react-native-harness/config': specifier: workspace:* version: link:../config diff --git a/tsconfig.json b/tsconfig.json index 17c62baa..acde8899 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,9 @@ { "path": "./packages/tools" }, + { + "path": "./packages/cache" + }, { "path": "./packages/react-native-harness" }, diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 5203e7c9..72449980 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -111,7 +111,10 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | | `coverage.native.ios.pods` | Experimental list of CocoaPods target names to instrument for iOS native coverage. | | `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | -| `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | +| `cache` | Cache configuration object. | +| `cache.metro` | Enable persistent Metro transform and file-map caching under `.harness/cache` (default: `true`). | +| `cache.version` | User-controlled salt folded into the Metro cache version. Bump it to force-invalidate previously cached transforms. | +| `unstable__enableMetroCache` | Deprecated. Use `cache.metro` instead. | ## Test Runners diff --git a/website/src/docs/guides/ci-cd.md b/website/src/docs/guides/ci-cd.md index afa4c9c7..ae17381f 100644 --- a/website/src/docs/guides/ci-cd.md +++ b/website/src/docs/guides/ci-cd.md @@ -247,9 +247,9 @@ If your workflow does not define AVD details, the action can still run the tests ## Metro cache -React Native Harness can persist Metro's transformation cache under `.harness/metro-cache` in your project root. Enabling it in config (`unstable__enableMetroCache: true`) speeds up repeated Metro runs. +React Native Harness persists Metro's transform cache under `.harness/cache/metro` and its file-map (haste) cache under `.harness/cache/metro-file-map`, both in your project root. This is on by default (`cache.metro: true`); set `cache.metro: false` in config to disable it. The deprecated `unstable__enableMetroCache` option is still honored as an alias for one release. -When you use the `callstackincubator/react-native-harness` GitHub Action, Metro cache restoration and saving is handled automatically for the resolved `projectRoot`. You do not need to add a separate `actions/cache` step for `.harness/metro-cache`. +When you use the `callstackincubator/react-native-harness` GitHub Action, Metro cache restoration and saving is handled automatically for the resolved `projectRoot`. You do not need to add a separate `actions/cache` step for `.harness/cache/metro` or `.harness/cache/metro-file-map`. ## Web in CI