Skip to content

Commit cc1b1b8

Browse files
committed
module: ensure successful import returns the same result
1 parent 7def7df commit cc1b1b8

File tree

5 files changed

+150
-12
lines changed

5 files changed

+150
-12
lines changed

lib/internal/modules/esm/loader.js

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@
44
require('internal/modules/cjs/loader');
55

66
const {
7+
ArrayPrototypeJoin,
8+
ArrayPrototypeMap,
9+
ArrayPrototypeSort,
710
FunctionPrototypeCall,
11+
JSONStringify,
12+
ObjectKeys,
813
ObjectSetPrototypeOf,
914
PromisePrototypeThen,
15+
SafeMap,
16+
PromiseResolve,
1017
SafeWeakMap,
1118
} = primordials;
1219

@@ -75,6 +82,11 @@ class DefaultModuleLoader {
7582
*/
7683
#defaultConditions = getDefaultConditions();
7784

85+
/**
86+
* Import cache
87+
*/
88+
#importCache = new SafeMap();
89+
7890
/**
7991
* Map of already-loaded CJS modules to use
8092
*/
@@ -145,8 +157,7 @@ class DefaultModuleLoader {
145157
* @param {string | undefined} parentURL The URL of the module importing this
146158
* one, unless this is the Node.js entry
147159
* point.
148-
* @param {Record<string, string>} importAssertions Validations for the
149-
* module import.
160+
* @param {Record<string, string>} importAssertions The import attributes.
150161
* @returns {ModuleJob} The (possibly pending) module job
151162
*/
152163
getModuleJob(specifier, parentURL, importAssertions) {
@@ -227,6 +238,34 @@ class DefaultModuleLoader {
227238
return job;
228239
}
229240

241+
#serializeCache(specifier, parentURL, importAssertions) {
242+
let cache = this.#importCache.get(parentURL);
243+
let specifierCache;
244+
if (cache == null) {
245+
this.#importCache.set(parentURL, cache = new SafeMap());
246+
} else {
247+
specifierCache = cache.get(specifier);
248+
}
249+
250+
if (specifierCache == null) {
251+
cache.set(specifier, specifierCache = { __proto__: null });
252+
}
253+
254+
const serializedAttributes = ArrayPrototypeJoin(
255+
ArrayPrototypeMap(
256+
ArrayPrototypeSort(ObjectKeys(importAssertions)),
257+
(key) => JSONStringify(key) + JSONStringify(importAssertions[key])),
258+
',');
259+
260+
return { specifierCache, serializedAttributes };
261+
}
262+
263+
cacheStatic(specifier, parentURL, importAssertions, result) {
264+
const { specifierCache, serializedAttributes } = this.#serializeCache(specifier, parentURL, importAssertions);
265+
266+
specifierCache[serializedAttributes] = result;
267+
}
268+
230269
/**
231270
* This method is usually called indirectly as part of the loading processes.
232271
* Internally, it is used directly to add loaders. Use directly with caution.
@@ -237,13 +276,43 @@ class DefaultModuleLoader {
237276
* @param {string} parentURL Path of the parent importing the module.
238277
* @param {Record<string, string>} importAssertions Validations for the
239278
* module import.
240-
* @returns {Promise<ExportedHooks | KeyedExports[]>}
279+
* @returns {Promise<ExportedHooks>}
241280
* A collection of module export(s) or a list of collections of module
242281
* export(s).
243282
*/
244283
async import(specifier, parentURL, importAssertions) {
284+
const { specifierCache, serializedAttributes } = this.#serializeCache(specifier, parentURL, importAssertions);
285+
const removeCache = () => {
286+
delete specifierCache[serializedAttributes];
287+
};
288+
if (specifierCache[serializedAttributes] != null) {
289+
if (PromiseResolve(specifierCache[serializedAttributes]) !== specifierCache[serializedAttributes]) {
290+
const { module } = await specifierCache[serializedAttributes].run();
291+
return module.getNamespace();
292+
}
293+
const fallback = () => {
294+
if (specifierCache[serializedAttributes] != null) {
295+
return PromisePrototypeThen(specifierCache[serializedAttributes], undefined, fallback);
296+
}
297+
const result = this.#import(specifier, parentURL, importAssertions);
298+
specifierCache[serializedAttributes] = result;
299+
PromisePrototypeThen(result, undefined, removeCache);
300+
return result;
301+
};
302+
return PromisePrototypeThen(specifierCache[serializedAttributes], undefined, fallback);
303+
}
304+
const result = this.#import(specifier, parentURL, importAssertions);
305+
specifierCache[serializedAttributes] = result;
306+
PromisePrototypeThen(result, undefined, removeCache);
307+
return result;
308+
}
309+
310+
async #import(specifier, parentURL, importAssertions) {
245311
const moduleJob = this.getModuleJob(specifier, parentURL, importAssertions);
246312
const { module } = await moduleJob.run();
313+
314+
const { specifierCache, serializedAttributes } = this.#serializeCache(specifier, parentURL, importAssertions);
315+
specifierCache[serializedAttributes] = moduleJob;
247316
return module.getNamespace();
248317
}
249318

lib/internal/modules/esm/module_job.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,12 @@ class ModuleJob {
7272
// so that circular dependencies can't cause a deadlock by two of
7373
// these `link` callbacks depending on each other.
7474
const dependencyJobs = [];
75-
const promises = this.module.link((specifier, assertions) => {
76-
const job = this.loader.getModuleJob(specifier, url, assertions);
75+
const promises = this.module.link(async (specifier, attributes) => {
76+
const job = this.loader.getModuleJob(specifier, url, attributes);
7777
ArrayPrototypePush(dependencyJobs, job);
78-
return job.modulePromise;
78+
const result = await job.modulePromise;
79+
this.loader.cacheStatic(specifier, url, attributes, job);
80+
return result;
7981
});
8082

8183
if (promises !== undefined)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict';
2+
const common = require('../common');
3+
const tmpdir = require('../common/tmpdir');
4+
5+
const assert = require('node:assert');
6+
const fs = require('node:fs/promises');
7+
const { pathToFileURL } = require('node:url');
8+
9+
tmpdir.refresh();
10+
const tmpDir = pathToFileURL(tmpdir.path);
11+
12+
const target = new URL(`./${Math.random()}.mjs`, tmpDir);
13+
14+
(async () => {
15+
16+
await assert.rejects(import(target), { code: 'ERR_MODULE_NOT_FOUND' });
17+
18+
await fs.writeFile(target, 'export default "actual target"\n');
19+
20+
const moduleRecord = await import(target);
21+
22+
await fs.rm(target);
23+
24+
assert.strictEqual(await import(target), moduleRecord);
25+
})().then(common.mustCall());
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { spawnPromisified } from '../common/index.mjs';
2+
import tmpdir from '../common/tmpdir.js';
3+
4+
import assert from 'node:assert';
5+
import fs from 'node:fs/promises';
6+
import { execPath } from 'node:process';
7+
import { pathToFileURL } from 'node:url';
8+
9+
tmpdir.refresh();
10+
const tmpDir = pathToFileURL(tmpdir.path);
11+
12+
const target = new URL(`./${Math.random()}.mjs`, tmpDir);
13+
14+
await assert.rejects(import(target), { code: 'ERR_MODULE_NOT_FOUND' });
15+
16+
await fs.writeFile(target, 'export default "actual target"\n');
17+
18+
const moduleRecord = await import(target);
19+
20+
await fs.rm(target);
21+
22+
assert.strictEqual(await import(target), moduleRecord);
23+
24+
// Add the file back, it should be deleted by the subprocess.
25+
await fs.writeFile(target, 'export default "actual target"\n');
26+
27+
assert.deepStrictEqual(
28+
await spawnPromisified(execPath, [
29+
'--input-type=module',
30+
'--eval',
31+
[`import * as d from${JSON.stringify(target)};`,
32+
'import{rm}from"node:fs/promises";',
33+
`await rm(new URL(${JSON.stringify(target)}));`,
34+
'import{strictEqual}from"node:assert";',
35+
`strictEqual(JSON.stringify(await import(${JSON.stringify(target)})),JSON.stringify(d));`].join(''),
36+
]),
37+
{
38+
code: 0,
39+
signal: null,
40+
stderr: '',
41+
stdout: '',
42+
});

test/es-module/test-esm-initialization.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,22 @@ import { describe, it } from 'node:test';
88
describe('ESM: ensure initialization happens only once', { concurrency: true }, () => {
99
it(async () => {
1010
const { code, stderr, stdout } = await spawnPromisified(execPath, [
11+
'--experimental-import-meta-resolve',
1112
'--loader',
1213
fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'),
1314
'--no-warnings',
1415
fixtures.path('es-modules', 'runmain.mjs'),
1516
]);
1617

17-
// Length minus 1 because the first match is the needle.
18-
const resolveHookRunCount = (stdout.match(/resolve passthru/g)?.length ?? 0) - 1;
19-
2018
assert.strictEqual(stderr, '');
2119
/**
22-
* resolveHookRunCount = 2:
23-
* 1. fixtures/…/runmain.mjs
20+
* 'resolve passthru' appears 4 times in the output:
21+
* 1. fixtures/…/runmain.mjs (entry point)
2422
* 2. node:module (imported by fixtures/…/runmain.mjs)
23+
* 3. doesnt-matter.mjs (first import.meta.resolve call)
24+
* 4. doesnt-matter.mjs (second import.meta.resolve call)
2525
*/
26-
assert.strictEqual(resolveHookRunCount, 2);
26+
assert.strictEqual(stdout.match(/resolve passthru/g)?.length, 4);
2727
assert.strictEqual(code, 0);
2828
});
2929
});

0 commit comments

Comments
 (0)