Skip to content

extractEsmShSubpath always drops the subpath for scoped esm.sh packages #4098

Description

@kojiwakayama

What

extractEsmShSubpath in src/transforms/import-rewriter/strategies/import-map-strategy.ts returns "" for every scoped package.

if (pathname.startsWith("@")) {
  const parts = pathname.split("/");
  if (parts.length <= 2) return "";

  const packageParts = parts.slice(0, 2).join("/");
  const afterPackage = pathname.slice(packageParts.length);
  const versionMatch = afterPackage.match(/^@[^/]+(.*)$/);

  return versionMatch?.[1] ?? "";
}

Trace https://esm.sh/@scope/pkg@1.0/sub:

step value
pathname @scope/pkg@1.0/sub
parts ["@scope", "pkg@1.0", "sub"]
packageParts @scope/pkg@1.0already contains the version
afterPackage /sub
versionMatch null, because /sub does not start with @
return ""

The regex expects the version to still be ahead of it, but parts.slice(0, 2) consumed it. The unscoped branch does not have this problem, which is why unscoped subpaths work.

Impact

In resolveImportWithMap, an empty subpath skips the esmShPackage + subpath lookup:

const subpath = extractEsmShSubpath(specifier);
if (subpath) {
  const fullKey = esmShPackage + subpath;
  ...
}

So for a scoped package, an import map entry keyed on @scope/pkg/sub never matches, and the specifier resolves to the package root instead — or to null when only a package-plus-subpath key exists. Silent wrong resolution rather than an error.

Suggested fix

Split the version off before computing the remainder, so scoped and unscoped paths share one shape. Something like matching ^(@[^/]+/[^/@]+)(?:@[^/]+)?(.*)$ and returning group 2 covers both, but the exact parse should be chosen by someone who owns esm.sh specifier handling.

Provenance

Found during a test-quality audit of src/transforms (PR #4097). That PR adds three cases covering the unscoped subpath tail, which pass. The scoped case was deliberately omitted — the only assertion that passes today is one pinning the dropped subpath as expected, which is the behavior this audit exists to remove.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions