Skip to content

Commit 3f5810c

Browse files
authored
feat: speed-up watch mode (#14)
This pull request significantly speeds up watch mode by reusing the same Harness instance across test runs, so it no longer needs to be reinitialized each time.
1 parent 44e9b67 commit 3f5810c

12 files changed

Lines changed: 172 additions & 170 deletions

File tree

apps/playground/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
projects: [
33
{
4-
runner: '@react-native-harness/jest',
4+
preset: '@react-native-harness/jest',
55
testMatch: [
66
'<rootDir>/src/__tests__/**/*.(test|spec|harness).(js|jsx|ts|tsx)',
77
],

packages/jest/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@
1212
"types": "./dist/index.d.ts",
1313
"import": "./dist/index.js",
1414
"default": "./dist/index.js"
15+
},
16+
"./jest-preset": {
17+
"development": "./src/jest-preset.ts",
18+
"types": "./dist/jest-preset.d.ts",
19+
"import": "./dist/jest-preset.js",
20+
"default": "./dist/jest-preset.js"
21+
},
22+
"./global-setup": {
23+
"development": "./src/global-setup.ts",
24+
"types": "./dist/global-setup.d.ts",
25+
"import": "./dist/global-setup.js",
26+
"default": "./dist/global-setup.js"
27+
},
28+
"./global-teardown": {
29+
"development": "./src/global-teardown.ts",
30+
"types": "./dist/global-teardown.d.ts",
31+
"import": "./dist/global-teardown.js",
32+
"default": "./dist/global-teardown.js"
1533
}
1634
},
1735
"dependencies": {
@@ -22,6 +40,7 @@
2240
"chalk": "^4.1.2",
2341
"jest-message-util": "^30.2.0",
2442
"jest-runner": "^30.2.0",
43+
"jest-util": "^30.2.0",
2544
"p-limit": "^7.1.1",
2645
"tslib": "^2.3.0",
2746
"yargs": "^17.7.2"

packages/jest/src/env.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Harness } from '@react-native-harness/cli/external';
2+
import type { Config as HarnessConfig } from '@react-native-harness/config';
3+
4+
declare global {
5+
var HARNESS: Harness;
6+
var HARNESS_CONFIG: HarnessConfig;
7+
}
8+
9+
export {};

packages/jest/src/global-setup.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
getConfig,
3+
TestRunnerConfig,
4+
type Config as HarnessConfig,
5+
type TestRunnerConfig as HarnessTestRunnerConfig,
6+
} from '@react-native-harness/config';
7+
import type { Config as JestConfig } from 'jest-runner';
8+
import {
9+
getHarness as getHarnessExternal,
10+
type Harness,
11+
} from '@react-native-harness/cli/external';
12+
import { preRunMessage } from 'jest-util';
13+
import { getAdditionalCliArgs, HarnessCliArgs } from './cli-args.js';
14+
import { logTestEnvironmentReady, logTestRunHeader } from './logs.js';
15+
16+
const getHarnessConfig = async (
17+
globalConfig: JestConfig.GlobalConfig
18+
): Promise<HarnessConfig> => {
19+
const projectRoot = globalConfig.rootDir;
20+
const { config: harnessConfig } = await getConfig(projectRoot);
21+
return harnessConfig;
22+
};
23+
24+
const getHarnessRunner = (
25+
config: HarnessConfig,
26+
cliArgs: HarnessCliArgs
27+
): HarnessTestRunnerConfig => {
28+
const selectedRunnerName = cliArgs.harnessRunner ?? config.defaultRunner;
29+
const runner = config.runners.find(
30+
(runner) => runner.name === selectedRunnerName
31+
);
32+
33+
if (!runner) {
34+
throw new Error(`Runner "${selectedRunnerName}" not found`);
35+
}
36+
37+
return runner;
38+
};
39+
40+
const getHarness = async (runner: TestRunnerConfig): Promise<Harness> => {
41+
return await getHarnessExternal(runner);
42+
};
43+
44+
export default async function (globalConfig: JestConfig.GlobalConfig) {
45+
preRunMessage.remove(process.stderr);
46+
const harnessConfig =
47+
global.HARNESS_CONFIG ?? (await getHarnessConfig(globalConfig));
48+
const isWatchMode = globalConfig.watch || globalConfig.watchAll;
49+
50+
if (global.HARNESS) {
51+
// Do not setup again if HARNESS is already initialized
52+
// This is useful when running tests in watch mode
53+
54+
if (harnessConfig.resetEnvironmentBetweenTestFiles) {
55+
// In watch mode, we want to restart the environment before each test run
56+
await new Promise((resolve) => {
57+
global.HARNESS.bridge.once('ready', resolve);
58+
global.HARNESS.environment.restart();
59+
});
60+
}
61+
62+
return;
63+
}
64+
65+
if (isWatchMode) {
66+
// In watch mode, we want to dispose the Harness when the process exits.
67+
process.on('exit', async () => {
68+
await global.HARNESS.bridge.dispose();
69+
await global.HARNESS.environment.dispose();
70+
});
71+
}
72+
73+
const cliArgs = getAdditionalCliArgs();
74+
const selectedRunner = getHarnessRunner(harnessConfig, cliArgs);
75+
76+
if (globalConfig.collectCoverage) {
77+
// This is going to be used by @react-native-harness/babel-preset
78+
// to enable instrumentation of test files.
79+
process.env.RN_HARNESS_COLLECT_COVERAGE = 'true';
80+
}
81+
82+
logTestRunHeader(selectedRunner);
83+
const harness = await getHarness(selectedRunner);
84+
logTestEnvironmentReady(selectedRunner);
85+
86+
global.HARNESS_CONFIG = harnessConfig;
87+
global.HARNESS = harness;
88+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Config as JestConfig } from 'jest-runner';
2+
3+
export default async function (globalConfig: JestConfig.GlobalConfig) {
4+
const isWatchMode = globalConfig.watch || globalConfig.watchAll;
5+
6+
if (isWatchMode) {
7+
// In watch mode, we don't want to dispose the Harness.
8+
9+
return;
10+
}
11+
12+
await global.HARNESS.bridge.dispose();
13+
await global.HARNESS.environment.dispose();
14+
}

