metro icon indicating copy to clipboard operation
metro copied to clipboard

Ignoring packages during dependency resolution

Open higleyc opened this issue 5 years ago • 11 comments

Do you want to request a feature or report a bug? Question/feature

What is the current behavior?

We have a dependency which is built as a C library. In order to use and test this with our RN app, we have one package with our node bindings (node-dep) and another with our RN bindings (rn-dep). So our code looks like:

let dep = null
if (typeof navigator === 'undefined' || navigator.product !== 'ReactNative') {
    dep = require("node-dep")
} else {
    dep = require("rn-dep")
}

We blacklistRE this dependency because if it is discovered, you see this package itself specifies a main module field that could not be resolved, presumably because it is not a valid RN package.

The node version of the package is not needed when packaging for RN, but metro discovers the conditional code requiring it and complains that it can't be found: error: bundling failed: Error: Unable to resolve module `node-dep` from `/home/omit/node_modules/other_dependency/file.js`: Module `node-dep` does not exist in the Haste module map or in these directories: /home/omit/node_modules

So either we blacklistRE the node package to exclude it, and it fails to be found (though not needed), or we do not blacklistRE and it fails to package because it's not an RN package.

What is the expected behavior?

Is there a way to tell metro to simply ignore any require("node-dep") and just have the app crash at runtime if it were encountered? I'd like to be able to blacklist the package directory and just have metro skip it over. dynamicDepsInPackages does not seem helpful here.

Please provide your exact Metro configuration and mention your Metro, node, yarn/npm version and operating system.

metro.config.js:

const blacklist = require('metro-config/src/defaults/blacklist')
const blacklistRE = blacklist([/.*node-dep.*/])

module.exports = {
  resolver: {
    blacklistRE,
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: false,
      },
    }),
  },
};

metro - 0.51.1 (determined by RN version)

yarn - 1.22.0

node - 10.17.0

OS - Linux

higleyc avatar Feb 19 '20 17:02 higleyc

Hey @higleyc I wanted to do the exact same thing, did you find a solution?

nacho-carnicero avatar Apr 09 '20 11:04 nacho-carnicero

@nacho-carnicero sadly, no. My "solution" was to just obfuscate the require by building the package name from an array - seems to prevent metro from discovering it.

higleyc avatar Apr 09 '20 12:04 higleyc

@higleyc could you possibly provide an example of what you mean? blacklistRE is grossly inadequate for proper blacklisting capabilities - and seems to be ignored in rare cases even when it should result in a perfect match. One Web Assembly file and kaboom...

Nantris avatar Apr 25 '20 19:04 Nantris

@Slapbox sure, like this:

let dep
if (typeof navigator === 'undefined' || navigator.product !== 'ReactNative') {
    let pname = ""
    let x = ["n", "o", "d", "e", "-", "d", "e", "p"]
    for (let i = 0; i < x.length; ++i) {
        pname += x[i];
    }
    dep = require(pname)
} else {
    dep = require('rn-dep')
}

higleyc avatar Apr 27 '20 22:04 higleyc

Clever! Really appreciate the follow-up!

Nantris avatar Apr 28 '20 18:04 Nantris

I ended up finding a solution that works great for my use case and that would work for this one without having to do that funky obfuscation. This solution uses the resolveRequest option of the metro config in order to point to a dumb file for the dependencies that are problematic, and uses the normal resolver for the others. Make sure to add the packages you want to ignore to the blacklistedModules array.

To make it work add this to your metro.config.js file:

/**
 * Metro configuration for React Native
 * https://github.com/facebook/react-native
 *
 * @format
 */

const OriginalResolver = require("metro-resolver");
const path = require("path");

const blacklistedModules = ["https", "http", "zlib"];

module.exports = {
  resolver: {
    resolveRequest: (context, realModuleName, platform, moduleName) => {
      if (blacklistedModules.includes(moduleName)) {
        return {
          filePath: path.resolve(__dirname + "/src/shim-module.js"),
          type: "sourceFile"
        };
      } else {
        return OriginalResolver.resolve(
          { ...context, resolveRequest: undefined },
          moduleName,
          platform
        );
      }
    }
  }
};

In my case /src/shim-module.js is a file with this content:

/**
 * File that shims a module. See the file metro.config.js at the root
 */
module.exports = {};

Hope this helps!

nacho-carnicero avatar Jan 28 '21 10:01 nacho-carnicero

Thanks @nacho-carnicero, I had to make a small change to your code for it work with RN 0.61, but now it works great! Here is my resolver function in case it can help someone else:

const OriginalResolver = require("metro-resolver");
const path = require("path");

const blacklistedModules = ["https", "http", "zlib"];

module.exports = {
  resolver: {
    resolveRequest: (context, moduleName, platform) => {
      if (blacklistedModules.includes(moduleName)) {
        return {
          filePath: path.resolve(__dirname + "/src/shim-module.js"),
          type: "sourceFile"
        };
      } else {
        return OriginalResolver.resolve(
          { ...context, resolveRequest: undefined },
          moduleName,
          platform
        );
      }
    }
  }
};

dafuga avatar Jun 16 '21 06:06 dafuga

It seems that on a Windows machine @nacho-carnicero's workaround is not working - various modules cannot be resolved. More specifically, the issue appears in modules which require files identified by using a parent path like ../Observable.js. Any ideas?

georgitodorov avatar Feb 15 '22 09:02 georgitodorov

@georgitodorov - I'm not sure what the issue you're seeing might be, but I figured I'd share. We use code like below. I hope it might help.


const exclusionList = require('metro-config/src/defaults/exclusionList');

const regexStrings = [
  '.*[\\\\]android[\\\\]ReactAndroid[\\\\].*',
  '.*[\\\\]versioned-react-native[\\\\].*',
  'node_modules[\\\\]react[\\\\]dist[\\\\].*',
]

const constructBlacklistRE = () => {
  const formedRegexes = regexStrings.map(piece => new RegExp(piece));
  console.log(formedRegexes);
  return exclusionList(formedRegexes);
};

const config = {
  resolver: {
    blacklistRE: constructBlacklistRE(),
  }
}

Nantris avatar Feb 15 '22 19:02 Nantris

This issue remains relevant, because despite the config solution working as expected, it's not the best solution for libraries, because then these libraries have to inform users how to correctly configure their metro config.

The array concat method remains the only one available for libraries. Webpack has magic comments, perhaps this could be useful for Metro too. Something like a /** metroExclude */ or /** metroIgnore */ inside the require.

(I also noticed that a simpler method works: require(['node-', 'dep'].join('')))

staltz avatar Jul 11 '23 12:07 staltz

I ended up finding a solution that works great for my use case and that would work for this one without having to do that funky obfuscation. This solution uses the resolveRequest option of the metro config in order to point to a dumb file for the dependencies that are problematic, and uses the normal resolver for the others. Make sure to add the packages you want to ignore to the blacklistedModules array.

To make it work add this to your metro.config.js file:

/**
 * Metro configuration for React Native
 * https://github.com/facebook/react-native
 *
 * @format
 */

const OriginalResolver = require("metro-resolver");
const path = require("path");

const blacklistedModules = ["https", "http", "zlib"];

module.exports = {
  resolver: {
    resolveRequest: (context, realModuleName, platform, moduleName) => {
      if (blacklistedModules.includes(moduleName)) {
        return {
          filePath: path.resolve(__dirname + "/src/shim-module.js"),
          type: "sourceFile"
        };
      } else {
        return OriginalResolver.resolve(
          { ...context, resolveRequest: undefined },
          moduleName,
          platform
        );
      }
    }
  }
};

In my case /src/shim-module.js is a file with this content:

/**
 * File that shims a module. See the file metro.config.js at the root
 */
module.exports = {};

Hope this helps!

I did more or less the exact same thing in an attempt to remove a package with native dependencies conflicting with expo-dev-laucnher only on android.

const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

const customResolver = (context, moduleName, platform) => {
  // Logic to resolve the module name to a file path...

  // Toggle to test on ios
  const isAndroid = platform !== 'ios';

  if (moduleName.includes('theModuleNameToReplaces') && isAndroid) {
    const newModulePath = path.resolve(
      __dirname,
      'custom-metro-config-module-replacements/@someModule/the-module-name/index.js',
    );

    console.log({ newModulePath });

    return {
      filePath: newModulePath,
      type: 'sourceFile',
    };
  } else {
    // Optionally, chain to the standard Metro resolver.
    return context.resolveRequest(context, moduleName, platform);
  }
};

const isLocalDevelopment = process.env.APP_ENV === 'pr';

// only apply custom module resolver in local android devleopment
const configModuleResolver = isLocalDevelopment ? customResolver : null;

const config = async () => {
  const {
    resolver: { sourceExts, assetExts },
  } = await getDefaultConfig(__dirname);

  return {
    resolver: {
      assetExts: assetExts.filter((ext) => ext !== 'svg'),
      sourceExts: [...sourceExts, 'svg', 'tsx', 'js', 'ts', 'jsx', 'cjs'],
      resolveRequest: configModuleResolver,
    },
    transformer: {
      assetPlugins: ['expo-asset/tools/hashAssetFiles'],
      babelTransformerPath: require.resolve('react-native-svg-transformer'),
      minifierConfig: {
        keep_classnames: true, // Preserve class names
        keep_fnames: true, // Preserve function names
        mangle: {
          keep_classnames: true, // Preserve class names
          keep_fnames: true, // Preserve function names
        },
      },
    },
  };
};

const defaultConfig = config();

module.exports = defaultConfig;

Basically io.insert-koin:koin-core:3.2.0 is installed in the package I need in production but it conflicts with expo-dev-launcher as it requires io.insert-koin:koin-core:3.1.2..

I was able to confirm on iOS at runtime that the mocked module import path was being replaced, but still found the native dependencies were installed at build time and remain to be a problem on android startup as a dev client build 😞

Anyone got any ideas on how to dynamically prevent a package from being installed into node_modules? (without needing to manually do it every time). Is this possible via metro, maybe to delete the module after install instead of replacing the import path?

I need the dependency to remain in our package.json for UAT and Production builds.

thx for any help or guidance...