feat(resolver): add check for sync resolver (#16064)

This commit is contained in:
Simen Bekkhus
2026-04-29 10:38:57 +02:00
committed by GitHub
parent 4720586255
commit b0b424bf37
8 changed files with 117 additions and 4 deletions
+1
View File
@@ -3,6 +3,7 @@
### Features
- `[@jest/transform]` Add `canTransformSync(filename)` on `ScriptTransformer` so callers can pick the sync vs async transform path ([#16062](https://github.com/jestjs/jest/pull/16062))
- `[jest-resolve]` Add `canResolveSync()` on `Resolver` so callers can detect when a user-configured resolver only exports an `async` hook ([#16064](https://github.com/jestjs/jest/pull/16064))
- `[jest-runtime]` Use synchronous `evaluate()` for ES modules without top-level `await` on Node versions that support it (v24.9+), and prefer the synchronous transform path when a sync transformer is configured ([#16062](https://github.com/jestjs/jest/pull/16062))
## 30.3.0
@@ -41,7 +41,7 @@ exports[`moduleNameMapper wrong array configuration 1`] = `
12 | module.exports = () => 'test';
13 |
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:1117:17)
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:1144:17)
at Object.require (index.js:10:1)
at Object.require (__tests__/index.js:10:20)"
`;
@@ -71,7 +71,7 @@ exports[`moduleNameMapper wrong configuration 1`] = `
12 | module.exports = () => 'test';
13 |
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:1117:17)
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:1144:17)
at Object.require (index.js:10:1)
at Object.require (__tests__/index.js:10:20)"
`;
@@ -26,7 +26,7 @@ exports[`shows a proper error from deep requires 1`] = `
12 | test('dummy', () => {
13 | expect(1).toBe(1);
at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/index.js:863:11)
at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/index.js:890:11)
at Object.<anonymous> (node_modules/discord.js/src/index.js:21:12)
at Object.require (__tests__/test.js:10:1)"
`;
@@ -37,7 +37,7 @@ exports[`show error message with matching files 1`] = `
| ^
9 |
at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/index.js:863:11)
at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/index.js:890:11)
at Object.require (index.js:8:18)
at Object.require (__tests__/test.js:8:11)"
`;
@@ -0,0 +1,17 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
module.exports = {
async: function userResolverAsync(path, options) {
return Promise.resolve('module');
},
sync: function userResolverSync(path, options) {
return 'module';
},
};
@@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
module.exports = {
sync: function userResolver(path, options) {
return 'module';
},
};
@@ -857,3 +857,55 @@ describe('Resolver.getGlobalPaths()', () => {
expect(globalPaths).toStrictEqual([]);
});
});
describe('canResolveSync', () => {
// The other suites auto-mock `userResolver`/`userResolverAsync`, which
// changes their shape; load the real modules here to test detection.
beforeAll(() => {
jest.unmock('../__mocks__/userResolver');
jest.unmock('../__mocks__/userResolverAsync');
});
afterAll(() => {
jest
.mock('../__mocks__/userResolver')
.mock('../__mocks__/userResolverAsync');
});
it('returns true when no user resolver is configured', () => {
const moduleMap = ModuleMap.create('/');
const resolver = new Resolver(moduleMap, {} as ResolverConfig);
expect(resolver.canResolveSync()).toBe(true);
});
it('returns true when the user resolver exports a plain function', () => {
const moduleMap = ModuleMap.create('/');
const resolver = new Resolver(moduleMap, {
resolver: require.resolve('../__mocks__/userResolver'),
} as ResolverConfig);
expect(resolver.canResolveSync()).toBe(true);
});
it('returns true when the user resolver exports a `sync` hook', () => {
const moduleMap = ModuleMap.create('/');
const resolver = new Resolver(moduleMap, {
resolver: require.resolve('../__mocks__/userResolverSync'),
} as ResolverConfig);
expect(resolver.canResolveSync()).toBe(true);
});
it('returns true when the user resolver exports both `sync` and `async`', () => {
const moduleMap = ModuleMap.create('/');
const resolver = new Resolver(moduleMap, {
resolver: require.resolve('../__mocks__/userResolverDual'),
} as ResolverConfig);
expect(resolver.canResolveSync()).toBe(true);
});
it('returns false when the user resolver only exports `async`', () => {
const moduleMap = ModuleMap.create('/');
const resolver = new Resolver(moduleMap, {
resolver: require.resolve('../__mocks__/userResolverAsync'),
} as ResolverConfig);
expect(resolver.canResolveSync()).toBe(false);
});
});
+29
View File
@@ -59,6 +59,7 @@ export default class Resolver {
private readonly _moduleNameCache: Map<string, string>;
private readonly _modulePathCache: Map<string, Array<string>>;
private readonly _supportsNativePlatform: boolean;
private _canResolveSync: boolean | undefined;
constructor(moduleMap: IModuleMap, options: ResolverConfig) {
this._options = {
@@ -341,6 +342,34 @@ export default class Resolver {
return null;
}
// True when synchronous module resolution is available with the configured
// resolver. False when a user has configured a resolver that only exports
// an `async` hook — in that case `findNodeModule` silently falls back to
// the default resolver, which will not honor the user's mappings, so
// callers that need correctness should defer to the async API.
//
// The contract is "plain function or `.sync` hook = sync"; an `async
// function` in either slot is a user-side contract violation we don't
// defend against here.
//
// Memoized: the resolver config is immutable per-`Resolver`. A throwing
// `loadResolver` (malformed user resolver) is treated as `false` so
// callers route to the async path, which will surface the actual error.
canResolveSync(): boolean {
if (this._canResolveSync != null) return this._canResolveSync;
let result: boolean;
try {
const resolverModule = loadResolver(this._options.resolver);
result =
typeof resolverModule === 'function' ||
typeof resolverModule.sync === 'function';
} catch {
result = false;
}
this._canResolveSync = result;
return result;
}
resolveModule(
from: string,
moduleName: string,