Skip to content

[experiment] implement tc39 Defer Module Evaluation #16567

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

Jack-Works
Copy link
Contributor

@Jack-Works Jack-Works commented Dec 14, 2022

Does this PR introduce a breaking change?

no?

What needs to be documented once your changes are merged?

Defer import

This implements the semantics of the TC39 proposal Deferring Module Evaluation.

New options:

experiments.deferImport: true

To use the native syntax, install acorn-import-defer as a peer dependency.

// index.mjs
import defer * as ns from './mod.mjs'

function some_work() {
    console.log('deferred import value', ns.f)
}
console.log('entry runs')
setTimeout(some_work, 1000)

// mod.mjs
export function f() {
}
console.log('init cost!')

Result:

entry runs
// 1000ms later
init cost!
deferred import value, function f() {}

Other things to notice

Alternative syntax

import defer * as ns from './mod.mjs'

can be written as

import * as ns from /* webpackDefer: true */ './mod.mjs'

I suggest putting the magic comment after the from keyword, which is compatible with TypeScript, Babel, SWC, and Flow if the necessary option to preserve comments is enabled.

Please do not put it after the "import" keyword (like import /* webpackDefer: true */ * as ns from './mod.mjs'), the filesystem cache is broken, and I don't know why.

ESBuild is not compatible, please read evanw/esbuild#1439 and evanw/esbuild#309. Esbuild may support this feature ad-hoc like other webpack magic comments.

@alexander-akait
Copy link
Member

Yeah, we can implement this under the experimental flag, feel free to ping when it's ready

@webpack-bot
Copy link
Contributor

For maintainers only:

  • This needs to be documented (issue in webpack/webpack.js.org will be filed when merged)
  • This needs to be backported to webpack 4 (issue will be created when merged)

@Jack-Works Jack-Works force-pushed the lazy-import branch 2 times, most recently from 7420ab0 to 32408f7 Compare December 16, 2022 14:21
@Jack-Works

This comment was marked as resolved.

@alexander-akait
Copy link
Member

Just update snaphots (-u):

FAIL test/StatsTestCases.basictest.js (11.925 s, 87 MB heap size)
  ● Console

@alexander-akait
Copy link
Member

Test suite failed to run

Unable to find JEST_STATE_SYMBOL in Symbol(jest-native-promise), Symbol(jest-native-now), Symbol(jest-native-read-file), Symbol(jest-native-write-file), Symbol(jest-native-exists-file), Symbol($$jest-matchers-object), Symbol(graceful-fs.queue)

Is it locally?

@Jack-Works

This comment was marked as resolved.

@alexander-akait
Copy link
Member

@Jack-Works Sounds like a bug in jest... do you use the same steps as in our CI file? Based on CI report you just need to update snapshot, no more

@alexander-akait
Copy link
Member

Can you update StatsTestCases using -u flag for jest, thank you

@Jack-Works

This comment was marked as resolved.

@alexander-akait
Copy link
Member

alexander-akait commented Dec 20, 2022

@Jack-Works What is node version? You can try to change, also just check you have the same versions as in the lock file

@Jack-Works

This comment was marked as resolved.

@Jack-Works

This comment was marked as resolved.

@alexander-akait
Copy link
Member

Looks like 18.0.0 is the first breaking Node version. I don't know what happened exactly.

Weird, I don't have problem with jest locally... Okay, we can try to update jest (maybe new versions resolve this problem)

Also can you use Node@16 to update snapshots?

@Jack-Works

This comment was marked as resolved.

@Jack-Works
Copy link
Contributor Author

I tried to add some test cases (6041d87) but it seems like does not work... Did I do right?

@alexander-akait
Copy link
Member

That will be a little pain, I need to adjust my setup (installing nvm-for-windows seems to require uninstalling all npm globals and the current node install), I'll use this as the last solution joy

You can use virtual machine here 😄

@Jack-Works

This comment was marked as resolved.

@Jack-Works

This comment was marked as resolved.

@Jack-Works Jack-Works force-pushed the lazy-import branch 2 times, most recently from 9161b0f to 5250fd6 Compare March 23, 2023 11:09
@Jack-Works
Copy link
Contributor Author

@Jack-Works

Please do not put it after the "import" keyword (like import /* webpackDefer: true */ * as ns from './mod.mjs'), the filesystem cache is broken, and I don't know why.

Can you provide feedback on this? Maybe we miss something to serialize? What error do you have?

If the comment is put after import, the cache created from .a is not hit in .b ("Pack got invalid because of write to ..."). As I can recall, the hash of the module is changed.

@Jack-Works
Copy link
Contributor Author

