Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,3 @@ npm-debug.*
*.mobileprovision
*.orig.*
web-build/
cache/
5 changes: 5 additions & 0 deletions .nx/version-plans/persist-metro-cache.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 18 additions & 3 deletions actions/shared/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions packages/bundler-metro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
55 changes: 3 additions & 52 deletions packages/bundler-metro/src/__tests__/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,16 @@
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')
);
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);
});
});
119 changes: 119 additions & 0 deletions packages/bundler-metro/src/__tests__/withRnHarness.test.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand All @@ -14,10 +19,27 @@ type MinimalMetroConfig = {
};
};

const { ensureDomainDirectories } = vi.hoisted(() => ({
ensureDomainDirectories: vi.fn(),
}));

vi.mock('@react-native-harness/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@react-native-harness/config')>()),
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,
})),
}));

Expand Down Expand Up @@ -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<ReturnType<typeof getConfig>>);

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<ReturnType<typeof getConfig>>);

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$/,
);
});
});
1 change: 0 additions & 1 deletion packages/bundler-metro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 11 additions & 16 deletions packages/bundler-metro/src/metro-cache.ts
Original file line number Diff line number Diff line change
@@ -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<MixedOutput>
>,
];
};
// 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<MixedOutput>
>,
];
};
22 changes: 0 additions & 22 deletions packages/bundler-metro/src/paths.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
Loading
Loading