Skip to content

Commit b9e3a55

Browse files
authored
feat: support exports in package.json (#11961)
1 parent a5f58b5 commit b9e3a55

File tree

18 files changed

+158
-54
lines changed

18 files changed

+158
-54
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- `[@jest/expect-utils]` New module exporting utils for `expect` ([#12323](https://github.com/facebook/jest/pull/12323))
1414
- `[jest-jasmine2, jest-runtime]` [**BREAKING**] Use `Symbol` to pass `jest.setTimeout` value instead of `jasmine` specific logic ([#12124](https://github.com/facebook/jest/pull/12124))
1515
- `[jest-jasmine2, jest-types]` [**BREAKING**] Move all `jasmine` specific types from `@jest/types` to its own package ([#12125](https://github.com/facebook/jest/pull/12125))
16+
- `[jest-resolver]` [**BREAKING**] Add support for `package.json` `exports` ([11961](https://github.com/facebook/jest/pull/11961))
1617
- `[jest-snapshot]` [**BREAKING**] Migrate to ESM ([#12342](https://github.com/facebook/jest/pull/12342))
1718
- `[jest-worker]` [**BREAKING**] Allow only absolute `workerPath` ([#12343](https://github.com/facebook/jest/pull/12343))
1819

examples/angular/jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
moduleFileExtensions: ['ts', 'html', 'js', 'json'],
33
setupFilesAfterEnv: ['<rootDir>/setupJest.js'],
4-
testEnvironment: 'jsdom',
4+
testEnvironment: '<rootDir>/test-env.js',
55
transform: {
66
'\\.[tj]s$': ['babel-jest', {configFile: require.resolve('./.babelrc')}],
77
},

examples/angular/test-env.js

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use strict';
2+
3+
const {
4+
TestEnvironment: JSDOMTestEnvironment,
5+
} = require('jest-environment-jsdom');
6+
7+
module.exports = class AngularEnv extends JSDOMTestEnvironment {
8+
exportConditions() {
9+
// we need to include `node` as `rxjs` defines `node`, `es2015`, `default`, not `browser` or `require`
10+
// https://github.com/ReactiveX/rxjs/pull/6821
11+
return super.exportConditions().concat('node');
12+
}
13+
};

packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/main.js

Whitespace-only changes.

packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedDefault.js

Whitespace-only changes.

packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedRequire.js

Whitespace-only changes.

packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/other.js

Whitespace-only changes.

packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json

+16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/require.js

Whitespace-only changes.

packages/jest-resolve/src/__mocks__/conditions/node_modules/import/package.json

-6
This file was deleted.

packages/jest-resolve/src/__mocks__/conditions/node_modules/require/package.json

-6
This file was deleted.

packages/jest-resolve/src/__tests__/resolve.test.ts

+59-5
Original file line numberDiff line numberDiff line change
@@ -160,24 +160,78 @@ describe('findNodeModule', () => {
160160
});
161161

162162
test('resolves with import', () => {
163-
const result = Resolver.findNodeModule('import', {
163+
const result = Resolver.findNodeModule('exports', {
164164
basedir: conditionsRoot,
165165
conditions: ['import'],
166166
});
167167

168168
expect(result).toEqual(
169-
path.resolve(conditionsRoot, './node_modules/import/file.js'),
169+
path.resolve(conditionsRoot, './node_modules/exports/import.js'),
170170
);
171171
});
172172

173173
test('resolves with require', () => {
174-
const result = Resolver.findNodeModule('require', {
174+
const result = Resolver.findNodeModule('exports', {
175175
basedir: conditionsRoot,
176176
conditions: ['require'],
177177
});
178178

179179
expect(result).toEqual(
180-
path.resolve(conditionsRoot, './node_modules/require/file.js'),
180+
path.resolve(conditionsRoot, './node_modules/exports/require.js'),
181+
);
182+
});
183+
184+
test('gets default when nothing is passed', () => {
185+
const result = Resolver.findNodeModule('exports', {
186+
basedir: conditionsRoot,
187+
conditions: [],
188+
});
189+
190+
expect(result).toEqual(
191+
path.resolve(conditionsRoot, './node_modules/exports/default.js'),
192+
);
193+
});
194+
195+
test('respects order in package.json, not conditions', () => {
196+
const resultImport = Resolver.findNodeModule('exports', {
197+
basedir: conditionsRoot,
198+
conditions: ['import', 'require'],
199+
});
200+
const resultRequire = Resolver.findNodeModule('exports', {
201+
basedir: conditionsRoot,
202+
conditions: ['require', 'import'],
203+
});
204+
205+
expect(resultImport).toEqual(resultRequire);
206+
});
207+
208+
test('supports nested paths', () => {
209+
const result = Resolver.findNodeModule('exports/nested', {
210+
basedir: conditionsRoot,
211+
conditions: [],
212+
});
213+
214+
expect(result).toEqual(
215+
path.resolve(conditionsRoot, './node_modules/exports/nestedDefault.js'),
216+
);
217+
});
218+
219+
test('supports nested conditions', () => {
220+
const resultRequire = Resolver.findNodeModule('exports/deeplyNested', {
221+
basedir: conditionsRoot,
222+
conditions: ['require'],
223+
});
224+
const resultDefault = Resolver.findNodeModule('exports/deeplyNested', {
225+
basedir: conditionsRoot,
226+
conditions: [],
227+
});
228+
229+
expect(resultRequire).toEqual(
230+
path.resolve(conditionsRoot, './node_modules/exports/nestedRequire.js'),
231+
);
232+
233+
expect(resultDefault).toEqual(
234+
path.resolve(conditionsRoot, './node_modules/exports/nestedDefault.js'),
181235
);
182236
});
183237
});
@@ -251,8 +305,8 @@ describe('resolveModule', () => {
251305
const src = require.resolve('../');
252306
const resolved = resolver.resolveModule(src, 'mockJsDependency', {
253307
paths: [
254-
path.resolve(__dirname, '../../src/__tests__'),
255308
path.resolve(__dirname, '../../src/__mocks__'),
309+
path.resolve(__dirname, '../../src/__tests__'),
256310
],
257311
});
258312
expect(resolved).toBe(require.resolve('../__mocks__/mockJsDependency.js'));

packages/jest-resolve/src/defaultResolver.ts

+52-30
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import {resolve} from 'path';
8+
import {isAbsolute} from 'path';
99
import pnpResolver from 'jest-pnp-resolver';
1010
import {sync as resolveSync} from 'resolve';
1111
import {
1212
Options as ResolveExportsOptions,
1313
resolve as resolveExports,
1414
} from 'resolve.exports';
15+
import slash = require('slash');
1516
import type {Config} from '@jest/types';
1617
import {
1718
PkgJson,
@@ -59,10 +60,8 @@ export default function defaultResolver(
5960
...options,
6061
isDirectory,
6162
isFile,
62-
packageFilter: createPackageFilter(
63-
options.conditions,
64-
options.packageFilter,
65-
),
63+
packageFilter: createPackageFilter(path, options.packageFilter),
64+
pathFilter: createPathFilter(path, options.conditions, options.pathFilter),
6665
preserveSymlinks: false,
6766
readPackageSync,
6867
realpathSync,
@@ -82,45 +81,68 @@ function readPackageSync(_: unknown, file: Config.Path): PkgJson {
8281
}
8382

8483
function createPackageFilter(
85-
conditions?: Array<string>,
84+
originalPath: Config.Path,
8685
userFilter?: ResolverOptions['packageFilter'],
8786
): ResolverOptions['packageFilter'] {
88-
function attemptExportsFallback(pkg: PkgJson) {
89-
const options: ResolveExportsOptions = conditions
90-
? {conditions, unsafe: true}
91-
: // no conditions were passed - let's assume this is Jest internal and it should be `require`
92-
{browser: false, require: true};
93-
94-
try {
95-
return resolveExports(pkg, '.', options);
96-
} catch {
97-
return undefined;
98-
}
87+
if (shouldIgnoreRequestForExports(originalPath)) {
88+
return userFilter;
9989
}
10090

101-
return function packageFilter(pkg, packageDir) {
91+
return function packageFilter(pkg, ...rest) {
10292
let filteredPkg = pkg;
10393

10494
if (userFilter) {
105-
filteredPkg = userFilter(filteredPkg, packageDir);
106-
}
107-
108-
if (filteredPkg.main != null) {
109-
return filteredPkg;
95+
filteredPkg = userFilter(filteredPkg, ...rest);
11096
}
11197

112-
const indexInRoot = resolve(packageDir, './index.js');
113-
114-
// if the module contains an `index.js` file in root, `resolve` will request
115-
// that if there is no `main`. Since we don't wanna break that, add this
116-
// check
117-
if (isFile(indexInRoot)) {
98+
if (filteredPkg.exports == null) {
11899
return filteredPkg;
119100
}
120101

121102
return {
122103
...filteredPkg,
123-
main: attemptExportsFallback(filteredPkg),
104+
// remove `main` so `resolve` doesn't look at it and confuse the `.`
105+
// loading in `pathFilter`
106+
main: undefined,
124107
};
125108
};
126109
}
110+
111+
function createPathFilter(
112+
originalPath: Config.Path,
113+
conditions?: Array<string>,
114+
userFilter?: ResolverOptions['pathFilter'],
115+
): ResolverOptions['pathFilter'] {
116+
if (shouldIgnoreRequestForExports(originalPath)) {
117+
return userFilter;
118+
}
119+
120+
const options: ResolveExportsOptions = conditions
121+
? {conditions, unsafe: true}
122+
: // no conditions were passed - let's assume this is Jest internal and it should be `require`
123+
{browser: false, require: true};
124+
125+
return function pathFilter(pkg, path, relativePath, ...rest) {
126+
let pathToUse = relativePath;
127+
128+
if (userFilter) {
129+
pathToUse = userFilter(pkg, path, relativePath, ...rest);
130+
}
131+
132+
if (pkg.exports == null) {
133+
return pathToUse;
134+
}
135+
136+
// this `index` thing can backfire, but `resolve` adds it: https://github.com/browserify/resolve/blob/f1b51848ecb7f56f77bfb823511d032489a13eab/lib/sync.js#L192
137+
const isRootRequire =
138+
pathToUse === 'index' && !originalPath.endsWith('/index');
139+
140+
const newPath = isRootRequire ? '.' : slash(pathToUse);
141+
142+
return resolveExports(pkg, newPath, options) || pathToUse;
143+
};
144+
}
145+
146+
// if it's a relative import or an absolute path, exports are ignored
147+
const shouldIgnoreRequestForExports = (path: Config.Path) =>
148+
path.startsWith('.') || isAbsolute(path);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "NODE_PATH_dir",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "test_root",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
}
6+
}

packages/jest-runtime/src/index.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ export default class Runtime {
754754
this._virtualMocks,
755755
from,
756756
moduleName,
757-
isInternal ? undefined : {conditions: this.cjsConditions},
757+
{conditions: this.cjsConditions},
758758
);
759759
let modulePath: string | undefined;
760760

@@ -782,11 +782,9 @@ export default class Runtime {
782782
}
783783

784784
if (!modulePath) {
785-
modulePath = this._resolveModule(
786-
from,
787-
moduleName,
788-
isInternal ? undefined : {conditions: this.cjsConditions},
789-
);
785+
modulePath = this._resolveModule(from, moduleName, {
786+
conditions: this.cjsConditions,
787+
});
790788
}
791789

792790
if (this.unstable_shouldLoadAsEsm(modulePath)) {

0 commit comments

Comments
 (0)