Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(node-resolve): mark module as external if resolved module is external #799

Merged
merged 6 commits into from Apr 22, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
340 changes: 180 additions & 160 deletions packages/node-resolve/src/index.js
Expand Up @@ -54,6 +54,7 @@ export function nodeResolve(opts = {}) {
const rootDir = options.rootDir || process.cwd();
let { dedupe } = options;
let rollupOptions;
let inNestedResolve = false;

if (typeof dedupe !== 'function') {
dedupe = (importee) =>
Expand All @@ -71,201 +72,220 @@ export function nodeResolve(opts = {}) {
const browserMapCache = new Map();
let preserveSymlinks;

return {
name: 'node-resolve',
const doResolveId = async (context, importee, importer, opts) => {
// strip query params from import
const [importPath, params] = importee.split('?');
const importSuffix = `${params ? `?${params}` : ''}`;
importee = importPath;

buildStart(options) {
rollupOptions = options;
const baseDir = !importer || dedupe(importee) ? rootDir : dirname(importer);

for (const warning of warnings) {
this.warn(warning);
// https://github.com/defunctzombie/package-browser-field-spec
const browser = browserMapCache.get(importer);
if (useBrowserOverrides && browser) {
const resolvedImportee = resolve(baseDir, importee);
if (browser[importee] === false || browser[resolvedImportee] === false) {
return { id: ES6_BROWSER_EMPTY };
}
const browserImportee =
browser[importee] ||
browser[resolvedImportee] ||
browser[`${resolvedImportee}.js`] ||
browser[`${resolvedImportee}.json`];
if (browserImportee) {
importee = browserImportee;
}
}

({ preserveSymlinks } = options);
},

generateBundle() {
readCachedFile.clear();
isFileCached.clear();
isDirCached.clear();
},
const parts = importee.split(/[/\\]/);
let id = parts.shift();
let isRelativeImport = false;

if (id[0] === '@' && parts.length > 0) {
// scoped packages
id += `/${parts.shift()}`;
} else if (id[0] === '.') {
// an import relative to the parent dir of the importer
id = resolve(baseDir, importee);
isRelativeImport = true;
}

async resolveId(importee, importer, opts) {
if (importee === ES6_BROWSER_EMPTY) {
return importee;
if (
!isRelativeImport &&
resolveOnly.length &&
!resolveOnly.some((pattern) => pattern.test(id))
) {
if (normalizeInput(rollupOptions.input).includes(importee)) {
return null;
}
// ignore IDs with null character, these belong to other plugins
if (/\0/.test(importee)) return null;
return false;
}

if (/\0/.test(importer)) {
importer = undefined;
}
const importSpecifierList = [];

// strip query params from import
const [importPath, params] = importee.split('?');
const importSuffix = `${params ? `?${params}` : ''}`;
importee = importPath;
if (importer === undefined && !importee[0].match(/^\.?\.?\//)) {
// For module graph roots (i.e. when importer is undefined), we
// need to handle 'path fragments` like `foo/bar` that are commonly
// found in rollup config files. If importee doesn't look like a
// relative or absolute path, we make it relative and attempt to
// resolve it. If we don't find anything, we try resolving it as we
// got it.
importSpecifierList.push(`./${importee}`);
}

const baseDir = !importer || dedupe(importee) ? rootDir : dirname(importer);
const importeeIsBuiltin = builtins.has(importee);

if (importeeIsBuiltin) {
// The `resolve` library will not resolve packages with the same
// name as a node built-in module. If we're resolving something
// that's a builtin, and we don't prefer to find built-ins, we
// first try to look up a local module with that name. If we don't
// find anything, we resolve the builtin which just returns back
// the built-in's name.
importSpecifierList.push(`${importee}/`);
}

// https://github.com/defunctzombie/package-browser-field-spec
const browser = browserMapCache.get(importer);
if (useBrowserOverrides && browser) {
const resolvedImportee = resolve(baseDir, importee);
if (browser[importee] === false || browser[resolvedImportee] === false) {
return ES6_BROWSER_EMPTY;
// TypeScript files may import '.js' to refer to either '.ts' or '.tsx'
if (importer && importee.endsWith('.js')) {
for (const ext of ['.ts', '.tsx']) {
if (importer.endsWith(ext) && extensions.includes(ext)) {
importSpecifierList.push(importee.replace(/.js$/, ext));
}
const browserImportee =
browser[importee] ||
browser[resolvedImportee] ||
browser[`${resolvedImportee}.js`] ||
browser[`${resolvedImportee}.json`];
if (browserImportee) {
importee = browserImportee;
}
}

importSpecifierList.push(importee);

const warn = (...args) => context.warn(...args);
const isRequire =
opts && opts.custom && opts.custom['node-resolve'] && opts.custom['node-resolve'].isRequire;
const exportConditions = isRequire ? conditionsCjs : conditionsEsm;

const resolvedWithoutBuiltins = await resolveImportSpecifiers({
importer,
importSpecifierList,
exportConditions,
warn,
packageInfoCache,
extensions,
mainFields,
preserveSymlinks,
useBrowserOverrides,
baseDir,
moduleDirectories
});

const resolved =
importeeIsBuiltin && preferBuiltins
? {
packageInfo: undefined,
hasModuleSideEffects: () => null,
hasPackageEntry: true,
packageBrowserField: false
}
: resolvedWithoutBuiltins;
if (!resolved) {
return null;
}

const { packageInfo, hasModuleSideEffects, hasPackageEntry, packageBrowserField } = resolved;
let { location } = resolved;
if (packageBrowserField) {
if (Object.prototype.hasOwnProperty.call(packageBrowserField, location)) {
if (!packageBrowserField[location]) {
browserMapCache.set(location, packageBrowserField);
return { id: ES6_BROWSER_EMPTY };
}
location = packageBrowserField[location];
}
browserMapCache.set(location, packageBrowserField);
}

const parts = importee.split(/[/\\]/);
let id = parts.shift();
let isRelativeImport = false;

if (id[0] === '@' && parts.length > 0) {
// scoped packages
id += `/${parts.shift()}`;
} else if (id[0] === '.') {
// an import relative to the parent dir of the importer
id = resolve(baseDir, importee);
isRelativeImport = true;
if (hasPackageEntry && !preserveSymlinks) {
const fileExists = await exists(location);
if (fileExists) {
location = await realpath(location);
}
}

idToPackageInfo.set(location, packageInfo);

if (
!isRelativeImport &&
resolveOnly.length &&
!resolveOnly.some((pattern) => pattern.test(id))
) {
if (normalizeInput(rollupOptions.input).includes(importee)) {
return null;
if (hasPackageEntry) {
if (importeeIsBuiltin && preferBuiltins) {
if (!isPreferBuiltinsSet && resolvedWithoutBuiltins && resolved !== importee) {
context.warn(
`preferring built-in module '${importee}' over local alternative at '${resolvedWithoutBuiltins.location}', pass 'preferBuiltins: false' to disable this behavior or 'preferBuiltins: true' to disable this warning`
);
}
return false;
} else if (jail && location.indexOf(normalize(jail.trim(sep))) !== 0) {
return null;
}
}

const importSpecifierList = [];

if (importer === undefined && !importee[0].match(/^\.?\.?\//)) {
// For module graph roots (i.e. when importer is undefined), we
// need to handle 'path fragments` like `foo/bar` that are commonly
// found in rollup config files. If importee doesn't look like a
// relative or absolute path, we make it relative and attempt to
// resolve it. If we don't find anything, we try resolving it as we
// got it.
importSpecifierList.push(`./${importee}`);
if (options.modulesOnly && (await exists(location))) {
const code = await readFile(location, 'utf-8');
if (isModule(code)) {
return {
id: `${location}${importSuffix}`,
moduleSideEffects: hasModuleSideEffects(location)
};
}
return null;
}
const result = {
id: `${location}${importSuffix}`,
moduleSideEffects: hasModuleSideEffects(location)
};
return result;
};

const importeeIsBuiltin = builtins.has(importee);
return {
name: 'node-resolve',

if (importeeIsBuiltin) {
// The `resolve` library will not resolve packages with the same
// name as a node built-in module. If we're resolving something
// that's a builtin, and we don't prefer to find built-ins, we
// first try to look up a local module with that name. If we don't
// find anything, we resolve the builtin which just returns back
// the built-in's name.
importSpecifierList.push(`${importee}/`);
}
buildStart(options) {
rollupOptions = options;

// TypeScript files may import '.js' to refer to either '.ts' or '.tsx'
if (importer && importee.endsWith('.js')) {
for (const ext of ['.ts', '.tsx']) {
if (importer.endsWith(ext) && extensions.includes(ext)) {
importSpecifierList.push(importee.replace(/.js$/, ext));
}
}
for (const warning of warnings) {
this.warn(warning);
}

importSpecifierList.push(importee);

const warn = (...args) => this.warn(...args);
const isRequire =
opts && opts.custom && opts.custom['node-resolve'] && opts.custom['node-resolve'].isRequire;
const exportConditions = isRequire ? conditionsCjs : conditionsEsm;

const resolvedWithoutBuiltins = await resolveImportSpecifiers({
importer,
importSpecifierList,
exportConditions,
warn,
packageInfoCache,
extensions,
mainFields,
preserveSymlinks,
useBrowserOverrides,
baseDir,
moduleDirectories
});

const resolved =
importeeIsBuiltin && preferBuiltins
? {
packageInfo: undefined,
hasModuleSideEffects: () => null,
hasPackageEntry: true,
packageBrowserField: false
}
: resolvedWithoutBuiltins;
if (!resolved) {
return null;
}
({ preserveSymlinks } = options);
},

const { packageInfo, hasModuleSideEffects, hasPackageEntry, packageBrowserField } = resolved;
let { location } = resolved;
if (packageBrowserField) {
if (Object.prototype.hasOwnProperty.call(packageBrowserField, location)) {
if (!packageBrowserField[location]) {
browserMapCache.set(location, packageBrowserField);
return ES6_BROWSER_EMPTY;
}
location = packageBrowserField[location];
}
browserMapCache.set(location, packageBrowserField);
}
generateBundle() {
readCachedFile.clear();
isFileCached.clear();
isDirCached.clear();
},

if (hasPackageEntry && !preserveSymlinks) {
const fileExists = await exists(location);
if (fileExists) {
location = await realpath(location);
}
async resolveId(importee, importer, opts) {
if (importee === ES6_BROWSER_EMPTY) {
return importee;
}
// ignore IDs with null character, these belong to other plugins
if (/\0/.test(importee)) return null;

idToPackageInfo.set(location, packageInfo);
if (/\0/.test(importer)) {
importer = undefined;
}

if (hasPackageEntry) {
if (importeeIsBuiltin && preferBuiltins) {
if (!isPreferBuiltinsSet && resolvedWithoutBuiltins && resolved !== importee) {
this.warn(
`preferring built-in module '${importee}' over local alternative at '${resolvedWithoutBuiltins.location}', pass 'preferBuiltins: false' to disable this behavior or 'preferBuiltins: true' to disable this warning`
);
const resolved = await doResolveId(this, importee, importer, opts);
if (resolved && !inNestedResolve) {
// if a plugin invoked by `this.resolve` also does `this.resolve`
// we need to break the loop
inNestedResolve = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think having a global flag here is a good idea. As resolving is inherently asynchronous, concurrent resolves can mess up the value of this flag. I fear this problem needs to be solved in Rollup core by correctly tracking uses of the skipSelf flag: If a plugin uses this.resolve with the skipSelf flag, then Rollup needs to remember this when calling resolveId of other plugins so that when another plugin subsequently calls this.resolve for THE SAME ID (that is important), the original plugin is still skipped (and this should be remembered transitively). I believe this is the issue you are trying to solve?
I thought about this myself some time ago but did not implement anything yet, maybe I should.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yep. I was thinking it would only be called once at a time on a single instance, but wasn't thinking about multiple ids in parallel.

I believe this is the issue you are trying to solve?

I think that would work for this case, although the difference with what you proposed is that the node-resolve plugin would be skipped completely when this.resolve invoked from commonjs meaning later plugins might get invoked that wouldn't usually, vs now where it would still stop when node-resolve returns something (although what it returns now is not really the correct thing, given it could map to an external).

Not sure where the best place to break the chain is, and also when breaking the chain if there is a sensible value that could be returned or if we should actually signal an infinite loop back to the thing that called this.resolve.

Confusing problem to think about :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. But to my understanding, this change is not essential to the functionality you are implementing, right? So you could remove it from here without any regressions and one could always add something later, be it here or in Rollup core.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite understanding. The change here to do the nested this.resolve is needed (unless there is another solution I'm missing) to fix #609

I don't mind if the fix is entirely here (with a tweak to keep a inNestedResolve flag against the id) or partly here (to do the this.resolve) but with a core change to prevent the loops.

Also this isn't a blocker for me btw. I just saw the issue and decided to attempt to fix it, so not sure it's a particularly urgent problem to solve.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, should have read the entire thread. The problem is that this is an issue in core that affects all plugins. But to fix it just for node-resolve, you could maintain a data structure that contains the source/importer of what we are just resolving to remember to skip exactly this call when resolving. From Rollup side, each combination will be resolved exactly once. But you could clean up the data structure once a resolution is done to account for plugins resolving the same modules at a later stage.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with removing that extra test and simplifying the plugin to use the core change. The edge case is so many layers deep to think about it does seem unlikely, and there's still the way of opting out if needed in the future like you mentioned in the docs.

It's bugging me though I still can't actually understand why the extra loop round is fixing the test with commonjs 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the question is more why it was broken in the first place without the second round. The reason is that the CommonJS plugin was doing this:

this.resolve(importee, importer, {
      skipSelf: true,
      custom: { 'node-resolve': { isRequire: isProxyModule || isRequiredModule } }
    })

passing special options to the node-resolve plugin to change resolution, and this is the call that was skipped. Admittedly we could also try to hash the custom options object in the loop prevention, but this is dangerous as we cannot go for object identity as it is likely recreated for each call, and there is no requirement to have it serialisable via JSON stringify.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do a JSON.stringify in a try-catch as a "best effort" approach.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But now I have written this nice description and I am feeling the actual logic is getting more and more complex. Maybe we start with what we have now and see if this causes issues anywhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh thanks that was the missing piece. Agree ideally the custom should also be part of the key, but waiting until that actually causes a problem (if it does) seems reasonable. Will update this PR

try {
const resolvedResolved = await this.resolve(resolved.id, importer, { skipSelf: true });
if (resolvedResolved && resolvedResolved.external) {
return false;
}
return false;
} else if (jail && location.indexOf(normalize(jail.trim(sep))) !== 0) {
return null;
} finally {
inNestedResolve = false;
}
}

if (options.modulesOnly && (await exists(location))) {
const code = await readFile(location, 'utf-8');
if (isModule(code)) {
return {
id: `${location}${importSuffix}`,
moduleSideEffects: hasModuleSideEffects(location)
};
}
return null;
}
const result = {
id: `${location}${importSuffix}`,
moduleSideEffects: hasModuleSideEffects(location)
};
return result;
return resolved;
},

load(importee) {
Expand Down
@@ -0,0 +1 @@
import 'external';

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.