packages/jest/src/index.ts

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,8 @@ import type {
1010
} from 'jest-runner';
1111
import pLimit from 'p-limit';
1212
import { runHarnessTestFile } from './run.js';
13-
import { getHarness } from '@react-native-harness/cli/external';
14-
import {
15-
getConfig,
16-
Config as HarnessConfig,
17-
TestRunnerConfig as HarnessTestRunnerConfig,
18-
} from '@react-native-harness/config';
19-
import { getAdditionalCliArgs, HarnessCliArgs } from './cli-args.js';
13+
import { Config as HarnessConfig } from '@react-native-harness/config';
2014
import type { Harness } from '@react-native-harness/cli/external';
21-
import { logTestEnvironmentReady, logTestRunHeader } from './logs.js';
2215

2316
class CancelRun extends Error {
2417
constructor(message?: string) {
@@ -27,21 +20,6 @@ class CancelRun extends Error {
2720
}
2821
}
2922

30-
const getHarnessRunner = (
31-
config: HarnessConfig,
32-
cliArgs: HarnessCliArgs
33-
): HarnessTestRunnerConfig => {
34-
const selectedRunnerName = cliArgs.harnessRunner ?? config.defaultRunner;
35-
const runner = config.runners.find(
36-
(runner) => runner.name === selectedRunnerName
37-
);
38-
39-
if (!runner) {
40-
throw new Error(`Runner "${selectedRunnerName}" not found`);
41-
}
42-
43-
return runner;
44-
};
4523
export default class JestHarness implements CallbackTestRunnerInterface {
4624
readonly isSerial = true;
4725

@@ -63,37 +41,18 @@ export default class JestHarness implements CallbackTestRunnerInterface {
6341
throw new Error('Parallel test running is not supported');
6442
}
6543

66-
const projectRoot = this.#globalConfig.rootDir;
67-
const { config: harnessConfig } = await getConfig(projectRoot);
68-
const cliArgs = getAdditionalCliArgs();
69-
const selectedRunner = getHarnessRunner(harnessConfig, cliArgs);
70-
71-
logTestRunHeader(selectedRunner);
72-
73-
if (this.#globalConfig.collectCoverage) {
74-
// This is going to be used by @react-native-harness/babel-preset
75-
// to enable instrumentation of test files.
76-
process.env.RN_HARNESS_COLLECT_COVERAGE = 'true';
77-
}
78-
79-
const harness = await getHarness(selectedRunner);
80-
81-
logTestEnvironmentReady(selectedRunner);
82-
83-
try {
84-
return await this._createInBandTestRun(
85-
tests,
86-
watcher,
87-
harness,
88-
harnessConfig,
89-
onStart,
90-
onResult,
91-
onFailure
92-
);
93-
} finally {
94-
harness.bridge.dispose();
95-
await harness.environment.dispose();
96-
}
44+
const harness = global.HARNESS;
45+
const harnessConfig = global.HARNESS_CONFIG;
46+
47+
return await this._createInBandTestRun(
48+
tests,
49+
watcher,
50+
harness,
51+
harnessConfig,
52+
onStart,
53+
onResult,
54+
onFailure
55+
);
9756
}
9857

9958
async _createInBandTestRun(

packages/jest/src/jest-preset.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
runner: '@react-native-harness/jest',
3+
globalSetup: '@react-native-harness/jest/global-setup',
4+
globalTeardown: '@react-native-harness/jest/global-teardown',
5+
};

packages/runtime/assets/moduleSystem.flow.js

Lines changed: 9 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ global[`${__METRO_GLOBAL_PREFIX__}__d`] = (define: DefineFn);
8585
global.__c = clear;
8686
global.__registerSegment = registerSegment;
8787
global.__resetAllModules = resetAllModules;
88+
global.__clearModule = clearModule;
8889

8990
var modules = clear();
9091

@@ -95,6 +96,14 @@ function resetAllModules() {
9596
});
9697
}
9798

99+
function clearModule(moduleId: ModuleID) {
100+
if (!modules.has(moduleId)) {
101+
return;
102+
}
103+
104+
modules.delete(moduleId);
105+
}
106+
98107
// Don't use a Symbol here, it would pull in an extra polyfill with all sorts of
99108
// additional stuff (e.g. Array.from).
100109
const EMPTY = {};
@@ -223,15 +232,6 @@ function metroRequire(
223232

224233
const module = modules.get(moduleIdReallyIsNumber);
225234

226-
// Optionally return a lazy proxy that triggers evaluation on first access.
227-
if (
228-
module &&
229-
!module.isInitialized &&
230-
shouldUseLazyModuleProxy(moduleIdReallyIsNumber, module)
231-
) {
232-
return createModuleEvaluationProxy(moduleIdReallyIsNumber);
233-
}
234-
235235
return module && module.isInitialized
236236
? module.publicModule.exports
237237
: guardedLoadModule(moduleIdReallyIsNumber, module);
@@ -398,92 +398,6 @@ function packModuleId(value: {
398398
}
399399
metroRequire.packModuleId = packModuleId;
400400

401-
// ------------------------------
402-
// Lazy evaluation via Proxy (opt-in)
403-
// ------------------------------
404-
const LAZY_REQUIRE_FLAG_KEY =
405-
__METRO_GLOBAL_PREFIX__ + '__LAZY_REQUIRE_BY_ACCESS';
406-
const LAZY_REQUIRE_ALLOW_DEV_KEY =
407-
__METRO_GLOBAL_PREFIX__ + '__LAZY_REQUIRE_ALLOW_DEV';
408-
const LAZY_REQUIRE_WHITELIST_KEY =
409-
__METRO_GLOBAL_PREFIX__ + '__LAZY_REQUIRE_WHITELIST';
410-
411-
function isLazyRequireEnabled(): boolean {
412-
// const enabled = global[LAZY_REQUIRE_FLAG_KEY];
413-
// if (!enabled) {
414-
// return false;
415-
// }
416-
// // In DEV, require explicit opt-in due to HMR/export inspection.
417-
// return __DEV__ ? !!global[LAZY_REQUIRE_ALLOW_DEV_KEY] : true;
418-
return true;
419-
}
420-
421-
function isModuleWhitelisted(moduleId: ModuleID): boolean {
422-
const list = global[LAZY_REQUIRE_WHITELIST_KEY];
423-
if (list == null) {
424-
// No whitelist provided -> treat as disabled for safety unless explicitly enabled
425-
// at call site (we still require shouldUseLazyModuleProxy to decide).
426-
return false;
427-
}
428-
return Array.isArray(list) && list.indexOf(moduleId) !== -1;
429-
}
430-
431-
function shouldUseLazyModuleProxy(
432-
moduleId: ModuleID,
433-
module: ?ModuleDefinition
434-
): boolean {
435-
if (!isLazyRequireEnabled()) {
436-
return false;
437-
}
438-
if (!module || module.isInitialized) {
439-
return false;
440-
}
441-
// Only enable for explicitly whitelisted modules to avoid changing semantics
442-
// of side-effect-only requires.
443-
return isModuleWhitelisted(moduleId);
444-
}
445-
446-
function createModuleEvaluationProxy(moduleId: ModuleID): Exports {
447-
let evaluated = false;
448-
const ensure = (): Exports => {
449-
if (!evaluated) {
450-
// Evaluate the module using the same guarded path used by metroRequire.
451-
// Pass the current (possibly undefined) ModuleDefinition for better error messages.
452-
const existing = modules.get(moduleId);
453-
// guardedLoadModule will throw appropriately if unknown or failing.
454-
// It will also set module.isInitialized and publicModule.exports.
455-
// $FlowFixMe[incompatible-call]
456-
guardedLoadModule(moduleId, existing);
457-
console.log('evaluated module', moduleId);
458-
evaluated = true;
459-
}
460-
// $FlowFixMe[incompatible-type]
461-
const initializedModule: ModuleDefinition | void = modules.get(moduleId);
462-
// $FlowFixMe[incompatible-use]
463-
return initializedModule
464-
? initializedModule.publicModule.exports
465-
: undefined;
466-
};
467-
468-
// Use an object target since CommonJS exports are objects in Metro.
469-
const target = {};
470-
return new Proxy(target, {
471-
get: (_t, prop) => Reflect.get(ensure(), prop),
472-
set: (_t, prop, value) => Reflect.set(ensure(), prop, value),
473-
has: (_t, prop) => Reflect.has(ensure(), prop),
474-
ownKeys: () => Reflect.ownKeys(ensure()),
475-
getOwnPropertyDescriptor: (_t, prop) =>
476-
Reflect.getOwnPropertyDescriptor(ensure(), prop),
477-
getPrototypeOf: () => Reflect.getPrototypeOf(ensure()),
478-
setPrototypeOf: (_t, proto) => Reflect.setPrototypeOf(ensure(), proto),
479-
isExtensible: () => Reflect.isExtensible(ensure()),
480-
preventExtensions: () => Reflect.preventExtensions(ensure()),
481-
defineProperty: (_t, prop, desc) =>
482-
Reflect.defineProperty(ensure(), prop, desc),
483-
deleteProperty: (_t, prop) => Reflect.deleteProperty(ensure(), prop),
484-
});
485-
}
486-
487401
const moduleDefinersBySegmentID: Array<?ModuleDefiner> = [];
488402
const definingSegmentByModuleID: Map<ModuleID, number> = new Map();
489403

0 commit comments

Comments
 (0)