Dynamic `import.defer()` semantics
JSC ships dynamic import.defer() — a new module phase whose namespace executes synchronously the first time a string-keyed property is touched.
JSTests/modules/import-defer-dynamic-evaluation.js
The TC39 Deferred Module Evaluation proposal separates module linking from module execution: a deferred namespace proxy is returned immediately and the module body only runs on first non-Symbol property access. JSC's module pipeline is split across the engine (graph traversal, bytecode, microtask scheduling) and WebCore bindings (ScriptModuleLoader, JSDOMGlobalObject), so the new "import phase" parameter must be propagated through both layers.
This commit implements the dynamic import.defer(specifier) form. The module graph is loaded and linked but the deferred root is not executed; GatherAsynchronousTransitiveDependencies() collects unexecuted top-level-await (TLA) modules in post-order, evaluates them through two new internal microtasks (DynamicImportDeferLoadSettled, DynamicImportDeferDependencySettled), and resolves the returned promise to a deferred module namespace once all settle. The AND-join that waits for all TLA dep promises reuses JSPromiseCombinatorsGlobalContext as a shared counter cell, a pattern borrowed from Promise.all internals but with the critical spec constraint that then must never be looked up on the dependency promises.
import.defer(specifier)
├─► load() + link()
├─► GatherAsyncTransitiveDeps() → [dep1, dep2, ...]
├─► evaluate(depN) ──► AND-join counter (DynamicImportDeferDependencySettled)
└─► resolve(deferredNamespace)
Namespace proxy dispatch:
Symbol key ──► return value (NO evaluation)
String key ──► evaluate() ──► return value
Significance
This change introduces new JS-visible API, new microtask types, a new promise AND-join, and new namespace proxy dispatch semantics spanning every layer of the JSC module system — historically the highest-density area for security bugs in JS engines.
Audit directions
- AND-join counter reuse (
DynamicImportDeferDependencySettled). The implementation reusesJSPromiseCombinatorsGlobalContextas a shared counter cell. Integer underflow, double-decrement, or early-resolve when the initial dep list is empty are classic bugs inPromise.all-style implementations. More critically, the spec requires the join to never look upthenon dep promises — any deviation allows attacker-controlled.thengetters to inject code into the combinator machinery. - Deferred namespace proxy Symbol/string dispatch. Symbol-keyed access must never trigger evaluation; non-symbol string access must. Edge cases include computed property accesses that cross the dispatch boundary,
Reflect.getwith a symbol-shaped string, or prototype chain lookups that bypass the proxy's own-property guard. GatherAsynchronousTransitiveDependencies()on cyclic graphs. The post-order traversal must correctly handle cycles. If the visited-set is not correctly initialised or is shared across concurrentimport.defer()calls for overlapping graphs, nodes could be missed, double-evaluated, or cause infinite traversal.- Import phase propagation through the binding layer.
importPhaseis threaded fromJSGlobalObjectFunctions.cppthroughJSModuleLoader::continueDynamicImport,ScriptModuleLoader::importModule,JSDOMGlobalObject::moduleLoaderImportModule, andJSAPIGlobalObject::moduleLoaderImportModule. Any site that defaults, truncates, or incorrectly forwards the phase silently executes a module that should remain deferred. - Microtask ordering and GC interaction. If a GC runs between the link step and the gather step, references to the deferred namespace object or the dependency list held inside
ModuleLoaderPayload/ModuleLoadingContextcould be stale or collected. The new payload types should be audited for GC marking completeness.