examples/lazy-compilation/README.md
Design notes for lazy compilation implementation.
import('./module') just works, no user code changes (future goal)rolldown:exports contract - proxy modules export this; POC uses lazyMagic helper, later Rolldown runtime will unwrap automaticallyimport() become new lazy boundaries/lazy request returns compiled code, browser loads it as an ES modulemodule.id) consistently throughout the runtimeErr or panic is fine for POCLazy compilation is a development optimization that defers compilation of dynamically imported modules until they are actually requested at runtime.
import() is compiled just-in-time when the browser executes itimport('./foo') should just workimport()) - static imports are always compiledWhen a lazy module is requested:
import() within the lazy module) are not compiled - they become their own lazy boundariesEntry
├── sync-dep-1 (compiled immediately)
├── sync-dep-2 (compiled immediately)
└── import('./lazy-a') ← lazy boundary
├── sync-dep-3 (compiled when lazy-a is requested)
├── sync-dep-4 (compiled when lazy-a is requested)
└── import('./lazy-b') ← another lazy boundary (NOT compiled yet)
Users should not need to change their code. import('./module') just works.
rolldown:exports ContractProxy modules export a special named export 'rolldown:exports':
// Proxy module for lazy ./foo.js (NOT EXECUTED state)
const lazyExports = (async () => {
await import(`/@vite/lazy?id=${encodeURIComponent($PROXY_MODULE_ID)}&clientId=...`);
return __rolldown_runtime__.loadExports($MODULE_ID);
})();
export { lazyExports as 'rolldown:exports' };
'rolldown:exports' is a promise that resolves to the real module's exports
Rolldown's transform_ast hook automatically wraps all dynamic imports with an unwrapping helper:
// User code (unchanged)
const mod = await import('./lazy.js');
// Transformed by lazy compilation plugin
const mod = await import('./lazy.js').then(__unwrap_lazy_compilation_entry);
The helper is injected into each module that has dynamic imports:
function __unwrap_lazy_compilation_entry(m) {
var e = m['rolldown:exports'];
return e ? e : m;
}
This is safe for ALL dynamic imports: lazy modules return the promise, non-lazy modules pass through unchanged
A proxy module has two states that determine what content the LazyCompilationPlugin returns:
Returns the stub template that fetches via /lazy endpoint:
// proxy-module-template.js
const lazyExports = (async () => {
await import(
`/@vite/lazy?id=${encodeURIComponent($PROXY_MODULE_ID)}&clientId=${__rolldown_runtime__.clientId}`
);
return __rolldown_runtime__.loadExports($MODULE_ID);
})();
export { lazyExports as 'rolldown:exports' };
Returns the fetched template that directly imports the real module:
// proxy-module-template-fetched.js
const lazyExports = (async () => {
const mod = await import($MODULE_ID);
return mod;
})();
export { lazyExports as 'rolldown:exports' };
The state transition is managed by LazyCompilationContext.mark_as_fetched().
The dev server handles /@vite/lazy?id=...&clientId=... requests:
/abs/path/foo.js?rolldown-lazy=1)DevEngine.compile_lazy_entry(proxyModuleId, clientId) (Rust) / DevEngine.compileEntry(moduleId, clientId) (TS)import($MODULE_ID) triggers compilation of actual moduleLazy compilation involves data at two scopes:
clientIdData shared across all connected browser tabs:
| Data | Description |
|---|---|
| Module Graph | All resolved and compiled modules |
lazy_entries | Set of proxy module IDs discovered during resolution |
fetched_entries | Set of proxy modules that have been fetched via /lazy request |
| Build Output | Bundled JS files on disk/memory |
| Watched Files | Files monitored for changes |
Key behavior: Once a lazy module is fetched by any client, all subsequent clients receive the fetched template (which imports the real module directly). The build output is refreshed after lazy compilation, so future page loads get the fetched template without needing a /lazy request.
Data specific to each browser tab:
| Data | Description |
|---|---|
clientId | Unique identifier for the browser tab |
executed_modules | Modules the browser has actually executed (used for HMR boundary computation) |
These are distinct concepts at different scopes:
Fetched (session-level): The browser sent a /lazy request for this proxy module. The server has compiled the actual module and its dependencies. All clients now receive the fetched template.
Executed (client-level): The browser has actually run the module's code. Used for HMR to determine which modules need updates for a specific client.
A module can be fetched but not executed by a particular client (e.g., Client A fetched it, Client B hasn't navigated to that route yet).
After successful lazy compilation:
DevEngine notifies the coordinator via ModuleChanged messageRebuild task and marks output as stale/lazy request needed)When multiple lazy entries share common dependencies, the server filters out modules the client has already executed using executed_modules (populated via hmr:module-registered messages from the browser).
Entry
├── import('./lazy-a') ← lazy boundary
│ └── shared.js (sync dep)
└── import('./lazy-b') ← lazy boundary
└── shared.js (sync dep)
Normal flow (works correctly):
/@vite/lazy?id=lazy-a → Server returns patch with lazy-a + shared.jsshared.js runs, sends hmr:module-registeredexecuted_modules with shared.js/@vite/lazy?id=lazy-b → Server filters out shared.jslazy-b only → No duplicate execution ✓Race condition (edge case):
If the browser sends two /@vite/lazy requests in rapid succession (before the hmr:module-registered message from the first patch arrives), the server may not know about executed modules yet:
/@vite/lazy?id=lazy-a/@vite/lazy?id=lazy-b (before lazy-a patch executes)shared.js includedshared.js runs twice ✗Potential future enhancement: Add a runtime guard in generated init functions to check if a module is already registered before executing:
function init_shared_0() {
// Guard: skip if already initialized
if (__rolldown_runtime__.modules['shared.js']) {
return;
}
// ... module code
}
This would provide defense-in-depth against the race condition.
IMPORTANT: All runtime module lookups use stable IDs (stable_id), which are relative paths from the cwd (e.g., src/module.js).
This ensures consistency between:
loadExports("src/module.js") callregisterModule("src/module.js", ...) callcreateModuleHotContext("src/module.js") callimport.meta.hot.accept("src/dep.js", ...) specifiersapplyUpdates([["src/boundary.js", "src/acceptedVia.js"]]) boundariesThe lazy compilation plugin computes the stable ID in-place using the cwd obtained from the build_start hook.
Note: The proxy module's /@vite/lazy?id=... request still uses the absolute path (with ?rolldown-lazy=1), and the fetched template's import($MODULE_ID) also uses the absolute path for module resolution.
The LazyCompilationPlugin maintains two sets in LazyCompilationContext:
lazy_entries - All proxy module IDs created during resolutionfetched_entries - Proxy module IDs that have been fetched (requested at runtime via /lazy)When resolve_id is called for a dynamic import:
None (skip proxy creation, resolve to actual module)lazy_entriesWhen load is called for a proxy module:
fetched_entries → return fetched templateAfter successful lazy compilation, the dev engine notifies the coordinator:
// In DevEngine::compile_lazy_entry
if result.is_ok() {
self.notify_module_changed(proxy_module_id);
}
The coordinator handles ModuleChanged:
TaskInput::Rebuild with the module as changedhas_stale_bundle_output = trueThis ensures future page loads get the fetched template directly (no /lazy request needed).
Note: the current implementation notifies with the proxy module ID (includes ?rolldown-lazy=1), so the rebuild path should resolve or normalize it to a real module ID.
Err or panic - fine for POCclientId┌─────────────────────────────────────────────────────────────────────────┐
│ 1. INITIAL BUILD │
├─────────────────────────────────────────────────────────────────────────┤
│ - Entry + sync dependencies compiled normally │
│ - Dynamic imports (import()) → replaced with proxy modules │
│ - Proxy module ID: /abs/path/module.js?rolldown-lazy=1 │
│ - Proxy contains STUB template (fetches via /lazy endpoint) │
│ - Proxy exports 'rolldown:exports' promise │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ 2. BROWSER LOADS INITIAL BUNDLE │
├─────────────────────────────────────────────────────────────────────────┤
│ - Runtime initializes │
│ - Proxy module registers: registerModule("/abs/.../mod.js?rolldown-lazy=1")
│ - Stub template is ready to fetch on demand │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ 3. USER CODE HITS: import('./lazy-module') │
├─────────────────────────────────────────────────────────────────────────┤
│ - Proxy module executes (stub template) │
│ - Fetches: /@vite/lazy?id=/abs/path/lazy-module.js?rolldown-lazy=1&clientId=xxx
│ - Browser waits on the promise │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ 4. DEV SERVER RECEIVES /lazy REQUEST │
├─────────────────────────────────────────────────────────────────────────┤
│ - Receives proxyModuleId = "/abs/path/lazy-module.js?rolldown-lazy=1" │
│ - Calls DevEngine.compile_lazy_entry(proxyModuleId, clientId) │
│ - DevEngine marks proxy as FETCHED in LazyCompilationContext │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ 5. PARTIAL SCAN FROM PROXY MODULE │
├─────────────────────────────────────────────────────────────────────────┤
│ - ScanMode::Partial([proxyModuleId]) │
│ - Plugin's load hook sees proxy is fetched → returns FETCHED template │
│ - Fetched template: import("/abs/path/lazy-module.js") │
│ - Plugin's resolve_id sees importer is fetched proxy → returns None │
│ - Dynamic import resolves to ACTUAL module (no new proxy) │
│ - Actual module + sync dependencies are compiled │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ 6. RETURN COMPILED JS TO BROWSER │
├─────────────────────────────────────────────────────────────────────────┤
│ - Response contains: │
│ - Proxy module (with fetched template) │
│ - Actual module (/abs/path/lazy-module.js) │
│ - All sync dependencies of actual module │
│ - Browser loads the code as an ES module │
│ - registerModule() called for each module │
│ - loadExports() finds actual module → returns real exports │
│ - Original import() promise resolves │
└─────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ 7. BUILD OUTPUT REFRESH (Background) │
├─────────────────────────────────────────────────────────────────────────┤
│ - DevEngine sends CoordinatorMsg::ModuleChanged { proxyModuleId } │
│ - Coordinator queues TaskInput::Rebuild │
│ - has_stale_bundle_output = true │
│ - Rebuild updates build output with fetched template │
│ - Future page loads get fetched template directly (no /lazy needed) │
└─────────────────────────────────────────────────────────────────────────┘
Problem: The proxy module, compiled module, and HMR runtime must use the same ID format for module lookups to work.
Solution: Use stable IDs (stable_id, relative paths from cwd) consistently in the runtime:
registerModule(stableId, exports)loadExports(stableId)createModuleHotContext(stableId)import.meta.hot.accept(stableId, callback)applyUpdates([[boundaryStableId, acceptedViaStableId]])The lazy compilation plugin computes the stable ID in its load hook using the cwd obtained from the build_start hook.
Problem: The initial lazy load worked correctly, but on page refresh:
/lazy againRoot cause: The proxy module content never changed after being fetched. The plugin always returned the same stub template.
Solution: Implement two-state proxy modules:
fetched_entries set to LazyCompilationContextlazy_ctx.mark_as_fetched(&proxy_module_id)load hook, check state and return appropriate template:
let template = if self.fetched_entries.contains(args.id) {
include_str!("./proxy-module-template-fetched.js")
} else {
include_str!("./proxy-module-template.js")
};
Problem: After marking proxy as fetched, the fetched template's import($MODULE_ID) was being intercepted by resolve_id hook, which created ANOTHER proxy for the same module - causing infinite recursion.
Solution: In resolve_id, skip proxy creation when the importer is a fetched proxy:
if let Some(importer) = args.importer {
if importer.contains("?rolldown-lazy=1") && self.fetched_entries.contains(importer) {
return Ok(None); // Let normal resolution happen
}
}
This allows the fetched template's dynamic import to resolve to the actual module.
Problem: After the first lazy load, the build output on disk still had the stub template. Page refresh would show the stub again, requiring another /lazy request.
Solution: Notify the coordinator to trigger a rebuild after successful lazy compilation (ideally with a real module ID):
// In DevEngine::compile_lazy_entry
if result.is_ok() {
self.notify_module_changed(proxy_module_id);
}
// notify_module_changed sends:
CoordinatorMsg::ModuleChanged { module_id }
// Coordinator handles it:
self.queued_tasks.push_back(TaskInput::Rebuild { changed_files });
self.has_stale_bundle_output = true;
Problem: The HMR finalizer was generating invalid JavaScript:
// INVALID - colon in identifier
var exports = __rolldown_runtime__.__export({ rolldown:exports: () => lazyExports });
Solution: Use is_validate_identifier_name() to detect non-identifier export names and use computed property syntax:
let computed = !is_validate_identifier_name(exported.as_str());
self.snippet.object_property_kind_object_property(exported, expr, computed)
This generates valid JavaScript:
// VALID - computed property
var exports = __rolldown_runtime__.__export({
['rolldown:exports']: () => lazyExports,
});
Problem: There were TWO implementations of rewrite_hot_accept_call_deps:
HmrAstFinalizer (for HMR patches)ScopeHoistingFinalizer (for regular builds with dev mode)Only updating one left the other using stable_id.
Solution: Always search for all implementations when changing behavior. Use grep to find all occurrences.
The lazy compilation plugin creates two distinct module IDs:
/abs/path/module.js?rolldown-lazy=1 (loaded initially, contains stub/fetched code)/abs/path/module.js (compiled on-demand, contains real code)The flow is:
module.js?rolldown-lazy=1 with stub template/@vite/lazy?id=...?rolldown-lazy=1loadExports("/abs/path/module.js") finds and returns the exportsThe lazy compilation plugin injects helper functions with double-underscore prefix (e.g., __unwrap_lazy_compilation_entry). This is a standard convention for internal/reserved identifiers in JavaScript bundlers and should not conflict with user code.
The injected helper function is inserted after any directive prologues (e.g., "use strict") to preserve their semantics. The plugin counts leading string literal expression statements and inserts the helper after them.
For future debugging, these files handle lazy compilation:
crates/rolldown_plugin_lazy_compilation/src/lazy_compilation_plugin.rs - Plugin with resolve_id, load, and transform_ast hooks, LazyCompilationContext with fetched state trackingcrates/rolldown_plugin_lazy_compilation/src/runtime_injector.rs - AST visitor for transforming dynamic imports and helper function generationcrates/rolldown_plugin_lazy_compilation/src/proxy-module-template.js - Stub template (not fetched)crates/rolldown_plugin_lazy_compilation/src/proxy-module-template-fetched.js - Fetched templatecrates/rolldown_dev/src/dev_engine.rs - compile_lazy_entry(), notify_module_changed()crates/rolldown_dev/src/types/coordinator_msg.rs - ModuleChanged message variantcrates/rolldown_dev/src/bundle_coordinator.rs - Handles ModuleChanged, triggers rebuildcrates/rolldown/src/hmr/hmr_stage.rs - compile_lazy_entry() partial scan logiccrates/rolldown/src/hmr/hmr_ast_finalizer.rs - Export generation with computed property supportcrates/rolldown/src/hmr/utils.rs - create_register_module_stmt(), create_module_hot_context_initializer_stmt()crates/rolldown_plugin_lazy_compilation/crates/rolldown_dev/examples/lazy/