Skip to content

feat: modernization using OXC + Rolldown ecosystem#211

Closed
benpsnyder wants to merge 7 commits into
aleclarson:masterfrom
benpsnyder:feat/oxc-upgrade
Closed

feat: modernization using OXC + Rolldown ecosystem#211
benpsnyder wants to merge 7 commits into
aleclarson:masterfrom
benpsnyder:feat/oxc-upgrade

Conversation

@benpsnyder

Copy link
Copy Markdown
Contributor

v7.0.0-alpha.1 — OXC + Rolldown modernization

Summary

  • Replace custom path-matching logic (mappings.ts + globrex) with oxc-resolver for native, high-performance tsconfig path resolution
  • Add Rolldown-compatible resolveId hook filters to skip unnecessary Rust-to-JS boundary calls
  • Delete src/mappings.ts entirely (replaced by oxc-resolver internals)
  • Migrate formatter from Prettier to oxfmt (OXC formatter)
  • Replace tsup with Rolldown for JS bundling — drops 101 transitive dependencies
  • Enable isolatedDeclarations and use oxc-transform for fast native .d.ts generation
  • Upgrade to TypeScript 6.0 RC — fix TS 6 strictness changes and deprecations
  • Remove stale devDependencies (globrex, esbuild, rollup, prettier, tsup)
  • Add packageManager and engines fields

Motivation

The plugin's resolution pipeline previously worked in three layers:

  1. tsconfck parses tsconfig files and discovers projects
  2. Custom mappings.ts compiles compilerOptions.paths into RegExp matchers using globrex
  3. Vite's resolver (this.resolve()) verifies each candidate path exists on disk

Steps 2 and 3 are now replaced by a single oxcResolver.sync() call. oxc-resolver is a Rust port of enhanced-resolve with built-in tsconfig paths support — it handles pattern matching, wildcard substitution, extension resolution, and filesystem verification in one native call.

Performance

oxc-resolver is 28x faster than webpack's enhanced-resolve (benchmark). For this plugin specifically, the improvement comes from:

  • Single native call replaces a JS loop over regex matches, each calling this.resolve() (which itself traverses the full Vite plugin pipeline)
  • No regex compilation at startup — oxc-resolver handles tsconfig path patterns natively
  • Hook filters (Rolldown/Vite 6.3+/Rollup 4.38+) skip the resolveId handler entirely for relative imports (./, ../) and virtual modules (\0), avoiding the JS function call overhead for the majority of imports that this plugin will never handle

The modules: [] configuration ensures oxc-resolver only resolves via tsconfig paths and baseUrl — it won't attempt node_modules resolution, keeping the behavior scoped to exactly what the plugin is responsible for.

What changed

File Change
src/resolver.ts createResolver() now creates a per-project ResolverFactory instance and uses oxcResolver.sync() instead of custom regex matching + Vite resolver delegation. Removed resolvePathsRootDir(). Replaced globrex with an inline compileGlob() for the include/exclude matcher.
src/index.ts resolveId converted from function to object hook format with filter.id regex. Added Rolldown detection logging in configResolved.
src/mappings.ts DeletedresolvePathMappings() is fully replaced by oxc-resolver's native tsconfig paths handling.
package.json Added oxc-resolver ^11.19.1. Removed globrex and @types/globrex.

Backward compatibility

This is a drop-in replacement. All 6 existing test fixtures pass unchanged:

  • with-baseUrl — paths + baseUrl resolution
  • without-baseUrl — paths without baseUrl (resolves relative to tsconfig directory)
  • configDir-syntax${configDir} variable substitution in paths and include
  • extends-paths — tsconfig inheritance via extends with paths defined in parent
  • paths-outside-root — path mappings that reference directories outside the project root (../my-utils/*)
  • project-reference — TypeScript composite project references

Preserved behaviors:

  • tsconfck is still used for tsconfig discovery, parsing, and file watching — oxc-resolver doesn't expose discovery APIs
  • Include/exclude filtering still works (inline glob compiler replaces globrex)
  • All plugin options work as before: loose, importerFilter, parseNative, configNames, projects, projectDiscovery, logFile, skip
  • .json import guard preserved — accidental resolution to .json files is still blocked unless the import specifier explicitly ends with .json
  • .d.ts resolution guard preserved
  • Query/hash parameter stripping and restoration preserved
  • Resolution cache per project preserved
  • Virtual module importer coercion (WXT framework compat) preserved

Hook filter compatibility:

  • Rollup 4.38+, Vite 6.3+, and Rolldown: the filter property is evaluated natively, skipping the JS handler for non-matching imports
  • Older Rollup/Vite: the filter property is silently ignored, and the handler function is called for all imports (identical to previous behavior)
  • The early-return checks for relative imports, virtual modules, and missing importers are kept inside the handler as a safety net for older runtimes

One subtle behavior difference: The .json import guard now applies to all resolutions (both paths and baseUrl), whereas previously it only applied to baseUrl-only resolution. In practice this is inconsequential — if a tsconfig path pattern maps to a .json file, the import specifier should include the .json extension explicitly (which passes the guard).

Prettier to oxfmt migration

oxfmt is OXC's Rust-based formatter — a Prettier-compatible drop-in replacement that is significantly faster.

What changed

File Change
.prettierrc Deleted
.oxfmtrc.json Created — auto-migrated via oxfmt --migrate=prettier, preserves all formatting options (singleQuote, semi, trailingComma, bracketSpacing, etc.) with printWidth: 80 explicitly set (oxfmt defaults to 100)
.vscode/settings.json Default formatter changed from esbenp.prettier-vscode to nicolo-ribaudo.oxfmt-vscode
.vscode/extensions.json Recommended extension changed from esbenp.prettier-vscode to nicolo-ribaudo.oxfmt-vscode
package.json prettier removed from devDeps, oxfmt added

Formatting behavior

The migrated .oxfmtrc.json preserves the original Prettier settings:

{
  "bracketSpacing": true,
  "jsxBracketSameLine": true,
  "singleQuote": true,
  "semi": false,
  "trailingComma": "es5",
  "printWidth": 80
}

Running oxfmt produced minor whitespace-only changes in 10 files (JSON formatting normalization, markdown table alignment). No source code logic was affected.

Usage

npx oxfmt          # format all files
npx oxfmt --check  # check formatting (CI)

Isolated declarations with oxc-transform

Replaced tsup's --dts (which uses the TypeScript compiler internally) with oxc-transform's isolatedDeclarationSync() for .d.ts generation. This is significantly faster because it operates on a single-file basis without needing the full TypeScript type checker.

Requirements

Enabled isolatedDeclarations: true in tsconfig.json. This requires all exported symbols to have explicit type annotations — the compiler can generate declarations from each file independently without cross-file type inference.

Source changes for isolatedDeclarations compliance

File Change
src/index.ts Added explicit vite.Plugin return type to default export
src/debug.ts Added explicit createDebug.Debugger type annotation
src/logFile.ts Added explicit void return type to write() method
src/path.ts Rewrote branded-type utilities as proper wrapper functions instead of as casts (TS 6 rejects direct casts between incompatible branded types)
src/resolver.ts Extracted TsconfigResolvers from ReturnType<> to an explicit interface with typed reset, get, watch methods

Build pipeline

rolldown -c              # JS bundle via Rolldown (9ms)
tsx scripts/dts.ts       # .d.ts via oxc-transform

Rolldown replaces tsup for JS bundling. tsup used esbuild internally and pulled in 101 transitive dependencies. Rolldown is a single Rust binary with native TypeScript support — no transpiler chain needed.

The rolldown.config.ts is minimal:

import { defineConfig } from 'rolldown'

export default defineConfig({
  input: 'src/index.ts',
  output: { file: 'dist/index.js', format: 'esm', sourcemap: true },
  external: [/^node:/, 'vite', 'debug', 'tsconfck', 'oxc-resolver'],
  platform: 'node',
})

The scripts/dts.ts script processes all src/*.ts files through isolatedDeclarationSync() and writes .d.ts files to dist/. JSDoc comments are preserved in the output.

TypeScript 6.0 RC

Upgraded from TypeScript 5.7.2 to 6.0.1-rc. Required changes:

Change Reason
as unknown as → wrapper functions in src/path.ts TS 6 rejects as casts between string and branded NormalizedPath type — properly wrapping the functions is the correct approach
@types/node ^22.2.0 → ^25.5.0 Old @types/node has Buffer type incompatibilities with TS 6
ignoreDeprecations: "6.0" in with-baseUrl fixture baseUrl is deprecated in TS 6 (removed in TS 7)
rootDir: ".." in paths-outside-root fixture TS 6 requires explicit rootDir when sources span multiple directories

CI: oxlint type-aware linting + format checking

The GitHub Actions workflow (.github/workflows/ci.yml) has been restructured into two jobs:

check job (ubuntu-latest only)

Runs formatting and linting checks that don't need cross-platform coverage:

  1. pnpm fmt:check — verifies all files are formatted with oxfmt
  2. pnpm lint — runs oxlint with type-aware linting and type-checking enabled

test job (ubuntu + windows matrix)

Unchanged — runs pnpm test across both OS targets.

oxlint configuration

.oxlintrc.json enables type-aware linting with TypeScript type-checking:

{
  "options": {
    "typeAware": true,
    "typeCheck": true
  },
  "rules": {
    "no-control-regex": "off",
    "typescript/unbound-method": "off"
  }
}

typeAware: true enables rules from the typescript-eslint family that require type information (e.g., no-floating-promises, no-unsafe-assignment). typeCheck: true additionally reports TypeScript diagnostics (equivalent to tsc --noEmit) — catching type errors without a separate tsc step.

Suppressed rules:

  • no-control-regex — the \0 null character in the resolveId hook filter is intentional (filters virtual module IDs)
  • typescript/unbound-methodsrc/path.ts re-exports path.posix.* functions by reference; these are pure functions that don't use this

New package.json scripts

{
  "fmt:check": "oxfmt --check",
  "lint": "oxlint src/"
}

LogEvent type fix

Updated src/logFile.ts to include the new 'resolved' event type (from oxc-resolver) and allow 'notFound' without the candidates field (no longer generated since mappings.ts is removed). This was caught by oxlint's type-checking.

DevDependency cleanup

Removed stale devDependencies that were no longer directly used:

Package Reason for removal
tsup (^6.5.0) Replaced by Rolldown for bundling. Drops 101 transitive dependencies.
esbuild (^0.11.12) Was a tsup transitive dep. No longer needed.
rollup (^2.45.2) Was a tsup transitive dep. No longer needed.
prettier (^2.8.7) Replaced by oxfmt.
globrex (^0.1.2) Replaced by inline compileGlob() in resolver.ts.
@types/globrex (^0.1.0) No longer needed.
klona (^2.0.4) Was unused — no imports found anywhere in src/ or test/.

Other cleanup

  • clean script fixed — removed git checkout HEAD dist which was a no-op (dist/ is gitignored). Now just rimraf dist.

Project metadata

Added packageManager and engines fields to package.json:

{
  "packageManager": "pnpm@10.32.0",
  "engines": {
    "node": ">=18"
  }
}

Dependency changes

Production dependencies

Before After
debug ^4.1.1 ^4.1.1 (unchanged)
globrex ^0.1.2 removed
tsconfck ^3.0.3 ^3.0.3 (unchanged)
oxc-resolver ^11.19.1 (added)

Dev dependencies

Before After
typescript ^5.7.2 6.0.1-rc
@types/node ^22.2.0 ^25.5.0
tsup ^6.5.0 removed
prettier ^2.8.7 removed
esbuild ^0.11.12 removed
rollup ^2.45.2 removed
@types/globrex ^0.1.0 removed
klona ^2.0.4 removed
rolldown 1.0.0-rc.9 (added)
oxfmt ^0.40.0 (added)
oxlint ^1.55.0 (added)
oxlint-tsgolint ^0.17.0 (added)
oxc-transform ^0.119.0 (added)
tsx ^4.21.0 (added)

oxc-resolver ships prebuilt native binaries for all major platforms (macOS, Linux, Windows; x64 and arm64). No compilation step is required during install.

Test plan

  • All 6 fixture tests pass (pnpm test)
  • TypeScript compilation succeeds for all fixtures (tsc -p .)
  • Vite build succeeds for all fixtures
  • Plugin builds successfully (pnpm build) — oxc-transform generates .d.ts
  • tsc --noEmit passes with TypeScript 6.0.1-rc and isolatedDeclarations: true
  • pnpm fmt:check passes (all files formatted with oxfmt)
  • pnpm lint passes (oxlint type-aware + type-check, 0 errors/warnings)
  • Verify in a real project with complex tsconfig paths
  • Test with projectDiscovery: "lazy" mode
  • Test with jsconfig.json (via configNames option)
  • Test under Rolldown-powered Vite (when available)

Versioning: why v7

This is a major version bump (7.0.0-alpha.1) because:

  1. Resolution engine replaced — oxc-resolver resolves paths natively instead of delegating to Vite's resolver. While all existing tests pass, edge cases in complex monorepos may behave differently.
  2. .json import guard broadened — now applies to paths resolution (previously only baseUrl). Imports like @/data that resolve to .json without an explicit .json extension will no longer match.
  3. Production dependency changedglobrex removed, oxc-resolver added. Consumers who vendor or audit dependencies will see a different tree.
  4. Node >=18 required — explicit engines constraint added.
  5. TypeScript 6.0 RC — test fixtures updated for TS 6 deprecations (baseUrl, explicit rootDir).

Alpha release strategy

Published as 7.0.0-alpha.1 with the alpha dist-tag so it won't affect existing users on ^6.x:

npm install vite-tsconfig-paths@alpha

This lets early adopters test the new resolution engine in real projects and report issues before a stable v7 release.

Toolchain summary

The entire dev toolchain is now OXC/Rust-native:

Tool Purpose Replaces
oxc-resolver tsconfig path resolution (runtime) custom regex + Vite resolver
rolldown JS bundling tsup (esbuild)
oxc-transform .d.ts generation tsup (tsc)
oxlint + oxlint-tsgolint linting + type-checking — (none before)
oxfmt formatting prettier

…dencies

- Replaced custom path resolution logic with oxc-resolver for improved performance.
- Migrated formatting from Prettier to oxfmt, updating configuration accordingly.
- Enabled isolated declarations in TypeScript configuration.
- Updated package dependencies, including TypeScript to version 6.0 RC.
- Removed unused dependencies and files, including globrex and mappings.ts.
- Enhanced CI workflow with formatting and linting checks.
@benpsnyder

Copy link
Copy Markdown
Contributor Author

Hi @aleclarson -- I am working on an OXC + Rolldown modernization of AnalogJS
see: https://github.com/analogjs/analog/pulls + alpha branch https://github.com/analogjs/analog/tree/alpha

I will thoroughly test usage of this overhaul on a 7.0.0-alpha.1 if you would be so kind as to accept, and I will follow-up with as many PRs as needed to stability.

@benpsnyder

Copy link
Copy Markdown
Contributor Author

Follow-up cleanup from code review (reuse / quality / efficiency audit)

Ran three parallel review passes — code reuse, quality, and efficiency — focused on regression risk. Four issues found and fixed:

Dead code removal:

  • Removed ViteResolve type and viteResolve parameter from the Resolver signature. Since oxc-resolver handles resolution directly, this.resolve() is never called — the entire viteResolve construction (closure + resolveOptions spread on every import) was dead work on the hot
    path.
  • Removed stale LogEvent variants (resolvedWithBaseUrl, resolvedWithPaths) and the NotFoundDetails type — these were vestiges of the old mappings.ts resolution paths that no longer exist.
  • Removed the this type annotation and unused options parameter from the resolveId handler, since the plugin context is no longer accessed.

Event loop fix:

  • Switched oxcResolver.sync() → await oxcResolver.async(). The sync call blocked the Node.js event loop for every import resolution. During Vite's concurrent pre-bundling (scanning hundreds of files), this serialized all resolutions on the main thread. The async variant yields
    between calls, preventing starvation of WebSocket messages and file watchers. The handler was already async, so this was a drop-in change with no behavior difference.

Performance guard:

  • Wrapped the inspect() call in createResolver() with debug.enabled check. Previously it eagerly serialized the full tsconfig object (depth 10) on every project load even when debug logging was off.

Peer dependency:

  • vite peer changed from "*" to ">=5.0.0". Vite 2–4 are EOL/maintenance, and the v7 toolchain (TS 6, Node >=18, Rolldown hook filters) targets modern environments.

All 6 fixture tests pass, 0 lint warnings, 0 type errors.

- Removed unused parameters and types from the resolver and log file.
- Updated the resolver to use async resolution for improved performance.
- Enhanced logging events by removing unnecessary details.
- Introduced a mechanism to coalesce concurrent resolutions for the same specifier, reducing duplicate native calls during parallel pre-bundling.
- Added an inflight resolutions map to track ongoing resolution promises.
- Enhanced logging to capture resolution events more effectively.
@benpsnyder

Copy link
Copy Markdown
Contributor Author

Promise coalescing for async resolution cache

The switch from oxcResolver.sync() to await oxcResolver.async() introduced a TOCTOU gap on the resolutionCache. During Vite's parallel pre-bundling phase, multiple modules can resolve the same specifier concurrently — both calls miss the cache, both dispatch native resolution
work, and the cache benefit is lost for exactly the workload where it matters most.

Added an inflightResolutions Map that stores the in-flight Promise. Concurrent callers for the same specifier now share a single native resolution call. The entry is cleaned up in a finally block once the resolution settles.

Before (async without coalescing):
resolve("@/utils") → cache miss → native call #1
resolve("@/utils") → cache miss → native call #2 (duplicate)

After (with coalescing):
resolve("@/utils") → cache miss → native call #1, store Promise
resolve("@/utils") → cache miss → reuse Promise from #1

No other issues found in this round. The remaining review findings were either pre-existing (resolutionCache growth bounded by import graph size, cleared on tsconfig reload) or false positives (debug.enabled inconsistency at call sites that only pass cheap string args).

benpsnyder and others added 3 commits March 15, 2026 20:34
- Upgraded `debug` to version 4.4.3, `tsconfck` to version 3.1.6, `@types/debug` to version 4.1.12, `execa` to version 9.6.1, `rimraf` to version 6.1.3, and `vitest` to version 4.1.0.
- Adjusted `vite-tsconfig-paths` specifier to 'link:' in both package files.
- Updated corresponding versions in pnpm-lock.yaml to reflect changes in package.json.
The lockfile recorded `vite: *` but package.json declares
`vite: ">=5.0.0"` as a peer dependency. This mismatch causes
ERR_PNPM_OUTDATED_LOCKFILE when installing this package as a
git-hosted dependency with --frozen-lockfile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pumano

pumano commented Mar 16, 2026

Copy link
Copy Markdown

Looks really great! Less deps, more performance

@aleclarson

Copy link
Copy Markdown
Owner

Thanks for the PR.

oxc-resolver is 28x faster than webpack's enhanced-resolve (benchmark)

Can you write a benchmark comparing the current plugin (v6) with your rewrite?

Note that Vite 8 has tsconfig paths resolution built-in, so idk if a rewrite of this plugin is worth it.

@benpsnyder

Copy link
Copy Markdown
Contributor Author

Thanks for the PR.

oxc-resolver is 28x faster than webpack's enhanced-resolve (benchmark)

Can you write a benchmark comparing the current plugin (v6) with your rewrite?

Note that Vite 8 has tsconfig paths resolution built-in, so idk if a rewrite of this plugin is worth it.

Yes and I agree but projects on older Vite might be able to take advantage. IDK why people won't update but ... just saying.
Also why not update it before "archiving".
We are using this re-written version on Analog and it works like a charm.

@benpsnyder

Copy link
Copy Markdown
Contributor Author

@aleclarson -- I ran both a direct plugin benchmark and a real-world Analog benchmark against the pre-rewrite and rewrite revisions. 🚀

🔀 Commits compared

vite-tsconfig-paths:

  • v6 / master: eec0e9e
  • rewrite: 28d2620

Analog adoption:

  • before fork adoption: 113cbc09
  • after fork adoption: 9ba62b89

🧪 Plugin benchmark

For the main upstream benchmark I used the existing test/plugin.test.ts suite, since it already covers the six compatibility fixtures in this PR:

  • with-baseUrl
  • without-baseUrl
  • configDir-syntax
  • extends-paths
  • paths-outside-root
  • project-reference

Each timed run includes both TypeScript declaration emit and a Vite build for every fixture.

Environment:

  • Darwin arm64
  • node v25.5.0
  • hyperfine --warmup 1 --runs 8

I verified that all 6 fixtures pass on both revisions before timing. ✅

Benchmark v6 mean rewrite mean Delta
pnpm vitest run test/plugin.test.ts 4.676s 3.834s 18.0% less time (1.22x faster)
pnpm build 2.354s 2.079s 11.7% less time (1.13x faster)

The first row is the most important one, since it exercises the full compatibility surface instead of only the package build pipeline. 🎯

🏗️ Real-world Analog benchmark

I also benchmarked the actual Analog targets that currently load vite-tsconfig-paths in this repo:

  • router via packages/router/vite.config.ts
  • vite-plugin-nitro via packages/vite-plugin-nitro/vite.config.ts

Those were measured through the real Nx commands, not an ad hoc script:

  • pnpm nx test vite-plugin-nitro --skip-nx-cache
  • pnpm nx test router --skip-nx-cache
  • pnpm nx run-many --target test --projects router,vite-plugin-nitro --skip-nx-cache

Environment:

  • Darwin arm64
  • node v25.5.0
  • pnpm 10.32.0
  • NX_DAEMON=false
  • hyperfine --warmup 1 --runs 3

I verified the targets pass on both revisions before timing. ✅

Analog target before mean after mean Delta
pnpm nx test vite-plugin-nitro --skip-nx-cache 4.712s 3.726s 20.9% less time (1.26x faster)
pnpm nx test router --skip-nx-cache 23.014s 22.966s 0.2% less time (1.00x faster)
pnpm nx run-many --target test --projects router,vite-plugin-nitro --skip-nx-cache 21.193s 21.588s 1.9% more time for the rewrite (0.98x)

📌 Interpretation

  • The direct plugin benchmark is clearly positive: the rewrite is faster on the plugin's own full fixture surface.
  • In Analog, the most plugin-sensitive target here is vite-plugin-nitro:test, and it also improved materially.
  • router:test is basically flat. That target pulls in a larger dependency/build chain, so the resolver cost is a much smaller part of total wall time.
  • The combined run-many benchmark is also effectively flat, with a slight regression in this sample. With only 3 runs and larger Nx/build overhead in the mix, I would treat that as noise rather than strong evidence either way. 📉📈

⚠️ Setup caveat

One practical note: the post-adoption Analog revision points at the GitHub fork tarball. In the temporary benchmark worktree, installing with ignored lifecycle scripts left vite-tsconfig-paths source-only with no dist/, so for the real-world Analog benchmark I linked in a clean built checkout of the exact same fork commit before timing. The code under test was still the same rewrite commit; this just ensured Vite could resolve the package entrypoint correctly.

✅ Takeaway

If the question is "does the rewrite make the plugin itself faster?", the answer from the fixture benchmark is yes.

If the question is "does Analog see a broad end-to-end win everywhere?", the answer is more nuanced:

  • the direct vite-plugin-nitro usage improved nicely
  • the broader Nx-driven router and combined runs were roughly flat in this sample

So I would summarize it as: strong plugin-level win, measurable win on at least one real Analog target, and no clear repo-wide end-to-end speedup signal yet. ✨

@benpsnyder

Copy link
Copy Markdown
Contributor Author

@aleclarson need anything else from me for this to go forward?

Updated the resolver logic to skip implicit .d.ts file resolutions that are not explicitly imported, allowing for better handling of type overrides defined in tsconfig files. This change prevents potential issues while still permitting explicit imports of declaration files.
@aleclarson

Copy link
Copy Markdown
Owner

thanks for the reminder. published as 7.0.0-alpha.1 and tag next.

future PRs should target the v7 branch until stable release.

@aleclarson aleclarson closed this Mar 29, 2026
@aleclarson

Copy link
Copy Markdown
Owner

i generated release notes here: https://github.com/aleclarson/vite-tsconfig-paths/releases/tag/v7.0.0-alpha.1

if you spot any errors, let me know and i'll correct it! 👍

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.

3 participants