diff --git a/eslint.config.mjs b/eslint.config.mjs index 152c530825e273..84c0df7bb31dd9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,7 @@ const { globalIgnores } = await importEslintTool('eslint/config'); const { default: js } = await importEslintTool('@eslint/js'); const { default: babelEslintParser } = await importEslintTool('@babel/eslint-parser'); const babelPluginSyntaxImportSource = resolveEslintTool('@babel/plugin-syntax-import-source'); +const babelPluginImportDefer = resolveEslintTool('@babel/plugin-syntax-import-defer'); const { default: jsdoc } = await importEslintTool('eslint-plugin-jsdoc'); const { default: regexpPlugin } = await importEslintTool('eslint-plugin-regexp'); const { default: markdown } = await importEslintTool('@eslint/markdown'); @@ -105,6 +106,7 @@ export default [ babelOptions: { plugins: [ babelPluginSyntaxImportSource, + babelPluginImportDefer, ], }, requireConfigFile: false, diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 11d5b0a3cb66f9..a09af9ea990b37 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -28,6 +28,7 @@ const { kErrored, kEvaluated, kEvaluating, + kDeferPhase, kEvaluationPhase, kInstantiated, kUninstantiated, @@ -164,7 +165,7 @@ class ModuleJobBase { debug(`ModuleJobBase.syncLink() ${this.url} -> ${request.specifier}`, job); assert(!isPromise(job)); assert(job.module instanceof ModuleWrap); - if (request.phase === kEvaluationPhase) { + if (this.shouldRunModule(request.phase)) { ArrayPrototypePush(evaluationDepJobs, job); } modules[idx] = job.module; @@ -199,6 +200,13 @@ class ModuleJobBase { } } } + + shouldLinkModule(phase) { + return phase >= kDeferPhase; + } + shouldRunModule(phase) { + return phase === kEvaluationPhase; + } } /* A ModuleJob tracks the loading of a single Module, and the ModuleJobs of @@ -227,7 +235,7 @@ class ModuleJob extends ModuleJobBase { this.modulePromise = PromiseResolve(moduleOrModulePromise); } - if (this.phase === kEvaluationPhase) { + if (this.shouldLinkModule(this.phase)) { // Promise for the list of all dependencyJobs. this.linked = this.link(requestType); // This promise is awaited later anyway, so silence @@ -279,7 +287,7 @@ class ModuleJob extends ModuleJobBase { const dependencyJobPromise = this.loader.getOrCreateModuleJob(this.url, request, requestType); const modulePromise = PromisePrototypeThen(dependencyJobPromise, (job) => { debug(`ModuleJob.asyncLink() ${this.url} -> ${request.specifier}`, job); - if (request.phase === kEvaluationPhase) { + if (this.shouldRunModule(request.phase)) { ArrayPrototypePush(evaluationDepJobs, job); } return job.modulePromise; @@ -380,7 +388,7 @@ class ModuleJob extends ModuleJobBase { } runSync(parent) { - assert(this.phase === kEvaluationPhase); + assert(this.shouldRunModule(this.phase)); assert(this.module instanceof ModuleWrap); let status = this.module.getStatus(); @@ -427,7 +435,7 @@ class ModuleJob extends ModuleJobBase { async run(isEntryPoint = false) { debug('ModuleJob.run()', this.module); - assert(this.phase === kEvaluationPhase); + assert(this.shouldRunModule(this.phase)); await this.#instantiate(); if (isEntryPoint) { globalThis[entry_point_module_private_symbol] = this.module; @@ -475,7 +483,7 @@ class ModuleJobSync extends ModuleJobBase { assert(this.module instanceof ModuleWrap); this.linked = undefined; this.type = importAttributes.type; - if (phase === kEvaluationPhase) { + if (this.shouldLinkModule(phase)) { this.linked = this.link(requestType); } } @@ -494,7 +502,7 @@ class ModuleJobSync extends ModuleJobBase { } async run() { - assert(this.phase === kEvaluationPhase); + assert(this.shouldRunModule(this.phase)); // This path is hit by a require'd module that is imported again. const status = this.module.getStatus(); debug('ModuleJobSync.run()', status, this.module); @@ -523,7 +531,7 @@ class ModuleJobSync extends ModuleJobBase { runSync(parent) { debug('ModuleJobSync.runSync()', this.module); - assert(this.phase === kEvaluationPhase); + assert(this.shouldRunModule(this.phase)); // TODO(joyeecheung): add the error decoration logic from the async instantiate. this.module.instantiate(); // If --experimental-print-required-tla is true, proceeds to evaluation even diff --git a/src/module_wrap.cc b/src/module_wrap.cc index 87a8b4d57726af..4f47648f15135f 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -555,6 +555,8 @@ ModulePhase to_phase_constant(ModuleImportPhase phase) { switch (phase) { case ModuleImportPhase::kEvaluation: return kEvaluationPhase; + case ModuleImportPhase::kDefer: + return kDeferPhase; case ModuleImportPhase::kSource: return kSourcePhase; default: @@ -1682,6 +1684,7 @@ void ModuleWrap::CreatePerContextProperties(Local target, V(Module::Status, kErrored); V(ModulePhase, kEvaluationPhase); + V(ModulePhase, kDeferPhase); V(ModulePhase, kSourcePhase); #undef V } diff --git a/src/module_wrap.h b/src/module_wrap.h index a91a7cb6573415..14a8f1a4f2d611 100644 --- a/src/module_wrap.h +++ b/src/module_wrap.h @@ -35,7 +35,8 @@ enum HostDefinedOptions : int { enum ModulePhase : int { kSourcePhase = 1, - kEvaluationPhase = 2, + kDeferPhase = 2, + kEvaluationPhase = 3, }; /** diff --git a/test/es-module/test-defer-import-eval.mjs b/test/es-module/test-defer-import-eval.mjs new file mode 100644 index 00000000000000..18824a0218db95 --- /dev/null +++ b/test/es-module/test-defer-import-eval.mjs @@ -0,0 +1,25 @@ +// Flags: --js-defer-import-eval + +// Tests that defer import actually evaluates the imported module +// only when properties that it exports are accessed. + +import '../common/index.mjs'; +import * as assert from 'assert'; + +globalThis.eval_list = []; + +import defer * as deferred from '../fixtures/es-modules/module-deferred-eval.mjs'; + +assert.strictEqual(globalThis.eval_list.length, 0); + +// Attempts to define a property on the deferred module. This should +// trigger its execution, similar to accessing the `foo` property. +assert.throws(() => Object.defineProperty(deferred.prop, 'newProp', { value: 15 }), TypeError); + +assert.strictEqual(deferred.foo, 42); + +// Check that the module has been evaluated at this point. +assert.partialDeepStrictEqual(['defer-1'], globalThis.eval_list); + +// Clean-up +delete globalThis.eval_list; diff --git a/test/es-module/test-defer-import-with-module-tree.mjs b/test/es-module/test-defer-import-with-module-tree.mjs new file mode 100644 index 00000000000000..eec5a9bf997f7e --- /dev/null +++ b/test/es-module/test-defer-import-with-module-tree.mjs @@ -0,0 +1,9 @@ +// Flags: --js-defer-import-eval + +import '../common/index.mjs'; + +import defer * as deferred from '../fixtures/es-modules/module-with-module-tree.mjs'; + +console.log(deferred.bar); + +delete globalThis.eval_list; diff --git a/test/fixtures/es-modules/module-deferred-eval.mjs b/test/fixtures/es-modules/module-deferred-eval.mjs new file mode 100644 index 00000000000000..181ac4c07f0600 --- /dev/null +++ b/test/fixtures/es-modules/module-deferred-eval.mjs @@ -0,0 +1,8 @@ +if (!globalThis.eval_list) { + globalThis.eval_list = []; +} +globalThis.eval_list.push('defer-1'); + +export const foo = 42; + +console.log('executed'); diff --git a/test/fixtures/es-modules/module-with-module-tree.mjs b/test/fixtures/es-modules/module-with-module-tree.mjs new file mode 100644 index 00000000000000..b45cf230fc5046 --- /dev/null +++ b/test/fixtures/es-modules/module-with-module-tree.mjs @@ -0,0 +1,3 @@ +import './module-deferred-eval.mjs'; + +export const bar = 64; diff --git a/tools/eslint/package-lock.json b/tools/eslint/package-lock.json index 1859609891f370..dede7fa4aa1967 100644 --- a/tools/eslint/package-lock.json +++ b/tools/eslint/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@babel/core": "^8.0.0-rc.6", "@babel/eslint-parser": "^8.0.0-rc.6", + "@babel/plugin-syntax-import-defer": "^8.0.0-rc.6", "@babel/plugin-syntax-import-source": "^8.0.0-rc.6", "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.2", @@ -201,6 +202,21 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/plugin-syntax-import-defer": { + "version": "8.0.0-rc.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-defer/-/plugin-syntax-import-defer-8.0.0-rc.6.tgz", + "integrity": "sha512-VUzalsGv2W89DJbKyXy8mP7uhsXFZoE4td5iDndOGART94WLXvnKuF72ndJFFYE8t4eRS0zX5PZFmMGBVGmIUw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.0-rc.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0-rc.6" + } + }, "node_modules/@babel/plugin-syntax-import-source": { "version": "8.0.0-rc.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-source/-/plugin-syntax-import-source-8.0.0-rc.6.tgz", diff --git a/tools/eslint/package.json b/tools/eslint/package.json index dca626efb2baab..0002933967b711 100644 --- a/tools/eslint/package.json +++ b/tools/eslint/package.json @@ -5,6 +5,7 @@ "dependencies": { "@babel/core": "^8.0.0-rc.6", "@babel/eslint-parser": "^8.0.0-rc.6", + "@babel/plugin-syntax-import-defer": "^8.0.0-rc.6", "@babel/plugin-syntax-import-source": "^8.0.0-rc.6", "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.2",