Skip to content

feat(core): enable Rspack runtime mode by default - #1806

Draft
Timeless0911 wants to merge 2 commits into
mainfrom
david/feat-rspack-runtime-mode
Draft

feat(core): enable Rspack runtime mode by default#1806
Timeless0911 wants to merge 2 commits into
mainfrom
david/feat-rspack-runtime-mode

Conversation

@Timeless0911

@Timeless0911 Timeless0911 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Override @rspack/core globally with @rspack-canary/core@2.1.8-canary-4b189917-20260802173728.
  • Enable experiments.runtimeMode: 'rspack' by default while preserving user overrides.
  • Refresh affected runtime snapshots and output assertions.
  • Keep this PR as a draft while the remaining compiler correctness and generated-output issues documented in the follow-up comments are investigated.

Related Links

Checklist

  • Tests updated (or not required).
  • Documentation updated (or not required).

Timeless0911 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Known issues and cold-cache verification

Retested from fresh source archives after clearing all local Rspack filesystem caches and generated integration outputs, using:

  • @rsbuild/core@2.1.8
  • @rspack/core@2.1.6
  • Node.js 25.9.0

Cold-cache correction: the earlier HMR claim was a false positive and has been removed. Rspack recognizes __webpack_require__.i as a magic runtime global, lowers it to interceptModuleExecution, and exposes it through __rspack_context.i. A browser execution test and a same-project webpack-cache-to-rspack transition both completed without page, console, or request errors.

1. Missing compatGetDefaultExport import in modern-module output

This is a correctness regression. The generated entry imports only rspackRequire, but calls compatGetDefaultExport:

import { rspackRequire } from './runtime.mjs';

const shared = rspackRequire('1');
var shared_default = compatGetDefaultExport(shared);

The shared runtime exports compatGetDefaultExport; the consuming entry does not import it.

Repository reproduction:

pnpm exec rstest tests/integration/parser-javascript/module/index.test.ts

Actual result:

ReferenceError: compatGetDefaultExport is not defined

Minimal Rspack configuration:

// build.cjs
const path = require('node:path');
const { rspack } = require('@rspack/core');

rspack(
  {
    mode: 'none',
    target: 'node',
    entry: {
      a: './src/a.js',
      b: './src/b.js',
    },
    experiments: {
      runtimeMode: 'rspack',
    },
    optimization: {
      runtimeChunk: { name: 'runtime' },
    },
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: '[name].mjs',
      chunkFilename: '[name].mjs',
      module: true,
      chunkFormat: false,
      chunkLoading: 'import',
      library: { type: 'modern-module' },
    },
  },
  (error, stats) => {
    if (error || stats.hasErrors()) {
      console.error(error || stats.toString({ all: false, errors: true }));
      process.exitCode = 1;
    }
  },
);
// src/shared.cjs
module.exports = 'shared';

// src/a.js
import shared from './shared.cjs';
export const value = `${shared}:a`;

// src/b.js
import shared from './shared.cjs';
export const value = `${shared}:b`;
node build.cjs
node -e "import('./dist/a.mjs').then(console.log)"

The same source and configuration pass with Rspack 2.1.6 in webpack runtime mode and with Rspack 2.1.5 in rspack runtime mode. The regression is isolated to the modern-module lexical runtime path added in web-infra-dev/rspack#14741.

2. Dead require bootstraps in five outputs

This is a non-blocking output optimization, not a currently reachable runtime error.

Five Rspack-mode outputs contain a loader whose module table is not declared:

function __rspack_require(moduleId) {
  // ...
  __rspack_modules[moduleId](
    module,
    module.exports,
    __rspack_context,
  );
}

Affected outputs:

  • tests/integration/asset/hash/dist/cjs/bundle/index.cjs
  • tests/integration/asset/limit/dist/cjs/bundle-default/index.cjs
  • tests/integration/asset/path/dist/cjs/bundle/index.cjs
  • tests/integration/asset/svgr/dist/cjs/bundle-default/index.cjs
  • tests/integration/asset/public-path/dist/umd/index.js

Cold-output analysis confirms that, in every file:

  • __rspack_modules has no declaration and is referenced only inside the loader;
  • __rspack_require has no direct calls;
  • __rspack_context.r is assigned once and is never read or called.

The unresolved reference is therefore never evaluated.

Repository reproduction:

pnpm exec rstest tests/integration/asset/index.test.ts

Conservatively removing the module cache declaration, loader body, and unused __rspack_context.r assignment removes approximately 2,142 raw bytes. In the SVGR output, the context declaration also becomes unused; including it brings the removable total to approximately 2,169 raw bytes.

These figures describe removable code, not the Rspack-vs-webpack size delta. Four corresponding webpack-mode outputs also contain an uncalled loader body, although their __webpack_require__ function object remains in use as a helper namespace. Across the five corresponding outputs, the actual Rspack-vs-webpack delta is approximately +856 raw bytes / +259 gzip bytes.

3. Redundant runtime declarations and exports

This is also a non-blocking output optimization.

Counting extra declarations after the first declaration in the same lexical/function scope, the cold Rspack corpus contains:

  • publicPath: 10
  • initializeSharing: 4
  • scriptNonce: 2
  • total: 16 extra declarations across 12 files

The webpack corpus contains one pre-existing duplicate publicPath declaration in one file. The net Rspack-mode increase is therefore 15 declarations across 11 additional files.

For example:

var publicPath;
var publicPath = '/public/path/';

These var redeclarations are valid JavaScript; they are simply redundant.

The bundleless public-path runtime also emits and exports an unused var rspackRequire = {}. That runtime file is 112 bytes, of which approximately 55 bytes are attributable to the unused declaration/export.

Cold output-size comparison

The following comparison uses the same 878 emitted JS/MJS/CJS files in both cold source archives, excluding 14 generated .rsbuild inspection/configuration copies. The only intended configuration difference is runtimeMode: 'webpack' versus runtimeMode: 'rspack'. gzip is the sum of individually compressing each file.

Format Raw delta Gzip delta
ESM +124 B +570 B
CJS +15,499 B +9,331 B
UMD +532 B +233 B
MF +20,557 B +3,368 B
Other JS +8,575 B +1,042 B
Total +45,287 B +14,544 B

Other JS is included explicitly so that the format rows sum to the reported total.

Current integration-test status

The cold rspack-mode run executes 83 test files / 323 tests:

  • 321 passed
  • 1 failed
  • 1 todo

The only integration execution failure is the missing compatGetDefaultExport import described above.

Running webpack mode against the current PR expectations produces:

  • 297 passed
  • 25 failed
  • 1 todo

All 25 failures are snapshot or generated-string assertion mismatches because this branch has updated those expectations to the rspack-mode representation; they are not webpack-runtime execution failures.

Additional compiler and output correctness regressions that are not covered by the current Rslib integration suite are documented in the follow-up comment.

Given the confirmed integration failure and the additional minimal correctness regressions, this should remain a draft and runtimeMode: 'rspack' should not become the default until those issues are fixed.

Timeless0911 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Additional output optimization: redundant createRequire alias

The two ESM outputs covered by tests/integration/parser-javascript/require/index.test.ts now contain:

import { createRequire as external_node_module_createRequire } from "node:module";
const _require = external_node_module_createRequire(import.meta.url);

There is no local createRequire collision in either fixture. With the same experiments.runtimeMode: 'rspack' configuration, Rspack 2.1.5 emits the shorter form:

import { createRequire } from "node:module";
const _require = createRequire(import.meta.url);

Rspack 2.1.6 with runtimeMode: 'webpack' also keeps the shorter form. The behavior therefore starts at the 2.1.6 modern-module lexical runtime boundary introduced by web-infra-dev/rspack#14741, authored by LingyuCoder. That PR added RspackExport and conservative top-level identifier reservation/deconfliction; the external binding alias is a consequence of that path, rather than an Rslib transform.

The alias adds approximately 59 raw bytes / 15 gzip bytes per affected output (118 raw / 30 gzip bytes for these two fixtures). A possible upstream optimization is to preserve the imported name when it is actually free, and fall back to the generated external_* alias only when a real collision exists.

The assertion changes in Rslib commit 75b9f1ae only record the new generated output; they are not the source of the behavior.

The same alias also appears in tests/integration/shims/cjs/dist/esm/index.js. Across the three currently observed outputs, the generated alias accounts for approximately 177 extra raw bytes / 46 gzip bytes.

Timeless0911 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Additional Rspack 2.1.6 blockers

A second audit found three more correctness regressions in the new RspackExport path. All three use a normal modern-module build with:

{
  mode: 'none',
  target: 'node',
  entry: './src/index.js',
  experiments: { runtimeMode: 'rspack' },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.mjs',
    module: true,
    chunkFormat: false,
    chunkLoading: 'import',
    library: { type: 'modern-module' },
  },
}

The common version/mode matrix is:

Rspack runtimeMode Result
2.1.5 rspack passes
2.1.6 webpack passes
2.1.6 rspack fails

These regressions start with web-infra-dev/rspack#14741 / merge commit 1701c586.

1. Exported function named context crashes the compiler

Minimal source:

export function context() {}

Rspack 2.1.6 aborts the process instead of returning a compilation error:

Panic occurred at runtime. Please file an issue on GitHub with the panic info and backtrace below:

should set internal name for __nested_rspack_require_16_23__

Rspack 2.1.5 emits and executes the expected output:

function context() {}
export { context };

2. A CommonJS-local rspackRequire produces invalid JavaScript

// src/cjs.cjs
const rspackRequire = 42;
module.exports = rspackRequire;

// src/index.js
import value from './cjs.cjs';
export { value };

Rspack 2.1.6 generates a module factory whose new runtime parameter collides with the valid source binding:

moduleFactories.add({
  1(module, __unused_rspack_exports, rspackRequire) {
    const rspackRequire = 42;
    module.exports = rspackRequire;
  },
});

Node rejects the bundle with:

SyntaxError: Identifier 'rspackRequire' has already been declared

3. export const exports = ... is silently removed

Minimal source:

export const exports = 42;

Rspack 2.1.6 succeeds, but generates:

const __nested_rspack_exports__ = 42;
export {};

Importing the result yields an empty namespace instead of { exports: 42 }. This also reproduces in production/minified mode, where the complete output becomes export{};.

Over-conservative deconfliction is also observable

Even when a pure ESM chunk has no runtime, names such as rspackRequire, publicPath, modules, and context are reserved and renamed. This is not only readability/size churn: function, class, and inferred arrow-function .name values change.

For example:

export function rspackRequire() {}
export const actualName = rspackRequire.name;

returns "rspackRequire" with 2.1.5 and with 2.1.6 webpack mode, but returns a module-prefixed generated name with 2.1.6 rspack mode.

The current integration artifacts also contain 126 references to source-offset-based names such as __nested_rspack_require_38719_38726__ across seven ESM files, although those context bindings live in nested scopes and cannot collide with a top-level runtime binding. The identifier spelling alone adds roughly 3.7 KB raw / 0.7 KB gzip across those fixtures and makes content hashes sensitive to unrelated source-offset changes.

This corrects the earlier generalization that a pure single-entry modern-module chunk without a runtime is byte-for-byte identical between modes: that is only true when application bindings do not match the conservatively reserved runtime-name set.

Packaging note

The root pnpm.overrides entry affects this repository's install only. The packed @rslib/core manifest contains @rsbuild/core: "~2.1.8"; Rsbuild 2.1.8 in turn contains @rspack/core: "~2.1.5". A fresh install currently resolves 2.1.6, but an existing consumer lockfile may remain on 2.1.5. The root override therefore does not enforce the tested Rspack patch version for downstream users.

Likely upstream fix locations

  • RuntimeCodeTemplate::render_runtime_argument() now returns rspackRequire for RspackExport, and the JavaScript runtime renderer injects it directly as the third factory parameter. That parameter needs a module-scope collision-free allocated name, and generated require() calls must use the same allocation; a source-string .contains() check is not sufficient.
  • EsmLibraryPlugin::collect_rspack_export_runtime_used_names() currently collects every runtime-module variable, every RuntimeGlobal, and all core runtime variables, then adds them to every chunk's all_used_names. Reservation should be scoped to names actually emitted/imported by the current chunk and to the relevant lexical scope.
  • When an application binding is renamed, direct-export metadata must be updated as well. The export const exports = 42 case shows that the renamed local binding and public export map currently diverge.
  • Regression coverage should execute the final modern-module output for: a CJS-local rspackRequire that also calls require(), export const exports = 42, export function context() {}, and preserved function/class .name values.

Timeless0911 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Non-blocking output follow-up

A cold-cache AST scan found redundant top-level runtime invocations emitted as (function () { ... }).call(this).

For this count, “top-level runtime wrapper” is defined precisely as:

  • a direct SourceFile expression statement;
  • whose expression is an anonymous, zero-argument function expression;
  • invoked with exactly .call(this).

Using that definition:

  • Rspack mode emits 279 wrappers across 279 files.
  • Webpack mode emits none.
  • 278 wrapper bodies contain no ThisExpression at any depth.
  • The remaining wrapper contains three ThisExpression nodes, but all are inside a nested ordinary function and therefore do not bind to the wrapper invocation.
  • A scope-aware scan finds zero uses of the wrapper-bound this.
  • No wrapper body contains a direct eval(...) call.

Thirteen additional renderer IIFEs occur inside UMD/MF library shells; they are intentionally excluded from the top-level count above.

Conservatively replacing .call(this) only for the 278 bodies with no ThisExpression would save approximately 2,502 raw bytes / 1,358 gzip bytes across the cold integration corpus. A scope-aware optimization covering all 279 top-level wrappers would save approximately 2,511 raw bytes / 1,362 gzip bytes.

Timeless0911 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Canary cold-revalidation update

This supersedes the current-status conclusions in the earlier Rspack 2.1.6 comments; those comments remain useful as version-specific history.

I removed all local node_modules, generated outputs, Rspack/Rsbuild caches, and TypeScript build caches, then reinstalled and rebuilt with:

@rspack/core -> @rspack-canary/core@2.1.8-canary-4b189917-20260802173728
experiments.runtimeMode: "rspack"

Current validation status:

  • package build: passed
  • unit: 173/173 passed
  • integration: 317 passed, 5 failed, 1 todo
  • e2e: 7/7 passed

The five integration failures are stale generated-string expectations for improvements made by this canary: the duplicate public-path declaration, dead SVGR bootstrap, and three long createRequire aliases were removed/shortened. There is no integration runtime execution failure.

The canary also fixes the previously reported missing compatGetDefaultExport import, context panic, CommonJS-local rspackRequire collision, dropped exports export, 126 normal source-offset runtime-name references, 16 duplicate runtime declarations, and five dangling require bootstraps. The original external_node_module_createRequire report should therefore be considered fixed.

The remaining issues are below.

Scope note: issues 1 and 2 are not current Rslib integration execution failures. They require specific input binding names under modern-module Rspack output; the concrete Rslib case for issue 2 additionally requires a published bundleless output to be consumed and bundled again downstream.

1. A specific exported rspackRequire function-declaration shape aborts the compiler

This is a new canary correctness regression, but it is a narrow input-source edge case rather than a current Rslib integration failure.

It requires all of the following:

  • the input is compiled with runtimeMode: "rspack", module output, and library.type: "modern-module";
  • a user source module or generated virtual module declares a top-level function named exactly rspackRequire;
  • that function is exported, either directly or through an export specifier; and
  • apart from the export syntax, the module contains no ordinary identifier read of that binding.

The current Rslib test suite contains no source fixture with this exact shape.

Install:

{
  "devDependencies": {
    "@rspack/core": "npm:@rspack-canary/core@2.1.8-canary-4b189917-20260802173728"
  }
}

Minimal source:

// src.js
export function rspackRequire() {}

Minimal build:

// build.mjs
import path from "node:path";
import rspack from "@rspack/core";

rspack(
  {
    mode: "production",
    context: import.meta.dirname,
    entry: "./src.js",
    experiments: {
      outputModule: true,
      runtimeMode: "rspack",
    },
    optimization: {
      minimize: false,
    },
    output: {
      path: path.join(import.meta.dirname, "dist"),
      filename: "index.mjs",
      library: {
        type: "modern-module",
      },
    },
  },
  (error, stats) => {
    if (error || stats.hasErrors()) {
      console.error(error || stats.toString({ colors: false }));
      process.exitCode = 1;
    }
  },
);

Expected output:

function rspackRequire() {}
export { rspackRequire };

Actual result: no output is produced and the process aborts with exit code 134.

Panic occurred at runtime.
should set internal name for __nested_rspack_require_16_29__

Controls:

  • this canary with runtimeMode: "webpack" compiles and preserves rspackRequire
  • Rspack 2.1.6 with runtimeMode: "rspack" compiles, although it still has the observable .name issue below

Trigger boundary in an Rslib workflow: original user source or a plugin-generated virtual module can hit this during its own build if it contains the exact declaration above. In a secondary-consumption scenario, an already-emitted library file would need to contain the same exported, non-self-referential function declaration and then be fed back to Rspack.

The existing Vue fixture is not an issue-1 reproduction or a failing test. Its generated rslib-runtime~0.js does export rspackRequire, which proves that the name can occur in real Rslib output, but the function reads itself in modules[moduleId](module, module.exports, rspackRequire). That extra identifier read prevents this panic.

2. Downstream rebundling can change observable function/class .name for runtime-reserved bindings

This is not a failure of the first Rslib build. The concrete Rslib trigger is a secondary-consumption flow:

  1. Rslib emits bundleless modern-module code containing a top-level helper such as rspackRequire.
  2. That emitted library file is published or otherwise consumed as input by another project.
  3. The downstream project bundles the emitted file again with Rspack runtime mode and modern-module output.
  4. Rspack treats the generated helper as ordinary input source and renames the local binding.
  5. The difference becomes observable only if downstream code relies on function/class .name, a stack name, or equivalent reflection.

The standalone source below reproduces the same name-deconfliction behavior without setting up two projects:

export function rspackRequire() {}
export class moduleFactories {}
export const publicPath = () => {};

export const observedNames = [
  rspackRequire.name,
  moduleFactories.name,
  publicPath.name,
];

Expected runtime value:

["rspackRequire", "moduleFactories", "publicPath"]

Actual output:

function __nested_rspack_require_16_29__() {}
class src_moduleFactories {}
const src_publicPath = () => {};

const observedNames = [
  __nested_rspack_require_16_29__.name,
  src_moduleFactories.name,
  src_publicPath.name,
];

export {
  __nested_rspack_require_16_29__ as rspackRequire,
  observedNames,
  src_moduleFactories as moduleFactories,
  src_publicPath as publicPath,
};

Actual runtime value:

[
  "__nested_rspack_require_16_29__",
  "src_moduleFactories",
  "src_publicPath",
]

The public ESM export names and ordinary calls remain unchanged; only the underlying function/class names are observable as different. If a consumer does not rely on .name or equivalent reflection, this does not change runtime behavior.

This semantic issue predates the canary: Rspack 2.1.6 rspack mode also changes the names, while webpack mode preserves all three.

A concrete secondary-consumption source is the Vue fixture. Its existing repository test covers only the first Rslib build and snapshots the emitted files; it does not perform the downstream rebundle and is not currently failing. That first build emits this real shared runtime:

function rspackRequire(moduleId) {
  // module cache and factory dispatch
}
export { rspackRequire, moduleFactories };

It is consumed by tests/integration/vue/dist/bundleless/Button/Button.js. Rebundle that emitted rslib-runtime~0.js as a modern-module entry with the configuration above:

original runtime:          rspackRequire.name === "rspackRequire"
canary + webpack runtime:  rspackRequire.name === "rspackRequire"
canary + rspack runtime:   rspackRequire.name === "__nested_rspack_require_40__"

The second build above was verified separately as a downstream-consumption reproduction; it is not an existing Rslib integration case.

The names also come from real generated paths: the cold integration corpus contains 19 runtime exports involving rspackRequire, 18 involving moduleFactories, and one bundleless asset runtime exporting publicPath. In the current generated runtime, moduleFactories is an object and publicPath is a string; the directly observable real-output .name case is the downstream rebundle of rspackRequire.

Repository reproduction for the source runtime:

pnpm exec rstest tests/integration/vue/index.test.ts

3. Runtime helpers still use unnecessary .call(this) wrappers

Minimal source:

export const answer = 42;

Minimal configuration:

{
  mode: "production",
  target: "node",
  entry: "./src.js",
  experiments: {
    runtimeMode: "rspack",
  },
  optimization: {
    minimize: false,
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "index.cjs",
    library: {
      type: "commonjs2",
    },
  },
}

Actual runtime output:

(function () {
  // rspack runtime helpers
}).call(this);

Expected output:

(function () {
  // same runtime helpers
})();

The latest cold integration corpus contains 292 such wrappers: 279 top-level wrappers plus 13 nested UMD/MF renderer wrappers. A scope-aware AST check found:

  • 288 bodies with no ThisExpression
  • four bodies where this occurs only inside nested ordinary functions and therefore does not bind to the wrapper invocation
  • zero uses of the wrapper-bound this
  • zero direct eval(...) calls

All 292 wrappers are therefore independent of .call(this). Replacing it with a direct call saves 2,628 raw bytes; the measured counterfactual is approximately 1,446 gzip bytes.

This appears in normal user-facing builds, not only the minimal reproduction:

  1. bundled Node CJS library with named exports: tests/integration/format/default/dist/bundle-cjs/index.cjs
  2. Node library using require.resolve and createRequire(import.meta.url): tests/integration/parser-javascript/require/require-resolve/dist/cjs/index.cjs
  3. Web UMD library importing an asset with assetPrefix/publicPath: tests/integration/asset/public-path/dist/umd/index.js

Representative repository commands:

pnpm exec rstest tests/integration/parser-javascript/require/index.test.ts -t "require.resolve"
pnpm exec rstest tests/integration/asset/index.test.ts -t "set the assets public path"

The expected change for all three is limited to }).call(this); becoming })(); (or eliminating the wrapper entirely); the runtime body remains unchanged.

4. Unused rspackRequire export in the bundleless public-path runtime

This is a pre-existing, non-blocking output optimization.

A bundleless ESM build that imports an asset and configures a non-auto assetPrefix emits a shared public-path runtime.

Repository reproduction:

pnpm exec rstest tests/integration/asset/index.test.ts -t "set the assets public path"

Relevant source and configuration:

tests/integration/asset/public-path/src/index.js
tests/integration/asset/public-path/rslib.config.ts

Actual runtime:

// dist/esm/bundle-false/rslib-runtime~2.js
var rspackRequire = {};
var publicPath = "/public/path/";

export { rspackRequire, publicPath };

The only consumer imports publicPath:

// dist/esm/bundle-false/assets/image.js
import { publicPath } from "../rslib-runtime~2.js";

const image_namespaceObject =
  publicPath + "static/image/image.png";

No emitted module imports or reads rspackRequire.

Expected runtime:

var publicPath = "/public/path/";

export { publicPath };

Removing the unused declaration and export saves 39 raw bytes / 24 gzip bytes in this runtime chunk. It also avoids exposing an unused generated binding from the shared runtime module.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant