Repository: oxc-project/oxc-resolver
Pull request #137 (2024-04-23 06:16:15)

fix: lazily read package.json.exports for shared resolvers
relates #135

This PR removes all encapsulations around imports / exports map so we can dynamically retrieve and evaluate their values :-(

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/137?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) Report
Attention: Patch coverage is `95.23810%` with `2 lines` in your changes are missing coverage. Please review.
> Project coverage is 95.02%. Comparing base [(`b2e2094`)](https://app.codecov.io/gh/oxc-project/oxc-resolver/commit/b2e2094a460b12f6b38ae02abc74e4fcea99fd05?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) to head [(`fbffb26`)](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/137?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project).

| [Files](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/137?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) | Patch % | Lines |
|---|---|---|
| [src/lib.rs](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/137?src=pr&el=tree&filepath=src%2Flib.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project#diff-c3JjL2xpYi5ycw==) | 92.00% | [2 Missing :warning: ](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/137?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main     #137   +/-   ##
=======================================
  Coverage   95.01%   95.02%           
=======================================
  Files          11       11           
  Lines        2246     2250    +4     
=======================================
+ Hits         2134     2138    +4     
  Misses        112      112           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/oxc-project/oxc-resolver/pull/137?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oxc-project).

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/oxc-project/oxc-resolver/branches/exports)

### Merging #137 will **degrade performances by 31.98%**

<sub>Comparing <code>exports</code> (fbffb26) with <code>main</code> (b2e2094)</sub>



### Summary

`❌ 2` regressions



> :warning: _Please fix the performance issues or [acknowledge them on CodSpeed](https://codspeed.io/oxc-project/oxc-resolver/branches/exports)._

### Benchmarks breakdown

|     | Benchmark | `main` | `exports` | Change |
| --- | --------- | ----------------------- | ------------------- | ------ |
| ❌ | `resolver[multi-thread]` | 1.9 ms | 2.8 ms | -31.01% |
| ❌ | `resolver[single-thread]` | 1.8 ms | 2.6 ms | -31.98% |


Patch:
diff --git a/src/lib.rs b/src/lib.rs
index d8d49a2..6d2a14b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -60,7 +60,6 @@ mod tsconfig;
 #[cfg(test)]
 mod tests;
 
-use rustc_hash::FxHashSet;
 use std::{
     borrow::Cow,
     cmp::Ordering,
@@ -69,6 +68,9 @@ use std::{
     path::{Component, Path, PathBuf},
     sync::Arc,
 };
+
+use rustc_hash::FxHashSet;
+use serde_json::Value as JSONValue;
 use typescript_tsconfig_json::ExtendsField;
 
 pub use crate::{
@@ -86,11 +88,11 @@ use crate::{
     cache::{Cache, CachedPath},
     context::ResolveContext as Ctx,
     file_system::FileSystemOs,
+    package_json::ImportExportMap,
     path::{PathUtil, SLASH_START},
     specifier::Specifier,
     tsconfig::{ProjectReference, TsConfig},
 };
-use nodejs_package_json::{ImportExportField, ImportExportKey, ImportExportMap};
 
 type ResolveResult = Result<Option<CachedPath>, ResolveError>;
 
@@ -749,16 +751,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         };
         // 3. Parse DIR/NAME/package.json, and look for "exports" field.
         // 4. If "exports" is null or undefined, return.
-        if package_json.exports.is_empty() {
-            return Ok(None);
-        };
         // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
         //    `package.json` "exports", ["node", "require"]) defined in the ESM resolver.
         // Note: The subpath is not prepended with a dot on purpose
-        for exports in &package_json.exports {
+        for exports in package_json.exports_fields(&self.options.exports_fields) {
             if let Some(path) = self.package_exports_resolve(
                 cached_path.path(),
-                subpath,
+                &format!(".{subpath}"),
                 exports,
                 &self.options.condition_names,
                 ctx,
@@ -784,30 +783,28 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
             return Ok(None);
         };
         // 3. If the SCOPE/package.json "exports" is null or undefined, return.
-        if !package_json.exports.is_empty() {
-            // 4. If the SCOPE/package.json "name" is not the first segment of X, return.
-            if let Some(subpath) = package_json
-                .name
-                .as_ref()
-                .and_then(|package_name| Self::strip_package_name(specifier, package_name))
-            {
-                // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE),
-                // "." + X.slice("name".length), `package.json` "exports", ["node", "require"])
-                // defined in the ESM resolver.
-                let package_url = package_json.directory();
-                // Note: The subpath is not prepended with a dot on purpose
-                // because `package_exports_resolve` matches subpath without the leading dot.
-                for exports in &package_json.exports {
-                    if let Some(cached_path) = self.package_exports_resolve(
-                        package_url,
-                        subpath,
-                        exports,
-                        &self.options.condition_names,
-                        ctx,
-                    )? {
-                        // 6. RESOLVE_ESM_MATCH(MATCH)
-                        return self.resolve_esm_match(specifier, &cached_path, &package_json, ctx);
-                    }
+        // 4. If the SCOPE/package.json "name" is not the first segment of X, return.
+        if let Some(subpath) = package_json
+            .name
+            .as_ref()
+            .and_then(|package_name| Self::strip_package_name(specifier, package_name))
+        {
+            // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE),
+            // "." + X.slice("name".length), `package.json` "exports", ["node", "require"])
+            // defined in the ESM resolver.
+            let package_url = package_json.directory();
+            // Note: The subpath is not prepended with a dot on purpose
+            // because `package_exports_resolve` matches subpath without the leading dot.
+            for exports in package_json.exports_fields(&self.options.exports_fields) {
+                if let Some(cached_path) = self.package_exports_resolve(
+                    package_url,
+                    &format!(".{subpath}"),
+                    exports,
+                    &self.options.condition_names,
+                    ctx,
+                )? {
+                    // 6. RESOLVE_ESM_MATCH(MATCH)
+                    return self.resolve_esm_match(specifier, &cached_path, &package_json, ctx);
                 }
             }
         }
@@ -1143,18 +1140,16 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                         cached_path.package_json(&self.cache.fs, &self.options, ctx)?
                     {
                         // 5. If pjson is not null and pjson.exports is not null or undefined, then
-                        if !package_json.exports.is_empty() {
-                            // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions).
-                            for exports in &package_json.exports {
-                                if let Some(path) = self.package_exports_resolve(
-                                    cached_path.path(),
-                                    subpath,
-                                    exports,
-                                    &self.options.condition_names,
-                                    ctx,
-                                )? {
-                                    return Ok(Some(path));
-                                }
+                        // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions).
+                        for exports in package_json.exports_fields(&self.options.exports_fields) {
+                            if let Some(path) = self.package_exports_resolve(
+                                cached_path.path(),
+                                &format!(".{subpath}"),
+                                exports,
+                                &self.options.condition_names,
+                                ctx,
+                            )? {
+                                return Ok(Some(path));
                             }
                         }
                         // 6. Otherwise, if packageSubpath is equal to ".", then
@@ -1187,18 +1182,17 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         &self,
         package_url: &Path,
         subpath: &str,
-        exports: &ImportExportField,
+        exports: &JSONValue,
         conditions: &[String],
         ctx: &mut Ctx,
     ) -> ResolveResult {
         // 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 ImportExportField::Map(map) = exports {
+        if let JSONValue::Object(map) = exports {
             let mut has_dot = false;
             let mut without_dot = false;
             for key in map.keys() {
-                has_dot =
-                    has_dot || matches!(key, ImportExportKey::Main | ImportExportKey::Pattern(_));
-                without_dot = without_dot || matches!(key, ImportExportKey::CustomCondition(_));
+                has_dot = has_dot || key.starts_with(|s| s == '.' || s == '#');
+                without_dot = without_dot || !key.starts_with(|s| s == '.' || s == '#');
                 if has_dot && without_dot {
                     return Err(ResolveError::InvalidPackageConfig(
                         package_url.join("package.json"),
@@ -1208,7 +1202,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         }
         // 2. If subpath is equal to ".", then
         // Note: subpath is not prepended with a dot when passed in.
-        if subpath.is_empty() {
+        if subpath == "." {
             // enhanced-resolve appends query and fragment when resolving exports field
             // https://github.com/webpack/enhanced-resolve/blob/a998c7d218b7a9ec2461fc4fddd1ad5dd7687485/lib/ExportsFieldPlugin.js#L57-L62
             // This is only need when querying the main export, otherwise ctx is passed through.
@@ -1216,24 +1210,23 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 let query = ctx.query.clone().unwrap_or_default();
                 let fragment = ctx.fragment.clone().unwrap_or_default();
                 return Err(ResolveError::PackagePathNotExported(
-                    format!("./{subpath}{query}{fragment}"),
+                    format!("./{}{query}{fragment}", subpath.trim_start_matches('.')),
                     package_url.join("package.json"),
                 ));
             }
             // 1. Let mainExport be undefined.
             let main_export = match exports {
-                ImportExportField::None => None,
                 // 2. If exports is a String or Array, or an Object containing no keys starting with ".", then
-                ImportExportField::String(_) | ImportExportField::Array(_) => {
+                JSONValue::String(_) | JSONValue::Array(_) => {
                     // 1. Set mainExport to exports.
                     Some(exports)
                 }
                 // 3. Otherwise if exports is an Object containing a "." property, then
-                ImportExportField::Map(map) => {
+                JSONValue::Object(map) => {
                     // 1. Set mainExport to exports["."].
-                    map.get(&ImportExportKey::Main).map_or_else(
+                    map.get(".").map_or_else(
                         || {
-                            if map.keys().any(|key| matches!(key, ImportExportKey::Pattern(_))) {
+                            if map.keys().any(|key| key.starts_with("./") || key.starts_with('#')) {
                                 None
                             } else {
                                 Some(exports)
@@ -1242,6 +1235,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                         Some,
                     )
                 }
+                _ => None,
             };
             // 4. If mainExport is not undefined, then
             if let Some(main_export) = main_export {
@@ -1262,7 +1256,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
             }
         }
         // 3. Otherwise, if exports is an Object and all keys of exports start with ".", then
-        if let ImportExportField::Map(exports) = exports {
+        if let JSONValue::Object(exports) = exports {
             // 1. Let matchKey be the string "./" concatenated with subpath.
             // Note: `package_imports_exports_resolve` does not require the leading dot.
             let match_key = &subpath;
@@ -1281,7 +1275,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         }
         // 4. Throw a Package Path Not Exported error.
         Err(ResolveError::PackagePathNotExported(
-            format!(".{subpath}"),
+            subpath.to_string(),
             package_url.join("package.json"),
         ))
     }
@@ -1346,7 +1340,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         // 1. If matchKey is a key of matchObj and does not contain "*", then
         if !match_key.contains('*') {
             // 1. Let target be the value of matchObj[matchKey].
-            if let Some(target) = match_obj.get(&ImportExportKey::Pattern(match_key.to_string())) {
+            if let Some(target) = match_obj.get(match_key) {
                 // 2. Return the result of PACKAGE_TARGET_RESOLVE(packageURL, target, null, isImports, conditions).
                 return self.package_target_resolve(
                     package_url,
@@ -1366,7 +1360,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         // 2. Let expansionKeys be the list of keys of matchObj containing only a single "*", sorted by the sorting function PATTERN_KEY_COMPARE which orders in descending order of specificity.
         // 3. For each key expansionKey in expansionKeys, do
         for (expansion_key, target) in match_obj {
-            if let ImportExportKey::Pattern(expansion_key) = expansion_key {
+            if expansion_key.starts_with("./") || expansion_key.starts_with('#') {
                 // 1. Let patternBase be the substring of expansionKey up to but excluding the first "*" character.
                 if let Some((pattern_base, pattern_trailer)) = expansion_key.split_once('*') {
                     // 2. If matchKey starts with but is not equal to patternBase, then
@@ -1419,7 +1413,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         &self,
         package_url: &Path,
         target_key: &str,
-        target: &ImportExportField,
+        target: &JSONValue,
         pattern_match: Option<&str>,
         is_imports: bool,
         conditions: &[String],
@@ -1452,9 +1446,8 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
         }
 
         match target {
-            ImportExportField::None => {}
             // 1. If target is a String, then
-            ImportExportField::String(target) => {
+            JSONValue::String(target) => {
                 // 1. If target does not start with "./", then
                 if !target.starts_with("./") {
                     // 1. If isImports is false, or if target starts with "../" or "/", or if target is a valid URL, then
@@ -1495,14 +1488,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 return Ok(Some(value));
             }
             // 2. Otherwise, if target is a non-null Object, then
-            ImportExportField::Map(target) => {
+            JSONValue::Object(target) => {
                 // 1. If exports contains any index property keys, as defined in ECMA-262 6.1.7 Array Index, throw an Invalid Package Configuration error.
                 // 2. For each property p of target, in object insertion order as,
                 for (i, (key, target_value)) in target.iter().enumerate() {
                     // https://nodejs.org/api/packages.html#conditional-exports
                     // "default" - the generic fallback that always matches. Can be a CommonJS or ES module file. This condition should always come last.
                     // Note: node.js does not throw this but enhanced-resolve does.
-                    let is_default = matches!(key, ImportExportKey::CustomCondition(condition) if condition == "default");
+                    let is_default = key == "default";
                     if i < target.len() - 1 && is_default {
                         return Err(ResolveError::InvalidPackageConfigDefault(
                             package_url.join("package.json"),
@@ -1510,9 +1503,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                     }
 
                     // 1. If p equals "default" or conditions contains an entry for p, then
-                    if is_default
-                        || matches!(key, ImportExportKey::CustomCondition(condition) if conditions.contains(condition))
-                    {
+                    if is_default || conditions.contains(key) {
                         // 1. Let targetValue be the value of the p property in target.
                         // 2. Let resolved be the result of PACKAGE_TARGET_RESOLVE( packageURL, targetValue, patternMatch, isImports, conditions).
                         let resolved = self.package_target_resolve(
@@ -1535,12 +1526,12 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 return Ok(None);
             }
             // 3. Otherwise, if target is an Array, then
-            ImportExportField::Array(targets) => {
+            JSONValue::Array(targets) => {
                 // 1. If _target.length is zero, return null.
                 if targets.is_empty() {
                     // Note: return PackagePathNotExported has the same effect as return because there are no matches.
                     return Err(ResolveError::PackagePathNotExported(
-                        format!(".{}", pattern_match.unwrap_or(".")),
+                        pattern_match.unwrap_or(".").to_string(),
                         package_url.join("package.json"),
                     ));
                 }
@@ -1570,6 +1561,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
                 // 3. Return or throw the last fallback resolution null return or error.
                 // Note: see `resolved.is_err() && i == targets.len()`
             }
+            _ => {}
         }
         // 4. Otherwise, if target is null, return null.
         Ok(None)
diff --git a/src/package_json.rs b/src/package_json.rs
index d4e075b..e72eaeb 100644
--- a/src/package_json.rs
+++ b/src/package_json.rs
@@ -3,12 +3,14 @@
 //! Code related to export field are copied from [Parcel's resolver](https://github.com/parcel-bundler/parcel/blob/v2/packages/utils/node-resolver-rs/src/package_json.rs)
 use std::path::{Path, PathBuf};
 
-use nodejs_package_json::{BrowserField, ImportExportField, ImportExportMap};
+use nodejs_package_json::BrowserField;
 use serde::Deserialize;
-use serde_json::Value;
+use serde_json::Value as JSONValue;
 
 use crate::{path::PathUtil, ResolveError, ResolveOptions};
 
+pub type ImportExportMap = serde_json::Map<String, JSONValue>;
+
 /// Deserialized package.json
 #[derive(Debug, Default)]
 pub struct PackageJson {
@@ -18,7 +20,7 @@ pub struct PackageJson {
     /// Realpath to `package.json`. Contains the `package.json` filename.
     pub realpath: PathBuf,
 
-    raw_json: std::sync::Arc<serde_json::Value>,
+    raw_json: std::sync::Arc<JSONValue>,
 
     /// The "name" field defines your package's name.
     /// The "name" field can be used in addition to the "exports" field to self-reference a package using its name.
@@ -26,11 +28,6 @@ pub struct PackageJson {
     /// <https://nodejs.org/api/packages.html#name>
     pub name: Option<String>,
 
-    /// The "exports" field allows defining the entry points of a package when imported by name loaded either via a node_modules lookup or a self-reference to its own name.
-    ///
-    /// <https://nodejs.org/api/packages.html#exports>
-    pub exports: Vec<ImportExportField>,
-
     /// In addition to the "exports" field, there is a package "imports" field to create private mappings that only apply to import specifiers from within the package itself.
     ///
     /// <https://nodejs.org/api/packages.html#subpath-imports>
@@ -52,10 +49,9 @@ impl PackageJson {
         json: &str,
         options: &ResolveOptions,
     ) -> Result<Self, serde_json::Error> {
-        let mut raw_json: Value = serde_json::from_str(json)?;
+        let mut raw_json: JSONValue = serde_json::from_str(json)?;
         let mut package_json = Self::default();
 
-        package_json.exports.reserve_exact(options.exports_fields.len());
         package_json.browser_fields.reserve_exact(options.alias_fields.len());
 
         if let Some(json_object) = raw_json.as_object_mut() {
@@ -76,11 +72,8 @@ impl PackageJson {
                 json_object.get("name").and_then(|field| field.as_str()).map(ToString::to_string);
 
             // Add imports.
-            package_json.imports = json_object
-                .get("imports")
-                .map(ImportExportMap::deserialize)
-                .transpose()?
-                .map(Box::new);
+            package_json.imports =
+                json_object.get("imports").and_then(|v| v.as_object()).cloned().map(Box::new);
 
             // Dynamically create `browser_fields`.
             let dir = path.parent().unwrap();
@@ -107,14 +100,6 @@ impl PackageJson {
                     package_json.browser_fields.push(browser_field);
                 }
             }
-
-            // Dynamically create `exports`.
-            for object_path in &options.exports_fields {
-                if let Some(exports) = Self::get_value_by_path(json_object, object_path) {
-                    let exports = ImportExportField::deserialize(exports)?;
-                    package_json.exports.push(exports);
-                }
-            }
         }
 
         package_json.path = path;
@@ -124,9 +109,9 @@ impl PackageJson {
     }
 
     fn get_value_by_path<'a>(
-        fields: &'a serde_json::Map<String, serde_json::Value>,
+        fields: &'a serde_json::Map<String, JSONValue>,
         path: &[String],
-    ) -> Option<&'a serde_json::Value> {
+    ) -> Option<&'a JSONValue> {
         if path.is_empty() {
             return None;
         }
@@ -153,7 +138,7 @@ impl PackageJson {
     /// They are: `description`, `keywords`, `scripts`,
     /// `dependencies` and `devDependencies`, `peerDependencies`, `optionalDependencies`.
     #[cfg(feature = "package_json_raw_json_api")]
-    pub fn raw_json(&self) -> &std::sync::Arc<serde_json::Value> {
+    pub fn raw_json(&self) -> &std::sync::Arc<JSONValue> {
         &self.raw_json
     }
 
@@ -184,6 +169,20 @@ impl PackageJson {
             .filter_map(|value| value.as_str())
     }
 
+    /// The "exports" field allows defining the entry points of a package when imported by name loaded either via a node_modules lookup or a self-reference to its own name.
+    ///
+    /// <https://nodejs.org/api/packages.html#exports>
+    pub(crate) fn exports_fields<'a>(
+        &'a self,
+        exports_fields: &'a [Vec<String>],
+    ) -> impl Iterator<Item = &'a JSONValue> + '_ {
+        exports_fields.iter().filter_map(|object_path| {
+            self.raw_json
+                .as_object()
+                .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
+        })
+    }
+
     /// Resolve the request string for this package.json by looking at the `browser` field.
     ///
     /// # Errors
@@ -210,13 +209,10 @@ impl PackageJson {
         Ok(None)
     }
 
-    fn alias_value<'a>(
-        key: &Path,
-        value: &'a serde_json::Value,
-    ) -> Result<Option<&'a str>, ResolveError> {
+    fn alias_value<'a>(key: &Path, value: &'a JSONValue) -> Result<Option<&'a str>, ResolveError> {
         match value {
-            serde_json::Value::String(value) => Ok(Some(value.as_str())),
-            serde_json::Value::Bool(b) if !b => Err(ResolveError::Ignored(key.to_path_buf())),
+            JSONValue::String(value) => Ok(Some(value.as_str())),
+            JSONValue::Bool(b) if !b => Err(ResolveError::Ignored(key.to_path_buf())),
             _ => Ok(None),
         }
     }


Test patch:
diff --git a/src/tests/exports_field.rs b/src/tests/exports_field.rs
index 7e4d081..3fa2fb7 100644
--- a/src/tests/exports_field.rs
+++ b/src/tests/exports_field.rs
@@ -2,12 +2,12 @@
 //!
 //! The huge exports field test cases are at the bottom of this file.
 
-use crate::{Ctx, ImportExportField, PathUtil, ResolveError, ResolveOptions, Resolver};
+use crate::{Ctx, PathUtil, ResolveError, ResolveOptions, Resolver};
 use serde_json::json;
 use std::path::Path;
 
 #[test]
-fn test() {
+fn test_simple() {
     let f = super::fixture().join("exports-field");
     let f2 = super::fixture().join("exports-field2");
     let f4 = super::fixture().join("exports-field-error");
@@ -64,8 +64,8 @@ fn test() {
         ("resolver should respect query parameters #2. Direct matching", f2.clone(), "exports-field?foo", ResolveError::PackagePathNotExported("./?foo".into(), p2.clone())),
         ("resolver should respect fragment parameters #2. Direct matching", f2, "exports-field#foo", ResolveError::PackagePathNotExported("./#foo".into(), p2.clone())),
         ("relative path should not work with exports field", f.clone(), "./node_modules/exports-field/dist/main.js", ResolveError::NotFound("./node_modules/exports-field/dist/main.js".into())),
-        ("backtracking should not work for request", f.clone(), "exports-field/dist/../../../a.js", ResolveError::InvalidPackageTarget("./lib/../../../a.js".to_string(), "/dist/".to_string(), p.clone())),
-        ("backtracking should not work for exports field target", f.clone(), "exports-field/dist/a.js", ResolveError::InvalidPackageTarget("./../../a.js".to_string(), "/dist/a.js".to_string(), p.clone())),
+        ("backtracking should not work for request", f.clone(), "exports-field/dist/../../../a.js", ResolveError::InvalidPackageTarget("./lib/../../../a.js".to_string(), "./dist/".to_string(), p.clone())),
+        ("backtracking should not work for exports field target", f.clone(), "exports-field/dist/a.js", ResolveError::InvalidPackageTarget("./../../a.js".to_string(), "./dist/a.js".to_string(), p.clone())),
         ("not exported error", f.clone(), "exports-field/anything/else", ResolveError::PackagePathNotExported("./anything/else".to_string(), p.clone())),
         ("request ending with slash #1", f.clone(), "exports-field/", ResolveError::PackagePathNotExported("./".to_string(), p.clone())),
         ("request ending with slash #2", f.clone(), "exports-field/dist/", ResolveError::PackagePathNotExported("./dist/".to_string(), p.clone())),
@@ -182,6 +182,27 @@ fn field_name_path() {
     assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/src/index.js")));
 }
 
+#[test]
+fn shared_resolvers() {
+    let f3 = super::fixture().join("exports-field3");
+
+    let resolver1 = Resolver::new(ResolveOptions {
+        exports_fields: vec![vec!["exportsField".into(), "exports".into()]],
+        extensions: vec![".js".into()],
+        ..ResolveOptions::default()
+    });
+    let resolved_path = resolver1.resolve(&f3, "exports-field").map(|r| r.full_path());
+    assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/main.js")));
+
+    let resolver2 = resolver1.clone_with_options(ResolveOptions {
+        exports_fields: vec![vec!["ex".into()]],
+        extensions: vec![".js".into()],
+        ..ResolveOptions::default()
+    });
+    let resolved_path = resolver2.resolve(&f3, "exports-field").map(|r| r.full_path());
+    assert_eq!(resolved_path, Ok(f3.join("node_modules/exports-field/index")));
+}
+
 #[test]
 fn extension_alias_1_2() {
     let f = super::fixture().join("exports-field-and-extension-alias");
@@ -276,15 +297,14 @@ fn extension_alias_throw_error() {
 struct TestCase {
     name: &'static str,
     expect: Option<Vec<&'static str>>,
-    exports_field: ImportExportField,
+    exports_field: serde_json::Value,
     request: &'static str,
     condition_names: Vec<&'static str>,
 }
 
 #[allow(clippy::needless_pass_by_value)]
-fn exports_field(value: serde_json::Value) -> ImportExportField {
-    let s = serde_json::to_string(&value).unwrap();
-    serde_json::from_str(&s).unwrap()
+fn exports_field(value: serde_json::Value) -> serde_json::Value {
+    value
 }
 
 #[test]
@@ -2494,7 +2514,7 @@ fn test_cases() {
         let resolved = Resolver::default()
             .package_exports_resolve(
                 Path::new(""),
-                case.request.trim_start_matches('.'),
+                case.request,
                 &case.exports_field,
                 &case.condition_names.iter().map(ToString::to_string).collect::<Vec<_>>(),
                 &mut Ctx::default(),
diff --git a/src/tests/imports_field.rs b/src/tests/imports_field.rs
index 99cb1b0..4fb1e25 100644
--- a/src/tests/imports_field.rs
+++ b/src/tests/imports_field.rs
@@ -8,7 +8,7 @@ use crate::{Ctx, ImportExportMap, PathUtil, ResolveError, ResolveOptions, Resolv
 use std::path::Path;
 
 #[test]
-fn test() {
+fn test_simple() {
     let f = super::fixture().join("imports-field");
     let f2 = super::fixture().join("imports-exports-wildcard/node_modules/m/");