Not sure why Github Actions / integration (ubuntu-latest, 10.x, b) (pull_request) and Github Actions / integration (windows-latest, 10.x, b) (pull_request) fails with Error: Cannot find module 'acorn-import-defer'. Maybe it is related to CI cache (I changed it from devDep to dep), or is it ok to inline it cc @nicolo-ribaudo? Import attributes is currently inlined.

@nicolo-ribaudo
Copy link

Yeah feel free to inline if needed. But maybe @alexander-akait could you try re-running CI without cache first?

@Jack-Works Jack-Works force-pushed the lazy-import branch 2 times, most recently from de9c023 to db2bd21 Compare May 2, 2025 14:54
Copy link

codspeed-hq bot commented May 2, 2025

CodSpeed Performance Report

Merging #16567 will degrade performances by 69.75%

Comparing Jack-Works:lazy-import (741514a) with main (10fb556)

Summary

⚡ 3 improvements
❌ 3 regressions
✅ 127 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Benchmarks breakdown

Benchmark BASE HEAD Change
benchmark "devtool-eval", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 37.4 ms 42.6 ms -12.13%
benchmark "devtool-eval-source-map", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 42.7 ms 50.6 ms -15.73%
benchmark "devtool-source-map", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 42.7 ms 9.9 ms ×4.3
benchmark "future-defaults", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 47.7 ms 11.7 ms ×4.1
benchmark "many-chunks-commonjs", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 53.9 ms 9.8 ms ×5.5
benchmark "react", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 10.8 ms 35.8 ms -69.75%

Copy link

codspeed-hq bot commented May 2, 2025

CodSpeed Performance Report

Merging #16567 will degrade performances by 23.26%

Comparing Jack-Works:lazy-import (de9c023) with main (3f36f91)

Summary

❌ 1 regressions
✅ 44 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Benchmarks breakdown

Benchmark BASE HEAD Change
benchmark "typescript-long", scenario '{"name":"mode-development","mode":"development"}' 27.8 s 36.2 s -23.26%

@nicolo-ribaudo
Copy link

The same test becomes 23% slower in development and 23% faster in production? 🤔

@Jack-Works
Copy link
Contributor Author

hi @alexander-akait I think it is ready now!

@@ -46,6 +46,8 @@ class ImportParserPlugin {
Array.from(enumerable, e => [e]);
parser.hooks.importCall.tap("ImportParserPlugin", expr => {
const param = parser.evaluateExpression(expr.source);
// TODO support deferImport
// this requires the evaluateExpression to support Object literals.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jack-Works So we don't support import('something', { defer: true })? Do we have this in spec?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jack-Works So we don't support import('something', { defer: true })? Do we have this in spec?

No, the syntax becomes import.defer('something') (this comment is written in a very early stage), I'll update this soon.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

* @this {TODO}
* @param {TODO} node node
* @returns {TODO} import.meta
*/

This comment was marked as resolved.

Copy link
Member

@alexander-akait alexander-akait left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jack-Works Sorry for delay, there are two question, and we can merge (want to merge it this week), thank you for your work

Co-authored-by: Nicolo Ribaudo <hello@nicr.dev>
@Jack-Works
Copy link
Contributor Author

updated, but import.defer() is marked as not implemented. I may implement it in the future

@alexander-akait
Copy link
Member

@Jack-Works Just for information - any blockers for import.defer('something')?

@Jack-Works
Copy link
Contributor Author

@Jack-Works Just for information - any blockers for import.defer('something')?

Not yet, I didn't do any research on this because I'm focusing on other things right now. I emit this error to make the PR merge-ready (without a breaking import.defer that acts as import).

@nicolo-ribaudo
Copy link

Fyi, TypeScript just merged support for this :)

])},`,
`defineProperty: ${runtimeTemplate.basicFunction("_, name", [
init,
// TODO:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we should do here in future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Module Namespace object's defineProperty checks the new property descriptor. It only returns true when the new property descriptor is the same as the current one.

This behaviour is skipped since webpack's implementation (lib/runtime/MakeNamespaceObjectRuntimeModule.js) for normal namespace object is skipping this too.

environment: {
optionalChaining: false
}
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very weird, I will resolve it before merge, no need action from you

]),
"}",
// we don't fully emulate ES Module semantics in this Proxy to align with normal webpack esm namespace object.
"return new Proxy(ns, handler);"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to be compatible with es5 for Proxy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to support ES5 with known exported names as a getter, but I'd like to leave it to the future if anyone asks for it.

`ownKeys: ${runtimeTemplate.basicFunction("", [
init,
`var keys = Reflect.ownKeys(ns).filter(${runtimeTemplate.expressionFunction('x !== "then"', "x")}).concat([Symbol.toStringTag]);`,
"return Reflect.ownKeys(keys);"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a typo here? maybe want to return keys?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jack-Works Can you look at this and we can merge, thank you for your work and help

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! This is indeed an error, will fix soon.

@@ -1162,6 +1162,12 @@ class JavascriptModulesPlugin {
buf.push("");
}

if (runtimeRequirements.has(RuntimeGlobals.makeDeferredNamespaceObject)) {
buf.push("// The deferred module cache");
buf.push("var __webpack_module_deferred_exports__ = {};");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I apologize, could you please clarify why this is necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the deferred module is an ES Module, to get rid of all proxy handlers after the module initialization (search delete handler.get), the underlying object of the Proxy (ns in return new Proxy(ns, handler);) must be the export object of the module, but this object is not created until the module is evaluated (search exports: {} in JavascriptModulesPlugin).

This extra store __webpack_module_deferred_exports__ allows the exports object to be created and shared with all deferred importers (__webpack_module_deferred_exports__[moduleId] = { __proto__: null }).

This extra store is used when the module initializes:

// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
	id: moduleId,
	// no module.loaded
    exports: __webpack_module_deferred_exports__[moduleId] || {}
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your detailed clarification for all comment. ❤️

To be honest, if I understand correctly, I believe we don’t need it. We init module.exports first and then evaluate the module code. If I’ve misunderstood, could you provide an example of a scenario where removing it would cause issues?

Additionally, we already have some helper runtime functions to ensure CJS module compatibility(i.e. RuntimeGlobals.createFakeNamespaceObject for exportType: default and RuntimeGlobals.compatGetDefaultExport for exportType: dynamic), which should be used instead.

Would you mind if I co-authored this PR? It would be great to land it in v5.100.0.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We init module.exports first and then evaluate the module code.

module.exports is initialized when the module first time imported, but this object (__webpack_module_deferred_exports__[moduleId]) is required internally (explained above, as the first argument of the Proxy and module.exports of that module in the future) when a deferred namespace object of this module is created, at that time, this module may not be imported yet.

If I’ve misunderstood, could you provide an example of a scenario where removing it would cause issues?

It's possible to implement this feature without __webpack_module_deferred_exports__, but that will have a runtime cost that every access to the deferred module namespace object will trigger the Proxy handler. I cannot give an example because it is a runtime optimization.

Would you mind if I co-authored this PR?

No problem, but I wonder what changes you're proposing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but that will have a runtime cost that every access to the deferred module namespace object will trigger the Proxy handler

I might get you now, so __webpack_module_deferred_exports__[moduleId] is used to preserve the original ns reference, enabling safe removal of the proxy handler in ESM namespace modules. Without it, reassigning to ns would not work.

but I wonder what changes you're proposing

I think we should use RuntimeGlobals.createFakeNamespaceObject and RuntimeGlobals.compatGetDefaultExport for CJS compatibility.

Copy link
Member

@hai-x hai-x Jun 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jack-Works Does the following change make sense to you?

diff --git a/lib/RuntimePlugin.js b/lib/RuntimePlugin.js
index 5d4d94d47..09eaf1e49 100644
--- a/lib/RuntimePlugin.js
+++ b/lib/RuntimePlugin.js
@@ -102,6 +102,7 @@ const TREE_DEPENDENCIES = {
        [RuntimeGlobals.makeDeferredNamespaceObject]: [
                RuntimeGlobals.definePropertyGetters,
                RuntimeGlobals.makeNamespaceObject,
+               RuntimeGlobals.compatGetDefaultExport,
                RuntimeGlobals.createFakeNamespaceObject,
                RuntimeGlobals.hasOwnProperty,
                RuntimeGlobals.require
diff --git a/lib/runtime/MakeDeferredNamespaceObjectRuntime.js b/lib/runtime/MakeDeferredNamespaceObjectRuntime.js
index 0dba55e28..7c3b0e282 100644
--- a/lib/runtime/MakeDeferredNamespaceObjectRuntime.js
+++ b/lib/runtime/MakeDeferredNamespaceObjectRuntime.js
@@ -106,44 +106,47 @@ class MakeDeferredNamespaceObjectRuntimeModule extends HelperRuntimeModule {
                        "var cachedModule = __webpack_module_cache__[moduleId];",
                        ...(strictError ? strictModuleCache : nonStrictModuleCache),
                        "",
-                       "if (mode == 1) {",
-                       Template.indent([
-                               // if this var is changed to let or const, please define a new ns in the outer scope.
-                               "var ns = { __proto__: null };",
-                               `${RuntimeGlobals.makeNamespaceObject}(ns);`,
-                               `${RuntimeGlobals.definePropertyGetters}(ns, {`,
-                               Template.indent([
-                                       `"default": ${runtimeTemplate.returningFunction(
-                                               `${RuntimeGlobals.require}(moduleId)`
-                                       )}`
-                               ]),
-                               "});",
-                               "return ns;"
-                       ]),
-                       "}",
-                       "",
                        `var init = ${runtimeTemplate.basicFunction("", [
                                `ns = ${RuntimeGlobals.require}(moduleId);`,
                                hasAsync
                                        ? `if (${RuntimeGlobals.asyncModuleExportSymbol} in ns) ns = ns[${RuntimeGlobals.asyncModuleExportSymbol}];`
                                        : "",
                                "init = null;",
-                               "if (mode == 3) {",
-                               Template.indent(["if (ns.__esModule) mode = 0;", "else mode = 2;"]),
-                               "}",
-                               "if (mode) return ns;",
+                               "if (mode == 0) {",
                                // this optimization only applies to esm because it's .exports cannot be assigned to another object.
-                               "delete handler.defineProperty;",
-                               "delete handler.deleteProperty;",
-                               "delete handler.set;",
-                               "delete handler.get;",
-                               "delete handler.has;",
-                               "delete handler.ownKeys;",
-                               "delete handler.getOwnPropertyDescriptor;",
-                               "return ns;"
+                               Template.indent([
+                                       "delete handler.defineProperty;",
+                                       "delete handler.deleteProperty;",
+                                       "delete handler.set;",
+                                       "delete handler.get;",
+                                       "delete handler.has;",
+                                       "delete handler.ownKeys;",
+                                       "delete handler.getOwnPropertyDescriptor;",
+                               ]),
+                               "} else if (mode == 1) {",
+                               Template.indent([
+                                       `ns = ${RuntimeGlobals.createFakeNamespaceObject}(ns);`,
+                               ]),
+                               "} else if (mode == 2) {",
+                               Template.indent([
+                                       `ns = ${RuntimeGlobals.createFakeNamespaceObject}(ns, 2);`,
+                               ]),
+                               "} else if (mode == 3) {",
+                               Template.indent([
+                                       `${RuntimeGlobals.definePropertyGetters}(ns, {`,
+                                       Template.indent([
+                                               `"default": ${RuntimeGlobals.compatGetDefaultExport}(ns)`
+                                       ]),
+                                       "});",
+                               ]),
+                               "}"
                        ])};`,
                        "",
-                       `ns = ${
+                       `var ns = ${
                                strictError ? "" : "cachedModule && cachedModule.exports || "
                        }__webpack_module_deferred_exports__[moduleId] || (__webpack_module_deferred_exports__[moduleId] = { __proto__: null });`,
                        "var handler = {",
@@ -158,11 +161,7 @@ class MakeDeferredNamespaceObjectRuntimeModule extends HelperRuntimeModule {
                                        ]),
                                        "}",
                                        init,
-                                       "// possible mode: 0, 2",
-                                       "// for mode 2, ns.default is re-evaluated every time to reflect the latest module.exports value.",
-                                       "// for the rest, we access the initial namespace object to keep esm semantics (export list is static).",
-                                       `if (mode == 0 || name !== "default" || ${RuntimeGlobals.hasOwnProperty}(ns, name)) return ns[name];`,
-                                       `return ${RuntimeGlobals.require}(moduleId);`
+                                       `return ns[name];`
                                ])},`,
                                `has: ${runtimeTemplate.basicFunction("_, name", [
                                        "switch (name) {",

Copy link
Contributor Author

@Jack-Works Jack-Works Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I guess, but a little change:

-if (mode == 0) {
+if (mode == 0 || mode == 3 && ns.__esModule) {

It passes all tests so I guess this change is correct. Do you want to push a commit or let me do that?

const props = [
`/* ${exportsType} */ get a() {`,
exportsType === "namespace" || exportsType === "dynamic"
? Template.indent([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate why we need handle namespace/dynamic here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is for runtime performance.

If exportsType is "namespace", we generate the following code:

const some_module_webpack_module_123 = {
  get a() {
    var exports = init code;
    Object.defineProperty(this, "a", { value: exports });
    return exports;
  }
}

On the second access to this object, the getter will not be triggered again, and access the real module export directly.

For "dynamic", it's only possible do this optimization when there is a __esModule on it, so we generate this:

const some_module_webpack_module_123 = {
  get a() {
    var exports = init code;
    if (exports.__esModule) Object.defineProperty(this, "a", { value: exports });
    return exports;
  }
}

For the rest cases, it's impossible to do any optimization, so it will generate

const some_module_webpack_module_123 = {
  get a() {
    return init code;
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Ready for Merge
Development

Successfully merging this pull request may close these issues.

8 participants