Skip to content

output.inlineDynamicImports

Overview

inlineDynamicImports: true inlines all dynamically imported modules into the main chunk, producing a single output file. This article digs into the full implementation from the source level.

javascript
// rollup.config.js
export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'es',
    inlineDynamicImports: true
  }
};

Version and reference notes

This article analyzes the current Rollup source. References below focus on file paths and key function names. Since the source evolves, line numbers may change; use code snippets or function names to locate them.


Core question: Why can Promise.resolve().then(() => namespace) replace import()?

This is the key to understanding the feature. Let's start from the ECMAScript spec and derive why the transformation is valid.

ECMAScript semantic requirements for import()

According to the ECMAScript spec, import('./foo.js') must satisfy three core semantics:

RequirementExplanation
Returns a PromiseThe import() expression must evaluate to a Promise object
Resolve value is a Module NamespaceThe Promise must resolve to the target module namespace object
Async execution guarantee.then() callbacks never run synchronously, even if cached

Any correct substitute must satisfy all three.

Detailed analysis: how each condition is satisfied

Condition 1: Returns a Promise

javascript
Promise.resolve().then(() => namespace)

Evaluation steps:

  1. Promise.resolve() creates a fulfilled Promise
  2. .then(callback) returns a new Promise
  3. The new Promise resolves to the callback return value after the callback runs

Therefore, the caller still gets a Promise; the following all work:

javascript
// await syntax
const module = await import('./foo.js');

// .then() syntax
import('./foo.js').then(m => console.log(m));

// Promise.all syntax
const [a, b] = await Promise.all([import('./a.js'), import('./b.js')]);

Condition 2: resolve value is a Module Namespace Object

Rollup generates a namespace variable for each dynamically imported module:

javascript
var foo$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  foo: foo,
  bar: bar
});

This object approximates the behavior of an ECMAScript Module Namespace Object under Rollup's semantics, but configuration and context affect details:

Spec requirementRollup implementation
[[Prototype]] is null__proto__: null (always added)
Non-extensible, non-configurableObject.freeze() (only when output.freeze is true)
Symbol.toStringTagGenerated only when output.generatedCode.symbols is enabled
Includes all named exportsProperties from module.getExportedVariablesByName()

Note: This is still a "plain object + constraints" implementation, not a language-level Module Namespace Exotic Object. It aims for a minimal, sufficient observable behavior.

Source location: src/ast/variables/NamespaceVariable.ts (renderBlock)

typescript
renderBlock(options: RenderOptions): string {
  const memberVariables = this.module.getExportedVariablesByName();
  const members: [key: string | null, value: string][] = [...memberVariables.entries()]
    .filter(([name, variable]) => !name.startsWith('*') && variable.included)
    .map(([name, variable]) => {
      // 如果变量被提前引用或可重新赋值,使用 getter 形式
      if (this.referencedEarly || variable.isReassigned || variable === this) {
        return [
          null,
          `get ${stringifyObjectKeyIfNeeded(name)}${_}()${_}{${_}return ${variable.getName(
            getPropertyAccess
          )}${s}${_}}`
        ];
      }
      // 否则直接引用变量
      return [name, variable.getName(getPropertyAccess)];
    });

  // 关键:添加 __proto__: null
  members.unshift([null, `__proto__:${_}null`]);

  let output = getObject(members, { lineBreakIndent: { base: '', t } });

  // 关键:根据配置可选冻结对象(output.freeze 默认 true)
  if (freeze) {
    output = `/*#__PURE__*/Object.freeze(${output})`;
  }

  return `${cnst} ${name}${_}=${_}${output};`;
}

Notes:

  • If namespace merging exists (mergedNamespaces), the flow goes through the mergeNamespaces helper; freeze and Symbol.toStringTag are handled there.
  • Symbol.toStringTag injection also depends on output.generatedCode.symbols, which is on by default but can be disabled.

About the getter form: When an exported variable can be reassigned, getters ensure the latest value on every access:

javascript
// 如果 foo 可能被重新赋值
var foo$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  get foo() { return foo; }  // 使用 getter
});

Condition 3: Async execution guarantee

This is the most critical point. The callback in Promise.resolve().then(cb) never runs synchronously.

Per the ECMAScript Promise spec, .then() callbacks are always scheduled in the microtask queue, even if the Promise is already resolved:

javascript
console.log('A');
Promise.resolve().then(() => console.log('B'));
console.log('C');
// 输出顺序: A, C, B

Native import() follows the same behavior. Even if the module is cached, the .then() callback is async, so the timing semantics match.

Note: This transformation only preserves Promise and microtask ordering, not the full loading/parsing/execution error semantics of native import(). After inlining, modules execute synchronously during bundle initialization, so error timing may differ (see "Semantic differences" below).

Why not other options?

OptionIssue
namespaceNot a Promise; all .then() and await will fail
Promise.resolve(namespace)Works, but Rollup reuses the .then() form from getDirectReturnFunction
new Promise(resolve => resolve(namespace))More verbose with identical effect

Full transformation flow: from source to output

Let's trace a concrete example end to end.

Input code

javascript
// main.js
console.log('main: start');
const promise = import('./foo.js');
promise.then(m => console.log(m.foo));
console.log('main: end');

// foo.js
console.log('foo: side effect');
export const foo = 42;

Flow visualization

inlineDynamicImports Transform Flow

8 STEPS
Option Validation
Validate
Checks mutual exclusivity constraints with multiple entries, preserveModules, and manualChunks
normalizeOutputOptions.ts:184-216
Execution Order Analysis
Analyze
Traverses entry modules to assign execIndex; dynamic import modules receive higher execIndex values
executionOrder.ts:22-84
Chunk Assignment
Transform
Places all modules into a single chunk, completely bypassing the getChunkAssignments() algorithm
Bundle.ts:181-192
Module Sorting
Transform
Sorts by execIndex in ascending order, ensuring entry module code precedes dynamic module code
Bundle.ts:197
Namespace Registration
Transform
Marks dynamically imported modules within the same chunk and generates namespace objects
Chunk.ts:1549-1561
Dynamic Import Resolution
Transform
When chunk === this, calls setInternalResolution to set up inlineNamespace
Chunk.ts:1390-1410
Code Generation
Render
import() → Promise.resolve().then(() => namespace)
ImportExpression.ts:196-213
Namespace Rendering
Render
Generates Object.freeze({ __proto__: null, ...exports })
NamespaceVariable.ts:130-184
▸ Final Output
const promise = Promise.resolve().then(function () { return namespace; });
Validate
Analyze
Transform
Render

Stage 1: Option validation

Source location: src/utils/options/normalizeOutputOptions.ts (getInlineDynamicImports)

typescript
const getInlineDynamicImports = (
  config: OutputOptions,
  inputOptions: NormalizedInputOptions
): NormalizedOutputOptions['inlineDynamicImports'] => {
  const inlineDynamicImports = config.inlineDynamicImports || false;
  const { input } = inputOptions;

  // 关键检查:不能与多入口一起使用
  if (inlineDynamicImports && (Array.isArray(input) ? input : Object.keys(input)).length > 1) {
    return error(
      logInvalidOption(
        'output.inlineDynamicImports',
        URL_OUTPUT_INLINEDYNAMICIMPORTS,
        'multiple inputs are not supported when "output.inlineDynamicImports" is true'
      )
    );
  }
  return inlineDynamicImports;
};

Why this restriction?

Single-file output cannot separate multiple entry boundaries. With multiple entries, each entry should have its own output file, which conflicts with "merge everything into one file" semantics.

Mutual exclusivity checks (same file getPreserveModules / getManualChunks) also include:

  • Cannot be used with preserveModules (one wants a single file, the other preserves module structure)
  • Cannot be used with manualChunks (manual chunking conflicts with disabling chunking)

Stage 2: Module execution order analysis

Source location: src/utils/executionOrder.ts (analyseModuleExecution)

This is a key preprocessing step. Rollup must determine execution order so it can lay out code correctly in the output file.

typescript
export function analyseModuleExecution(entryModules: readonly Module[]): {
  cyclePaths: string[][];
  orderedModules: Module[];
} {
  let nextExecIndex = 0;
  const dynamicImports = new Set<Module>();
  const analysedModules = new Set<Module | ExternalModule>();
  const parents = new Map<Module | ExternalModule, Module | null>();
  const orderedModules: Module[] = [];

  const analyseModule = (module: Module | ExternalModule) => {
    if (module instanceof Module) {
      // 1. 先递归处理静态依赖
      for (const dependency of module.dependencies) {
        handleSyncLoadedModule(dependency, module);
      }

      // 2. 隐式要求提前执行的模块
      for (const dependency of module.implicitlyLoadedBefore) {
        dynamicImports.add(dependency);
      }

      // 3. 处理动态导入:TLA 会被当作同步依赖
      for (const { node: { resolution, scope } } of module.dynamicImports) {
        if (resolution instanceof Module) {
          if (scope.context.usesTopLevelAwait) {
            handleSyncLoadedModule(resolution, module);
          } else {
            dynamicImports.add(resolution);
          }
        }
      }

      orderedModules.push(module);
    }

    // 4. 分配 execIndex
    module.execIndex = nextExecIndex++;
    analysedModules.add(module);
  };

  // 第一轮:处理入口模块及其静态依赖
  for (const currentEntry of entryModules) {
    if (!parents.has(currentEntry)) {
      parents.set(currentEntry, null);
      analyseModule(currentEntry);
    }
  }

  // 第二轮:处理动态导入的模块(非 TLA 路径)
  for (const currentEntry of dynamicImports) {
    if (!parents.has(currentEntry)) {
      parents.set(currentEntry, null);
      analyseModule(currentEntry);
    }
  }

  return { cyclePaths, orderedModules };
}

Key insight: Usually dynamically imported modules are processed in the second pass, so their execIndex is greater than entries and static deps. But if the import happens in a module with Top-Level Await, the target module is treated as a sync dependency and may be ordered earlier.

In our example (typical case without TLA):

ModuleexecIndexReason
main.js0Entry module, first pass
foo.js1Dynamic import target, second pass

Stage 3: Chunk assignment (core branch)

Source location: src/Bundle.ts (generateChunks)

This is the core implementation of inlineDynamicImports, done in a single line:

typescript
const executableModule = inlineDynamicImports
  ? [{ alias: null, modules: includedModules }]  // ← 关键:所有模块放入单个数组元素
  : preserveModules
    ? includedModules.map(module => ({ alias: null, modules: [module] }))
    : getChunkAssignments(
        this.graph.entryModules,
        manualChunkAliasByEntry,
        experimentalMinChunkSize,
        this.inputOptions.onLog,
        typeof manualChunks === 'function',
        onlyExplicitManualChunks
      );

Code walkthrough:

  1. includedModules is the array of all included (tree-shaken) modules
  2. With inlineDynamicImports: true, all modules are wrapped into a single { alias: null, modules: [...] }
  3. This bypasses the complex getChunkAssignments() chunking algorithm
  4. Result: only one Chunk instance is created, containing all modules

Comparison with other modes:

ModeBehavior
inlineDynamicImports: trueAll modules -> 1 chunk
preserveModules: trueEach module -> 1 chunk
DefaultUse getChunkAssignments()

Stage 4: Module ordering

Source location: src/Bundle.ts (inside generateChunks, sortByExecutionOrder)

typescript
for (const { alias, modules } of executableModule) {
  sortByExecutionOrder(modules);  // ← 关键:按 execIndex 排序
  const chunk = new Chunk(modules, ...);
}

sortByExecutionOrder is straightforward (src/utils/executionOrder.ts):

