Skip to content

fix: preserve subpath when resolving npm: specifiers - #98

Open
bartlomieju wants to merge 15 commits into
mainfrom
fix/npm-subpath-resolution
Open

bartlomieju wants to merge 15 commits into
mainfrom
fix/npm-subpath-resolution

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Mar 30, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Fix npm subpath resolution: npm:preact@^10.22.0/jsx-runtime now correctly resolves to preact/jsx-runtime instead of just preact. The parser in resolveDeno() was dropping everything after the version.
  • Fix workspace member import map resolution: Use loader.resolveSync with the importer URL before falling back to import.meta.resolve. This lets bare specifiers resolve through workspace member deno.json import maps, not just the root one.
  • Fix dev mode /@id/ empty responses: Add configureServer middleware that pre-warms the client environment's module graph for /@id/ requests. Vite 7+ per-environment module graphs cause virtual modules discovered during SSR (e.g. island components) to be missing from the client graph.
  • Add exclude option: Configurable list of string prefixes or RegExp patterns for module IDs that should be skipped by the plugin's resolveId hooks. This lets other plugins (e.g. Fresh) handle their own virtual modules (fresh-island::, fresh:) without the Deno loader erroring on unsupported schemes.

exclude usage

deno({ exclude: ["fresh-island", "fresh:"] })
deno({ exclude: /^fresh-island::/ })

Test plan

  • All existing tests pass (13/13)
  • New resolves npm: subpath test — imports useState from npm:preact@^10.24.0/hooks
  • Verify with JSR modules using @jsxImportSource npm:preact@^10/jsx-runtime
  • Verify with Deno workspace where import maps are in member deno.json
  • Verify islands hydrate in dev mode (browser requests to /@id/ return content)
  • Verify exclude skips virtual module IDs from Fresh

🤖 Generated with Claude Code

bartlomieju and others added 15 commits March 30, 2026 20:23
The npm specifier parser in resolveDeno() was extracting only the package
name and dropping any subpath. For example, `npm:preact@^10.22.0/jsx-runtime`
resolved to `preact` instead of `preact/jsx-runtime`, causing Rollup to
import from the wrong entry point.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When resolveViteSpecifier has an importer, use loader.resolveSync with
the importer URL first. This allows workspace member import maps to be
consulted correctly. Previously, only import.meta.resolve was used for
bare specifiers, which only sees the root deno.json import map and
silently fails for mappings defined in workspace member deno.json files.

The import.meta.resolve fallback is kept for cases without an importer
or when the loader cannot resolve the specifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vite 7+ per-environment module graphs cause /@id/ URLs to return empty
responses when a module was only resolved in the SSR environment. This
happens because Vite's /@id/ handler looks up the client module graph
for browser requests, but virtual modules discovered during SSR (e.g.
island components) are only in the SSR graph.

Add a configureServer middleware that calls transformRequest on the
client environment before Vite's built-in handler, ensuring the module
is resolved and loaded in the client graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a configurable `exclude` option that accepts string prefixes,
RegExp patterns, or an array of either. Matching IDs are skipped by
both resolveId hooks, letting other plugins (e.g. Fresh) handle their
own virtual modules like fresh-island:: and fresh: without the Deno
loader erroring on unsupported schemes.

Also removes debug console.log statements from configureServer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous pre-middleware approach had a race condition: connect
doesn't support async middleware natively, so next() fired before
transformRequest completed, and Vite's built-in handler would serve
an empty response.

Switch to a post-middleware (returned from configureServer) that runs
after Vite's built-in /@id/ handler. When Vite fails to find the
module in the client graph, the request falls through to our handler
which resolves it via transformRequest and writes the response directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When resolveViteSpecifier calls loader.resolveSync with a bare specifier
like "preact/jsx-dev-runtime", the Deno loader may resolve it to the
package main entry (preact/dist/preact.mjs) instead of the correct
subpath export (preact/jsx-dev-runtime/dist/jsxRuntime.mjs).

Skip file:// results that point into node_modules and return null for
npm: results so Vite's native resolver handles them. Vite correctly
reads package.json exports maps for subpath resolution.

This fixes dev mode resolution of npm subpath imports from onLoad
callback output (e.g. JSX transforms producing
"npm:preact@^10/jsx-dev-runtime").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When loader.resolveSync returns a file:// URL for a cached JSR module
(outside node_modules), the previous code returned the plain file path
immediately. This bypassed the deno specifier creation at the end of
resolveViteSpecifier, so the load hook never fired and the onLoad
callback (e.g. Babel JSX transform) was skipped. Vite's built-in
esbuild transform would then handle the JSX, resolving
preact/jsx-dev-runtime to the wrong entry.

Now non-node_modules file:// results set id = resolvedUrl and continue
through resolveDeno, which creates a deno specifier with the correct
media type. The load hook fires, onLoad applies the JSX transform, and
the npm: imports in the transformed code go through the prefix plugin
for correct subpath resolution.

Also removes leftover debug logging from prefixPlugin.

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

When the onLoad callback returns already-transformed code (e.g. Babel
JSX output), Vite's esbuild transform would run on it again, adding
duplicate JSX runtime imports that resolve to the wrong entry point.

Adding loader: "js" to the load result tells Vite the code is already
plain JavaScript, so esbuild skips its JSX transformation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the onLoad callback isn't configured or returns null, the load
hook returns the raw transpiled source. If it contains
@jsxImportSource npm:preact@^10.22.0, Vite's esbuild transform picks
it up and resolves preact/jsx-dev-runtime to the wrong entry point.

Strip @jsxRuntime, @jsxImportSource, and @jsxImportSourceTypes pragma
comments before returning, matching the same stripping already done
for onLoad results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
JSR modules cached in node_modules/.deno/ are inside the project root,
so isInsideRoot was true and resolveViteSpecifier returned a plain file
path. The load hook gates on isDenoSpecifier(id), so it never fired —
no onLoad callback, no JSX pragma stripping.

Exclude node_modules paths from the isInsideRoot check so these files
get deno specifiers and go through the load hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the browser requests a deno:: virtual module via /@id/, Vite
calls resolveId again with the deno specifier. The hook was returning
undefined ("I don't handle this"), so Vite never proceeded to the
load step.

Return the ID itself to confirm the module is valid, allowing Vite to
proceed to the load hook where onLoad and pragma stripping happen.

Also removes debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The @deno/loader already transpiles TSX/JSX to plain JavaScript. Without
loader: "js" in the load result, Vite infers the type from the virtual
module's original extension (.tsx/.jsx) and runs esbuild's JSX transform
on already-transpiled code, overriding any JSX transformation done by
the onLoad callback.

Return loader: "js" for both onLoad results and the default code path
so esbuild treats the output as plain JS.

Also removes debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the loader:"js" approach with a dedicated deno:skip-esbuild
transform plugin that runs before esbuild. When it sees a deno::
specifier, it returns { code } early, preventing esbuild from
re-processing already-transpiled JSX based on the .tsx extension.

This is more reliable than loader:"js" since transform hooks with
enforce:"pre" run before Vite's default esbuild transform pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of checking isDenoSpecifier(id) in the transform hook (which
depends on the ID format being preserved between hooks), store IDs
processed by the load hook in a shared Set. The deno:skip-esbuild
transform plugin checks the Set to determine if esbuild should be
skipped, making it resilient to any ID transformations Vite applies
between the load and transform phases.

Also removes debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The enforce:"pre" transform hook doesn't skip esbuild — Vite runs all
transform hooks in sequence, so returning { code } just passes it to
the next transform including esbuild.

Instead, use the config hook to add an esbuild.exclude pattern matching
deno:: virtual module IDs (\x00deno::). This tells Vite's esbuild
plugin to skip these modules entirely, preventing it from re-processing
JSX that @deno/loader or the onLoad callback already transformed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant