Skip to content

[Bug]: resolve.alias: $1 capture group not expanded when regex uses lookahead (HybridRegex::Ecma path) #9602

@mrfranta

Description

@mrfranta

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.

Metadata

Metadata

Labels

No labels
No labels

Type

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions