Investigating Rspack Dynamic Import Tree Shaking After Introducing React.lazy
I recently ran into an interesting issue. I had several debug panels used only in development. Initially, they were exported and imported directly in the usual way. When the production condition was false, this code was removed correctly, and the bundle looked as expected.
Later, I put the panels behind React.lazy so development could load them on demand, while production would neither execute them nor emit their async chunks. Intuitively, this still seemed like ordinary dead code elimination: the condition is false, the branch is unreachable, and the import() inside it should disappear. But the output disagreed. Regular imports were removed, while some of the lazy versions still produced async chunks.
That led to this investigation, from the initial symptom to a minimal reproduction and then into Rspack's source code.
Background
If you're new to frontend build tools, think of this as a question about how a bundler decides which code to keep.
Source code is usually split into modules. A page might import components, utilities, styles, and development-only debug panels. During a production build, the bundler analyzes these modules and tries to retain only what production actually needs. Later optimization and minification stages remove unused exports and unreachable branches, shorten variable names, and reduce output size.
Roughly speaking, a production build goes through these steps:
Read source code starting from an entry file such as src/app.tsx.
Parse each module and identify structures such as import, export, if, and import().
Replace compile-time constants, for example replacing process.env.REACT_APP_ENVIRONMENT with "production".
Build a module graph from references between modules to determine their dependencies.
Perform module-level optimizations, such as checking whether exports are used—what we commonly call tree shaking.
Split chunks according to synchronous and asynchronous references. An import() usually becomes a separate async chunk.
Run the minimize/minimizer stage for minification, constant folding, and much of the unreachable-code elimination.
Finally, emit chunks as files such as index.js, static/js/async/*.js, and source maps.
These stages aren't completely separate; real implementations interleave and revisit them. For this investigation, one point matters most: if the bundler can determine that a branch is unreachable while parsing the current module, it may never collect the branch's import() at all. Once that import has become an async chunk, removing it later is harder.
Ideally, the bundler can see while parsing the current file that a branch will never run:
if ("production" === "development") {
// 这里永远进不来
}In that case, the branch can usually be skipped early. Things become more complicated once the condition is wrapped in an abstraction, for example:
import { isDevelopment } from "./environment";
if (isDevelopment) {
// 这里对人来说也是 false
}A person can follow environment.ts and see that isDevelopment is false in production. The bundler may not perform that cross-module reasoning at the same stage with the same information. This matters especially for import(): it isn't just ordinary code; it also causes the bundler to create an asynchronous chunk. Once created, that chunk is harder to remove than ordinary unused code.
Almost everything in this article revolves around that difference: even when both are unreachable in production, regular static references and React.lazy(() => import(...)) follow different paths inside the bundler.
Terminology
Tree Shaking: removing unused code during bundling. The name evokes shaking unused leaves off a tree.
Dead Code Elimination / DCE: removing code that can never execute, such as a branch inside if (false) { ... }. It often accompanies tree shaking, but the focus differs: tree shaking asks whether something is used; DCE asks whether it can execute.
Minimize / Minimizer: the production build's minification and optimization stage. In Rspack, this corresponds to options such as optimization.minimize and optimization.minimizer. Much of the constant folding, unreachable-branch removal, and variable renaming happens here.
Static import / regular import: an ordinary static import such as import Button from "./Button". It establishes a dependency when the module loads and usually enters the main bundle or a synchronous chunk.
Dynamic import: import("./Panel"), which returns a Promise. Bundlers usually generate a separate async chunk for it, loaded when needed at runtime.
React.lazy: React's API for lazy-loading components, commonly written as const Panel = lazy(() => import("./Panel")). The inner import() actually triggers code splitting; React.lazy wraps that Promise as a React component.
Chunk: a block of code in the build output. Roughly, it is a JavaScript file, or part of one, that the browser eventually loads.
Async chunk: a chunk created by an asynchronous loading relationship such as import(). It generally loads on demand at runtime rather than executing alongside the initial main bundle.
Source map: a mapping between source code and built output. Here, I use source maps to help determine whether a debug panel's source remains represented in the final output.
DefinePlugin: the Webpack/Rspack mechanism for replacing constant expressions at compile time, such as replacing process.env.REACT_APP_ENVIRONMENT with "production".
Parser stage: the stage in which the bundler reads and analyzes one module's source. If it can already prove a branch unreachable, it may never collect the import() inside it.
Module graph: the dependency graph between modules, such as A importing B and B importing C.
Chunk graph: the graph describing how modules are assigned to final chunks. An import() usually affects this graph because it may introduce an async chunk.
Side effect: observable behavior that occurs when a module is imported, such as modifying globals, registering events, logging, or making requests. A bundler cannot freely discard a module with side effects.
Minimal Reproduction
To rule out other factors in the application code, I built a minimal demo. The reproduction looked roughly like this:
import { lazy, Suspense } from 'react';
import RegularEnvironmentObjectDebugPanel from './debug-panel/regular/environment-object';
import RegularImportedConstDebugPanel from './debug-panel/regular/imported-const';
import RegularInlineEnvDebugPanel from './debug-panel/regular/inline-env';
import RegularLocalConstDebugPanel from './debug-panel/regular/local-const';
import RegularStaticGetterDebugPanel from './debug-panel/regular/static-getter';
import { Environment, RuntimeEnvironment, isDevelopment } from './environment';
const localIsDevelopment =
process.env.REACT_APP_ENVIRONMENT === 'development';
const LazyInlineEnv =
process.env.REACT_APP_ENVIRONMENT === 'development'
? lazy(() =>
import(
/* webpackChunkName: "lazy-debug-panel-inline-env" */
'./debug-panel/lazy/inline-env'
),
)
: null;
const LazyLocalConst = localIsDevelopment
? lazy(() =>
import(
/* webpackChunkName: "lazy-debug-panel-local-const" */
'./debug-panel/lazy/local-const'
),
)
: null;
const LazyImportedConst = isDevelopment
? lazy(() =>
import(
/* webpackChunkName: "lazy-debug-panel-imported-const" */
'./debug-panel/lazy/imported-const'
),
)
: null;
const LazyEnvironmentObject = Environment.isDevelopment
? lazy(() =>
import(
/* webpackChunkName: "lazy-debug-panel-environment-object" */
'./debug-panel/lazy/environment-object'
),
)
: null;
const LazyStaticGetter = RuntimeEnvironment.isDevelopment
? lazy(() =>
import(
/* webpackChunkName: "lazy-debug-panel-static-getter" */
'./debug-panel/lazy/static-getter'
),
)
: null;environment.ts contains several common abstractions:
export const isDevelopment =
process.env.REACT_APP_ENVIRONMENT === "development";
export const Environment = {
isDevelopment,
} as const;
export class RuntimeEnvironment {
static get isDevelopment() {
return process.env.REACT_APP_ENVIRONMENT === "development";
}
}In production, REACT_APP_ENVIRONMENT is "production", so theoretically none of these debug panels should enter the output.
Observed Behavior
A production build with Rsbuild/Rspack produced these results:
Case | Lazy packaged | Lazy in source map | Regular packaged | Regular in source map |
|---|---|---|---|---|
Inline env | No | No | No | No |
Local const | No | No | No | No |
Imported const | Yes | Yes | No | No |
Environment object | Yes | Yes | No | No |
Static getter | Yes | Yes | Yes | Yes |
There are two key points in this table:
With process.env.REACT_APP_ENVIRONMENT === 'development' written directly, the lazy chunk is removed.
Rspack also removes the lazy chunk when const localIsDevelopment = process.env... === 'development' is declared in the same module.
But when the boolean is imported from another module, the regular static import can be removed while the lazy async chunk is still generated.
The getter fares worse. To the bundler, RuntimeEnvironment.isDevelopment isn't a simple constant read, so both regular and lazy versions remain.
For comparison, I added a Webpack build to the same project. Its production results were:
Case | Lazy packaged | Lazy in source map | Regular packaged | Regular in source map |
|---|---|---|---|---|
Inline env | No | No | No | No |
Local const | Yes | Yes | No | No |
Imported const | Yes | Yes | No | No |
Environment object | Yes | Yes | Yes | Yes |
Static getter | Yes | Yes | Yes | Yes |
In this case, Rspack actually goes one optimization further than Webpack: it handles the same-module const, whereas Webpack doesn't remove that lazy chunk with the current configuration.
Reading the Build Output
The tables above aren't guesses based on appearances; they come from the output files. After a production build, dist looks roughly like this:
dist/
index.html
static/js/
index.js
index.js.map
async/
lazy-debug-panel-imported-const.js
lazy-debug-panel-imported-const.js.map
lazy-debug-panel-environment-object.js
lazy-debug-panel-environment-object.js.map
lazy-debug-panel-static-getter.js
lazy-debug-panel-static-getter.js.mapStart by separating the files into three categories:
index.html is the entry HTML file, which loads the main JavaScript.
static/js/index.js is the main bundle. It contains the application entry, React runtime, Rspack runtime, and modules included synchronously.
static/js/async/*.js contains async chunks split out by import(). If a lazy-debug-panel-xxx.js file exists, the corresponding dynamic import entered the chunk graph and ultimately produced a file.
An async chunk is usually small. For example, lazy-debug-panel-imported-const.js contains a structure like this:
(self.webpackChunkrsbuild_tree_shaking_repro =
self.webpackChunkrsbuild_tree_shaking_repro || []).push([
[825],
{
551(e, r, _) {
_.r(r);
_.d(r, { default: () => u });
function u() {
return jsx("div", {
children: "LAZY_IMPORTED_CONST_DEBUG_PANEL_INCLUDED",
});
}
},
},
]);This file doesn't run the entire application independently. It registers a set of modules in the global chunk queue. [825] is the chunk ID, and 551 is a module ID inside it. LAZY_IMPORTED_CONST_DEBUG_PANEL_INCLUDED is a sentinel string I deliberately added to verify that the debug panel's code reached the output.
The main index.js bundle also contains a mapping from chunk IDs to filenames, roughly like this:
__webpack_require__.u = (id) =>
"static/js/async/" +
{
254: "lazy-debug-panel-environment-object",
603: "lazy-debug-panel-static-getter",
825: "lazy-debug-panel-imported-const",
}[id] +
".js";Further down in the main bundle, these correspond one-to-one with the lazy components in app.tsx. The source looks like this:
const LazyInlineEnv =
process.env.REACT_APP_ENVIRONMENT === 'development'
? lazy(() => import('./debug-panel/lazy/inline-env'))
: null;
const LazyLocalConst = localIsDevelopment
? lazy(() => import('./debug-panel/lazy/local-const'))
: null;
const LazyImportedConst = isDevelopment
? lazy(() => import('./debug-panel/lazy/imported-const'))
: null;
const LazyEnvironmentObject = Environment.isDevelopment
? lazy(() => import('./debug-panel/lazy/environment-object'))
: null;
const LazyStaticGetter = RuntimeEnvironment.isDevelopment
? lazy(() => import('./debug-panel/lazy/static-getter'))
: null;The production output is minified onto one line. Extracting the relevant fragment directly from dist/static/js/index.js gives:
var f = !1,
d = f,
p = (function () {
function e() {
!(function (e, n) {
if (!(e instanceof n))
throw new TypeError("Cannot call a class as a function");
})(this, e);
}
var n, t;
return (
(n = e),
(t = [
{
key: "isDevelopment",
get: function () {
return !1;
},
},
]),
null && c(n.prototype, null),
t && c(n, t),
e
);
})(),
m = d
? (0, u.lazy)(function () {
return l.e(254).then(l.bind(l, 328));
})
: null,
h = p.isDevelopment
? (0, u.lazy)(function () {
return l.e(603).then(l.bind(l, 83));
})
: null;
function g() {
return (0, a.jsxs)(u.Suspense, {
fallback: null,
children: [
null,
null,
null,
m && (0, a.jsx)(m, {}),
h && (0, a.jsx)(h, {}),
!1,
false,
f,
d && (0, a.jsx)(i, {}),
p.isDevelopment && (0, a.jsx)(s, {}),
],
});
}One point is crucial: this async chunk is actually unreachable at runtime.
The reason is straightforward: these conditions have become false in production. In the fragment above, f is false, and d=f, so d is also false. In g(), the App component, the first three children entries are already null,null,null, corresponding to LazyInlineEnv, LazyLocalConst, and LazyImportedConst. Although m and h still have lazy-loader forms, they are guarded by d and p.isDevelopment, respectively. Since d is false and the p.isDevelopment getter returns false, both ternaries evaluate to null. Their lazy(...) branches don't run, and neither do the inner l.e(...) calls.
Here, l.e(...) is the minified form of __webpack_require__.e(...), the runtime entry point for loading async chunks. If it doesn't run, the browser doesn't request the corresponding lazy-debug-panel-xxx.js. So the file exists, but this output shows no reachable path to it in production.
The question isn't whether the debug panel executes in production. It doesn't. The real question is: if the branch is already unreachable, why is its async chunk file still emitted?
To make these checks more reliable, I added a simple analysis script to the demo:
const lazyPackaged = files.includes(
`static/js/async/${testCase.lazyChunk}.js`,
);
const lazyInMap = sourceMaps.some((content) =>
content.includes("debug-panel/lazy/imported-const.tsx"),
);
const regularPackaged = jsFiles.some((content) =>
content.includes("REGULAR_IMPORTED_CONST_DEBUG_PANEL_INCLUDED"),
);These checks correspond to the table columns:
Lazy packaged: whether the corresponding async chunk file is emitted.
Lazy in source map: whether the lazy debug panel's source path remains in a source map.
Regular packaged: whether the regular debug panel's sentinel string remains in the main bundle or synchronous JavaScript.
Regular in source map: whether the regular debug panel's source path remains in a source map.
This distinguishes two easily confused facts: code being unreachable at runtime doesn't guarantee its absence from the output, and a regular import being tree-shaken doesn't guarantee removal of an async chunk created by a dynamic import.
Why Minimize Doesn't Remove the Async Chunk
Seeing children:[null,null,null,...] in the output raises an obvious question: if the main bundle already shows that these lazy components won't render, why doesn't the minimize stage remove their async chunk files too?
The key is that minimize is better at optimizing code within the current JavaScript file than at rewriting the entire chunk graph.
For the main index.js bundle, the minimizer can do plenty:
Fold process.env.REACT_APP_ENVIRONMENT === 'development' into false.
Reduce JSX branches known to be false to null or false.
Remove unreachable functions, variables, and expressions within the current file.
Shorten variable names, for example turning runtime identifiers such as __webpack_require__ into l.
But an async chunk file isn't an ordinary function body inside the main bundle. It is already an independent output unit in the chunk graph:
app.tsx
└─ import("./debug-panel/lazy/imported-const")
└─ AsyncDependenciesBlock
└─ async chunk: lazy-debug-panel-imported-const.jsBy the minimize stage, lazy-debug-panel-imported-const.js is no longer just an expression in the main bundle's AST that can be deleted directly. It is an async chunk asset recorded in the chunk graph. The minimizer may reduce its reference in the main bundle to unreachable code, or even fold expressions into null, but it usually won't work backward and delete an existing chunk because that chunk's loader is unreachable in the main bundle at runtime.
This is harder than ordinary dead-code removal for several reasons:
import() affects both the module graph and the chunk graph, not just an expression in the current module.
An async chunk may have multiple references. Removing it requires proving that it is unreachable from every entry and in every runtime.
Chunk filenames, runtime loading maps, source maps, and preload/prefetch information may already depend on it.
With cross-module bindings, getters, re-exports, or side-effectful modules, it is difficult for a minimizer to prove unreachability safely from the final JavaScript text alone.
Such optimization generally needs to happen earlier: either the parser never collects the import(), or chunk-graph optimization proves the async block's dependency condition inactive. The final minimize stage alone struggles to turn "this lazy loader never runs in the main bundle" into "delete another async chunk file that has already been emitted."
First, Separate the Questions
Three different issues are easy to mix together:
Can process.env.REACT_APP_ENVIRONMENT become a string literal at compile time?
Can the parser determine that the conditional expression is definitely true or false?
If the parser has already seen the import() and created an async dependency block, can later optimization remove its async chunk?
Rsbuild handles the first question correctly. loadEnv({ prefixes: ['REACT_APP_'] }) turns matching environment variables into publicVars, which are passed through source.define to Rspack's DefinePlugin. So:
process.env.REACT_APP_ENVIRONMENT === 'development'First becomes this in a production build:
"production" === 'development'That expression is clearly false.
The real dividing line is the second question: is that false available while parsing the current module?
Why an Inline Environment Check Works
The inline version looks like this:
const LazyInlineEnv =
process.env.REACT_APP_ENVIRONMENT === 'development'
? lazy(() => import('./debug-panel/lazy/inline-env'))
: null;While Rspack parses this module, DefinePlugin can match the complete identifier directly: process.env.REACT_APP_ENVIRONMENT.
In Rspack's source, the DefinePlugin parser hook runs when evaluating an identifier:
crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/parser.rs
evaluate_identifier: around line 102.
crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/walk_data.rs
with_on_evaluate_identifier: around line 263.
DefinePlugin passes the defined code fragment to the parser for evaluation. Thus, process.env.REACT_APP_ENVIRONMENT can be treated as "production" in the current expression.
Then ConstPlugin handles the conditional expression:
crates/rspack_plugin_javascript/src/parser_plugin/const/mod.rs
expression_conditional_operation: around line 29.
parser.evaluate_expression(&expression.test): around line 34.
param.as_bool(): around line 35.
The key logic can be simplified to:
let param = parser.evaluate_expression(&expression.test);
if let Some(bool) = param.as_bool() {
// 条件能确定成 true / false
Some(bool)
} else {
None
}The walker then uses that result to visit only the branch known to be reachable:
crates/rspack_plugin_javascript/src/visitors/dependency/parser/walk.rs
walk_conditional_expression: around line 1102.
if let Some(result): around line 1108.
result ? walk cons : walk alt: around lines 1109–1113.
In production, the test is false, so the walker visits only the : null branch. It never enters lazy(() => import(...)).
That's why the inline check produces no async chunk: the chunk isn't deleted later; the parser never reaches the import() in the first place.
Why a Same-Module Local Const Also Works in Rspack
The added local-const case is:
const localIsDevelopment =
process.env.REACT_APP_ENVIRONMENT === 'development';
const LazyLocalConst = localIsDevelopment
? lazy(() => import('./debug-panel/lazy/local-const'))
: null;Rspack can remove this async chunk because it supports inlining constants. The source entry points are:
crates/rspack_plugin_javascript/src/parser_plugin/inline_const.rs
pre_declarator: around line 85.
Only top-level constants are handled: around lines 91–95.
parser.evaluate_expression(declarator.init): around line 100.
to_evaluated_inlinable_value: around line 106.
tag_const_variable: around line 113.
In other words, within the same module:
const localIsDevelopment = "production" === "development";This top-level constant can be tagged as an inlineable boolean. When a later conditional reads localIsDevelopment, the parser can retrieve its value and follow the same path as before: ConstPlugin determines that the test is false, the walker visits only null, and no async block is created for the dynamic import.
This also explains why Rspack does better than Webpack in this case. At least with the reproduction's current configuration, Webpack doesn't use that same-module constant to skip the import() branch before collecting dynamic imports, so it still emits lazy-debug-panel-local-const.js.
Why an Imported Const Doesn't Work
The central case is:
import { isDevelopment } from './environment';
const LazyImportedConst = isDevelopment
? lazy(() => import('./debug-panel/lazy/imported-const'))
: null;Although environment.ts is simple:
export const isDevelopment =
process.env.REACT_APP_ENVIRONMENT === "development";When Rspack parses app.tsx, isDevelopment isn't an ordinary constant in the current module. It is an ESM imported binding.
During parsing, the conditional test therefore isn't a local value that can directly yield as_bool(). ConstPlugin cannot return Some(false), so the walker takes the path for an unknown condition.
Rspack's walker doesn't simply give up when a condition is unknown. It collects a branch guard:
crates/rspack_plugin_javascript/src/visitors/dependency/parser/walk.rs
collect_dependencies_in_branch_guard: around line 1115.
Apply a guard to the consequent: around lines 1128–1131.
Apply the inverse guard to the alternate: around lines 1135–1138.
Simplified, this is:
if condition_is_known {
walk(known_branch)
} else {
walk(consequent with guard)
walk(alternate with !guard)
}The walker still visits the lazy branch in isDevelopment ? lazy(() => import(...)) : null, but it carries a branch guard.
Once the walker enters lazy(() => import(...)), the import() parser plugin creates a dynamic-import dependency and an async block:
crates/rspack_plugin_javascript/src/parser_plugin/import_parser_plugin.rs
import_call: around line 261.
Create ImportDependency: around line 417.
Create AsyncDependenciesBlock: around line 435.
parser.add_block(Box::new(block)): around line 449.
The question now changes from "should the parser see import()?" to "can later optimization mark this existing async block inactive and skip it?"
Rspack does preserve a branch guard on ImportDependency:
crates/rspack_plugin_javascript/src/dependency/esm/import_dependency.rs
The branch_guard field: around lines 33–34.
set_branch_guard: around line 68.
get_condition: around line 167.
The branch guard is converted into a dependency condition:
crates/rspack_plugin_javascript/src/dependency/branch_guard.rs
compose_dependency_condition: around line 126.
If the guard resolves to Some(false), return ConnectionState::Active(false): around lines 155–166.
There is even dedicated logic to resolve ESM imported boolean guards:
crates/rspack_plugin_javascript/src/dependency/branch_guard.rs
resolve_esm_imported_boolean_guard: around line 273.
Read the imported export's used name: around lines 293–301.
Continue only for UsedName::Inlined: around lines 301–303.
Return only if the inline value is a boolean: around lines 305–307.
There is a crucial limitation, though: this relies on the imported export already being analyzed as UsedName::Inlined(Boolean(false)). That information comes from broader export-inlining analysis; it isn't inherently available when parsing the conditional in app.tsx.
A comment in InlineExportsPlugin also explains why it runs during optimize_dependencies:
crates/rspack_plugin_javascript/src/plugin/inline_exports_plugin.rs
Comment: around lines 102–106.
Hook: CompilationOptimizeDependencies, around line 107.
The comment essentially says that export inlining affects side-effects optimization, and buildChunkGraph can use dependency conditions to determine whether a dependency is still active. If an imported export is inlined, the dependency may become inactive and no longer be processed by buildChunkGraph.
That sounds as though it should remove the imported-const async chunk. But the reproduction doesn't remove it. In this specific case, the guard therefore hasn't caused the import() async block to be fully skipped during chunk-graph construction. The regular static import disappears, while the dynamic-import async block remains and produces a chunk.
Rspack's Compilation Flow
The following diagram connects the stages above:
@startuml
title Key Rspack Stages for Conditions and Dynamic Imports
skinparam monochrome true
skinparam shadowing false
start
:BuildModuleGraphPhase;
:Parse app.tsx;
if (Can the condition evaluate to a boolean in this module?) then (yes)
:ConstPlugin returns Some(true/false);
:The walker visits only the resolved branch;
if (Does the resolved branch contain import()?) then (yes)
:ImportParserPlugin creates ImportDependency;
:Create AsyncDependenciesBlock;
else (no)
:import() is never visited;
:No async block is created;
endif
else (no)
:The walker visits both branches;
:Attach a branch guard to dependencies in each branch;
if (Does the branch contain import()?) then (yes)
:ImportParserPlugin creates ImportDependency;
:Create AsyncDependenciesBlock;
endif
endif
:FinishModules / Seal;
:OptimizeDependencies;
:InlineExportsPlugin attempts to inline exports;
:BuildChunkGraph;
if (Is the async block / dependency considered inactive?) then (yes)
:Exclude it from the final chunk graph;
else (no)
:Create or reuse an async chunk;
:CreateChunkAssets emits async chunk files;
endif
stop
@endumlDiagram unavailable. Use Show code to inspect the source.
Putting the individual cases into the diagram makes this clearer:
@startuml
title How Guard Patterns Diverge During Parsing
skinparam monochrome true
skinparam shadowing false
start
:Read the condition for LazyXXX;
if (Inline env?) then (process.env...)
:DefinePlugin matches process.env.REACT_APP_ENVIRONMENT;
:test = "production" === "development";
:ConstPlugin evaluates to false;
:Visit only the null branch;
:No async chunk;
elseif (Local const?) then (localIsDevelopment)
:InlineConstPlugin marks a top-level const;
:Read localIsDevelopment as false;
:Visit only the null branch;
:No async chunk;
elseif (Imported const?) then (isDevelopment)
:Only an imported binding is available in this module;
:Cannot call as_bool() directly during parsing;
:Visit both branches;
:Collect import() as an async block;
:An async chunk may still be emitted in production;
elseif (Object / Getter?) then (Environment.isDevelopment)
:Property reads or getters are harder to prove constant;
:Visit both branches;
:Collect import();
:Code or chunks are more likely to remain;
endif
stop
@endumlDiagram unavailable. Use Show code to inspect the source.
Is This a Bug or an Intentional Design Choice?
I'd describe it as a current optimization boundary rather than simply a bug.
A dynamic import isn't just an ordinary expression; it changes the module graph and chunk graph. For a bundler, removing an unused export and removing an already-built async chunk group are different problems.
More specifically:
Inline environment checks and local constants can be proven unreachable during parsing, making them the cleanest cases: their import() is never collected.
Imported constants require cross-module constant propagation. Skipping the branch during parsing would require knowing another module's final exported value at that point, introducing stronger global analysis and coupling between stages.
Rspack's branch guards and export inlining show that it does consider this direction. But the reproduction indicates that this path doesn't yet cover removing a dynamic-import async chunk guarded by an imported boolean.
In theory, the optimization could go further. If an exported constant is proven to be a side-effect-free boolean literal and the dynamic-import block is controlled only by that guard, the chunk-graph stage could more aggressively mark it inactive. Such optimization requires care, though: ESM bindings, module side effects, re-exports, runtimes, and used exports across different chunks/runtimes all complicate the decision.
Comparison with Webpack
One trap in this comparison is to say, "Webpack can handle this, but Rspack can't."
The actual results are:
Webpack handles inline environment checks.
Webpack also fails to remove the lazy async chunk under an imported constant.
With the current configuration, Webpack doesn't even remove the lazy async chunk under a same-module local constant.
Rspack handles both inline environment checks and same-module local constants.
This investigation actually shows that Rspack is more aggressive about same-module constant folding. The uncovered path is cross-module imported constants leading to dynamic-import async chunks.
Practical Suggestions
If the goal is to avoid emitting a debug panel's async chunk in production, the most reliable approach is to put the condition directly in the same expression and module as the import():
const LazyDebugPanel =
process.env.REACT_APP_ENVIRONMENT === 'development'
? lazy(() => import('./debug-panel'))
: null;In Rspack, a top-level constant in the same module also works:
const isLocalDevelopment =
process.env.REACT_APP_ENVIRONMENT === 'development';
const LazyDebugPanel = isLocalDevelopment
? lazy(() => import('./debug-panel'))
: null;But with this form:
import { isDevelopment } from './environment';
const LazyDebugPanel = isDevelopment
? lazy(() => import('./debug-panel'))
: null;You cannot assume that the async chunk will be removed. A regular static import may be tree-shaken, while the chunk from a dynamic import remains in the output.
If debug panels have a significant impact on output size, I'd favor inline environment checks or local constants in the same module. Encapsulating environment variables is good for maintainability, but that abstraction boundary should preferably not cross the dynamic import's reachability check.
Summary
The core issue isn't that Rsbuild failed to inject environment variables, nor that React.lazy performs special magic. Rspack has different information available at different stages:
DefinePlugin can replace process.env.REACT_APP_ENVIRONMENT when it appears directly in the current module.
ConstPlugin can make the walker skip an unreachable branch only when the test evaluates to a boolean.
InlineConstPlugin lets top-level constants in the same module participate in that decision.
Imported constants require cross-module export-inlining information. Without a definite boolean during parsing, the import() is first collected into an AsyncDependenciesBlock.
Later optimization and minimization can remove regular code without necessarily removing async chunks that have already entered the chunk graph.
In one sentence: to stop an unreachable dynamic import from emitting an async chunk, make its unreachability apparent to the bundler while it parses the current module.