typescript
const compareExecIndex = <T extends OrderedExecutionUnit>(unitA: T, unitB: T) =>
  unitA.execIndex > unitB.execIndex ? 1 : -1;

export function sortByExecutionOrder(units: OrderedExecutionUnit[]): void {
  units.sort(compareExecIndex);
}

Why sorting?

It ensures the module code order in the output matches the original execution order. In our example:

  • main.js (execIndex=0) appears first
  • foo.js (execIndex=1) appears later

This guarantees: entry module sync code runs first, dynamic import modules run after entry code.

Stage 5: Namespace object registration

Source location: src/Chunk.ts (in addModule, includedNamespaces logic)

When a Chunk is created, it determines which modules need namespace objects:

typescript
for (const {
  node: { included, resolution }
} of module.dynamicImports) {
  if (
    included &&                                    // 动态导入被包含(未被 tree-shake)
    resolution instanceof Module &&                // 目标是内部模块(非外部模块)
    this.chunkByModule.get(resolution) === this && // 目标模块在同一个 chunk 内
    !this.includedNamespaces.has(resolution)       // 尚未注册
  ) {
    this.includedNamespaces.add(resolution);       // 注册到集合中
    this.ensureReexportsAreAvailableForModule(resolution);
  }
}

Key condition: this.chunkByModule.get(resolution) === this

This checks whether the dynamic import target module is in the same chunk. With inlineDynamicImports: true, internal modules are typically in the same chunk, so most internal dynamic imports hit this branch. But the following cases do not:

  • The dynamic import is tree-shaken out (included === false)
  • The target is an external module (resolution is not an internal Module)

includedNamespaces is a Set<Module> that records all modules that need namespace objects. The render phase later iterates this set to generate namespace variables.

Stage 6: Dynamic import resolution

Source location: src/Chunk.ts (setDynamicImportResolutions)

typescript
private setDynamicImportResolutions(fileName: string) {
  for (const resolvedDynamicImport of this.getIncludedDynamicImports()) {
    if (resolvedDynamicImport.chunk) {
      const { chunk, facadeChunk, node, resolution } = resolvedDynamicImport;

      if (chunk === this) {
        // 关键分支:目标模块在同一个 chunk 内
        node.setInternalResolution(resolution.namespace);
      } else {
        // 目标模块在不同 chunk 内
        node.setExternalResolution(
          (facadeChunk || chunk).exportMode,
          outputOptions,
          snippets,
          pluginDriver,
          accessedGlobalsByScope,
          `'${(facadeChunk || chunk).getImportPath(fileName)}'`,
          ...
        );
      }
    }
  }
}

Core logic:

  1. Iterate over all dynamic import expressions
  2. Check whether the target module's chunk is the current chunk (chunk === this)
  3. If it is the same chunk -> call setInternalResolution (inline)
  4. If it is a different chunk -> call setExternalResolution (keep dynamic import)

With inlineDynamicImports: true, internal modules are all in one chunk, so internal dynamic imports go through setInternalResolution. External dynamic imports still go through setExternalResolution and are rendered based on output format and plugin hooks.

Stage 7: Set inlineNamespace

Source location: src/ast/nodes/ImportExpression.ts (setInternalResolution)

typescript
setInternalResolution(inlineNamespace: NamespaceVariable): void {
  this.inlineNamespace = inlineNamespace;
}

This method simply stores the namespace variable reference on the ImportExpression node. The render phase checks this property to decide how to generate code.

Stage 8: Code generation

Source location: src/ast/nodes/ImportExpression.ts (render)

typescript
render(code: MagicString, options: RenderOptions): void {
  const {
    snippets: { _, getDirectReturnFunction, getObject, getPropertyAccess },
  } = options;

  if (this.inlineNamespace) {
    // 关键:生成 Promise.resolve().then(() => namespace) 代码
    const [left, right] = getDirectReturnFunction([], {
      functionReturn: true,
      lineBreakIndent: null,
      name: null
    });
    code.overwrite(
      this.start,
      this.end,
      `Promise.resolve().then(${left}${this.inlineNamespace.getName(getPropertyAccess)}${right})`
    );
    return;
  }

  // 正常渲染路径(非内联)...
}

