Module Loading: How V8 Wires Up ES Modules
V8 turns a module file into three tables of Cells, resolves the import graph by DFS, and evaluates modules in post-order so that a cycle gets a well-defined answer.
TL;DR: A parsed module becomes a
SourceTextModulewith three parallel tables:regular_exports,regular_importsandrequested_modules. Every export and every import is aCell- a one-slot mutable box. Importingxfrommdoes not copyx; it stores the same Cell in the importer’sregular_importstable, so a later write tom’sxis visible through the import. The graph is resolved by a DFS whose post-order gives you the spec’s evaluation order, and cycles are detected by the DFS ancestor index rather than by an explicit cycle finder.
An ES module is not a script with extra syntax. The difference is not
parsing - V8 parses import and export with the same machinery it uses
for everything else. The difference is linking: a module is a graph of
bindings that have to be connected to each other before any of them can
run, and the connection has to be a live connection, not a copy.
This is the part that trips people up. import { counter } from './m.js'
does not take a snapshot of counter. If m.js later does
counter = 5, the importer sees 5. If the importer does
counter = 6, the exporter sees 6. That is not a language feature
bolted on top; it falls out of how V8 represents module bindings.
Cells: the one-slot mutable box
The primitive that makes live bindings work is the Cell. A Cell is a
heap object with one field:
// src/objects/module.tq:6
@cppObjectLayoutDefinition
extern class Module extends HeapObject {
// The complete export table, mapping an export name to its cell.
exports: ObjectHashTable;
// Hash for this object (a random non-zero Smi).
hash: Smi;
status: Smi;
module_namespace: Cell|Undefined;
deferred_module_namespace: Cell|Undefined;The export table is an ObjectHashTable mapping export name to Cell.
The cell is the variable. If two modules refer to the same cell, they
share the variable.
The sign of the cell index encodes the direction. From the parser’s descriptor:
// src/ast/modules.h:100
// Import/export entries that are associated with a MODULE-allocated
// variable (i.e. regular_imports and regular_exports after Validate) use
// the cell_index value to encode the location of their cell. During
// variable allocation, this will be be copied into the variable's index
// field.
// Entries that are not associated with a MODULE-allocated variable have
// GetCellIndexKind(cell_index) == kInvalid.
int cell_index;and the encoding itself:
// src/ast/modules.cc:285
SourceTextModuleDescriptor::CellIndexKind
SourceTextModuleDescriptor::GetCellIndexKind(int cell_index) {
if (cell_index > 0) return kExport;
if (cell_index < 0) return kImport;
return kInvalid;
}Exports get 1, 2, 3, ...; imports get -1, -2, -3, .... Zero is
“not a module variable”. The assignment is a simple loop over the
descriptor’s maps:
// src/ast/modules.cc:292
void SourceTextModuleDescriptor::AssignCellIndices() {
int export_index = 1;
for (auto it = regular_exports_.begin(); it != regular_exports_.end();) {
auto current_key = it->first;
// This local name may be exported under multiple export names. Assign the
// same index to each such entry.
do {
Entry* entry = it->second;
DCHECK_NOT_NULL(entry->local_name);
DCHECK_NULL(entry->import_name);
DCHECK_LT(entry->module_request, 0);
DCHECK_EQ(entry->cell_index, 0);
entry->cell_index = export_index;
it++;
} while (it != regular_exports_.end() && it->first == current_key);
export_index++;
}The comment “This local name may be exported under multiple export names”
is the export { x, x as y } case. Both names point at the same
variable, so they get the same cell index, and the runtime will create
one cell for both.
Parsing into a descriptor
The parser does not build a SourceTextModule directly. It builds a
SourceTextModuleDescriptor, which is a zone-allocated structure (see
The Zone Allocator: How V8 Cheats by Never Freeing)
holding five maps and a vector:
// src/ast/modules.h:23
class SourceTextModuleDescriptor : public ZoneObject {
public:
explicit SourceTextModuleDescriptor(Zone* zone)
: module_requests_(zone),
special_exports_(zone),
namespace_imports_(zone),
regular_exports_(zone),
regular_imports_(zone) {}The five collections are:
module_requests_: the set of specifiers the module imports from, deduplicated by content so thatimport a from "x"; import b from "x"produces one request.special_exports_: star exports (export * from "x") and indirect exports (export {a} from "x").namespace_imports_:import * as ns from "x".regular_exports_: local exports,export let xandexport {x}.regular_imports_: named imports,import {x} from "y".
The “regular” prefix is the spec’s word: a regular binding is one with
a name, as opposed to a namespace binding (import * as ns).
Two interesting things happen at parse time.
First, the parser canonicalises indirect exports. Consider:
import { a as b } from "X";
export { b as c };The export { b as c } is semantically the same as
export { a as c } from "X". The parser rewrites it:
// src/ast/modules.h:248
// Find any implicitly indirect exports and make them explicit.
//
// An explicitly indirect export is an export entry arising from an export
// statement of the following form:
// export {a as c} from "X";
// An implicitly indirect export corresponds to
// export {b as c};
// in the presence of an import statement of the form
// import {a as b} from "X";
// This function finds such implicitly indirect export entries and rewrites
// them by filling in the import name and module request, as well as nulling
// out the local name. Effectively, it turns
// import {a as b} from "X"; export {b as c};
// into:
// import {a as b} from "X"; export {a as c} from "X";
// (The import entry is never deleted.)
void MakeIndirectExportsExplicit(Zone* zone);The rewrite moves the entry from regular_exports_ to special_exports_
and nulls out the local name:
// src/ast/modules.cc:246
void SourceTextModuleDescriptor::MakeIndirectExportsExplicit(Zone* zone) {
for (auto it = regular_exports_.begin(); it != regular_exports_.end();) {
Entry* entry = it->second;
DCHECK_NOT_NULL(entry->local_name);
auto ReExport = [&](const Entry* import_entry) {
// Found an indirect export. Patch export entry and move it from regular
// to special.
DCHECK_NULL(entry->import_name);
DCHECK_LT(entry->module_request, 0);
DCHECK_LE(0, import_entry->module_request);
DCHECK_LT(import_entry->module_request,
static_cast<int>(module_requests_.size()));
entry->import_name = import_entry->import_name;
entry->module_request = import_entry->module_request;
// Hack: When the indirect export cannot be resolved, we want the error
// message to point at the import statement, not at the export statement.
// Therefore we overwrite [entry]'s location here. Note that Validate()
// has already checked for duplicate exports, so it's guaranteed that we
// won't need to report any error pointing at the (now lost) export
// location.
entry->location = import_entry->location;
entry->local_name = nullptr;
AddSpecialExport(entry, zone);
it = regular_exports_.erase(it);
};The “hack” comment is a nice detail: after the rewrite, the export entry no longer has a meaningful source position of its own, so the error message for an unresolvable export is pointed at the import statement instead. That is the position a human would look at.
Second, the parser decides whether the module has a top-level await.
The trick is that a module body is compiled as a generator, with an
initial yield at the top:
// src/parsing/parser.cc:816
if (flags().is_module()) {
DCHECK(flags().is_module());
PrepareGeneratorVariables();
Expression* initial_yield = BuildInitialYield(
kNoSourcePosition, FunctionKind::kGeneratorFunction);
body.Add(
factory()->NewExpressionStatement(initial_yield, kNoSourcePosition));
ParseModuleItemList(&body);
// Modules will always have an initial yield. If there are any
// additional suspends, they are awaits, and we treat the module as a
// ModuleWithTopLevelAwait.
if (function_state.suspend_count() > 1) {
scope->set_module_has_toplevel_await();
}Compiling the module body as a generator function means the existing
generator machinery handles the suspension points. A top-level await
becomes a second suspend, and suspend_count() > 1 is how V8 knows the
module is async. The flag is stored in the module’s flags bitfield:
// src/objects/source-text-module.tq:8
bitfield struct SourceTextModuleFlags extends uint31 {
has_toplevel_await: bool: 1 bit;
async_evaluation_ordinal: uint32: 30 bit;
}From descriptor to heap objects
The descriptor is a parse-time structure. At instantiation it is
serialised into a SourceTextModuleInfo, which is a FixedArray with
five slots, and the module object itself is created from the
SharedFunctionInfo:
// src/heap/factory.cc:3936
DirectHandle<SourceTextModule> Factory::NewSourceTextModule(
DirectHandle<SharedFunctionInfo> sfi) {
DirectHandle<SourceTextModuleInfo> module_info(
sfi->scope_info()->ModuleDescriptorInfo(), isolate());
DirectHandle<ObjectHashTable> exports =
ObjectHashTable::New(isolate(), module_info->RegularExportCount());
DirectHandle<FixedArray> regular_exports =
NewFixedArray(module_info->RegularExportCount());
const uint32_t regular_imports_len =
module_info->regular_imports()->ulength().value();
DirectHandle<FixedArray> regular_imports = NewFixedArray(regular_imports_len);The tables are allocated empty. regular_exports has one slot per
regular export, regular_imports one slot per regular import, and
requested_modules one slot per module request. All three are
parallel-indexed by the cell index, so slot i of regular_imports is
the cell for the import with cell index -i-1:
// src/objects/source-text-module.cc:140
int SourceTextModule::ImportIndex(int cell_index) {
DCHECK_EQ(SourceTextModuleDescriptor::GetCellIndexKind(cell_index),
SourceTextModuleDescriptor::kImport);
return -cell_index - 1;
}Note the -1. Cell index -1 maps to slot 0. The offset is there
because cell index 0 is reserved for “not a module variable”, so
imports start at -1 and the array index has to be shifted.
The state machine
Before anything happens, a module is kUnlinked. It moves forward
through a fixed set of states, and the transitions are checked at compile
time:
// src/objects/module.h:72
enum Status : int {
kFirst,
// Order matters!
kUnlinked = kFirst,
kPreLinking,
kLinking,
kLinked,
kEvaluating,
kEvaluatingAsync,
kEvaluated,
kEvaluatedError,
kLast = kEvaluatedError
};with the legal edges spelled out:
// src/objects/module.h:99
static constexpr bool IsValidTransition(Status from, Status to) {
switch (from) {
case kUnlinked:
return to == kPreLinking;
case kPreLinking:
return to == kLinking || to == kLinked || to == kUnlinked;
case kLinking:
return to == kLinked || to == kUnlinked;
case kLinked:
return to == kEvaluating;
case kEvaluating:
return to == kEvaluatingAsync || to == kEvaluated ||
to == kEvaluatedError;
case kEvaluatingAsync:
return to == kEvaluated || to == kEvaluatedError;
case kEvaluated:
case kEvaluatedError:
return false;
}
}V8 goes further than checking at runtime. There is a typestate handle
Module::InState<ModuleT, kStates...> that only compiles when the
module is in one of the listed states, so a function that requires a
kLinking module cannot be handed a kEvaluating one:
// src/objects/module.h:125
// Typestate handle proving at compile time that [handle] is in one
// of [kStates...] and restricting state-dependent operations, transitions,
// and fields to the states where they are valid. Obtained only by branching
// via MatchState or as the result of a checked transition.
template <typename ModuleT, Status... kStates>
class InState;and the fields are restricted with C++20 requires clauses:
// src/objects/module.h:385
inline Tagged<SharedFunctionInfo> sfi() const
requires(IsAllowed<kUnlinked, kPreLinking, kEvaluatedError>());
inline Tagged<JSFunction> function() const
requires(IsAllowed<kLinking>());
inline Tagged<JSGeneratorObject> generator() const
requires(IsAllowed<kLinked, kEvaluating, kEvaluatingAsync, kEvaluated>());This is unusual for a JavaScript engine and it is a real quality win: the module lifecycle has a dozen states and the transition rules are non-trivial, and encoding them in the type system means a mistake is a compile error rather than a heisenbug.
Linking: the DFS
The linking phase has two halves.
PrepareInstantiate runs first and is purely about fetching the
dependencies. It calls the embedder’s resolver callback once per module
request and stores the resulting module objects in requested_modules:
// src/objects/source-text-module.cc:406
bool SourceTextModule::PrepareInstantiate(
Isolate* isolate, DirectHandle<InState<kPreLinking>> module,
v8::Local<v8::Context> context,
const Module::UserResolveCallbacks& callbacks) {
// One of the callbacks must be set, otherwise we cannot resolve.
DCHECK_IMPLIES(callbacks.module_callback == nullptr,
callbacks.module_callback_by_index != nullptr);
// Obtain requested modules.
DirectHandle<SourceTextModuleInfo> module_info(module->info(), isolate);
DirectHandle<FixedArray> module_requests(module_info->module_requests(),
isolate);
DirectHandle<FixedArray> requested_modules(module->requested_modules(),
isolate);This is where the embedder gets to say what “x” means. V8 does not know about file systems; the host does. The callback receives the specifier string and the import attributes and returns a module.
Then it recurses, so the whole graph is fetched before anything is resolved:
// src/objects/source-text-module.cc:486
// Recurse.
const uint32_t requested_modules_len = requested_modules->ulength().value();
for (uint32_t i = 0; i < requested_modules_len; ++i) {
DirectHandle<ModuleRequest> module_request(
Cast<ModuleRequest>(module_requests->get(i)), isolate);
if (module_request->phase() == ModuleImportPhase::kSource) {
continue;
}
Handle<Module> requested_module(Cast<Module>(requested_modules->get(i)),
isolate);
if (!Module::PrepareInstantiate(isolate, requested_module, context,
callbacks)) {
return false;
}
}Then it creates the export cells and the indirect export placeholders:
// src/objects/source-text-module.cc:502
// Set up local exports.
// TODO(neis): Create regular_exports array here instead of in factory method?
for (uint32_t i = 0, n = module_info->RegularExportCount(); i < n; ++i) {
int cell_index = module_info->RegularExportCellIndex(i);
DirectHandle<FixedArray> export_names(
module_info->RegularExportExportNames(i), isolate);
CreateExport(isolate, module, cell_index, export_names);
}CreateExport is where a local export becomes a Cell:
// src/objects/source-text-module.cc:155
void SourceTextModule::CreateExport(Isolate* isolate,
DirectHandle<InState<kPreLinking>> module,
int cell_index,
DirectHandle<FixedArray> names) {
const uint32_t names_len = names->ulength().value();
DCHECK_LT(0, names_len);
DirectHandle<Cell> cell = isolate->factory()->NewCell();
module->regular_exports()->set(ExportIndex(cell_index), *cell);
Handle<ObjectHashTable> exports(module->exports(), isolate);
for (uint32_t i = 0; i < names_len; ++i) {
DirectHandle<String> name(Cast<String>(names->get(i)), isolate);
DCHECK(IsTheHole(exports->Lookup(name)));
exports = ObjectHashTable::Put(isolate, exports, name, cell);
}
module->set_exports(*exports);
}One Cell, put into regular_exports at the slot for this cell index,
and into the export hash table under every name it is exported under.
That last part is what makes export { x, x as y } work: both names
point at the same cell.
The indirect exports go in as placeholders - the export table gets the
SourceTextModuleInfoEntry for now, and the entry is replaced with the
real cell later:
// src/objects/source-text-module.cc:511
// Partially set up indirect exports.
// For each indirect export, we create the appropriate slot in the export
// table and store its SourceTextModuleInfoEntry there. When we later find
// the correct Cell in the module that actually provides the value, we replace
// the SourceTextModuleInfoEntry by that Cell (see ResolveExport).
FinishInstantiate is the real work. It is a recursive DFS:
// src/objects/source-text-module.cc:699
bool SourceTextModule::FinishInstantiate(
Isolate* isolate, Handle<InState<kPreLinking>> pre_linking_module,
ZoneForwardList<Handle<InState<kLinking>>>* stack, unsigned* dfs_index,
Zone* zone, unsigned depth, unsigned* max_depth) {
// Instantiate SharedFunctionInfo and mark module as instantiating for
// the recursion.
Handle<InState<kLinking>> module =
TransitionToLinking(isolate, pre_linking_module, stack, dfs_index);
// Recurse.
DirectHandle<FixedArray> module_requests(module->info()->module_requests(),
isolate);TransitionToLinking pushes onto the DFS stack and assigns the DFS
index:
// src/objects/source-text-module.cc:531
Handle<SourceTextModule::InState<Module::kLinking>>
SourceTextModule::TransitionToLinking(
Isolate* isolate, Handle<InState<kPreLinking>> module,
ZoneForwardList<Handle<InState<kLinking>>>* stack, unsigned* dfs_index) {
DirectHandle<SharedFunctionInfo> shared(module->sfi(), isolate);
DirectHandle<JSFunction> function =
Factory::JSFunctionBuilder{isolate, shared, isolate->native_context()}
.Build();
module->set_code(*function);
Handle<InState<kLinking>> linking = TransitionStatus<kLinking>(module);
linking->set_dfs_index(*dfs_index);
linking->set_dfs_ancestor_index(*dfs_index);
(*dfs_index)++;
stack->push_front(linking);
return linking;
}Two indices: dfs_index is a global counter (when was this module
first visited) and dfs_ancestor_index is the index of the lowest
ancestor still on the stack. A module is the root of a strongly
connected component exactly when the two are equal:
// src/objects/source-text-module.cc:597
// 13/16. If module.[[DFSAncestorIndex]] = module.[[DFSIndex]], then
if (module->dfs_ancestor_index() == module->dfs_index()) {
// This is the root of its strongly connected component.
This is the standard way to find SCC roots in a DFS, and it is how V8 detects cycles without a separate cycle-finding pass. When the DFS comes back from a child and the child’s ancestor index is lower than ours, the child is part of our SCC and we adopt its index:
// src/objects/source-text-module.cc:735
MatchState(
requested_module,
[&](DirectHandle<Module::InState<Module, kLinking>> m) {
// SyntheticModules go straight to kLinked so this must be a
// SourceTextModule.
module->UpdateDfsAncestorIndex(Cast<InState<kLinking>>(m));
},After the recursion, the module resolves its own imports and indirect exports:
// src/objects/source-text-module.cc:749
// Resolve imports (InitializeEnvironment step 7) and indirect exports
// (InitializeEnvironment step 1).
//
// Note: Environment and namespace creation (InitializeEnvironment steps 5-6
// and 7.b-26) is performed by RunInitializationCode in
// TransitionComponentToLinked once all imports and indirect exports in the
// strongly connected component are resolved.
DirectHandle<FixedArray> regular_imports(module_info->regular_imports(),
isolate);
const uint32_t regular_imports_len = regular_imports->ulength().value();
for (uint32_t i = 0; i < regular_imports_len; ++i) {
DirectHandle<SourceTextModuleInfoEntry> entry(
Cast<SourceTextModuleInfoEntry>(regular_imports->get(i)), isolate);
Handle<String> name(Cast<String>(entry->import_name()), isolate);
MessageLocation loc(script, entry->beg_pos(), entry->end_pos());
ResolveSet resolve_set(zone);
DirectHandle<Cell> cell;
if (!ResolveImport(isolate, module, name, entry->module_request(), loc,
true, &resolve_set)
.ToHandle(&cell)) {
return false;
}
module->regular_imports()->set(ImportIndex(entry->cell_index()), *cell);
}That last line is the whole live-binding mechanism in one statement. The
cell that ResolveImport returned - the exporter’s cell - is stored
into the importer’s regular_imports table. Two modules, one cell.
ResolveImport in turn calls ResolveExport on the target module, and
ResolveExport is where indirect exports and star exports get
untangled:
// src/objects/source-text-module.cc:210
MaybeHandle<Cell> SourceTextModule::ResolveExport(
Isolate* isolate,
Handle<InState<kPreLinking, kLinking, kLinked, kEvaluatingAsync, kEvaluated,
kEvaluatedError>>
module,
DirectHandle<String> module_specifier, Handle<String> export_name,
MessageLocation loc, bool must_resolve, Module::ResolveSet* resolve_set) {
DCHECK(!export_name.is_null());
Handle<Object> object(module->exports()->Lookup(export_name), isolate);
if (IsCell(*object)) {
// Already resolved (e.g. because it's a local export).
return Cast<Cell>(object);
}If the export table already has a cell for that name, we are done - that is the local export case, and it is why the same cell is shared.
If the table has a hole, the export is indirect or a star export, and
the real cell lives in another module. The recursion happens here, and it
needs cycle detection - resolving a -> b -> a would otherwise loop
forever:
// src/objects/source-text-module.cc:225
// Check for cycle before recursing.
{
// Attempt insertion with a null string set.
auto result = resolve_set->insert({module, nullptr});
UnorderedStringSet*& name_set = result.first->second;
if (result.second) {
// |module| wasn't in the map previously, so allocate a new name set.
Zone* zone = resolve_set->zone();
name_set = zone->New<UnorderedStringSet>(zone);
} else if (name_set->count(export_name)) {
// Cycle detected.
if (must_resolve) {
isolate->ThrowAt(isolate->factory()->NewSyntaxError(
MessageTemplate::kCyclicModuleDependency,
export_name, module_specifier),
&loc);
return MaybeHandle<Cell>();
}
return MaybeHandle<Cell>();
}
name_set->insert(export_name);
}The ResolveSet is a map from module to the set of export names
currently being resolved for that module. A cycle is “I am already
resolving this exact name in this exact module”. Note that the set is
per-module, not global: resolving a.x and b.x at the same time is
fine, but resolving a.x while resolving a.x is not.
And when the indirect export finally resolves, the placeholder is replaced with the real cell:
// src/objects/source-text-module.cc:274
// The export table may have changed but the entry in question should be
// unchanged.
Handle<ObjectHashTable> exports(linking_module->exports(), isolate);
DCHECK(IsSourceTextModuleInfoEntry(exports->Lookup(export_name)));
exports = ObjectHashTable::Put(isolate, exports, export_name, cell);
linking_module->set_exports(*exports);
return cell;Star exports (export * from "x") are handled separately, because they
export many names and the set of names is only known once the target
module’s own exports are known. FetchStarExports walks the star
targets and merges their export tables, with a rule that default is
never re-exported:
// src/objects/source-text-module.cc:859
if (name->Equals(roots.default_string())) continue;
if (!IsTheHole(exports->Lookup(name))) continue;and if two star exports provide the same name with different cells, the name is marked ambiguous and becomes inaccessible:
// src/objects/source-text-module.cc:863
auto insert_result = more_exports.insert(std::make_pair(name, cell));
if (!insert_result.second) {
auto it = insert_result.first;
if (*it->second == *cell || IsUndefined(*it->second)) {
// We already recorded this mapping before, or the name is already
// known to be ambiguous. In either case, there's nothing to do.
} else {
DCHECK(IsCell(*it->second));
// Different star exports provide different cells for this name, hence
// mark the name as ambiguous.
it->second = isolate->factory()->undefined_value();
}
}undefined as the ambiguity marker is a nice trick: the export table
maps names to cells, and undefined is not a cell, so the ambiguity is
represented by an out-of-band value in a table whose value type is
Cell.
The namespace object
import * as ns from "m" gives you a namespace object: a special exotic
object whose properties are the module’s exports, and are live.
The namespace is built lazily and cached in a cell:
// src/objects/module.cc:485
Handle<Cell> Module::GetModuleNamespaceCell(Isolate* isolate,
Handle<Module> module,
ModuleImportPhase phase) {
ReadOnlyRoots roots(isolate);
DCHECK(phase == ModuleImportPhase::kEvaluation ||
phase == ModuleImportPhase::kDefer);
Tagged<Object> maybe_cell = phase == ModuleImportPhase::kEvaluation
? module->module_namespace()
: module->deferred_module_namespace();
if (!IsUndefined(maybe_cell)) {
return handle(Cast<Cell>(maybe_cell), isolate);
}
Handle<Cell> cell = isolate->factory()->NewCell();The cell is used as a cache slot so that two importers of the same
namespace get the same object. That matters: import * as a from "m"
and import * as b from "m" must give a === b.
The namespace’s properties are accessors, not data properties. Each export name gets an accessor that goes back to the module’s export table and reads the cell:
// src/objects/module.cc:567
// Create the properties in the namespace object. Transition the object
// to dictionary mode so that property addition is faster.
PropertyAttributes attr = DONT_DELETE;
JSObject::NormalizeProperties(isolate, ns, CLEAR_INOBJECT_PROPERTIES,
static_cast<int>(names.size()),
"JSModuleNamespace");
JSObject::NormalizeElements(isolate, ns);
DirectHandle<AccessorInfo> module_namespace_property_accessor =
isolate->factory()->module_namespace_property_accessor();
for (const auto& name : names) {
uint32_t index = 0;
if (name->AsArrayIndex(&index)) {
JSObject::SetNormalizedElement(
ns, index, module_namespace_property_accessor,
PropertyDetails(PropertyKind::kAccessor, attr,
PropertyCellType::kMutable));
} else {
JSObject::SetNormalizedProperty(
ns, name, module_namespace_property_accessor,
PropertyDetails(PropertyKind::kAccessor, attr,
PropertyCellType::kMutable))
.Check();
}
}
JSObject::PreventExtensions(isolate, ns, kThrowOnError).ToChecked();Every property is an accessor, so reading ns.x goes through
ModuleNamespaceEntryGetter:
// src/builtins/accessors.cc:244
void Accessors::ModuleNamespaceEntryGetter(
v8::Local<v8::Name> name, const v8::PropertyCallbackInfo<v8::Value>& info) {
i::Isolate* isolate = reinterpret_cast<i::Isolate>(info.GetIsolate());
HandleScope scope(isolate);
Tagged<JSModuleNamespace> holder =
Cast<JSModuleNamespace>(*Utils::OpenDirectHandle(*info.Holder()));
DirectHandle<Object> result;
if (holder->GetExport(isolate, Cast<String>(Utils::OpenDirectHandle(*name)))
.ToHandle(&result)) {
info.GetReturnValue().Set(Utils::ToLocal(result));
}
}and GetExport reads the cell, with a temporal-dead-zone check:
// src/objects/module.cc:610
MaybeDirectHandle<Object> JSModuleNamespace::GetExport(
Isolate* isolate, DirectHandle<String> name) {
DirectHandle<Object> object(module()->exports()->Lookup(name), isolate);
if (IsTheHole(*object)) {
return isolate->factory()->undefined_value();
}
DirectHandle<Object> value(Cast<Cell>(*object)->value(), isolate);
if (IsTdzHole(*value)) {
// According to https://tc39.es/ecma262/#sec-InnerModuleLinking
// step 10 and
// https://tc39.es/ecma262/#sec-source-text-module-record-initialize-environment
// step 8-25, variables must be declared in Link. And according to
// https://tc39.es/ecma262/#sec-module-namespace-exotic-objects-get-p-receiver,
// here accessing uninitialized variable error should be thrown.
THROW_NEW_ERROR(isolate,
NewReferenceError(
MessageTemplate::kAccessedUninitializedVariable, name));
}
return value;
}The TDZ hole is a third state a cell can be in: uninitialised. That
handles the import { x } from "m" where m has a circular dependency
on the importer - the cell exists but has no value yet, and touching it
is a ReferenceError.
The namespace is also made non-extensible and optimised as a prototype, with a comment that gives both reasons:
// src/objects/module.cc:593
// Optimize the namespace object as a prototype, for two reasons:
// - The object's map is guaranteed not to be shared. ICs rely on this.
// - We can store a pointer from the map back to the namespace object.
// Turbofan can use this for inlining the access.
JSObject::OptimizeAsPrototype(ns);Reading and writing at runtime
The bytecode that accesses a module variable is LdaModuleVariable and
StaModuleVariable. The implementation is where the cell indirection
becomes visible:
// src/interpreter/interpreter-generator.cc:870
// LdaModuleVariable <cell_index> <depth>
//
// Load the contents of a module variable into the accumulator. The variable is
// identified by <cell_index>. <depth> is the depth of the current context
// relative to the module context.
IGNITION_HANDLER(LdaModuleVariable, InterpreterAssembler) {
TNode<IntPtrT> cell_index = BytecodeOperandImmIntPtr(0);
TNode<Uint32T> depth = BytecodeOperandUImm(1);
TNode<Context> module_context = GetContextAtDepth(GetContext(), depth);
TNode<SourceTextModule> module =
CAST(LoadContextElementNoCell(module_context, Context::EXTENSION_INDEX));
Label if_export(this), if_import(this), end(this);
Branch(IntPtrGreaterThan(cell_index, IntPtrConstant(0)), &if_export,
&if_import);
BIND(&if_export);
{
TNode<FixedArray> regular_exports = LoadObjectField<FixedArray>(
module, offsetof(SourceTextModule, regular_exports_));
// The actual array index is (cell_index - 1).
TNode<IntPtrT> export_index = IntPtrSub(cell_index, IntPtrConstant(1));
TNode<Cell> cell =
CAST(LoadFixedArrayElement(regular_exports, export_index));
SetAccumulator(LoadObjectField(cell, offsetof(Cell, maybe_value_)));
Goto(&end);
}Positive cell index means export, negative means import, and the
depth operand says how far up the context chain the module context is.
The module object is reached through the context’s extension slot.
The store side is more restricted:
// src/interpreter/interpreter-generator.cc:944
BIND(&if_import);
{
// Not supported (probably never).
Abort(AbortReason::kUnsupportedModuleOperation);
Goto(&end);
}You cannot assign to an import. That is the spec rule - imports are read-only bindings - and V8 enforces it at the bytecode level rather than with a runtime check. The comment “probably never” is honest: the compiler should never generate a store to an import, so this is a belt-and-braces abort.
TurboFan does the same thing with a different mechanism, and it has a nice optimisation: if the module is a compile-time constant, the cell itself is a constant:
// src/compiler/js-typed-lowering.cc:1794
if (module_type.IsHeapConstant()) {
SourceTextModuleRef module_constant =
module_type.AsHeapConstant()->Ref().AsSourceTextModule();
OptionalCellRef cell_constant =
module_constant.GetCell(broker(), cell_index);
if (cell_constant.has_value()) {
return jsgraph()->ConstantNoHole(*cell_constant, broker());
}
}The cell is a constant, but the value inside the cell is not - and that is exactly right, because the cell is mutable. The import is constant; the binding it points at is not.
Evaluation: DFS post-order
Evaluation is the same shape as linking: a recursive DFS that runs each
module’s body after its dependencies. InnerModuleEvaluation is the
heart of it:
// src/objects/source-text-module.cc:1368
bool SourceTextModule::InnerModuleEvaluation(
Isolate* isolate, Handle<SourceTextModule> module,
ZoneForwardList<Handle<InState<kEvaluating>>>* stack, unsigned* dfs_index) {
// InnerModuleEvaluation(module, stack, index)
// The spec returns the module index for proper numbering of dependencies;
// we pass it by pointer instead and return a bool for success/failure.
return MatchState(
module,The state dispatch is the interesting bit. MatchState is a variadic
dispatcher over the module’s status, and the handlers are typed so that
each one only compiles for the states it handles:
// src/objects/source-text-module.cc:1376
[](DirectHandle<InState<kEvaluatingAsync, kEvaluating, kEvaluated>>) {
// 2. If module.[[Status]] is either EVALUATING-ASYNC or EVALUATED, then
// a. If module.[[EvaluationError]] is undefined, return index.
// 3. If module.[[Status]] is EVALUATING, return index.
return true;
},Step 2 is the cycle case: if the module is already kEvaluating, it is
on the DFS stack, and we return immediately without re-running it. That
is what makes a cycle terminate, and it is why the evaluation order
within a cycle is “whoever got there first”.
The recursion into dependencies is the post-order:
// src/objects/source-text-module.cc:1447
// 11. For each ModuleRequest Record required of
// module.[[RequestedModules]],
for (size_t i = 0, length = evaluation_list.size(); i < length; ++i) {
// b. If requiredModule.[[Phase]] is evaluation, then
Handle<Module> requested_module = evaluation_list[i];
// c. If requiredModule is a Cyclic Module Record, then
if (Handle<SourceTextModule> required_source_module;
TryCast(requested_module, &required_source_module)) {
// b. Set index to ? InnerModuleEvaluation(requiredModule, stack,
// index).
// (Out of order because InnerModuleEvaluation is type-driven.)
if (!InnerModuleEvaluation(isolate, required_source_module, stack,
dfs_index)) {
return false;
}The recursion happens before the module’s own body runs, so dependencies run first. That is the post-order.
There is one wrinkle: the order of the dependency list. V8 does not
simply use requested_modules in order. It deduplicates and handles the
defer phase specially:
// src/objects/source-text-module.cc:1415
Zone zone(isolate->allocator(), ZONE_NAME);
// There's an evaluation set to perform optimized check if a module is
// already in evaluation_list. It's necessary to keep evaluation order
// as it's seen to be spec compliant.
UnorderedModuleSet evaluation_set(&zone);
ZoneVector<Handle<Module>> evaluation_list(&zone);
UnorderedModuleSet seen_modules(&zone);The evaluation_set is a hash set, but the list preserves the
spec’s order. This matters because the spec’s evaluation order is
observable: if a.js and b.js both import c.js and both have
side effects, the order in which a and b run is specified.
The module’s own body then runs, either synchronously or as an async module:
// src/objects/source-text-module.cc:1549
// 12. If module.[[PendingAsyncDependencies]] > 0 or module.[[HasTLA]]
// is true, then
if (evaluating_module->HasPendingAsyncDependencies() ||
evaluating_module->has_toplevel_await()) {
// a. Assert: module.[[AsyncEvaluation]] is false and was never
// previously set to true.
DCHECK_EQ(evaluating_module->async_evaluation_ordinal(),
kNotAsyncEvaluated);
// b. Set module.[[AsyncEvaluation]] to true.
// c. NOTE: The order in which module records have their
// [[AsyncEvaluation]] fields transition to true is significant.
evaluating_module->set_async_evaluation_ordinal(
isolate->NextModuleAsyncEvaluationOrdinal());
// d. If module.[[PendingAsyncDependencies]] = 0, perform
// ExecuteAsyncModule(module).
// The execution may have been terminated and can not be resumed, so
// just raise the exception.
if (!evaluating_module->HasPendingAsyncDependencies()) {
MAYBE_RETURN(SourceTextModule::ExecuteAsyncModule(
isolate, evaluating_module),
false);
}
} else { // 13. Else,
// a. Perform ? module.ExecuteModule().
The “NOTE: The order in which module records have their [[AsyncEvaluation]] fields transition to true is significant” is the whole async-module scheduling mechanism in one comment. The ordinal assigned here is a global monotonically increasing counter, and it is used later to decide which async parent modules can resume, in what order.
Async modules and the top-level capability
An async module’s Evaluate returns a promise, and the module’s
“top level capability” is that promise:
// src/objects/source-text-module.cc:1010
MaybeDirectHandle<JSPromise> SourceTextModule::Evaluate(
Isolate* isolate, Handle<InState<kLinked>> module) {
DCHECK(IsUndefined(module->top_level_capability()));
// 5. Let stack be a new empty List.
Zone zone(isolate->allocator(), ZONE_NAME);
ZoneForwardList<Handle<InState<kEvaluating>>> stack(&zone);
unsigned dfs_index = 0;
// 6. Let capability be ! NewPromiseCapability(%Promise%).
DirectHandle<JSPromise> capability = isolate->factory()->NewJSPromise();
// 7. Set module.[[TopLevelCapability]] to capability.
module->set_top_level_capability(*capability);The Zone here is the DFS stack’s backing store - it is exactly the
“allocate a lot, throw it away” pattern the zone allocator is for.
The failure path is where the module state machine earns its keep. If evaluation throws, every module on the DFS stack has to be marked as errored, not just the one that threw:
// src/objects/source-text-module.cc:1030
if (!InnerModuleEvaluation(isolate, module, &stack, &dfs_index)) {
DirectHandle<Object> exception(isolate->exception(), isolate);
// a. For each Cyclic Module Record m in stack, do
DirectHandle<InState<kEvaluatedError>> errored_module;
for (DirectHandle<InState<kEvaluating>> m : stack) {
// i. Assert: m.[[Status]] is EVALUATING.
DCHECK(m->status() == kEvaluating);
// ii. Set m.[[Status]] to EVALUATED.
// iii. Set m.[[EvaluationError]] to result.
const bool is_root = *m == *module;
DirectHandle<InState<kEvaluatedError>> errored =
RecordError(isolate, m, exception);
if (is_root) errored_module = errored;
}and then the promise is rejected - except in the termination case, where V8 deliberately does not reject it:
// src/objects/source-text-module.cc:1048
// A termination exception cannot reject the capability: that would run
// embedder code (see Isolate::ReportPromiseReject). Drop the capability
// instead; Module::Evaluate materializes a rejected one for an errored
// module.
if (isolate->is_execution_terminating()) {The reasoning is subtle and worth spelling out: rejecting a promise runs embedder code (the unhandled-rejection hook), and during termination you must not run embedder code. So the capability is dropped and a fresh rejected one is created later if someone asks.
Trade-offs
What it buys.
- Live bindings for free. Because imports and exports are the same
Cellobject, “live” is not a special case in the runtime - it is the default. A write to an export is a write to a cell, and every importer sees it. - Correct cycles. The DFS ancestor index finds SCC roots without a separate graph algorithm, and the same mechanism gives the spec’s evaluation order.
- Compile-time state checking. The
InStatetypestate means the dozen-state lifecycle is checked by the compiler, not by convention.
What it costs.
- Everything is a heap object. Every module binding is a
Cellallocation. A module with a thousand exports has a thousand cells, and the export table is anObjectHashTableon top. - Namespace objects are accessor-based. Every property read on a namespace goes through a C++ accessor that does a hash lookup in the export table and a cell dereference. That is much slower than reading a data property, and it is the price of live bindings.
- The async machinery is intricate. The
async_evaluation_ordinal, the pending async dependency counters, the async parent module lists, and theAvailableAncestorsSetordering together implement a scheduling problem that is genuinely hard. - Two tables and a hash table per module.
regular_exportsandregular_importsare parallel arrays,exportsis a hash table, andrequested_modulesis another array. The bookkeeping is not free.
The bottom line
The mental model is: a module is three parallel arrays and a hash
table, and a binding is a Cell that two modules can share.
regular_exports[i] is the cell for the i-th local export.
regular_imports[i] is the cell for the i-th regular import - and
for an import, that cell is the same object as the exporter’s cell.
exports is a hash table from name to cell, so a namespace object can
look up a name and get the live value.
Everything else follows from that. The DFS is there to find the right order to initialise things in. The SCC detection is there to make cycles terminate. The TDZ hole is there because a cell can exist before its value does. And the accessor-based namespace is there because the value behind a name can change at any time.
The thing people get wrong
The belief is that import { x } from "m" copies x into the importing
module, so reassigning x in the importer is local and reassigning it
in the exporter is invisible. Both halves of that are wrong.
The import is a reference to a cell, so m’s later writes are visible.
And the importer cannot write to the binding at all - assigning to an
import is a syntax error, and at the bytecode level it is an
AbortReason::kUnsupportedModuleOperation rather than a runtime check.
The only way to change an imported binding is to call an exported
function that changes it, or to mutate an exported object.
The second misconception is that a module namespace is a snapshot of the
module’s exports taken at link time. It is not a snapshot at all: every
property on it is an accessor that reads through to the cell, which is
why Object.keys(ns) is stable but ns.x tracks the live value.