Reproduction link or steps
https://github.com/mrfranta/rolldown-alias-capture-group-bug#
yarn serve -> works
yarn build -> doesn't work
What is expected?
When resolve.alias uses a RegExp with $1 in the replacement string, the capture group should be expanded during build — matching the behavior documented at https://vite.dev/config/shared-options#resolve-alias and the behavior in dev mode.
For example, app/utils with find: /^@app(?!/(?:excluded))(/.*)?$/ and replacement: '/absolute/path/src/app$1' should resolve to /absolute/path/src/app/utils.
What is actually happening?
During build, the $1 capture group is not expanded — it remains as the literal string $1 in the resolved path. For example, app/utils resolves to /absolute/path/src/app$1 instead of /absolute/path/src/app/utils, causing an UNLOADABLE_DEPENDENCY error.
This happens because the regex contains a negative lookahead ((?!...)), which the Rust regex crate doesn't support. Rolldown falls back to the HybridRegex::Ecma (regress) code path in crates/rolldown_utils/src/js_regex.rs, which matches correctly but concatenates the replacement string literally without expanding $1 from the captured groups.
System Info
System:
OS: Windows 11 10.0.26200
CPU: (24) x64 12th Gen Intel(R) Core(TM) i7-12800HX
Memory: 26.56 GB / 63.70 GB
Binaries:
Node: 24.16.0
Yarn: 1.22.22
npm: 11.13.0
Browsers:
Chrome: 148.0.7778.217
Edge: Chromium (145.0.3800.58)
Firefox: 151.0.1
npmPackages:
vite: ^8.0.14 => 8.0.14
rolldown: 1.0.2 (transitive via vite)
Any additional comments?
Root Cause Analysis
The issue is in crates/rolldown_utils/src/js_regex.rs. The HybridRegex::replace method has two branches:
pub fn replace<'a>(&self, haystack: &'a str, replacement: &str) -> Cow<'a, str> {
match self {
HybridRegex::Optimize(r) => r.replace(haystack, replacement), // ✅ $1 works
HybridRegex::Ecma(reg) => {
let next = reg.find_iter(haystack).next();
let Some(m) = next else { return Cow::Borrowed(haystack) };
Cow::Owned(concat_string!(&haystack[..m.start()], replacement, &haystack[m.end()..]))
// ^^^^^^^^^^^
// literal string — $1 not expanded
}
}
}
Optimize (Rust regex crate) → r.replace() handles $1 natively ✅
Ecma (regress) → concatenates the replacement literally, ignoring capture groups ❌
Any regex using JS-specific features like negative lookahead ((?!...)) falls to the Ecma path because the Rust regex crate doesn't support lookaheads. The match works correctly, but the replacement doesn't expand $1.
The same issue exists in replace_all — the regress_regexp_replace_all function also uses literal replacement.
This is called from the builtin:vite-alias plugin in crates/rolldown_plugin_vite_alias/src/lib.rs:
StringOrRegex::Regex(find) => find.replace(importee, &matched_entry.replacement),
Why it works in dev but not build
In vite/dist/node/chunks/node.js, line ~29836:
applyToEnvironment(environment) {
if (environment.config.isBundled && !environment.config.resolve.alias.some((v) => v.customResolver))
return viteAliasPlugin({ entries: config.resolve.alias.map((item) => {
return { find: item.find, replacement: item.replacement };
}) });
return true;
}
Dev mode → uses rollup/plugin-alias (JS) → String.prototype.replace → $1 works
Build mode → uses builtin:vite-alias (Rust) → HybridRegex::Ecma → $1 broken
This is a regression from Vite 5/6 where rollup/plugin-alias was used for both dev and build.
Suggested Fix
Expand $1, $2, etc. references in the replacement string using regress::Match::captures in the Ecma branch of both replace and replace_all.
Reproduction link or steps
https://github.com/mrfranta/rolldown-alias-capture-group-bug#
yarn serve -> works
yarn build -> doesn't work
What is expected?
When resolve.alias uses a RegExp with $1 in the replacement string, the capture group should be expanded during build — matching the behavior documented at https://vite.dev/config/shared-options#resolve-alias and the behavior in dev mode.
For example, app/utils with find: /^@app(?!/(?:excluded))(/.*)?$/ and replacement: '/absolute/path/src/app$1' should resolve to /absolute/path/src/app/utils.
What is actually happening?
During build, the $1 capture group is not expanded — it remains as the literal string $1 in the resolved path. For example, app/utils resolves to /absolute/path/src/app$1 instead of /absolute/path/src/app/utils, causing an UNLOADABLE_DEPENDENCY error.
This happens because the regex contains a negative lookahead ((?!...)), which the Rust regex crate doesn't support. Rolldown falls back to the HybridRegex::Ecma (regress) code path in crates/rolldown_utils/src/js_regex.rs, which matches correctly but concatenates the replacement string literally without expanding $1 from the captured groups.
System Info
System: OS: Windows 11 10.0.26200 CPU: (24) x64 12th Gen Intel(R) Core(TM) i7-12800HX Memory: 26.56 GB / 63.70 GB Binaries: Node: 24.16.0 Yarn: 1.22.22 npm: 11.13.0 Browsers: Chrome: 148.0.7778.217 Edge: Chromium (145.0.3800.58) Firefox: 151.0.1 npmPackages: vite: ^8.0.14 => 8.0.14 rolldown: 1.0.2 (transitive via vite)Any additional comments?
Root Cause Analysis
The issue is in crates/rolldown_utils/src/js_regex.rs. The HybridRegex::replace method has two branches:
Optimize (Rust regex crate) → r.replace() handles $1 natively ✅
Ecma (regress) → concatenates the replacement literally, ignoring capture groups ❌
Any regex using JS-specific features like negative lookahead ((?!...)) falls to the Ecma path because the Rust regex crate doesn't support lookaheads. The match works correctly, but the replacement doesn't expand $1.
The same issue exists in replace_all — the regress_regexp_replace_all function also uses literal replacement.
This is called from the builtin:vite-alias plugin in crates/rolldown_plugin_vite_alias/src/lib.rs:
Why it works in dev but not build
In vite/dist/node/chunks/node.js, line ~29836:
Dev mode → uses rollup/plugin-alias (JS) → String.prototype.replace → $1 works
Build mode → uses builtin:vite-alias (Rust) → HybridRegex::Ecma → $1 broken
This is a regression from Vite 5/6 where rollup/plugin-alias was used for both dev and build.
Suggested Fix
Expand $1, $2, etc. references in the replacement string using regress::Match::captures in the Ecma branch of both replace and replace_all.