Skip to content

The shared resolver keeps the export conditions of its first caller, so import() can resolve the default branch #810

Description

@fengmk2

Reproduction

// node_modules/cond-pkg/package.json
{ "name": "cond-pkg", "exports": { "import": "./esm.mjs", "default": "./default.cjs" } }
// node_modules/cond-pkg/esm.mjs
export const flavor = 'import';
// node_modules/cond-pkg/default.cjs
exports.flavor = 'default';
// first.ts
export const first: string = 'first.ts';
$ node --import @oxc-node/core/register -e "require('./first.ts'); import('cond-pkg').then((m) => console.log(m.flavor))"
default

$ node --import @oxc-node/core/register -e "import('cond-pkg').then((m) => console.log(m.flavor))"
import

import('cond-pkg') should pick the import condition both times. The same on Node.js 22.18, 24.21, and 26.5 with @oxc-node/core 0.1.5.

Cause

The resolver lives in a process-wide OnceLock, RESOLVER_AND_TSCONFIG, and keeps the condition_names of whichever caller initializes it:

  • OxcTransformer::transform (the pirates path) and the transform task initialize it with no conditions (src/lib.rs#L437, #L492);
  • create_resolve initializes it with the request's context.conditions (#L937) and reuses it for every later request, whatever that request's conditions.

When a require() of a TypeScript file runs first (-e, the REPL, or a CommonJS preload that oxc-node transforms), later imports resolve with no conditions. A package with a default export then resolves to it, as above. Without default, oxc's resolution fails and the hook falls back to Node.js' resolver, which hides the bug but skips tsconfig paths and the format detection.

Suggested fix

Resolve each request with its own conditions, for example by keeping one resolver per condition set, cloned from the base so they share the caches:

static CONDITION_RESOLVERS: Mutex<Vec<(Vec<String>, Arc<Resolver>)>> = Mutex::new(Vec::new());

fn resolver_for_conditions(base: &Resolver, conditions: &[String]) -> Arc<Resolver> {
    let mut resolvers = CONDITION_RESOLVERS.lock().unwrap_or_else(PoisonError::into_inner);
    if let Some((_, resolver)) = resolvers.iter().find(|(names, _)| names == conditions) {
        return Arc::clone(resolver);
    }
    let resolver = Arc::new(base.clone_with_options(ResolveOptions {
        condition_names: conditions.to_vec(),
        ..base.options().clone()
    }));
    resolvers.push((conditions.to_vec(), Arc::clone(&resolver)));
    resolver
}

Vite+ carries this change in its vendored copy.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions