You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[node-resolve]: the external module render path is not expected #1873
Reproduced the StackBlitz scenario for #1873: rollup.config.js (with nodeResolve()) emits import { rollup } from 'rollup' while rollup-without-node-resolve.config.js emits import { rollup } from 'file:///.../node_modules/.../rollup.js'.
Compared rollup.config.js and rollup-without-node-resolve.config.js; both treat 'rollup' as external (per output), isolating the functional difference to the presence of nodeResolve().
Traced the discrepancy to the resolveId contract: core resolution returns an absolute id with { external: true }, while @rollup/plugin-node-resolve preserves the bare specifier by returning { id: 'rollup', external: true }, directly changing the emitted import; see Rollup’s resolveId docs (link).
Validated the behavior by stubbing resolveId to return { id: 'rollup', external: true } vs { id: '/abs/.../rollup.js', external: true }, confirming output[0].code switches between import ... from 'rollup' and an absolute file:///... path.
Noted alignment with Rollup’s renderExternal semantics—emitted specifiers mirror the external id; customizing the path is supported via output.renderExternal (docs).
What you’re seeing comes from node-resolve’s resolveId flow. When another plugin (or your external option) marks a specifier as external, node-resolve intentionally returns false so Rollup treats it as external without changing the specifier. That preserves the original text (e.g. 'rollup') instead of substituting an absolute path. See the “Handle plugins that manually make the result external” branch in packages/node-resolve/src/index.js.
Without node-resolve in the pipeline, Rollup core may use the fully resolved absolute file URL for that external in your setup, which explains the difference between the two runs.
If you want externals rendered as absolute paths, the recommended way is to control it via output.paths:
// rollup.config.jsimport{pathToFileURL}from'node:url';import{createRequire}from'node:module';constrequire=createRequire(import.meta.url);exportdefault{// ...external: ['rollup'],output: {format: 'esm',paths: (id)=>{if(id==='rollup'){// Map to the absolute file URL you want in outputreturnpathToFileURL(require.resolve('rollup/dist/es/rollup.js')).href;}returnid;}},plugins: [nodeResolve()]};
That approach keeps node-resolve’s behavior intact (don’t rewrite external specifiers) while giving you full control over how externals print in the generated code.
A couple of questions to make sure we’re covering your exact case:
Which versions are you using? (Rollup, @rollup/plugin-node-resolve, Node)
How are you marking modules external in the config (array, regex, function)? A short snippet would help.
If there’s a strong use case for node-resolve to preserve the resolved absolute id when something is external, we could discuss adding an opt-in (e.g., a flag to return the resolved id with external: true instead of false). That would be a behavior change, so we’d gate it behind an option if we go that route.
Activity
charliecreates commentedon Oct 20, 2025
Expand this to see my work.
rollup.config.js(withnodeResolve()) emitsimport { rollup } from 'rollup'whilerollup-without-node-resolve.config.jsemitsimport { rollup } from 'file:///.../node_modules/.../rollup.js'.rollup.config.jsandrollup-without-node-resolve.config.js; both treat'rollup'as external (per output), isolating the functional difference to the presence ofnodeResolve().resolveIdcontract: core resolution returns an absoluteidwith{ external: true }, while@rollup/plugin-node-resolvepreserves the bare specifier by returning{ id: 'rollup', external: true }, directly changing the emitted import; see Rollup’sresolveIddocs (link).resolveIdto return{ id: 'rollup', external: true }vs{ id: '/abs/.../rollup.js', external: true }, confirmingoutput[0].codeswitches betweenimport ... from 'rollup'and an absolutefile:///...path.renderExternalsemantics—emitted specifiers mirror the externalid; customizing the path is supported viaoutput.renderExternal(docs).charliecreates commentedon Oct 20, 2025
Thanks for the clear repro and pointers.
What you’re seeing comes from node-resolve’s resolveId flow. When another plugin (or your external option) marks a specifier as external, node-resolve intentionally returns false so Rollup treats it as external without changing the specifier. That preserves the original text (e.g. 'rollup') instead of substituting an absolute path. See the “Handle plugins that manually make the result external” branch in packages/node-resolve/src/index.js.
Without node-resolve in the pipeline, Rollup core may use the fully resolved absolute file URL for that external in your setup, which explains the difference between the two runs.
If you want externals rendered as absolute paths, the recommended way is to control it via output.paths:
That approach keeps node-resolve’s behavior intact (don’t rewrite external specifiers) while giving you full control over how externals print in the generated code.
A couple of questions to make sure we’re covering your exact case:
If there’s a strong use case for node-resolve to preserve the resolved absolute id when something is external, we could discuss adding an opt-in (e.g., a flag to return the resolved id with external: true instead of false). That would be a behavior change, so we’d gate it behind an option if we go that route.