facebook / metro

πŸš‡ The JavaScript bundler for React Native
https://metrobundler.dev
MIT License
5.23k stars 625 forks source link

Unable to resolve module when using symlinks. #1204

Open samsnori opened 9 months ago

samsnori commented 9 months ago

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

I've got a question or otherwise a bug report.

What is the current behavior?

I'm trying to import files from a shared library. I've got a git repository in which I store sources that I like to use throughout several applications. This is just a collection of Javascript files. This is not a package (e.g. doesn't have a package.json). This library is stored at <home>/shared-lib. I'm now building a React Native app in <home>/main-app/app and in this main app, I would like to use a file, e.g. <home>/shared-lib/src/components/Button.jsx.

After doing some research it seems that you should be able to get this to work via metro.config.js as mentioned:

I've created the following files and directory hierarchy. Note that there are several symlinks as I've been experimenting to get this to work. For example notice the app/shared-lib, app/shared-lib-nosymlink, but also main-app/shared-lib and main-app/shared-lib-no-symlink. Note the different values for dir_name in the metro.config.js below, which I've used to test different things in the metro.config.js below. Interestingly, when I use the main-app/app/shared-lib-no-symlink version everything works fine. But when I try to use a component from any of the symlink directories, I run into an error like: Unable to resolve module shared-lib/src/components/Button.

<home>
β”œβ”€β”€ main-app
β”‚Β Β  β”œβ”€β”€ app
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ android
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ios
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ node_modules
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ shared-lib -> /home/shared-lib/
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ shared-lib-no-symlink
β”‚Β Β  β”‚Β Β  └── __tests__
β”‚Β Β  β”œβ”€β”€ shared-lib -> /home/shared-lib/
β”‚Β Β  └── shared-lib-no-symlink
β”‚Β Β      └── src
└── shared-lib
    └── src
        └── components  

The contents of the shared-lib-* directories only contains:

.
└── src
    └── components
        └── Button.jsx

I've updated my metro.config.js, from the <home>/main-app/app/ directory in such a way that it should work with symlinks. E.g. <home>/main-app/app/mettro.config.js looks like:


/* file: <home>/main-app/app/metro.config.js */

const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('path');

/* ------------------------------------------------------- */

let dir_name = null;

/* Testing different approaches. */
dir_name = '/../shared-lib-no-symlink'; /* No symlink, outside project (FAILS) */
dir_name = '/../shared-lib';            /* Symlink, outside project */
dir_name = '/shared-lib-no-symlink';    /* No symlink, inside project (WORKS) */
dir_name = '/shared-lib';               /* Symlink, inside project (FAILS) */

let my_shared_lib_path = path.resolve(__dirname +dir_name);

/* ------------------------------------------------------- */

const config = {
  resolver: {
    extraNodeModules: {
      'shared-lib': my_shared_lib_path,
    },
    unstable_enableSymlinks: true /* Should not be necessary as it defaults to true: https://metrobundler.dev/docs/configuration/#unstable_enablesymlinks-experimental */
  },
  watchFolders: [
    my_shared_lib_path,
  ],
  resetCache: true,  /* https://metrobundler.dev/docs/configuration/#resetcache */
};

/* ------------------------------------------------------- */

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

/* ------------------------------------------------------- */

My goal is to include a source file into my project that is stored in another git repository outside the current React Native project. From my understanding, this should be possible using the metro.config.js as posted above. Obviously this doesn't work. What am I doing wrong?

If the current behavior is a bug, please provide the steps to reproduce and a minimal repository on GitHub that we can yarn install and yarn test.

I'm not sure if this is a bug or if I've misunderstood how to do this correctly.

What is the expected behavior?

I would love to be able to use source files that are located somewhere else into my project. These source files do not belong to a package per se; just a repository with sources.

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

$ npx react-native info
info Fetching system and libraries information...
System:
  OS: Linux 6.6 Arch Linux
  CPU: (24) x64 Intel(R) Core(TM) i9-10920X CPU @ 3.50GHz
  Memory: 38.13 GB / 62.49 GB
  Shell:
    version: 5.2.26
    path: /bin/bash
Binaries:
  Node:
    version: 18.12.1
    path: ~/.nvm/versions/node/v18.12.1/bin/node
  Yarn:
    version: 1.22.19
    path: ~/.nvm/versions/node/v18.12.1/bin/yarn
  npm:
    version: 8.19.2
    path: ~/.nvm/versions/node/v18.12.1/bin/npm
  Watchman: Not Found
SDKs:
  Android SDK: Not Found
IDEs:
  Android Studio: AI-231.9392.1.2311.11330709
Languages:
  Java:
    version: 17.0.10
    path: /usr/bin/javac
  Ruby:
    version: 3.0.6
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react:
    installed: 18.2.0
    wanted: 18.2.0
  react-native:
    installed: 0.73.3
    wanted: 0.73.3
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: false
iOS:
  hermesEnabled: Not found
  newArchEnabled: false
samsnori commented 9 months ago

Workaround

For now, I'm creating a mount point which solves this issue.

Great thing about this solution is that you don't have to change any of the configs either.

cd <home>/main-app
cd app
mkdir shared-lib-mounted

sudo mount --bind ../../shared-lib shared-lib-mounted

I'm very curious why symlinks aren't working though. I'm sure it turns out to be something silly

robhogan commented 9 months ago

Hi @samsnori - thanks for the report and particularly for all the detail here.

Could you clarify what import you're using in your JS/TS source, or what you'd like that to look like?

I'm also curious about the use of extraNodeModules - normally the values there would be expected to be directories containing package.json files (ie, valid packages). It sounds like you might be wanting to use something like import {Button} from 'shared-package', or are you just using relative paths and it's not working when the relative path includes a symlink?

samsnori commented 9 months ago

Hi @robhogan thanks for you reply. In my shared-lib directory, I've got a couple of JS files that I like to use across projects. These do not belong to a package so there is no index.js or package.json. E.g. for this test I created a file shared-lib/src/components/Button.jsx which contains:

/* ------------------------------------------------------- */

import { View, Text } from 'react-native';

/* ------------------------------------------------------- */

function Button(props) {
  return (
    <View>
      <Text>This is my fake button</Text>
    </View>
  );
}

/* ------------------------------------------------------- */

export {
  Button
};

Then I would like to import this anywhere in my main-app like:

import { Button } from 'shared-lib/components/Button'

This works when I use a plain directory (or mount point) but not when using a symlink. This is why I suspect that the symlink is causing this.

Tbh, the extraNodeModules is something that I copied from one of the many articles I found about this. I haven't tested if things will work when I remove this, I wouldn't think so, but I'll do a test.

robhogan commented 9 months ago

Interesting, thanks - tbh I’m surprised that works at all without a package.json, but the inconsistency is surprising in any case. An import that isn’t a relative or absolute file path should only resolve under those settings if it begins with a package name.

I don’t think we’ve done a lot of testing on the interaction between extraNodeModules and symlinks, what you’re seeing does seem like a bug one way or another. I’ll see if I can reproduce.

vitorsilvalima commented 8 months ago

Even more weird,

I have the following dependencies in my shared-lib and it does not work at all giving the following error:

Error: Unable to resolve module @babel/runtime/helpers/asyncToGenerator from /Users/vitorsilvalima/projects/diggy/packages/core/node_modules/zod/lib/helpers/parseUtil.js:

"dependencies": {
    "dayjs": "1.10.8",
    "mongoose": "^6.7.5",
    "zod": "^3.22.4"
  }

However, it works if I remove zod or if I install babel-runtime in my shared-lib which makes no sense at all.

samsnori commented 7 months ago

Yes, I've experienced that too. The state of RN tooling is extremely sad.

To be honest I think the issue is not entirely the fault of Metro, it's the whole "npm" / package / tooling space. The biggest flaw I see which causes -a lot- of these issues, is that each bundler or packager has it's own module resolution solutions. NPM should have provided a solution that could be used by bundlers instead.

It's also good to realize that Metro is most likely not going to spend time on this; the very first issue that was posted already mentioned similar issues. I understand the main developers have other priorities.

For me, I found a solution which "works" but is ugly.

stefang42 commented 5 months ago

Even more weird,

I have the following dependencies in my shared-lib and it does not work at all giving the following error:

Error: Unable to resolve module @babel/runtime/helpers/asyncToGenerator from /Users/vitorsilvalima/projects/diggy/packages/core/node_modules/zod/lib/helpers/parseUtil.js:

"dependencies": {
    "dayjs": "1.10.8",
    "mongoose": "^6.7.5",
    "zod": "^3.22.4"
  }

However, it works if I remove zod or if I install babel-runtime in my shared-lib which makes no sense at all.

I just ran into a similar issue in my project, where I include a module (with package.json, etc.) from an external path. I got Metro to include it by using resolver.extraNodeModules, but it wouldn't use the other dependencies from the local node_modules. After lots of searching, trial & error (and swearing, lots of swearing) I finally found a solution that works (at least for me):

const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');

const path = require("path");

const myExtraModuleDir = path.resolve(__dirname, "../../myExtraModule");
const extraNodeModules = {
  'myExtraModule': myExtraModuleDir,
};
const watchFolders = [
  // Include extra module dir to work around Metro's bug where resolver.extraNodeModules
  // does not work without corresponding watchFolders, see also
  // https://github.com/facebook/metro/issues/834
  myExtraModuleDir
];

const config = {
  watchFolders: watchFolders,
  resolver: {
    extraNodeModules: new Proxy(extraNodeModules, {
      get: (target, name) =>
        // redirects dependencies referenced from myExtraModule/ to local node_modules
        name in target ? target[name] : path.join(process.cwd(), `node_modules/${name}`),
    }),
    // unstable_enableSymlinks: true,  // defaults to true since Metro v0.79.0
  },
  resetCache: true,  // https://metrobundler.dev/docs/configuration/#resetcache
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

That "Proxy" part did the trick, inspired by this StackOverflow thread: Metro extraNodeModules does not work - Error: Unable to resolve module.

Seriously, this really should be easier and more straightforward.

rosko commented 1 week ago

@stefang42 Thank you, it works for me!

metro=0.80.12