Code walkthrough:

  1. If this.inlineNamespace exists, enter the inline render branch
  2. getDirectReturnFunction([], {...}) generates a function wrapper based on config
  3. If arrow functions are enabled, it produces () => and ``
  4. Otherwise it produces function () { return and ; }
  5. this.inlineNamespace.getName(getPropertyAccess) gets the namespace variable name (e.g. foo$1)
  6. code.overwrite(start, end, ...) replaces the original import('./foo.js') expression

Final generated code:

javascript
Promise.resolve().then(function () { return foo$1; })
// 或(如果启用箭头函数)
Promise.resolve().then(() => foo$1)

Stage 9: Namespace object rendering

Source location: src/ast/variables/NamespaceVariable.ts (renderBlock)

During chunk rendering, each module in includedNamespaces calls renderBlock:

typescript
renderBlock(options: RenderOptions): string {
  const memberVariables = this.module.getExportedVariablesByName();

  // 1. 收集所有导出
  const members: [key: string | null, value: string][] = [...memberVariables.entries()]
    .filter(([name, variable]) => !name.startsWith('*') && variable.included)
    .map(([name, variable]) => {
      // 如果变量被提前引用或可重新赋值,使用 getter
      if (this.referencedEarly || variable.isReassigned || variable === this) {
        return [null, `get ${name}() { return ${variable.getName(...)}; }`];
      }
      return [name, variable.getName(...)];
    });

  // 2. 添加 __proto__: null
  members.unshift([null, `__proto__: null`]);

  // 3. 生成对象字面量
  let output = getObject(members, ...);

  // 4. 添加 Object.freeze(如果启用)
  if (freeze) {
    output = `/*#__PURE__*/Object.freeze(${output})`;
  }

  // 5. 生成完整的变量声明
  return `${cnst} ${name} = ${output};`;
}

Final output

After all stages, our example produces:

javascript
// main.js 的代码(execIndex=0,排在前面)
console.log('main: start');
const promise = Promise.resolve().then(function () { return foo$1; });
promise.then(m => console.log(m.foo));
console.log('main: end');

// foo.js 的代码(execIndex=1,排在后面)
console.log('foo: side effect');
const foo = 42;

// foo.js 的命名空间对象
var foo$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  foo: foo
});

Note: Actual output can also include Symbol.toStringTag, namespace merge helpers, and Object.freeze differences depending on output.generatedCode.symbols, output.freeze, and export structure.


Execution order deep dive

Why execution order matches native import()?

This is where many people get confused. Let's walk through the execution steps:

Original code execution order (native import()):

1. console.log('main: start')           // main.js 同步代码
2. const promise = import('./foo.js')   // 触发异步加载,返回 Promise
3. promise.then(...)                    // 注册回调到微任务队列
4. console.log('main: end')             // main.js 同步代码继续
5. -- 同步代码执行完毕,处理微任务队列 --
6. console.log('foo: side effect')      // foo.js 模块执行
7. const foo = 42
8. m => console.log(m.foo)              // then 回调执行

Inlined code execution order:

javascript
// 生成的 bundle
console.log('main: start');
const promise = Promise.resolve().then(function () { return foo$1; });
promise.then(m => console.log(m.foo));
console.log('main: end');

console.log('foo: side effect');
const foo = 42;
var foo$1 = /*#__PURE__*/Object.freeze({ __proto__: null, foo: foo });

Execution steps:

1. console.log('main: start')           // 同步执行
2. Promise.resolve()                    // 创建已 resolved 的 Promise
3. .then(function () { return foo$1; }) // 注册回调到微任务队列(注意:此时还没执行!)
4. promise.then(...)                    // 再注册一个回调
5. console.log('main: end')             // 同步执行
6. console.log('foo: side effect')      // 同步执行(foo.js 代码在 main.js 之后)
7. const foo = 42                       // 同步执行
8. var foo$1 = ...                      // 同步执行
9. -- 同步代码执行完毕,处理微任务队列 --
10. function () { return foo$1; }       // 第一个 then 回调执行
11. m => console.log(m.foo)             // 第二个 then 回调执行

In the typical case (no exceptions, no TLA), output matches:

main: start
main: end
foo: side effect
42

Key insights:

  1. The callback in Promise.resolve().then(cb) is queued as a microtask and never runs immediately
  2. foo.js code appears later in the bundle, but runs during the sync phase
  3. By the time microtasks run, foo$1 has been initialized
  4. sortByExecutionOrder ensures foo.js is placed after main.js, which is what we need
  5. If module initialization throws or uses Top-Level Await, timing/error semantics can still differ from native import() (see "Semantic differences" below)

Key source comment: author's self-critique

Source location: src/utils/executionOrder.ts (comment above analyseModuleExecution)

typescript
// This process is currently faulty in so far as it only takes the first entry
// module into account and assumes that dynamic imports are imported in a
// certain order.
// A better algorithm would follow every possible execution path and mark which
// modules are executed before or after which other modules. THen the chunking
// would need to take care that in each chunk, all modules are always executed
// in the same sequence.

Summary of the comment:

The current algorithm is imperfect: it only considers the first entry module and assumes a specific dynamic import order. A better approach would track all execution paths and ensure each chunk preserves a consistent module order.

What the author acknowledges:

  1. Only the first entry module is considered
  2. Dynamic imports are assumed to execute in a specific order
  3. Not all possible execution paths are tracked

Why this tradeoff is acceptable:

  1. Complexity: Tracking all possible execution paths is a graph problem with high complexity
  2. User intent: Using inlineDynamicImports explicitly opts into a single-file output and away from code splitting
  3. Core semantics preserved: Promise asynchrony is preserved, which is what user code depends on
  4. Practical enough: For most real-world cases, the simplified algorithm is sufficient

Semantic differences: which scenarios behave differently?

Although the analysis above shows that "normal" dynamic import ordering matches, there are several scenarios where behavior diverges:

Scenario 1: Conditional dynamic imports

javascript
// main.js
if (shouldLoadFeature) {
  import('./feature.js');
}

// feature.js
console.log('feature: initializing');
initializeHeavyFeature();
BehaviorNative import()inlineDynamicImports
shouldLoadFeature = falsefeature.js does not runfeature.js runs

Reason: After inlining, feature.js code is always present and executes regardless of the condition.

Scenario 2: Delayed dynamic imports

javascript
// main.js
setTimeout(() => {
  import('./analytics.js');
}, 5000);

// analytics.js
console.log('analytics: loaded');
trackPageView();
BehaviorNative import()inlineDynamicImports
Load timingExecutes after 5sExecutes immediately

Reason: After inlining, analytics.js executes synchronously when the bundle loads.

Scenario 3: User interaction trigger

javascript
// main.js
button.addEventListener('click', () => {
  import('./editor.js').then(m => m.openEditor());
});

// editor.js
console.log('editor: loading heavy resources');
loadHeavyResources();  // 加载大量资源
export function openEditor() { ... }
BehaviorNative import()inlineDynamicImports
Initial loadeditor.js not loadedAll resources load immediately
On clickLoads editor.js thenAlready loaded, runs directly

Reason: After inlining, all code executes during initial load, losing on-demand loading.

Scenario 4: Module initialization throws (error timing difference)

javascript
// main.js
import('./broken.js').catch(err => console.error('import failed', err));
console.log('after import call');

// broken.js
throw new Error('boom');
BehaviorNative import()inlineDynamicImports
Error timingPromise rejects asynchronously (.catch works)May throw synchronously during bundle init
Impact scopeOnly the import fails; sync code continuesCan synchronously interrupt initialization

Reason: After inlining, module code runs synchronously at bundle initialization, so a throw can shift from async rejection to sync exception.

Scenario 5: Top-Level Await (ordering and timing differences)

When a dynamic import happens inside a module that uses Top-Level Await, Rollup's execution-order analysis treats the target as a sync dependency, which can change ordering (see analyseModuleExecution handling of usesTopLevelAwait). This means ordering and timing may deviate from the "dynamic imports always in the second pass" intuition.

Summary: When not to use inlineDynamicImports

Not suitable:

If your code relies on conditionals to avoid loading certain modules, inlineDynamicImports will load and execute them anyway. If you need deferred loading to optimize first paint, or on-demand loading of heavy features, inlining defeats those advantages. If modules have expensive initialization side effects (network, DOM), inlining will run those effects immediately. If your error handling depends on import()'s asynchronous rejection to isolate failures, inlining may surface errors synchronously and interrupt the whole initialization flow.

Suitable:

Worker or Service Worker single-file deployments are a typical fit because external dependencies are painful in those environments. Single-file library bundles also benefit by simplifying distribution. For simple apps that do not care about code splitting, inlineDynamicImports reduces the number of output files. The key requirement is that dynamically imported modules must not rely on delayed side effects, because after inlining, all module code executes synchronously on bundle load.


Use cases in detail

1. Web Worker / Service Worker

Worker environments make external dependencies tricky; a single file is more stable:

javascript
// rollup.config.js
export default {
  input: 'src/worker.js',
  output: {
    file: 'dist/worker.js',
    format: 'es',
    inlineDynamicImports: true
  }
};

Why is a single file good for workers?

IssueMulti-file (chunked)Single-file (inlined)
Deployment complexityMust deploy multiple chunks and path mappingDeploy 1 file
Path resolutionimport() path relative to worker fileNo runtime path logic
Service Worker cacheNeed to manage cache list for all chunksCache 1 URL
CORS configurationAll chunks need correct CORS headersOnly 1 file needs it
CDN deploymentChunk names include hashes, change per buildSingle file, easy to manage

Browser requirements for worker loading:

  • Same-origin policy: Worker scripts must be same-origin with the creating page
  • MIME type: Must be text/javascript or application/javascript
  • import() in module workers: Imported resources must be same-origin or CORS-enabled

2. Library bundling

Publish as a single-file UMD/IIFE library:

javascript
export default {
  input: 'src/index.js',
  output: {
    file: 'dist/my-library.js',
    format: 'umd',
    name: 'MyLibrary',
    inlineDynamicImports: true
  }
};

3. Simple apps or scripts

Small applications that do not need code splitting.


Usage constraints

According to src/utils/options/normalizeOutputOptions.ts (getInlineDynamicImports / getPreserveModules / getManualChunks):

ConstraintReasonSource location
Cannot be used with multiple inputsSingle-file output cannot separate entriesgetInlineDynamicImports
Cannot be used with preserveModulesSemantic conflict: single file vs module structuregetPreserveModules
Cannot be used with manualChunksSemantic conflict: manual chunking vs disabling chunksgetManualChunks

If you were to implement this feature, what would you do?

Assuming you are building a similar bundler, these are the key steps to implement inlineDynamicImports.

Step 1: Option validation

typescript
function validateOptions(options: Options) {
  if (options.inlineDynamicImports) {
    if (options.input.length > 1) {
      throw new Error('inlineDynamicImports 不支持多入口');
    }
    if (options.preserveModules) {
      throw new Error('inlineDynamicImports 与 preserveModules 互斥');
    }
    if (options.manualChunks) {
      throw new Error('inlineDynamicImports 与 manualChunks 互斥');
    }
  }
}

Step 2: Execution order analysis

typescript
// 简化伪代码(保留 TLA 与隐式依赖的关键分支)
function analyzeExecutionOrder(entryModules: Module[]) {
  let execIndex = 0;
  const dynamicImports = new Set<Module>();
  const visited = new Set<Module | ExternalModule>();

  function analyze(module: Module | ExternalModule) {
    if (visited.has(module)) return;
    visited.add(module);

    if (module instanceof Module) {
      for (const dep of module.dependencies) analyze(dep);
      for (const dep of module.implicitlyLoadedBefore) dynamicImports.add(dep);
      for (const { node: { resolution, scope } } of module.dynamicImports) {
        if (resolution instanceof Module) {
          if (scope.context.usesTopLevelAwait) {
            analyze(resolution); // TLA 视为同步依赖
          } else {
            dynamicImports.add(resolution);
          }
        }
      }
    }

    module.execIndex = execIndex++;
  }

  for (const entry of entryModules) analyze(entry);
  for (const dynamicModule of dynamicImports) analyze(dynamicModule);
}

Step 3: Chunk assignment

typescript
function assignChunks(modules: Module[], options: Options) {
  if (options.inlineDynamicImports) {
    // 所有模块放入单个 chunk
    return [{ modules }];
  } else {
    // 正常分块逻辑...
  }
}

Step 4: Module ordering

typescript
function sortModules(modules: Module[]) {
  modules.sort((a, b) => a.execIndex - b.execIndex);
}

Step 5: Mark namespace modules

typescript
function markNamespaceModules(chunk: Chunk) {
  for (const module of chunk.modules) {
    for (const dynamicImport of module.dynamicImports) {
      const { included, resolution } = dynamicImport;
      if (included && resolution instanceof Module && chunk.modules.includes(resolution)) {
        chunk.includedNamespaces.add(resolution);
      }
    }
  }
}

Step 6: Code generation

typescript
function renderImportExpression(node: ImportExpression, namespace: NamespaceVariable | null) {
  if (namespace) {
    // 内联模式
    return `Promise.resolve().then(function () { return ${namespace.name}; })`;
  } else {
    // 正常动态导入
    return `import(${node.source})`;
  }
}

function renderNamespace(module: Module) {
  const exports = module.getExports();
  const members = exports.map(e => `${e.name}: ${e.localName}`).join(', ');
  // 根据 output.freeze / generatedCode.symbols 等配置,渲染细节会有所不同
  return `var ${module.namespace.name} = /*#__PURE__*/Object.freeze({ __proto__: null, ${members} });`;
}

Source file index

FileKey locations / functionsPurpose
src/utils/options/normalizeOutputOptions.tsgetInlineDynamicImports / getPreserveModulesOption validation and mutual exclusivity
src/Bundle.tsgenerateChunksCore: single chunk assignment and ordering
src/Chunk.tssetDynamicImportResolutions / addModuleDynamic import inlining, namespace registration
src/ast/nodes/ImportExpression.tsrender / setInternalResolutionCore: Promise.resolve().then() generation and inlining
src/ast/variables/NamespaceVariable.tsrenderBlockCore: namespace object rendering
src/utils/executionOrder.tsanalyseModuleExecution / sortByExecutionOrderExecution order analysis and sorting

Summary

AspectDetails
Core transformimport('./x') -> Promise.resolve().then(function () { return namespace; })
Why it is correctSatisfies the three import() semantics in most cases; namespace details depend on freeze/symbols
Execution orderUses sortByExecutionOrder on execIndex; TLA and implicit deps can alter the "second pass" intuition
Semantics preservedPromise return value and async .then() scheduling (microtask queue)
Semantic differencesConditional/delayed/interaction-driven imports, error timing, and TLA-related ordering
Author's stanceSource comments admit the algorithm is "faulty", but the tradeoff is practical
Suitable scenariosSingle-file Worker deployments, library bundles, simple apps without code splitting
Not suitable scenariosConditional loading, delayed loading, large on-demand modules

Released under the CC BY-SA 4.0 License. (134a8ec)