ESM sandbox internals
This page documents the ESM execution engine for maintainers. For user-facing behavior, see Native ESM support.
Modern micro-apps ship native ES modules. A Vite dev server serves one <script type="module"> per source file, wires them together with import/export, and relies on the browser's native module loader. qiankun's classic sandbox — which wraps source in with (this) { … } and reads the app's exports from the last global the script assigned — cannot run that code at all: with is a SyntaxError under ESM's forced strict mode, and lifecycle functions come from export, not from writes to window.
The ESM sandbox is qiankun v3's answer. EsmSandboxEngine runs a micro-app's native ES module graph through the same JS sandbox membrane — no bundler, no iframe, and no build-time plugin. The native ESM loader still owns instantiation and evaluation, so top-level await, circular dependencies, live bindings, and hoisting all behave exactly as they would without qiankun.
Why a separate engine
The classic path and the ESM path solve fundamentally different problems:
| Classic | ESM | |
|---|---|---|
| Source wrapping | with (this) { … } blob | top-of-module const/let { … } = __qk_view destructuring |
| Global coverage | explicit window access and bare names already present on the sandbox or host global | only names in the per-module destructuring set (base = esmDestructurableGlobals) |
Implicit global write foo = 1 | an existing global name resolves through the Proxy; a completely new undeclared name can escape to the real global | strict-mode ReferenceError — never reaches the set trap |
| Lifecycle discovery | sandbox.latestSetProp (a window write) | the entry module's exports / export default { … } |
| Remount | top level does not re-run; retained lifecycle functions are called again | top level does not re-run; import(sameBlob) returns the same namespace |
| Module identity | direct blob URL | synthetic specifier → import map → blob URL |
Rather than reinvent module resolution, the engine reuses three existing pieces: the streaming HTML-entry loader, the Proxy membrane, and the browser's own module loader. It only inserts itself between fetch and evaluation.
What triggers it
The engine exists only when sandbox is enabled (its default). It is constructed per micro-app instance inside loadApp, alongside the membrane. With sandbox: false there is no ESM sandbox and no membrane at all.
Dispatch happens by DOM node type in the loader's streaming pipeline (packages/shared/src/assets-transpilers/module.ts):
<script type="module">(withsrcor inline) is routed to the engine. The transpiler removes thesrcattribute and stashes it ondata-src, stampingdata-esm="true", so the browser's native loader never fetches or executes the original URL — which would bypass the sandbox entirely.<script type="importmap">is parsed by qiankun itself. The element'stypeis rewritten toqiankun-importmapso the browser never merges the sub-app's map into the host document's import map.- Classic
<script>/text/javascriptcontinues through the classic transpiler (thewith (this)blob path). Mixing classic and ESM scripts in one HTML entry is supported.
The technique
The engine parses module syntax with the pure-JavaScript parser from es-module-lexer/js, rewrites the source, and hands the result to the native loader:
- Fetch the module through a separate response cache configured by
loadApp(cacheable → retryable → throwable). Module sources do not consume the cache capacity reserved for HTML entries, Classic scripts, and styles. - Scan it with
es-module-lexer/js. The parser is synchronously ready andprepareEsmLexer()returns an already-resolved Promise;start()does not warm a WASM lexer. - Rewrite the source so that:
- references to sandboxed globals are destructured from the membrane view at the top of the module (
const { window, document, … } = __qk_view); - each static import specifier is replaced with a synthetic specifier of the form
`${instanceKey}/${resolvedUrl}`; import.metabecomes a local object preserving the realurl, andimport()becomes a sandbox-aware__qk_dynamic_import(...).
- references to sandboxed globals are destructured from the membrane view at the top of the module (
- Map each synthetic specifier to a blob URL through a dynamically injected, document-level
<script type="importmap">. - Evaluate with native
import(blobUrl)in document order.
Because instantiation stays with the native loader, the engine never re-implements module semantics — it only redirects where each module's source and its globals come from.
How globals are rewritten
The rewrite does not wrap the module in a proxy scope. Instead it scans the source for identifiers that appear in the base global set and destructures just those from the membrane view:
- Stable objects (
window,document, and the rest of the base set) are bound withconst { … } = __qk_view. Property access on them stays live because the object itself is the proxied view. - Live-bindable dunder flags (
__X__, e.g.__VUE_OPTIONS_API__) are bound withletand tracked, so that when the sandbox records a write to such a global later, already-evaluated modules see the fresh value.
The header is bootstrapped by importing a per-instance runtime module — import { __qk_view, __qk_resolve, __qk_dynamic_import, __qk_track } from "<instanceKey>/__runtime__" — rather than by reading globalThis or calling eval. Using import bindings avoids a temporal-dead-zone ReferenceError and keeps the sandbox compatible with CSP: the only added requirement is script-src blob:, never 'unsafe-eval'.
Execution order
Loading and evaluation are deliberately split around the HTML stream:
- During streaming, each module script calls
compartment.registerDocumentModule(...)synchronously in document order. This immediately kicks off the async transpile (fetch → lexer → rewrite → recursively pre-fetch dependencies in parallel) but defers evaluation. Every task is queued. - After the stream ends, the loader calls
compartment.importDocumentModules(). The facade drives queued preparation, import-map updates, and nativeimport(blobUrl)evaluation in document order. It returnsPromise<ModuleNamespace | undefined>; the loader uses the ESM namespace when available and otherwise falls back to ClassiclatestSetProp.
Choosing the entry namespace
Once all modules have run, the engine selects which module namespace carries the lifecycle functions:
- If a module carried an explicit
entryattribute, that module's namespace is the entry, and its failure rejects the whole app. - Otherwise the first executed namespace that looks like a lifecycle object (or whose
.defaultdoes) wins — matching a Vite entry that doesexport default { bootstrap, mount, unmount }. - Otherwise the last executed namespace is used, matching a single
<script type="module">HTML.
A non-entry module that throws, including through rejected top-level await, is logged to console.error and skipped; it does not immediately fail the app because a Classic application may incidentally contain an unrelated module script. A failure in an explicitly marked entry rejects entry loading. Without an explicit entry, loadApp validates the selected successful namespace and fails later if no valid lifecycle object remains. That load failure enters single-spa's global handler for a route-registered application, while loadMicroApp exposes it through the instance lifecycle promises instead.
Import maps
The engine works with two layers of import map, and they never mix:
- The sub-app's own map (
<script type="importmap">) is parsed into an internal table (bareSpecifier → absolute URL) and used only to resolve the sub-app's bare specifiers. Only theimportsfield is honored —scopesis parsed, warned about, and ignored in v1. - The injected runtime map maps
<instanceKey>/<absoluteUrl>to the blob URL the browser actually imports.
Native import maps are document-level, append-only, and first-wins on conflict. Isolation between instances therefore rests entirely on the instance key:
instanceKey = `__qk_${appName}_${instanceId}_${++instanceSeq}__`instanceSeq is a global monotonic counter that is never reused, so a retired key can never collide with a live entry. Only fresh entries are appended; a genuine collision on the same specifier with a different target logs a console.error (the browser silently keeps the first).
Long-lived shells accumulate entries
Because import-map entries are irrevocable in a real document, a shell that repeatedly loads and unloads micro-apps accumulates dead entries — unbounded string growth over the page's lifetime. This is a documented v1 limitation.
Realm bridge and redeclaration probing
The rewritten blobs run in the real global scope, so a bare __qk_* reference in the wrong place would reach the real global and escape the membrane. Two defenses guard the bridge:
- The realm accessor that returns a module's membrane view is installed under a per-copy, crypto-random key on
globalThis, and is further indexed by an unguessable per-instance token that is inlined only inside that instance's own runtime-module blob. The membrane also blacklists__qk_*names as defense in depth. User code that tries to import any__qk_-prefixed synthetic specifier is rejected with aQiankunError. (Indirect escapes such as(0, eval)('globalThis')remain possible, exactly as in the classic sandbox, and are explicitly out of scope.) - Redeclaration probing handles the case where the injected
const { window, … }header would collide with a module's own top-levelconst window = …, which is a parse-timeSyntaxError. Because import-map entries are irrevocable once flushed, the engine must catch this before flushing. It imports a probe blob whose runtime specifier is swapped to a never-registered target: parsing surfaces the redeclaration error while resolution is guaranteed to fail afterward, so the module never actually evaluates. The offending identifier is extracted, added to an exclusion set, and the module is rewritten again.
Vite dev specifics
The engine is designed to run a Vite dev server's native ESM output directly. See Make a Vite app qiankun-ready for the sub-app setup.
/@vite/clientis stubbed. The stub keepsupdateStyle/removeStyle(routed to the virtual head through the proxieddocument) but returns a no-op hot context and never opens the HMR WebSocket.- HMR is actively disabled, not passively degraded. The real Vite client's HMR host is a serve-time literal, so its WebSocket would connect from inside the sandbox and trigger a destructive full-page
location.reload(). Disabling it is deliberate — during development, edit and reload manually. - React Fast Refresh requires its preamble to run before component modules; ordering matters for it to initialize correctly.
CSS-as-JS can be lost on remount
Vite serves CSS as JS modules that inject styles at the module top level. Because remount does not re-run top-level code (see below) while unmount clears the virtual head, such styles can disappear on the second mount.
Lifecycle and caching
loadApp uses separate LRU response caches: 512 entries for module sources and the existing 50 entries for HTML entries, Classic scripts, and styles. Both key requests by canonical URL and effective credentials, cache in-flight Promises, clone responses for each reader, and discard failed responses.
Within one qiankun runtime copy, live application instances share the module response cache, so a later instance can reuse source responses that were already downloaded. Because module sources have their own capacity, a typical 270-module Vite development graph does not evict HTML entries or styles. unload invalidates every cache entry the application used, including HTML entries, Classic scripts, styles, and module sources, so a later load requests them again; an unfinished download is cancelled only when no other application is still using it. The 512-entry limit bounds the number of responses, not their total bytes; least recently used entries are evicted at capacity. When using createSandbox or Compartment directly, response caching depends on the caller-provided fetch; createSandbox can route module-source requests separately through compartmentOptions.moduleHost.fetch, which takes precedence over the top-level fetch for module sources only.
The engine still owns each instance's descriptors, rewritten modules, and namespaces separately; membrane views are never shared across instances. The instance module graph is not limited to 512 entries. dispose() releases the instance caches and blob URLs.
The ESM sandbox retains its module graph across mount/unmount, which changes one important assumption compared with the classic sandbox:
- Remount does not re-run top-level code.
import(sameBlobUrl)returns the same module namespace, so a module's top level executes exactly once — onlymount(props)runs again. Any per-mount state (the app instance, a store, a router) must be created insidemount(), not at module scope. Classic applications should follow the same lifecycle discipline: qiankun also reuses their discovered lifecycle functions without re-running entry scripts on remount. unmount()retains the engine;unload()disposes of it. Manually loaded apps use the handle'sunload()orunloadMicroApp(name); route applications useunloadApplication. The unified sandboxdispose()clears framework-held module graphs and configuration references, revokes blob URLs, unregisters the instance, and removes its injected import map scripts. Native browser import map entries and module registrations cannot be undone. Loading again creates a new engine and executes the entry afresh. See loadMicroApp.
let app;
export async function bootstrap() {
// Runs once. Safe for one-time setup only.
}
export async function mount(props) {
// Runs on every (re)mount — create per-instance state here.
app = createApp(props.container);
app.render();
}
export async function unmount(props) {
app.unmount();
app = null;
}For the full lifecycle contract, see Micro-app lifecycle and props.
Limitations
The ESM sandbox trades some classic-sandbox behaviors for native module semantics. Document these for your sub-app authors:
- Implicit global writes throw.
foo = 1withoutvar/window.is aReferenceErrorin a strict ESM module — it never reaches the membrane's set trap. Code that relied on implicit globals being sandboxed breaks instead. - Only base-set globals are membraned. Just the names in each module's destructuring set — a subset of
esmDestructurableGlobals— route through the membrane. Value- or getter-typed globals that a one-off snapshot cannot represent (innerWidth,devicePixelRatio,length,name,status,event, …) fall back to the real global and cannot be cleaned up on unmount. - Typed imports use native loading.
import x from '...' with { type: 'json' | 'css' }, WASM, and similar are mapped straight to the original URL and loaded natively, without instance isolation, with a one-timeconsole.warn. They require correct MIME types and CORS from the sub-app server. Relative specifiers in typed dynamic imports resolve against the blob URL — use absolute URLs. - Firefox does not enable the required ESM behavior by default. The runtime provides no shim fallback; use a Classic build when Firefox support is required. See Browser support for the supported range and capability probe.
- Observability regresses without source maps. Uncaught errors'
error.stackpoints atblob:<host-origin>/<uuid>;//# sourceURLonly changes the DevTools display name, not the stack URL or line numbers. Production error reporting cannot directly map ESM sub-app frames to real files, so source maps become a production necessity rather than a nicety.
See also
- The JS sandbox — the Proxy membrane the ESM engine reuses
- HTML-entry streaming loading — the pipeline that dispatches module scripts
- Architecture overview — how the pieces fit together
- Make a Vite app qiankun-ready — sub-app setup for native ESM
