Repository: oxc-project/oxc-resolver
Pull request #358 (2025-01-13 09:18:23)

feat!: generic fs cache `type Resolver = ResolverGeneric<FsCache<FileSystemOs>>`
This PR makes the cache fully generic by introducing a `Cache` trait, and renaming the existing `Cache` to `FsCache`.

The `FileSystem` trait still exists, but is only relevant still for users of `FsCache`. By keeping the trait, I hope to minimize the amount of breaking changes, even though some still remain.

The most notable breaking change is that `ResolverGeneric::new_with_file_system(fs, ...)` has become `ResolverGeneric::new_with_cache(Arc::new(FsCache::new(fs)), ...)`.

Both `FsCache` and `FileSystem` are now behind a `fs_cache` feature flag, which is enabled by default.

I am actually considering to make both the `PackageJson` and `TsConfig` types generic as well (Biome already has its own, which ideally we would use directly for our custom `Cache` implementation). This should be achievable by introducing traits for them and adding them as associated types to the `Cache` trait. But before going too deep, I was hoping to first get feedback on the work so far :)

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/oxc-project/oxc-resolver/branches/arendjr%3Ageneric-cache)

### Merging #358 will **not alter performance**

<sub>Comparing <code>arendjr:generic-cache</code> (3f18e2f) with <code>main</code> (fede8dc)</sub>



### Summary

`✅ 3` untouched benchmarks  




Boshen: I can start integrating with Oxc and Rolldown once test passes. 
arendjr: What do you think about making `PackageJson` and `TsConfig`? I can do it now, so you only need to update once, or we can iterate on it so this PR doesn't become too big.
arendjr: Btw, I've come pretty far implementing a cache for Biome based on this PR, but it looks like I do need `PackageJson` and `TsConfig` traits now to make meaningful progress. I can do that on a separate PR on top of this one.
Boshen: A package.json + tsconfig.json trait is inevitable, let's do it!
Boshen: I've successfully tested this change in oxc and Rolldown, let's merge after CI passes!

Patch:
diff --git a/Cargo.toml b/Cargo.toml
index f81e86d..4b42ac5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -86,7 +86,9 @@ rayon = { version = "1.10.0" }
 vfs = "0.12.0" # for testing with in memory file system
 
 [features]
-default = []
+default = ["fs_cache"]
+## Provides the `FsCache` implementation.
+fs_cache = []
 ## Enables the [PackageJson::raw_json] API,
 ## which returns the `package.json` with `serde_json::Value`.
 package_json_raw_json_api = []
diff --git a/src/cache.rs b/src/cache.rs
index 5bd5176..14841cf 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -1,487 +1,95 @@
 use std::{
-    borrow::Cow,
-    cell::RefCell,
-    convert::AsRef,
-    hash::{BuildHasherDefault, Hash, Hasher},
-    io,
-    ops::Deref,
-    path::{Component, Path, PathBuf},
-    sync::{
-        atomic::{AtomicU64, Ordering},
-        Arc,
-    },
+    path::{Path, PathBuf},
+    sync::Arc,
 };
 
-use cfg_if::cfg_if;
-use dashmap::{DashMap, DashSet};
-use once_cell::sync::OnceCell as OnceLock;
-use rustc_hash::FxHasher;
+use crate::{tsconfig::TsConfig, Ctx, PackageJson, ResolveError, ResolveOptions};
 
-use crate::{
-    context::ResolveContext as Ctx, package_json::PackageJson, path::PathUtil, FileMetadata,
-    FileSystem, ResolveError, ResolveOptions, TsConfig,
-};
+#[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error
+pub trait Cache: Sized {
+    type Cp: CachedPath + Clone;
 
-static THREAD_COUNT: AtomicU64 = AtomicU64::new(1);
+    /// Clears the cache.
+    fn clear(&self);
 
-thread_local! {
-    /// Per-thread pre-allocated path that is used to perform operations on paths more quickly.
-    /// Learned from parcel <https://github.com/parcel-bundler/parcel/blob/a53f8f3ba1025c7ea8653e9719e0a61ef9717079/crates/parcel-resolver/src/cache.rs#L394>
-  pub static SCRATCH_PATH: RefCell<PathBuf> = RefCell::new(PathBuf::with_capacity(256));
-  pub static THREAD_ID: u64 = THREAD_COUNT.fetch_add(1, Ordering::SeqCst);
-}
+    /// Returns the cached value for a given path.
+    fn value(&self, path: &Path) -> Self::Cp;
 
-#[derive(Default)]
-pub struct Cache<Fs> {
-    pub(crate) fs: Fs,
-    paths: DashSet<PathEntry<'static>, BuildHasherDefault<IdentityHasher>>,
-    tsconfigs: DashMap<PathBuf, Arc<TsConfig>, BuildHasherDefault<FxHasher>>,
-}
+    /// Returns the canonical version of a `path`, resolving all symbolic links.
+    fn canonicalize(&self, path: &Self::Cp) -> Result<PathBuf, ResolveError>;
 
-/// An entry in the path cache. Can also be borrowed for lookups without allocations.
-enum PathEntry<'a> {
-    Owned(CachedPath),
-    Borrowed { hash: u64, path: &'a Path },
-}
+    /// Returns whether the given `path` points to a file.
+    fn is_file(&self, path: &Self::Cp, ctx: &mut Ctx) -> bool;
 
-impl Hash for PathEntry<'_> {
-    fn hash<H: Hasher>(&self, state: &mut H) {
-        match self {
-            PathEntry::Owned(entry) => {
-                entry.hash.hash(state);
-            }
-            PathEntry::Borrowed { hash, .. } => {
-                hash.hash(state);
-            }
-        }
-    }
-}
-
-impl PartialEq for PathEntry<'_> {
-    fn eq(&self, other: &Self) -> bool {
-        let self_path = match self {
-            PathEntry::Owned(info) => &info.path,
-            PathEntry::Borrowed { path, .. } => *path,
-        };
-        let other_path = match other {
-            PathEntry::Owned(info) => &info.path,
-            PathEntry::Borrowed { path, .. } => *path,
-        };
-        self_path.as_os_str() == other_path.as_os_str()
-    }
-}
-impl Eq for PathEntry<'_> {}
+    /// Returns whether the given `path` points to a file.
+    fn is_dir(&self, path: &Self::Cp, ctx: &mut Ctx) -> bool;
 
-impl<Fs: FileSystem> Cache<Fs> {
-    pub fn new(fs: Fs) -> Self {
-        Self { fs, paths: DashSet::default(), tsconfigs: DashMap::default() }
-    }
-
-    pub fn clear(&self) {
-        self.paths.clear();
-        self.tsconfigs.clear();
-    }
-
-    #[allow(clippy::cast_possible_truncation)]
-    pub fn value(&self, path: &Path) -> CachedPath {
-        // `Path::hash` is slow: https://doc.rust-lang.org/std/path/struct.Path.html#impl-Hash-for-Path
-        // `path.as_os_str()` hash is not stable because we may joined a path like `foo/bar` and `foo\\bar` on windows.
-        let hash = {
-            let mut hasher = FxHasher::default();
-            path.as_os_str().hash(&mut hasher);
-            hasher.finish()
-        };
-        let key = PathEntry::Borrowed { hash, path };
-        // A DashMap is just an array of RwLock<HashSet>, sharded by hash to reduce lock contention.
-        // This uses the low level raw API to avoid cloning the value when using the `entry` method.
-        // First, find which shard the value is in, and check to see if we already have a value in the map.
-        let shard = self.paths.determine_shard(hash as usize);
-        {
-            // Scope the read lock.
-            let map = self.paths.shards()[shard].read();
-            if let Some((PathEntry::Owned(entry), _)) = map.get(hash, |v| v.0 == key) {
-                return entry.clone();
-            }
-        }
-        let parent = path.parent().map(|p| self.value(p));
-        let cached_path = CachedPath(Arc::new(CachedPathImpl::new(
-            hash,
-            path.to_path_buf().into_boxed_path(),
-            parent,
-        )));
-        self.paths.insert(PathEntry::Owned(cached_path.clone()));
-        cached_path
-    }
+    /// Returns the package.json stored in the given directory, if one exists.
+    ///
+    /// `path` is the path to a directory from which the `package.json` will be
+    /// read.
+    #[allow(clippy::type_complexity)]
+    fn get_package_json(
+        &self,
+        path: &Self::Cp,
+        options: &ResolveOptions,
+        ctx: &mut Ctx,
+    ) -> Result<Option<(Self::Cp, Arc<PackageJson>)>, ResolveError>;
 
-    pub fn tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
+    /// Returns the tsconfig stored in the given path.
+    ///
+    /// `path` is either the path to a tsconfig (with or without `.json`
+    /// extension) or a directory from which the `tsconfig.json` will be read.
+    ///
+    /// `callback` can be used for modifying the returned tsconfig with
+    /// `extends`.
+    fn get_tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
         &self,
         root: bool,
         path: &Path,
-        callback: F, // callback for modifying tsconfig with `extends`
-    ) -> Result<Arc<TsConfig>, ResolveError> {
-        if let Some(tsconfig_ref) = self.tsconfigs.get(path) {
-            return Ok(Arc::clone(tsconfig_ref.value()));
-        }
-        let meta = self.fs.metadata(path).ok();
-        let tsconfig_path = if meta.is_some_and(|m| m.is_file) {
-            Cow::Borrowed(path)
-        } else if meta.is_some_and(|m| m.is_dir) {
-            Cow::Owned(path.join("tsconfig.json"))
-        } else {
-            let mut os_string = path.to_path_buf().into_os_string();
-            os_string.push(".json");
-            Cow::Owned(PathBuf::from(os_string))
-        };
-        let mut tsconfig_string = self
-            .fs
-            .read_to_string(&tsconfig_path)
-            .map_err(|_| ResolveError::TsconfigNotFound(path.to_path_buf()))?;
-        let mut tsconfig =
-            TsConfig::parse(root, &tsconfig_path, &mut tsconfig_string).map_err(|error| {
-                ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
-            })?;
-        callback(&mut tsconfig)?;
-        let tsconfig = Arc::new(tsconfig.build());
-        self.tsconfigs.insert(path.to_path_buf(), Arc::clone(&tsconfig));
-        Ok(tsconfig)
-    }
-}
-
-#[derive(Clone)]
-pub struct CachedPath(Arc<CachedPathImpl>);
-
-pub struct CachedPathImpl {
-    hash: u64,
-    path: Box<Path>,
-    parent: Option<CachedPath>,
-    meta: OnceLock<Option<FileMetadata>>,
-    canonicalized: OnceLock<Result<CachedPath, ResolveError>>,
-    canonicalizing: AtomicU64,
-    node_modules: OnceLock<Option<CachedPath>>,
-    package_json: OnceLock<Option<(CachedPath, Arc<PackageJson>)>>,
-}
-
-impl CachedPathImpl {
-    const fn new(hash: u64, path: Box<Path>, parent: Option<CachedPath>) -> Self {
-        Self {
-            hash,
-            path,
-            parent,
-            meta: OnceLock::new(),
-            canonicalized: OnceLock::new(),
-            canonicalizing: AtomicU64::new(0),
-            node_modules: OnceLock::new(),
-            package_json: OnceLock::new(),
-        }
-    }
-}
-
-impl Deref for CachedPath {
-    type Target = CachedPathImpl;
-
-    fn deref(&self) -> &Self::Target {
-        self.0.as_ref()
-    }
+        callback: F,
+    ) -> Result<Arc<TsConfig>, ResolveError>;
 }
 
-impl CachedPath {
-    pub fn path(&self) -> &Path {
-        &self.0.path
-    }
-
-    pub fn to_path_buf(&self) -> PathBuf {
-        self.path.to_path_buf()
-    }
-
-    pub fn parent(&self) -> Option<&Self> {
-        self.0.parent.as_ref()
-    }
+pub trait CachedPath: Sized {
+    fn path(&self) -> &Path;
 
-    fn meta<Fs: FileSystem>(&self, fs: &Fs) -> Option<FileMetadata> {
-        *self.meta.get_or_init(|| fs.metadata(&self.path).ok())
-    }
+    fn to_path_buf(&self) -> PathBuf;
 
-    pub fn is_file<Fs: FileSystem>(&self, fs: &Fs, ctx: &mut Ctx) -> bool {
-        if let Some(meta) = self.meta(fs) {
-            ctx.add_file_dependency(self.path());
-            meta.is_file
-        } else {
-            ctx.add_missing_dependency(self.path());
-            false
-        }
-    }
+    fn parent(&self) -> Option<&Self>;
 
-    pub fn is_dir<Fs: FileSystem>(&self, fs: &Fs, ctx: &mut Ctx) -> bool {
-        self.meta(fs).map_or_else(
-            || {
-                ctx.add_missing_dependency(self.path());
-                false
-            },
-            |meta| meta.is_dir,
-        )
-    }
-
-    pub fn canonicalize<Fs: FileSystem>(&self, cache: &Cache<Fs>) -> Result<PathBuf, ResolveError> {
-        let cached_path = self.canocalize_impl(cache)?;
-        let path = cached_path.to_path_buf();
-        cfg_if! {
-            if #[cfg(windows)] {
-                let path = crate::FileSystemOs::strip_windows_prefix(path);
-            }
-        }
-        Ok(path)
-    }
-
-    /// Returns the canonical path, resolving all symbolic links.
-    ///
-    /// <https://github.com/parcel-bundler/parcel/blob/4d27ec8b8bd1792f536811fef86e74a31fa0e704/crates/parcel-resolver/src/cache.rs#L232>
-    fn canocalize_impl<Fs: FileSystem>(&self, cache: &Cache<Fs>) -> Result<Self, ResolveError> {
-        // Check if this thread is already canonicalizing. If so, we have found a circular symlink.
-        // If a different thread is canonicalizing, OnceLock will queue this thread to wait for the result.
-        let tid = THREAD_ID.with(|t| *t);
-        if self.0.canonicalizing.load(Ordering::Acquire) == tid {
-            return Err(io::Error::new(io::ErrorKind::NotFound, "Circular symlink").into());
-        }
-
-        self.0
-            .canonicalized
-            .get_or_init(|| {
-                self.0.canonicalizing.store(tid, Ordering::Release);
-
-                let res = self.parent().map_or_else(
-                    || Ok(self.normalize_root(cache)),
-                    |parent| {
-                        parent.canocalize_impl(cache).and_then(|parent_canonical| {
-                            let path = parent_canonical.normalize_with(
-                                self.path().strip_prefix(parent.path()).unwrap(),
-                                cache,
-                            );
-
-                            if cache.fs.symlink_metadata(self.path()).is_ok_and(|m| m.is_symlink) {
-                                let link = cache.fs.read_link(path.path())?;
-                                if link.is_absolute() {
-                                    return cache.value(&link.normalize()).canocalize_impl(cache);
-                                } else if let Some(dir) = path.parent() {
-                                    // Symlink is relative `../../foo.js`, use the path directory
-                                    // to resolve this symlink.
-                                    return dir.normalize_with(&link, cache).canocalize_impl(cache);
-                                }
-                                debug_assert!(
-                                    false,
-                                    "Failed to get path parent for {:?}.",
-                                    path.path()
-                                );
-                            }
-
-                            Ok(path)
-                        })
-                    },
-                );
-
-                self.0.canonicalizing.store(0, Ordering::Release);
-                res
-            })
-            .clone()
-    }
-
-    pub fn module_directory<Fs: FileSystem>(
+    fn module_directory<C: Cache<Cp = Self>>(
         &self,
         module_name: &str,
-        cache: &Cache<Fs>,
+        cache: &C,
         ctx: &mut Ctx,
-    ) -> Option<Self> {
-        let cached_path = cache.value(&self.path.join(module_name));
-        cached_path.is_dir(&cache.fs, ctx).then_some(cached_path)
-    }
+    ) -> Option<Self>;
 
-    pub fn cached_node_modules<Fs: FileSystem>(
-        &self,
-        cache: &Cache<Fs>,
-        ctx: &mut Ctx,
-    ) -> Option<Self> {
-        self.node_modules.get_or_init(|| self.module_directory("node_modules", cache, ctx)).clone()
-    }
-
-    /// Get package.json of the given path.
-    ///
-    /// # Errors
-    ///
-    /// * [ResolveError::JSON]
-    pub fn package_json<Fs: FileSystem>(
-        &self,
-        options: &ResolveOptions,
-        cache: &Cache<Fs>,
-        ctx: &mut Ctx,
-    ) -> Result<Option<(Self, Arc<PackageJson>)>, ResolveError> {
-        // Change to `std::sync::OnceLock::get_or_try_init` when it is stable.
-        let result = self
-            .package_json
-            .get_or_try_init(|| {
-                let package_json_path = self.path.join("package.json");
-                let Ok(package_json_string) = cache.fs.read_to_string(&package_json_path) else {
-                    return Ok(None);
-                };
-                let real_path = if options.symlinks {
-                    self.canonicalize(cache)?.join("package.json")
-                } else {
-                    package_json_path.clone()
-                };
-                PackageJson::parse(package_json_path.clone(), real_path, &package_json_string)
-                    .map(|package_json| Some((self.clone(), (Arc::new(package_json)))))
-                    .map_err(|error| ResolveError::from_serde_json_error(package_json_path, &error))
-            })
-            .cloned();
-        // https://github.com/webpack/enhanced-resolve/blob/58464fc7cb56673c9aa849e68e6300239601e615/lib/DescriptionFileUtils.js#L68-L82
-        match &result {
-            Ok(Some((_, package_json))) => {
-                ctx.add_file_dependency(&package_json.path);
-            }
-            Ok(None) => {
-                // Avoid an allocation by making this lazy
-                if let Some(deps) = &mut ctx.missing_dependencies {
-                    deps.push(self.path.join("package.json"));
-                }
-            }
-            Err(_) => {
-                if let Some(deps) = &mut ctx.file_dependencies {
-                    deps.push(self.path.join("package.json"));
-                }
-            }
-        }
-        result
-    }
+    fn cached_node_modules<C: Cache<Cp = Self>>(&self, cache: &C, ctx: &mut Ctx) -> Option<Self>;
 
     /// Find package.json of a path by traversing parent directories.
     ///
     /// # Errors
     ///
     /// * [ResolveError::JSON]
-    pub fn find_package_json<Fs: FileSystem>(
+    fn find_package_json<C: Cache<Cp = Self>>(
         &self,
         options: &ResolveOptions,
-        cache: &Cache<Fs>,
+        cache: &C,
         ctx: &mut Ctx,
-    ) -> Result<Option<(Self, Arc<PackageJson>)>, ResolveError> {
-        let mut cache_value = self;
-        // Go up directories when the querying path is not a directory
-        while !cache_value.is_dir(&cache.fs, ctx) {
-            if let Some(cv) = &cache_value.parent {
-                cache_value = cv;
-            } else {
-                break;
-            }
-        }
-        let mut cache_value = Some(cache_value);
-        while let Some(cv) = cache_value {
-            if let Some(package_json) = cv.package_json(options, cache, ctx)? {
-                return Ok(Some(package_json));
-            }
-            cache_value = cv.parent.as_ref();
-        }
-        Ok(None)
-    }
-
-    pub fn add_extension<Fs: FileSystem>(&self, ext: &str, cache: &Cache<Fs>) -> Self {
-        SCRATCH_PATH.with_borrow_mut(|path| {
-            path.clear();
-            let s = path.as_mut_os_string();
-            s.push(self.path.as_os_str());
-            s.push(ext);
-            cache.value(path)
-        })
-    }
-
-    pub fn replace_extension<Fs: FileSystem>(&self, ext: &str, cache: &Cache<Fs>) -> Self {
-        SCRATCH_PATH.with_borrow_mut(|path| {
-            path.clear();
-            let s = path.as_mut_os_string();
-            let self_len = self.path.as_os_str().len();
-            let self_bytes = self.path.as_os_str().as_encoded_bytes();
-            let slice_to_copy = self.path.extension().map_or(self_bytes, |previous_extension| {
-                &self_bytes[..self_len - previous_extension.len() - 1]
-            });
-            // SAFETY: ???
-            s.push(unsafe { std::ffi::OsStr::from_encoded_bytes_unchecked(slice_to_copy) });
-            s.push(ext);
-            cache.value(path)
-        })
-    }
-
-    /// Returns a new path by resolving the given subpath (including "." and ".." components) with this path.
-    pub fn normalize_with<P, Fs>(&self, subpath: P, cache: &Cache<Fs>) -> Self
-    where
-        P: AsRef<Path>,
-        Fs: FileSystem,
-    {
-        let subpath = subpath.as_ref();
-        let mut components = subpath.components();
-        let Some(head) = components.next() else { return cache.value(subpath) };
-        if matches!(head, Component::Prefix(..) | Component::RootDir) {
-            return cache.value(subpath);
-        }
-        SCRATCH_PATH.with_borrow_mut(|path| {
-            path.clear();
-            path.push(&self.path);
-            for component in std::iter::once(head).chain(components) {
-                match component {
-                    Component::CurDir => {}
-                    Component::ParentDir => {
-                        path.pop();
-                    }
-                    Component::Normal(c) => {
-                        cfg_if! {
-                            if #[cfg(target_family = "wasm")] {
-                                // Need to trim the extra \0 introduces by https://github.com/nodejs/uvwasi/issues/262
-                                path.push(c.to_string_lossy().trim_end_matches('\0'));
-                            } else {
-                                path.push(c);
-                            }
-                        }
-                    }
-                    Component::Prefix(..) | Component::RootDir => {
-                        unreachable!("Path {:?} Subpath {:?}", self.path, subpath)
-                    }
-                }
-            }
-
-            cache.value(path)
-        })
-    }
-
-    #[inline]
-    #[cfg(windows)]
-    pub fn normalize_root<Fs: FileSystem>(&self, cache: &Cache<Fs>) -> Self {
-        if self.path().as_os_str().as_encoded_bytes().last() == Some(&b'/') {
-            let mut path_string = self.path.to_string_lossy().into_owned();
-            path_string.pop();
-            path_string.push('\\');
-            cache.value(&PathBuf::from(path_string))
-        } else {
-            self.clone()
-        }
-    }
-    #[inline]
-    #[cfg(not(windows))]
-    pub fn normalize_root<Fs: FileSystem>(&self, _cache: &Cache<Fs>) -> Self {
-        self.clone()
-    }
-}
+    ) -> Result<Option<(Self, Arc<PackageJson>)>, ResolveError>;
 
-/// Since the cache key is memoized, use an identity hasher
-/// to avoid double cache.
-#[derive(Default)]
-struct IdentityHasher(u64);
+    #[must_use]
+    fn add_extension<C: Cache<Cp = Self>>(&self, ext: &str, cache: &C) -> Self;
 
-impl Hasher for IdentityHasher {
-    fn write(&mut self, _: &[u8]) {
-        unreachable!("Invalid use of IdentityHasher")
-    }
+    #[must_use]
+    fn replace_extension<C: Cache<Cp = Self>>(&self, ext: &str, cache: &C) -> Self;
 
-    fn write_u64(&mut self, n: u64) {
-        self.0 = n;
-    }
+    /// Returns a new path by resolving the given subpath (including "." and
+    /// ".." components) with this path.
+    #[must_use]
+    fn normalize_with<C: Cache<Cp = Self>>(&self, subpath: impl AsRef<Path>, cache: &C) -> Self;
 
-    fn finish(&self) -> u64 {
-        self.0
-    }
+    #[must_use]
+    fn normalize_root<C: Cache<Cp = Self>>(&self, _cache: &C) -> Self;
 }
diff --git a/src/context.rs b/src/context.rs
index 9349de3..cc8cb4a 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,15 +1,9 @@
-use std::{
-    ops::{Deref, DerefMut},
-    path::{Path, PathBuf},
-};
+use std::path::{Path, PathBuf};
 
 use crate::error::ResolveError;
 
 #[derive(Debug, Default, Clone)]
-pub struct ResolveContext(ResolveContextImpl);
-
-#[derive(Debug, Default, Clone)]
-pub struct ResolveContextImpl {
+pub struct ResolveContext {
     pub fully_specified: bool,
 
     pub query: Option<String>,
@@ -29,20 +23,6 @@ pub struct ResolveContextImpl {
     depth: u8,
 }
 
-impl Deref for ResolveContext {
-    type Target = ResolveContextImpl;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl DerefMut for ResolveContext {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.0
-    }
-}
-
 impl ResolveContext {
     pub fn with_fully_specified(&mut self, yes: bool) {
         self.fully_specified = yes;
@@ -78,6 +58,11 @@ impl ResolveContext {
         self.resolving_alias = Some(alias);
     }
 
+    /// Increases the context's depth in order to detect recursion.
+    ///
+    /// ### Errors
+    ///
+    /// * [ResolveError::Recursion]
     pub fn test_for_infinite_recursion(&mut self) -> Result<(), ResolveError> {
         self.depth += 1;
         // 64 should be more than enough for detecting infinite recursion.
diff --git a/src/error.rs b/src/error.rs
index 456abf4..61778d2 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -107,7 +107,8 @@ impl ResolveError {
         matches!(self, Self::Ignored(_))
     }
 
-    pub(crate) fn from_serde_json_error(path: PathBuf, error: &serde_json::Error) -> Self {
+    #[must_use]
+    pub fn from_serde_json_error(path: PathBuf, error: &serde_json::Error) -> Self {
         Self::JSON(JSONError {
             path,
             message: error.to_string(),
diff --git a/src/file_system.rs b/src/file_system.rs
index 4cbaf75..6ddd4be 100644
--- a/src/file_system.rs
+++ b/src/file_system.rs
@@ -65,6 +65,21 @@ impl FileMetadata {
     pub const fn new(is_file: bool, is_dir: bool, is_symlink: bool) -> Self {
         Self { is_file, is_dir, is_symlink }
     }
+
+    #[must_use]
+    pub const fn is_file(self) -> bool {
+        self.is_file
+    }
+
+    #[must_use]
+    pub const fn is_dir(self) -> bool {
+        self.is_dir
+    }
+
+    #[must_use]
+    pub const fn is_symlink(self) -> bool {
+        self.is_symlink
+    }
 }
 
 #[cfg(feature = "yarn_pnp")]
diff --git a/src/fs_cache.rs b/src/fs_cache.rs
new file mode 100644
index 0000000..615754c
--- /dev/null
+++ b/src/fs_cache.rs
@@ -0,0 +1,499 @@
+use std::{
+    borrow::Cow,
+    cell::RefCell,
+    convert::AsRef,
+    hash::{BuildHasherDefault, Hash, Hasher},
+    io,
+    ops::Deref,
+    path::{Component, Path, PathBuf},
+    sync::{
+        atomic::{AtomicU64, Ordering},
+        Arc,
+    },
+};
+
+use cfg_if::cfg_if;
+use dashmap::{DashMap, DashSet};
+use once_cell::sync::OnceCell as OnceLock;
+use rustc_hash::FxHasher;
+
+use crate::{
+    cache::{Cache, CachedPath},
+    context::ResolveContext as Ctx,
+    package_json::PackageJson,
+    path::PathUtil,
+    FileMetadata, FileSystem, ResolveError, ResolveOptions, TsConfig,
+};
+
+static THREAD_COUNT: AtomicU64 = AtomicU64::new(1);
+
+thread_local! {
+    /// Per-thread pre-allocated path that is used to perform operations on paths more quickly.
+    /// Learned from parcel <https://github.com/parcel-bundler/parcel/blob/a53f8f3ba1025c7ea8653e9719e0a61ef9717079/crates/parcel-resolver/src/cache.rs#L394>
+  pub static SCRATCH_PATH: RefCell<PathBuf> = RefCell::new(PathBuf::with_capacity(256));
+  pub static THREAD_ID: u64 = THREAD_COUNT.fetch_add(1, Ordering::SeqCst);
+}
+
+/// Cache implementation used for caching filesystem access.
+#[derive(Default)]
+pub struct FsCache<Fs> {
+    pub(crate) fs: Fs,
+    paths: DashSet<PathEntry<'static>, BuildHasherDefault<IdentityHasher>>,
+    tsconfigs: DashMap<PathBuf, Arc<TsConfig>, BuildHasherDefault<FxHasher>>,
+}
+
+/// An entry in the path cache. Can also be borrowed for lookups without allocations.
+enum PathEntry<'a> {
+    Owned(FsCachedPath),
+    Borrowed { hash: u64, path: &'a Path },
+}
+
+impl Hash for PathEntry<'_> {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        match self {
+            PathEntry::Owned(entry) => {
+                entry.hash.hash(state);
+            }
+            PathEntry::Borrowed { hash, .. } => {
+                hash.hash(state);
+            }
+        }
+    }
+}
+
+impl PartialEq for PathEntry<'_> {
+    fn eq(&self, other: &Self) -> bool {
+        let self_path = match self {
+            PathEntry::Owned(info) => &info.path,
+            PathEntry::Borrowed { path, .. } => *path,
+        };
+        let other_path = match other {
+            PathEntry::Owned(info) => &info.path,
+            PathEntry::Borrowed { path, .. } => *path,
+        };
+        self_path.as_os_str() == other_path.as_os_str()
+    }
+}
+impl Eq for PathEntry<'_> {}
+
+impl<Fs: FileSystem> Cache for FsCache<Fs> {
+    type Cp = FsCachedPath;
+
+    fn clear(&self) {
+        self.paths.clear();
+        self.tsconfigs.clear();
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn value(&self, path: &Path) -> FsCachedPath {
+        // `Path::hash` is slow: https://doc.rust-lang.org/std/path/struct.Path.html#impl-Hash-for-Path
+        // `path.as_os_str()` hash is not stable because we may joined a path like `foo/bar` and `foo\\bar` on windows.
+        let hash = {
+            let mut hasher = FxHasher::default();
+            path.as_os_str().hash(&mut hasher);
+            hasher.finish()
+        };
+        let key = PathEntry::Borrowed { hash, path };
+        // A DashMap is just an array of RwLock<HashSet>, sharded by hash to reduce lock contention.
+        // This uses the low level raw API to avoid cloning the value when using the `entry` method.
+        // First, find which shard the value is in, and check to see if we already have a value in the map.
+        let shard = self.paths.determine_shard(hash as usize);
+        {
+            // Scope the read lock.
+            let map = self.paths.shards()[shard].read();
+            if let Some((PathEntry::Owned(entry), _)) = map.get(hash, |v| v.0 == key) {
+                return entry.clone();
+            }
+        }
+        let parent = path.parent().map(|p| self.value(p));
+        let cached_path = FsCachedPath(Arc::new(CachedPathImpl::new(
+            hash,
+            path.to_path_buf().into_boxed_path(),
+            parent,
+        )));
+        self.paths.insert(PathEntry::Owned(cached_path.clone()));
+        cached_path
+    }
+
+    fn canonicalize(&self, path: &Self::Cp) -> Result<PathBuf, ResolveError> {
+        let cached_path = self.canonicalize_impl(path)?;
+        let path = cached_path.to_path_buf();
+        cfg_if! {
+            if #[cfg(windows)] {
+                let path = crate::FileSystemOs::strip_windows_prefix(path);
+            }
+        }
+        Ok(path)
+    }
+
+    fn is_file(&self, path: &Self::Cp, ctx: &mut Ctx) -> bool {
+        if let Some(meta) = path.meta(&self.fs) {
+            ctx.add_file_dependency(path.path());
+            meta.is_file
+        } else {
+            ctx.add_missing_dependency(path.path());
+            false
+        }
+    }
+
+    fn is_dir(&self, path: &Self::Cp, ctx: &mut Ctx) -> bool {
+        path.meta(&self.fs).map_or_else(
+            || {
+                ctx.add_missing_dependency(path.path());
+                false
+            },
+            |meta| meta.is_dir,
+        )
+    }
+
+    fn get_package_json(
+        &self,
+        path: &Self::Cp,
+        options: &ResolveOptions,
+        ctx: &mut Ctx,
+    ) -> Result<Option<(Self::Cp, Arc<PackageJson>)>, ResolveError> {
+        // Change to `std::sync::OnceLock::get_or_try_init` when it is stable.
+        let result = path
+            .package_json
+            .get_or_try_init(|| {
+                let package_json_path = path.path.join("package.json");
+                let Ok(package_json_string) = self.fs.read_to_string(&package_json_path) else {
+                    return Ok(None);
+                };
+                let real_path = if options.symlinks {
+                    self.canonicalize(path)?.join("package.json")
+                } else {
+                    package_json_path.clone()
+                };
+                PackageJson::parse(package_json_path.clone(), real_path, &package_json_string)
+                    .map(|package_json| Some((path.clone(), (Arc::new(package_json)))))
+                    .map_err(|error| ResolveError::from_serde_json_error(package_json_path, &error))
+            })
+            .cloned();
+        // https://github.com/webpack/enhanced-resolve/blob/58464fc7cb56673c9aa849e68e6300239601e615/lib/DescriptionFileUtils.js#L68-L82
+        match &result {
+            Ok(Some((_, package_json))) => {
+                ctx.add_file_dependency(&package_json.path);
+            }
+            Ok(None) => {
+                // Avoid an allocation by making this lazy
+                if let Some(deps) = &mut ctx.missing_dependencies {
+                    deps.push(path.path.join("package.json"));
+                }
+            }
+            Err(_) => {
+                if let Some(deps) = &mut ctx.file_dependencies {
+                    deps.push(path.path.join("package.json"));
+                }
+            }
+        }
+        result
+    }
+
+    fn get_tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
+        &self,
+        root: bool,
+        path: &Path,
+        callback: F, // callback for modifying tsconfig with `extends`
+    ) -> Result<Arc<TsConfig>, ResolveError> {
+        if let Some(tsconfig) = self.tsconfigs.get(path) {
+            return Ok(Arc::clone(&tsconfig));
+        }
+        let meta = self.fs.metadata(path).ok();
+        let tsconfig_path = if meta.is_some_and(|m| m.is_file) {
+            Cow::Borrowed(path)
+        } else if meta.is_some_and(|m| m.is_dir) {
+            Cow::Owned(path.join("tsconfig.json"))
+        } else {
+            let mut os_string = path.to_path_buf().into_os_string();
+            os_string.push(".json");
+            Cow::Owned(PathBuf::from(os_string))
+        };
+        let mut tsconfig_string = self
+            .fs
+            .read_to_string(&tsconfig_path)
+            .map_err(|_| ResolveError::TsconfigNotFound(path.to_path_buf()))?;
+        let mut tsconfig =
+            TsConfig::parse(root, &tsconfig_path, &mut tsconfig_string).map_err(|error| {
+                ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
+            })?;
+        callback(&mut tsconfig)?;
+        let tsconfig = Arc::new(tsconfig.build());
+        self.tsconfigs.insert(path.to_path_buf(), Arc::clone(&tsconfig));
+        Ok(tsconfig)
+    }
+}
+
+impl<Fs: FileSystem> FsCache<Fs> {
+    pub fn new(fs: Fs) -> Self {
+        Self { fs, paths: DashSet::default(), tsconfigs: DashMap::default() }
+    }
+
+    /// Returns the canonical path, resolving all symbolic links.
+    ///
+    /// <https://github.com/parcel-bundler/parcel/blob/4d27ec8b8bd1792f536811fef86e74a31fa0e704/crates/parcel-resolver/src/cache.rs#L232>
+    fn canonicalize_impl(&self, path: &FsCachedPath) -> Result<FsCachedPath, ResolveError> {
+        // Check if this thread is already canonicalizing. If so, we have found a circular symlink.
+        // If a different thread is canonicalizing, OnceLock will queue this thread to wait for the result.
+        let tid = THREAD_ID.with(|t| *t);
+        if path.canonicalizing.load(Ordering::Acquire) == tid {
+            return Err(io::Error::new(io::ErrorKind::NotFound, "Circular symlink").into());
+        }
+
+        path.canonicalized
+            .get_or_init(|| {
+                path.canonicalizing.store(tid, Ordering::Release);
+
+                let res = path.parent().map_or_else(
+                    || Ok(path.normalize_root(self)),
+                    |parent| {
+                        self.canonicalize_impl(parent).and_then(|parent_canonical| {
+                            let normalized = parent_canonical.normalize_with(
+                                path.path().strip_prefix(parent.path()).unwrap(),
+                                self,
+                            );
+
+                            if self.fs.symlink_metadata(path.path()).is_ok_and(|m| m.is_symlink) {
+                                let link = self.fs.read_link(normalized.path())?;
+                                if link.is_absolute() {
+                                    return self.canonicalize_impl(&self.value(&link.normalize()));
+                                } else if let Some(dir) = normalized.parent() {
+                                    // Symlink is relative `../../foo.js`, use the path directory
+                                    // to resolve this symlink.
+                                    return self
+                                        .canonicalize_impl(&dir.normalize_with(&link, self));
+                                }
+                                debug_assert!(
+                                    false,
+                                    "Failed to get path parent for {:?}.",
+                                    normalized.path()
+                                );
+                            }
+
+                            Ok(normalized)
+                        })
+                    },
+                );
+
+                path.canonicalizing.store(0, Ordering::Release);
+                res
+            })
+            .clone()
+    }
+}
+
+#[derive(Clone)]
+pub struct FsCachedPath(Arc<CachedPathImpl>);
+
+pub struct CachedPathImpl {
+    hash: u64,
+    path: Box<Path>,
+    parent: Option<FsCachedPath>,
+    meta: OnceLock<Option<FileMetadata>>,
+    canonicalized: OnceLock<Result<FsCachedPath, ResolveError>>,
+    canonicalizing: AtomicU64,
+    node_modules: OnceLock<Option<FsCachedPath>>,
+    package_json: OnceLock<Option<(FsCachedPath, Arc<PackageJson>)>>,
+}
+
+impl CachedPathImpl {
+    const fn new(hash: u64, path: Box<Path>, parent: Option<FsCachedPath>) -> Self {
+        Self {
+            hash,
+            path,
+            parent,
+            meta: OnceLock::new(),
+            canonicalized: OnceLock::new(),
+            canonicalizing: AtomicU64::new(0),
+            node_modules: OnceLock::new(),
+            package_json: OnceLock::new(),
+        }
+    }
+}
+
+impl Deref for FsCachedPath {
+    type Target = CachedPathImpl;
+
+    fn deref(&self) -> &Self::Target {
+        self.0.as_ref()
+    }
+}
+
+impl CachedPath for FsCachedPath {
+    fn path(&self) -> &Path {
+        &self.0.path
+    }
+
+    fn to_path_buf(&self) -> PathBuf {
+        self.path.to_path_buf()
+    }
+
+    fn parent(&self) -> Option<&Self> {
+        self.0.parent.as_ref()
+    }
+
+    fn module_directory<C: Cache<Cp = Self>>(
+        &self,
+        module_name: &str,
+        cache: &C,
+        ctx: &mut Ctx,
+    ) -> Option<Self> {
+        let cached_path = cache.value(&self.path.join(module_name));
+        cache.is_dir(&cached_path, ctx).then_some(cached_path)
+    }
+
+    fn cached_node_modules<C: Cache<Cp = Self>>(&self, cache: &C, ctx: &mut Ctx) -> Option<Self> {
+        self.node_modules.get_or_init(|| self.module_directory("node_modules", cache, ctx)).clone()
+    }
+
+    /// Find package.json of a path by traversing parent directories.
+    ///
+    /// # Errors
+    ///
+    /// * [ResolveError::JSON]
+    fn find_package_json<C: Cache<Cp = Self>>(
+        &self,
+        options: &ResolveOptions,
+        cache: &C,
+        ctx: &mut Ctx,
+    ) -> Result<Option<(Self, Arc<PackageJson>)>, ResolveError> {
+        let mut cache_value = self;
+        // Go up directories when the querying path is not a directory
+        while !cache.is_dir(cache_value, ctx) {
+            if let Some(cv) = &cache_value.parent {
+                cache_value = cv;
+            } else {
+                break;
+            }
+        }
+        let mut cache_value = Some(cache_value);
+        while let Some(cv) = cache_value {
+            if let Some(package_json) = cache.get_package_json(cv, options, ctx)? {
+                return Ok(Some(package_json));
+            }
+            cache_value = cv.parent.as_ref();
+        }
+        Ok(None)
+    }
+
+    fn add_extension<C: Cache<Cp = Self>>(&self, ext: &str, cache: &C) -> Self {
+        SCRATCH_PATH.with_borrow_mut(|path| {
+            path.clear();
+            let s = path.as_mut_os_string();
+            s.push(self.path.as_os_str());
+            s.push(ext);
+            cache.value(path)
+        })
+    }
+
+    fn replace_extension<C: Cache<Cp = Self>>(&self, ext: &str, cache: &C) -> Self {
+        SCRATCH_PATH.with_borrow_mut(|path| {
+            path.clear();
+            let s = path.as_mut_os_string();
+            let self_len = self.path.as_os_str().len();
+            let self_bytes = self.path.as_os_str().as_encoded_bytes();
+            let slice_to_copy = self.path.extension().map_or(self_bytes, |previous_extension| {
+                &self_bytes[..self_len - previous_extension.len() - 1]
+            });
+            // SAFETY: ???
+            s.push(unsafe { std::ffi::OsStr::from_encoded_bytes_unchecked(slice_to_copy) });
+            s.push(ext);
+            cache.value(path)
+        })
+    }
+
+    /// Returns a new path by resolving the given subpath (including "." and ".." components) with this path.
+    fn normalize_with<C: Cache<Cp = Self>>(&self, subpath: impl AsRef<Path>, cache: &C) -> Self {
+        let subpath = subpath.as_ref();
+        let mut components = subpath.components();
+        let Some(head) = components.next() else { return cache.value(subpath) };
+        if matches!(head, Component::Prefix(..) | Component::RootDir) {
+            return cache.value(subpath);
+        }
+        SCRATCH_PATH.with_borrow_mut(|path| {
+            path.clear();
+            path.push(&self.path);
+            for component in std::iter::once(head).chain(components) {
+                match component {
+                    Component::CurDir => {}
+                    Component::ParentDir => {
+                        path.pop();
+                    }
+                    Component::Normal(c) => {
+                        cfg_if! {
+                            if #[cfg(target_family = "wasm")] {
+                                // Need to trim the extra \0 introduces by https://github.com/nodejs/uvwasi/issues/262
+                                path.push(c.to_string_lossy().trim_end_matches('\0'));
+                            } else {
+                                path.push(c);
+                            }
+                        }
+                    }
+                    Component::Prefix(..) | Component::RootDir => {
+                        unreachable!("Path {:?} Subpath {:?}", self.path, subpath)
+                    }
+                }
+            }
+
+            cache.value(path)
+        })
+    }
+
+    #[inline]
+    #[cfg(windows)]
+    fn normalize_root<C: Cache<Cp = Self>>(&self, cache: &C) -> Self {
+        if self.path().as_os_str().as_encoded_bytes().last() == Some(&b'/') {
+            let mut path_string = self.path.to_string_lossy().into_owned();
+            path_string.pop();
+            path_string.push('\\');
+            cache.value(&PathBuf::from(path_string))
+        } else {
+            self.clone()
+        }
+    }
+
+    #[inline]
+    #[cfg(not(windows))]
+    fn normalize_root<C: Cache<Cp = Self>>(&self, _cache: &C) -> Self {
+        self.clone()
+    }
+}
+
+impl FsCachedPath {
+    fn meta<Fs: FileSystem>(&self, fs: &Fs) -> Option<FileMetadata> {
+        *self.meta.get_or_init(|| fs.metadata(&self.path).ok())
+    }
+}
+
+impl Hash for FsCachedPath {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.hash.hash(state);
+    }
+}
+
+impl PartialEq for FsCachedPath {
+    fn eq(&self, other: &Self) -> bool {
+        self.path.as_os_str() == other.path.as_os_str()
+    }
+}
+
+impl Eq for FsCachedPath {}
+
+/// Since the cache key is memoized, use an identity hasher
+/// to avoid double cache.
+#[derive(Default)]
+struct IdentityHasher(u64);
+
+impl Hasher for IdentityHasher {
+    fn write(&mut self, _: &[u8]) {
+        unreachable!("Invalid use of IdentityHasher")
+    }
+
+    fn write_u64(&mut self, n: u64) {
+        self.0 = n;
+    }
+
+    fn finish(&self) -> u64 {
+        self.0
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 3e7f743..d4c5934 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -49,9 +49,12 @@
 
 mod builtins;
 mod cache;
-mod context;
+pub mod context;
 mod error;
+#[cfg(feature = "fs_cache")]
 mod file_system;
+#[cfg(feature = "fs_cache")]
+mod fs_cache;
 mod options;
 mod package_json;
 mod path;
@@ -74,27 +77,34 @@ use std::{
 use rustc_hash::FxHashSet;
 use serde_json::Value as JSONValue;
 
+#[cfg(feature = "fs_cache")]
+pub use crate::{
+    file_system::{FileMetadata, FileSystem, FileSystemOs},
+    fs_cache::{FsCache, FsCachedPath},
+};
+
 pub use crate::{
     builtins::NODEJS_BUILTINS,
+    cache::{Cache, CachedPath},
     error::{JSONError, ResolveError, SpecifierError},
-    file_system::{FileMetadata, FileSystem, FileSystemOs},
     options::{
         Alias, AliasValue, EnforceExtension, ResolveOptions, Restriction, TsconfigOptions,
         TsconfigReferences,
     },
     package_json::PackageJson,
     resolution::Resolution,
+    tsconfig::{
+        CompilerOptions, CompilerOptionsPathsMap, ExtendsField, ProjectReference, TsConfig,
+    },
 };
 use crate::{
-    cache::{Cache, CachedPath},
     context::ResolveContext as Ctx,
     package_json::JSONMap,
     path::{PathUtil, SLASH_START},
     specifier::Specifier,
-    tsconfig::{ExtendsField, ProjectReference, TsConfig},
 };
 
-type ResolveResult = Result<Option<CachedPath>, ResolveError>;
+type ResolveResult<Cp> = Result<Option<Cp>, ResolveError>;
 
 /// Context returned from the [Resolver::resolve_with_context] API
 #[derive(Debug, Default, Clone)]
@@ -107,36 +117,37 @@ pub struct ResolveContext {
 }
 
 /// Resolver with the current operating system as the file system
-pub type Resolver = ResolverGeneric<FileSystemOs>;
+#[cfg(feature = "fs_cache")]
+pub type Resolver = ResolverGeneric<FsCache<FileSystemOs>>;
 
-/// Generic implementation of the resolver, can be configured by the [FileSystem] trait
-pub struct ResolverGeneric<Fs> {
+/// Generic implementation of the resolver, can be configured by the [Cache] trait
+pub struct ResolverGeneric<C: Cache> {
     options: ResolveOptions,
-    cache: Arc<Cache<Fs>>,
+    cache: Arc<C>,
 }
 
-impl<Fs> fmt::Debug for ResolverGeneric<Fs> {
+impl<C: Cache> fmt::Debug for ResolverGeneric<C> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         self.options.fmt(f)
     }
 }
 
-impl<Fs: FileSystem + Default> Default for ResolverGeneric<Fs> {
+impl<C: Cache + Default> Default for ResolverGeneric<C> {
     fn default() -> Self {
         Self::new(ResolveOptions::default())
     }
 }
 
-impl<Fs: FileSystem + Default> ResolverGeneric<Fs> {
+impl<C: Cache + Default> ResolverGeneric<C> {
     #[must_use]
     pub fn new(options: ResolveOptions) -> Self {
-        Self { options: options.sanitize(), cache: Arc::new(Cache::new(Fs::default())) }
+        Self { options: options.sanitize(), cache: Arc::new(C::default()) }
     }
 }
 
-impl<Fs: FileSystem> ResolverGeneric<Fs> {
-    pub fn new_with_file_system(file_system: Fs, options: ResolveOptions) -> Self {
-        Self { cache: Arc::new(Cache::new(file_system)), options: options.sanitize() }
+impl<C: Cache> ResolverGeneric<C> {
+    pub fn new_with_cache(cache: Arc<C>, options: ResolveOptions) -> Self {
+        Self { cache, options: options.sanitize() }
     }
 
     /// Clone the resolver using the same underlying cache.
@@ -248,7 +259,8 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         let path = self.load_realpath(&cached_path)?;
         // enhanced-resolve: restrictions
         self.check_restrictions(&path)?;
-        let package_json = cached_path.find_package_json(&self.options, &self.cache, ctx)?;
+        let package_json =
+            cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)?;
         if let Some((_, package_json)) = &package_json {
             // path must be inside the package.
             debug_assert!(path.starts_with(package_json.directory()));
@@ -269,10 +281,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// <https://nodejs.org/api/modules.html#all-together>
     fn require(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         ctx.test_for_infinite_recursion()?;
 
         // enhanced-resolve: parse
@@ -286,10 +298,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn require_without_parse(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         // tsconfig-paths
         if let Some(path) = self.load_tsconfig_paths(cached_path, specifier, &mut Ctx::default())? {
             return Ok(path);
@@ -356,10 +368,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn require_absolute(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         // Make sure only path prefixes gets called
         debug_assert!(Path::new(specifier)
             .components()
@@ -385,16 +397,16 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     // 3. If X begins with './' or '/' or '../'
     fn require_relative(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         // Make sure only relative or normal paths gets called
         debug_assert!(Path::new(specifier).components().next().is_some_and(|c| matches!(
             c,
             Component::CurDir | Component::ParentDir | Component::Normal(_)
         )));
-        let cached_path = cached_path.normalize_with(specifier, &self.cache);
+        let cached_path = cached_path.normalize_with(specifier, self.cache.as_ref());
         // a. LOAD_AS_FILE(Y + X)
         // b. LOAD_AS_DIRECTORY(Y + X)
         if let Some(path) = self.load_as_file_or_directory(&cached_path, specifier, ctx)? {
@@ -406,10 +418,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn require_hash(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         debug_assert_eq!(specifier.chars().next(), Some('#'));
         // a. LOAD_PACKAGE_IMPORTS(X, dirname(Y))
         if let Some(path) = self.load_package_imports(cached_path, specifier, ctx)? {
@@ -420,10 +432,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn require_bare(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         // Make sure no other path prefixes gets called
         debug_assert!(Path::new(specifier)
             .components()
@@ -447,10 +459,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// <https://github.com/webpack/enhanced-resolve#escaping>
     fn load_parse<'s>(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &'s str,
         ctx: &mut Ctx,
-    ) -> Result<(Specifier<'s>, Option<CachedPath>), ResolveError> {
+    ) -> Result<(Specifier<'s>, Option<C::Cp>), ResolveError> {
         let parsed = Specifier::parse(specifier).map_err(ResolveError::Specifier)?;
         ctx.with_query_fragment(parsed.query, parsed.fragment);
 
@@ -469,10 +481,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_package_self_or_node_modules(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<CachedPath, ResolveError> {
+    ) -> Result<C::Cp, ResolveError> {
         let (_, subpath) = Self::parse_package_specifier(specifier);
         if subpath.is_empty() {
             ctx.with_fully_specified(false);
@@ -492,14 +504,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// LOAD_PACKAGE_IMPORTS(X, DIR)
     fn load_package_imports(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         // 1. Find the closest package scope SCOPE to DIR.
         // 2. If no scope was found, return.
         let Some((_, package_json)) =
-            cached_path.find_package_json(&self.options, &self.cache, ctx)?
+            cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)?
         else {
             return Ok(None);
         };
@@ -512,7 +524,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         Ok(None)
     }
 
-    fn load_as_file(&self, cached_path: &CachedPath, ctx: &mut Ctx) -> ResolveResult {
+    fn load_as_file(&self, cached_path: &C::Cp, ctx: &mut Ctx) -> ResolveResult<C::Cp> {
         // enhanced-resolve feature: extension_alias
         if let Some(path) = self.load_extension_alias(cached_path, ctx)? {
             return Ok(Some(path));
@@ -532,19 +544,19 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         Ok(None)
     }
 
-    fn load_as_directory(&self, cached_path: &CachedPath, ctx: &mut Ctx) -> ResolveResult {
+    fn load_as_directory(&self, cached_path: &C::Cp, ctx: &mut Ctx) -> ResolveResult<C::Cp> {
         // TODO: Only package.json is supported, so warn about having other values
         // Checking for empty files is needed for omitting checks on package.json
         // 1. If X/package.json is a file,
         if !self.options.description_files.is_empty() {
             // a. Parse X/package.json, and look for "main" field.
             if let Some((_, package_json)) =
-                cached_path.package_json(&self.options, &self.cache, ctx)?
+                self.cache.get_package_json(cached_path, &self.options, ctx)?
             {
                 // b. If "main" is a falsy value, GOTO 2.
                 for main_field in package_json.main_fields(&self.options.main_fields) {
                     // c. let M = X + (json main field)
-                    let cached_path = cached_path.normalize_with(main_field, &self.cache);
+                    let cached_path = cached_path.normalize_with(main_field, self.cache.as_ref());
                     // d. LOAD_AS_FILE(M)
                     if let Some(path) = self.load_as_file(&cached_path, ctx)? {
                         return Ok(Some(path));
@@ -564,19 +576,19 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_as_file_or_directory(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         if self.options.resolve_to_context {
-            return Ok(cached_path.is_dir(&self.cache.fs, ctx).then(|| cached_path.clone()));
+            return Ok(self.cache.is_dir(cached_path, ctx).then(|| cached_path.clone()));
         }
         if !specifier.ends_with('/') {
             if let Some(path) = self.load_as_file(cached_path, ctx)? {
                 return Ok(Some(path));
             }
         }
-        if cached_path.is_dir(&self.cache.fs, ctx) {
+        if self.cache.is_dir(cached_path, ctx) {
             if let Some(path) = self.load_as_directory(cached_path, ctx)? {
                 return Ok(Some(path));
             }
@@ -586,15 +598,15 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_extensions(
         &self,
-        path: &CachedPath,
+        path: &C::Cp,
         extensions: &[String],
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         if ctx.fully_specified {
             return Ok(None);
         }
         for extension in extensions {
-            let cached_path = path.add_extension(extension, &self.cache);
+            let cached_path = path.add_extension(extension, self.cache.as_ref());
             if let Some(path) = self.load_alias_or_file(&cached_path, ctx)? {
                 return Ok(Some(path));
             }
@@ -602,9 +614,9 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         Ok(None)
     }
 
-    fn load_realpath(&self, cached_path: &CachedPath) -> Result<PathBuf, ResolveError> {
+    fn load_realpath(&self, cached_path: &C::Cp) -> Result<PathBuf, ResolveError> {
         if self.options.symlinks {
-            cached_path.canonicalize(&self.cache)
+            self.cache.canonicalize(cached_path)
         } else {
             Ok(cached_path.to_path_buf())
         }
@@ -639,9 +651,9 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         Ok(())
     }
 
-    fn load_index(&self, cached_path: &CachedPath, ctx: &mut Ctx) -> ResolveResult {
+    fn load_index(&self, cached_path: &C::Cp, ctx: &mut Ctx) -> ResolveResult<C::Cp> {
         for main_file in &self.options.main_files {
-            let cached_path = cached_path.normalize_with(main_file, &self.cache);
+            let cached_path = cached_path.normalize_with(main_file, self.cache.as_ref());
             if self.options.enforce_extension.is_disabled() {
                 if let Some(path) = self.load_alias_or_file(&cached_path, ctx)? {
                     return Ok(Some(path));
@@ -659,12 +671,12 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_browser_field_or_alias(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         if !self.options.alias_fields.is_empty() {
             if let Some((package_url, package_json)) =
-                cached_path.find_package_json(&self.options, &self.cache, ctx)?
+                cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)?
             {
                 if let Some(path) =
                     self.load_browser_field(cached_path, None, &package_url, &package_json, ctx)?
@@ -686,11 +698,11 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         Ok(None)
     }
 
-    fn load_alias_or_file(&self, cached_path: &CachedPath, ctx: &mut Ctx) -> ResolveResult {
+    fn load_alias_or_file(&self, cached_path: &C::Cp, ctx: &mut Ctx) -> ResolveResult<C::Cp> {
         if let Some(path) = self.load_browser_field_or_alias(cached_path, ctx)? {
             return Ok(Some(path));
         }
-        if cached_path.is_file(&self.cache.fs, ctx) {
+        if self.cache.is_file(cached_path, ctx) {
             return Ok(Some(cached_path.clone()));
         }
         Ok(None)
@@ -698,10 +710,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_node_modules(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         #[cfg(feature = "yarn_pnp")]
         {
             if let Some(resolved_path) = self.load_pnp(cached_path, specifier, ctx)? {
@@ -715,7 +727,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         for module_name in &self.options.modules {
             for cached_path in std::iter::successors(Some(cached_path), |p| p.parent()) {
                 // Skip if /path/to/node_modules does not exist
-                if !cached_path.is_dir(&self.cache.fs, ctx) {
+                if !self.cache.is_dir(cached_path, ctx) {
                     continue;
                 }
 
@@ -728,9 +740,9 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 // 1. Try to interpret X as a combination of NAME and SUBPATH where the name
                 //    may have a @scope/ prefix and the subpath begins with a slash (`/`).
                 if !package_name.is_empty() {
-                    let cached_path = cached_path.normalize_with(package_name, &self.cache);
+                    let cached_path = cached_path.normalize_with(package_name, self.cache.as_ref());
                     // Try foo/node_modules/package_name
-                    if cached_path.is_dir(&self.cache.fs, ctx) {
+                    if self.cache.is_dir(&cached_path, ctx) {
                         // a. LOAD_PACKAGE_EXPORTS(X, DIR)
                         if let Some(path) =
                             self.load_package_exports(specifier, subpath, &cached_path, ctx)?
@@ -746,7 +758,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                         // i.e. `foo/node_modules/@scope` is not a directory for `foo/node_modules/@scope/package`
                         if package_name.starts_with('@') {
                             if let Some(path) = cached_path.parent() {
-                                if !path.is_dir(&self.cache.fs, ctx) {
+                                if !self.cache.is_dir(path, ctx) {
                                     continue;
                                 }
                             }
@@ -758,15 +770,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 // b. LOAD_AS_FILE(DIR/X)
                 // c. LOAD_AS_DIRECTORY(DIR/X)
 
-                let cached_path = cached_path.normalize_with(specifier, &self.cache);
+                let cached_path = cached_path.normalize_with(specifier, self.cache.as_ref());
 
                 // Perf: try the directory first for package specifiers.
                 if self.options.resolve_to_context {
-                    return Ok(cached_path
-                        .is_dir(&self.cache.fs, ctx)
-                        .then(|| cached_path.clone()));
+                    return Ok(self.cache.is_dir(&cached_path, ctx).then(|| cached_path.clone()));
                 }
-                if cached_path.is_dir(&self.cache.fs, ctx) {
+                if self.cache.is_dir(&cached_path, ctx) {
                     if let Some(path) = self.load_browser_field_or_alias(&cached_path, ctx)? {
                         return Ok(Some(path));
                     }
@@ -788,10 +798,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     #[cfg(feature = "yarn_pnp")]
     fn load_pnp(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<Option<CachedPath>, ResolveError> {
+    ) -> Result<Option<C::Cp>, ResolveError> {
         let Some(pnp_manifest) = &self.options.pnp_manifest else { return Ok(None) };
         let resolution =
             pnp::resolve_to_unqualified_via_manifest(pnp_manifest, specifier, cached_path.path());
@@ -826,18 +836,18 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn get_module_directory(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         module_name: &str,
         ctx: &mut Ctx,
-    ) -> Option<CachedPath> {
+    ) -> Option<C::Cp> {
         if module_name == "node_modules" {
-            cached_path.cached_node_modules(&self.cache, ctx)
+            cached_path.cached_node_modules(self.cache.as_ref(), ctx)
         } else if cached_path.path().components().next_back()
             == Some(Component::Normal(OsStr::new(module_name)))
         {
             Some(cached_path.clone())
         } else {
-            cached_path.module_directory(module_name, &self.cache, ctx)
+            cached_path.module_directory(module_name, self.cache.as_ref(), ctx)
         }
     }
 
@@ -845,12 +855,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         &self,
         specifier: &str,
         subpath: &str,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         // 2. If X does not match this pattern or DIR/NAME/package.json is not a file,
         //    return.
-        let Some((_, package_json)) = cached_path.package_json(&self.options, &self.cache, ctx)?
+        let Some((_, package_json)) =
+            self.cache.get_package_json(cached_path, &self.options, ctx)?
         else {
             return Ok(None);
         };
@@ -872,14 +883,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_package_self(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         // 1. Find the closest package scope SCOPE to DIR.
         // 2. If no scope was found, return.
         let Some((package_url, package_json)) =
-            cached_path.find_package_json(&self.options, &self.cache, ctx)?
+            cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)?
         else {
             return Ok(None);
         };
@@ -914,9 +925,9 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     fn resolve_esm_match(
         &self,
         specifier: &str,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         // 1. let RESOLVED_PATH = fileURLToPath(MATCH)
         // 2. If the file at RESOLVED_PATH exists, load RESOLVED_PATH as its extension format. STOP
         //
@@ -931,12 +942,12 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// enhanced-resolve: AliasFieldPlugin for [ResolveOptions::alias_fields]
     fn load_browser_field(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         module_specifier: Option<&str>,
-        package_url: &CachedPath,
+        package_url: &C::Cp,
         package_json: &PackageJson,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         let path = cached_path.path();
         let Some(new_specifier) = package_json.resolve_browser_field(
             path,
@@ -953,7 +964,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         if ctx.resolving_alias.as_ref().is_some_and(|s| s == new_specifier) {
             // Complete when resolving to self `{"./a.js": "./a.js"}`
             if new_specifier.strip_prefix("./").filter(|s| path.ends_with(Path::new(s))).is_some() {
-                return if cached_path.is_file(&self.cache.fs, ctx) {
+                return if self.cache.is_file(cached_path, ctx) {
                     Ok(Some(cached_path.clone()))
                 } else {
                     Err(ResolveError::NotFound(new_specifier.to_string()))
@@ -969,11 +980,11 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// enhanced-resolve: AliasPlugin for [ResolveOptions::alias] and [ResolveOptions::fallback].
     fn load_alias(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         aliases: &Alias,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         for (alias_key_raw, specifiers) in aliases {
             let alias_key = if let Some(alias_key) = alias_key_raw.strip_suffix('$') {
                 if alias_key != specifier {
@@ -1006,7 +1017,8 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                         }
                     }
                     AliasValue::Ignore => {
-                        let cached_path = cached_path.normalize_with(alias_key, &self.cache);
+                        let cached_path =
+                            cached_path.normalize_with(alias_key, self.cache.as_ref());
                         return Err(ResolveError::Ignored(cached_path.to_path_buf()));
                     }
                 }
@@ -1023,13 +1035,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_alias_value(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         alias_key: &str,
         alias_value: &str,
         request: &str,
         ctx: &mut Ctx,
         should_stop: &mut bool,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         if request != alias_value
             && !request.strip_prefix(alias_value).is_some_and(|prefix| prefix.starts_with('/'))
         {
@@ -1041,7 +1053,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 let alias_path = Path::new(alias_value).normalize();
                 // Must not append anything to alias_value if it is a file.
                 let cached_alias_path = self.cache.value(&alias_path);
-                if cached_alias_path.is_file(&self.cache.fs, ctx) {
+                if self.cache.is_file(&cached_alias_path, ctx) {
                     return Ok(None);
                 }
                 // Remove the leading slash so the final path is concatenated.
@@ -1075,7 +1087,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// # Errors
     ///
     /// * [ResolveError::ExtensionAlias]: When all of the aliased extensions are not found
-    fn load_extension_alias(&self, cached_path: &CachedPath, ctx: &mut Ctx) -> ResolveResult {
+    fn load_extension_alias(&self, cached_path: &C::Cp, ctx: &mut Ctx) -> ResolveResult<C::Cp> {
         if self.options.extension_alias.is_empty() {
             return Ok(None);
         }
@@ -1094,14 +1106,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         let Some(filename) = path.file_name() else { return Ok(None) };
         ctx.with_fully_specified(true);
         for extension in extensions {
-            let cached_path = cached_path.replace_extension(extension, &self.cache);
+            let cached_path = cached_path.replace_extension(extension, self.cache.as_ref());
             if let Some(path) = self.load_alias_or_file(&cached_path, ctx)? {
                 ctx.with_fully_specified(false);
                 return Ok(Some(path));
             }
         }
         // Bail if path is module directory such as `ipaddr.js`
-        if !cached_path.is_file(&self.cache.fs, ctx) {
+        if !self.cache.is_file(cached_path, ctx) {
             ctx.with_fully_specified(false);
             return Ok(None);
         }
@@ -1123,12 +1135,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// defaults to context configuration option.
     ///
     /// On non-Windows systems these requests are resolved as an absolute path first.
-    fn load_roots(
-        &self,
-        cached_path: &CachedPath,
-        specifier: &str,
-        ctx: &mut Ctx,
-    ) -> Option<CachedPath> {
+    fn load_roots(&self, cached_path: &C::Cp, specifier: &str, ctx: &mut Ctx) -> Option<C::Cp> {
         if self.options.roots.is_empty() {
             return None;
         }
@@ -1157,7 +1164,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         path: &Path,
         references: &TsconfigReferences,
     ) -> Result<Arc<TsConfig>, ResolveError> {
-        self.cache.tsconfig(root, path, |tsconfig| {
+        self.cache.get_tsconfig(root, path, |tsconfig| {
             let directory = self.cache.value(tsconfig.directory());
             tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig");
 
@@ -1199,7 +1206,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 let directory = tsconfig.directory().to_path_buf();
                 for reference in &mut tsconfig.references {
                     let reference_tsconfig_path = directory.normalize_with(&reference.path);
-                    let tsconfig = self.cache.tsconfig(
+                    let tsconfig = self.cache.get_tsconfig(
                         /* root */ true,
                         &reference_tsconfig_path,
                         |reference_tsconfig| {
@@ -1220,10 +1227,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn load_tsconfig_paths(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         let Some(tsconfig_options) = &self.options.tsconfig else {
             return Ok(None);
         };
@@ -1244,7 +1251,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
 
     fn get_extended_tsconfig_path(
         &self,
-        directory: &CachedPath,
+        directory: &C::Cp,
         tsconfig: &TsConfig,
         specifier: &str,
     ) -> Result<PathBuf, ResolveError> {
@@ -1273,10 +1280,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// PACKAGE_RESOLVE(packageSpecifier, parentURL)
     fn package_resolve(
         &self,
-        cached_path: &CachedPath,
+        cached_path: &C::Cp,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         let (package_name, subpath) = Self::parse_package_specifier(specifier);
 
         // 3. If packageSpecifier is a Node.js builtin module name, then
@@ -1292,13 +1299,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                     continue;
                 };
                 // 2. Set parentURL to the parent folder URL of parentURL.
-                let cached_path = cached_path.normalize_with(package_name, &self.cache);
+                let cached_path = cached_path.normalize_with(package_name, self.cache.as_ref());
                 // 3. If the folder at packageURL does not exist, then
                 //   1. Continue the next loop iteration.
-                if cached_path.is_dir(&self.cache.fs, ctx) {
+                if self.cache.is_dir(&cached_path, ctx) {
                     // 4. Let pjson be the result of READ_PACKAGE_JSON(packageURL).
                     if let Some((_, package_json)) =
-                        cached_path.package_json(&self.options, &self.cache, ctx)?
+                        self.cache.get_package_json(&cached_path, &self.options, ctx)?
                     {
                         // 5. If pjson is not null and pjson.exports is not null or undefined, then
                         // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions).
@@ -1318,8 +1325,8 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                             for main_field in package_json.main_fields(&self.options.main_fields) {
                                 // 1. Return the URL resolution of main in packageURL.
                                 let cached_path =
-                                    cached_path.normalize_with(main_field, &self.cache);
-                                if cached_path.is_file(&self.cache.fs, ctx) {
+                                    cached_path.normalize_with(main_field, self.cache.as_ref());
+                                if self.cache.is_file(&cached_path, ctx) {
                                     return Ok(Some(cached_path));
                                 }
                             }
@@ -1338,11 +1345,11 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     /// PACKAGE_EXPORTS_RESOLVE(packageURL, subpath, exports, conditions)
     fn package_exports_resolve(
         &self,
-        package_url: &CachedPath,
+        package_url: &C::Cp,
         subpath: &str,
         exports: &JSONValue,
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         let conditions = &self.options.condition_names;
         // 1. If exports is an Object with both a key starting with "." and a key not starting with ".", throw an Invalid Package Configuration error.
         if let JSONValue::Object(map) = exports {
@@ -1445,7 +1452,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         specifier: &str,
         package_json: &PackageJson,
         ctx: &mut Ctx,
-    ) -> Result<Option<CachedPath>, ResolveError> {
+    ) -> Result<Option<C::Cp>, ResolveError> {
         // 1. Assert: specifier begins with "#".
         debug_assert!(specifier.starts_with('#'), "{specifier}");
         //   2. If specifier is exactly equal to "#" or starts with "#/", then
@@ -1498,11 +1505,11 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         &self,
         match_key: &str,
         match_obj: &JSONMap,
-        package_url: &CachedPath,
+        package_url: &C::Cp,
         is_imports: bool,
         conditions: &[String],
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         // enhanced-resolve behaves differently, it throws
         // Error: CachedPath to directories is not possible with the exports field (specifier was ./dist/)
         if match_key.ends_with('/') {
@@ -1582,19 +1589,19 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
     #[allow(clippy::too_many_arguments)]
     fn package_target_resolve(
         &self,
-        package_url: &CachedPath,
+        package_url: &C::Cp,
         target_key: &str,
         target: &JSONValue,
         pattern_match: Option<&str>,
         is_imports: bool,
         conditions: &[String],
         ctx: &mut Ctx,
-    ) -> ResolveResult {
+    ) -> ResolveResult<C::Cp> {
         fn normalize_string_target<'a>(
             target_key: &'a str,
             target: &'a str,
             pattern_match: Option<&'a str>,
-            package_url: &CachedPath,
+            package_url: &impl CachedPath,
         ) -> Result<Cow<'a, str>, ResolveError> {
             let target = if let Some(pattern_match) = pattern_match {
                 if !target_key.contains('*') && !target.contains('*') {
@@ -1653,7 +1660,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 }
                 // 6. If patternMatch split on "/" or "\" contains any "", ".", "..", or "node_modules" segments, case insensitive and including percent encoded variants, throw an Invalid Module Specifier error.
                 // 7. Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch.
-                return Ok(Some(package_url.normalize_with(target.as_ref(), &self.cache)));
+                return Ok(Some(package_url.normalize_with(target.as_ref(), self.cache.as_ref())));
             }
             // 2. Otherwise, if target is a non-null Object, then
             JSONValue::Object(target) => {
diff --git a/src/tsconfig.rs b/src/tsconfig.rs
index af20e2c..3b0f165 100644
--- a/src/tsconfig.rs
+++ b/src/tsconfig.rs
@@ -78,16 +78,18 @@ impl TsConfig {
     /// # Panics
     ///
     /// * When the `tsconfig.json` path is misconfigured.
+    #[must_use]
     pub fn directory(&self) -> &Path {
         debug_assert!(self.path.file_name().is_some());
         self.path.parent().unwrap()
     }
 
-    pub(crate) fn parse(
-        root: bool,
-        path: &Path,
-        json: &mut str,
-    ) -> Result<Self, serde_json::Error> {
+    /// Parses the tsconfig from a JSON string.
+    ///
+    /// # Errors
+    ///
+    /// * Any error that can be returned by `serde_json::from_str()`.
+    pub fn parse(root: bool, path: &Path, json: &mut str) -> Result<Self, serde_json::Error> {
         _ = json_strip_comments::strip(json);
         let mut tsconfig: Self = serde_json::from_str(json)?;
         tsconfig.root = root;
@@ -103,7 +105,8 @@ impl TsConfig {
         Ok(tsconfig)
     }
 
-    pub(crate) fn build(mut self) -> Self {
+    #[must_use]
+    pub fn build(mut self) -> Self {
         if self.root {
             let dir = self.directory().to_path_buf();
             // Substitute template variable in `tsconfig.compilerOptions.paths`


Test patch:
diff --git a/src/tests/alias.rs b/src/tests/alias.rs
index be459db..769934a 100644
--- a/src/tests/alias.rs
+++ b/src/tests/alias.rs
@@ -10,10 +10,13 @@ use crate::{AliasValue, Resolution, ResolveContext, ResolveError, ResolveOptions
 #[test]
 #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
 fn alias() {
-    use std::path::{Path, PathBuf};
+    use std::{
+        path::{Path, PathBuf},
+        sync::Arc,
+    };
 
     use super::memory_fs::MemoryFS;
-    use crate::ResolverGeneric;
+    use crate::{FsCache, ResolverGeneric};
 
     let f = Path::new("/");
 
@@ -34,8 +37,8 @@ fn alias() {
         ("/dashed-name", ""),
     ]);
 
-    let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(
-        file_system,
+    let resolver = ResolverGeneric::new_with_cache(
+        Arc::new(FsCache::new(file_system)),
         ResolveOptions {
             alias: vec![
                 ("aliasA".into(), vec![AliasValue::from("a")]),
diff --git a/src/tests/dependencies.rs b/src/tests/dependencies.rs
index 0ec9f02..3442b9d 100644
--- a/src/tests/dependencies.rs
+++ b/src/tests/dependencies.rs
@@ -2,10 +2,10 @@
 
 #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
 mod windows {
-    use std::path::PathBuf;
+    use std::{path::PathBuf, sync::Arc};
 
     use super::super::memory_fs::MemoryFS;
-    use crate::{ResolveContext, ResolveOptions, ResolverGeneric};
+    use crate::{FsCache, ResolveContext, ResolveOptions, ResolverGeneric};
 
     fn file_system() -> MemoryFS {
         MemoryFS::new(&[
@@ -20,8 +20,8 @@ mod windows {
     fn test() {
         let file_system = file_system();
 
-        let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(
-            file_system,
+        let resolver = ResolverGeneric::new_with_cache(
+            Arc::new(FsCache::new(file_system)),
             ResolveOptions {
                 extensions: vec![".json".into(), ".js".into()],
                 modules: vec!["/modules".into(), "node_modules".into()],
diff --git a/src/tests/exports_field.rs b/src/tests/exports_field.rs
index 5451780..abdd6e2 100644
--- a/src/tests/exports_field.rs
+++ b/src/tests/exports_field.rs
@@ -6,7 +6,7 @@ use std::path::Path;
 
 use serde_json::json;
 
-use crate::{Ctx, PathUtil, ResolveError, ResolveOptions, Resolver};
+use crate::{cache::CachedPath, Cache, Ctx, PathUtil, ResolveError, ResolveOptions, Resolver};
 
 #[test]
 fn test_simple() {
diff --git a/src/tests/fallback.rs b/src/tests/fallback.rs
index 5eb98a5..b5c5fb3 100644
--- a/src/tests/fallback.rs
+++ b/src/tests/fallback.rs
@@ -3,10 +3,13 @@
 #[test]
 #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
 fn fallback() {
-    use std::path::{Path, PathBuf};
+    use std::{
+        path::{Path, PathBuf},
+        sync::Arc,
+    };
 
     use super::memory_fs::MemoryFS;
-    use crate::{AliasValue, ResolveError, ResolveOptions, ResolverGeneric};
+    use crate::{AliasValue, FsCache, ResolveError, ResolveOptions, ResolverGeneric};
 
     let f = Path::new("/");
 
@@ -28,8 +31,8 @@ fn fallback() {
         ("/e/dir/file", ""),
     ]);
 
-    let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(
-        file_system,
+    let resolver = ResolverGeneric::new_with_cache(
+        Arc::new(FsCache::new(file_system)),
         ResolveOptions {
             fallback: vec![
                 ("aliasA".into(), vec![AliasValue::Path("a".into())]),
diff --git a/src/tests/full_specified.rs b/src/tests/full_specified.rs
index e56cceb..9fac6e2 100644
--- a/src/tests/full_specified.rs
+++ b/src/tests/full_specified.rs
@@ -2,10 +2,10 @@
 
 #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
 mod windows {
-    use std::path::PathBuf;
+    use std::{path::PathBuf, sync::Arc};
 
     use super::super::memory_fs::MemoryFS;
-    use crate::{AliasValue, ResolveOptions, ResolverGeneric};
+    use crate::{AliasValue, FsCache, ResolveOptions, ResolverGeneric};
 
     fn file_system() -> MemoryFS {
         MemoryFS::new(&[
@@ -28,8 +28,8 @@ mod windows {
     fn test() {
         let file_system = file_system();
 
-        let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(
-            file_system,
+        let resolver = ResolverGeneric::new_with_cache(
+            Arc::new(FsCache::new(file_system)),
             ResolveOptions {
                 alias: vec![
                     ("alias1".into(), vec![AliasValue::from("/a/abc")]),
@@ -79,8 +79,8 @@ mod windows {
     fn resolve_to_context() {
         let file_system = file_system();
 
-        let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(
-            file_system,
+        let resolver = ResolverGeneric::new_with_cache(
+            Arc::new(FsCache::new(file_system)),
             ResolveOptions {
                 alias: vec![
                     ("alias1".into(), vec![AliasValue::from("/a/abc")]),
diff --git a/src/tests/imports_field.rs b/src/tests/imports_field.rs
index 113b6f1..04ae020 100644
--- a/src/tests/imports_field.rs
+++ b/src/tests/imports_field.rs
@@ -6,7 +6,9 @@ use std::path::Path;
 
 use serde_json::json;
 
-use crate::{Ctx, JSONMap, PathUtil, ResolveError, ResolveOptions, Resolver};
+use crate::{
+    cache::CachedPath, Cache, Ctx, JSONMap, PathUtil, ResolveError, ResolveOptions, Resolver,
+};
 
 #[test]
 fn test_simple() {
diff --git a/src/tests/simple.rs b/src/tests/simple.rs
index c5aee42..4fc8831 100644
--- a/src/tests/simple.rs
+++ b/src/tests/simple.rs
@@ -48,8 +48,10 @@ fn dashed_name() {
 
 #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
 mod windows {
+    use std::sync::Arc;
+
     use super::super::memory_fs::MemoryFS;
-    use crate::ResolveOptions;
+    use crate::{FsCache, ResolveOptions};
 
     #[test]
     fn no_package() {
@@ -58,8 +60,8 @@ mod windows {
         use crate::ResolverGeneric;
         let f = Path::new("/");
         let file_system = MemoryFS::new(&[]);
-        let resolver = ResolverGeneric::<MemoryFS>::new_with_file_system(
-            file_system,
+        let resolver = ResolverGeneric::new_with_cache(
+            Arc::new(FsCache::new(file_system)),
             ResolveOptions::default(),
         );
         let resolved_path = resolver.resolve(f, "package");
diff --git a/src/tests/tsconfig_paths.rs b/src/tests/tsconfig_paths.rs
index 82b2b07..dd95ffb 100644
--- a/src/tests/tsconfig_paths.rs
+++ b/src/tests/tsconfig_paths.rs
@@ -238,11 +238,14 @@ fn test_template_variable() {
 
 #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
 mod windows_test {
-    use std::path::{Path, PathBuf};
+    use std::{
+        path::{Path, PathBuf},
+        sync::Arc,
+    };
 
     use super::super::memory_fs::MemoryFS;
     use crate::{
-        ResolveError, ResolveOptions, ResolverGeneric, TsconfigOptions, TsconfigReferences,
+        FsCache, ResolveError, ResolveOptions, ResolverGeneric, TsconfigOptions, TsconfigReferences,
     };
 
     struct OneTest {
@@ -285,7 +288,7 @@ mod windows_test {
     }
 
     impl OneTest {
-        fn resolver(&self, root: &Path) -> ResolverGeneric<MemoryFS> {
+        fn resolver(&self, root: &Path) -> ResolverGeneric<FsCache<MemoryFS>> {
             let mut file_system = MemoryFS::default();
 
             file_system.add_file(&root.join("tsconfig.json"), &self.tsconfig);
@@ -308,7 +311,7 @@ mod windows_test {
                 options.main_fields.clone_from(main_fields);
             }
 
-            ResolverGeneric::<MemoryFS>::new_with_file_system(file_system, options)
+            ResolverGeneric::new_with_cache(Arc::new(FsCache::new(file_system)), options)
         }
     }