facebook / metro

🚇 The JavaScript bundler for React Native
https://metrobundler.dev
MIT License
5.25k stars 626 forks source link

Including local packages from outside project root. Can we do better? #1225

Open samsnori opened 9 months ago

samsnori commented 9 months ago

Introduction

My goal is to find a solution to include packages which reside outside of the directory tree of a project. By "project," I refer to a React Native directory that contains a package.json and node_modules generated using npx react-native init <name>.

Consider the application and package directories as follows:

/home/sam/apps/my-app
/home/sam/development/my-package

In this setup, my-app represents my React Native project, while my-package is my private UI component library. The objective is to incorporate my-package into my-app. my-package contains a package.json and some source files, typically located in the src directory.


Why?

The ability to store a package in a single location and share it across multiple projects offers a clean and efficient way to manage code. It eliminates the need to synchronize every project that utilizes the package.

Solution and the problem

A common approach to achieve this is through the use of a monorepos. However, this may not always be the most preferred solution. Curently, it appears taht employing a monorepo is the only viable option for sharing local packages between React Native projects.

Another potential solution involves leveraging the nodeModulesPaths and/or extraNodeModules features of metro.config.js. However, these features only function partially, and I'm in the process of investigating why and hopefully devising a fix.

Consider the scenario where I have my own UI component library in /home/sam/development/my-package, which I intend to utilize in several React Native apps, including /home/sam/apps/my-app. You can include the path to my-package in extraNodeModules as follows:

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

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

/* Path to `package.json` of my-package. */
let my_package_path = '/home/sam/development/my-package/';

const config = {
  resolver: {
    extraNodeModules: {
      'my-package': my_package_path,
    },
  },
  watchFolders: [
    my_package_path,
  ],
};

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

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

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

However this approach only partially works. When my-package utilizes any React Native components, such as View in my case this results in the following error:

ERROR  TypeError: Cannot read property 'useContext' of null
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem. 
    at View (http://10.0.2.2:8081/index.bundle//&platform=android&dev=true&lazy=true&minify=false&app=com.myapp&modulesOnly=false&runModule=true:183707:43)
    at UiButton
    at RCTView
    at View (http://10.0.2.2:8081/index.bundle//&platform=android&dev=true&lazy=true&minify=false&app=com.myapp&modulesOnly=false&runModule=true:59759:43)
    at App
    at RCTView
    at View (http://10.0.2.2:8081/index.bundle//&platform=android&dev=true&lazy=true&minify=false&app=com.myapp&modulesOnly=false&runModule=true:59759:43)
    at RCTView
    at View (http://10.0.2.2:8081/index.bundle//&platform=android&dev=true&lazy=true&minify=false&app=com.myapp&modulesOnly=false&runModule=true:59759:43)
    at AppContainer (http://10.0.2.2:8081/index.bundle//&platform=android&dev=true&lazy=true&minify=false&app=com.myapp&modulesOnly=false&runModule=true:59601:36)
    at myapp(RootComponent) (http://10.0.2.2:8081/index.bundle//&platform=android&dev=true&lazy=true&minify=false&app=com.myapp&modulesOnly=false&runModule=true:110702:28)

There might be several reasons for this as you explained read here. My suspicion is that, in this specific case, it might be due to versioning discrepancies between react-native or react. Interestingly I couldn't find a version mismatch when using the following commands:

npm ls react
npm ls react-native

Workaround

To fix this, I found this note:

This will be done by deleting the react and react-dom folders in the node_modules in the project you are developing.

After deleting the node_modules/{react, react-native} from my-package, we have to fix one more thing. Open metro.config.js of my-app and add the following fix for the removed react and react-native modules. To do this, make sure that react and react-native are also in the extraNodeModules. This is also mentioned here and here.

After these changes the metro.config.js of my-app looks like:

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

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

/* Path to `package.json` of my-package. */
let my_package_path = '/home/sam/development/my-package/';

const config = {
  resolver: {
    extraNodeModules: {
      'my-package': my_package_path,
      'react': path.resolve(__dirname, 'node_modules/react'),
      'react-native': path.resolve(__dirname, 'node_modules/react-native')
    },
  },
  /* We also add the path to our watch folders so changes are followed */
  watchFolders: [
    my_package_path,
  ],
};

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

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

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

🔥 Then, cleaning the cache and reinstalling the app onto your emulator or devices allows you to use a package from an external location. To clean run (or one of the variants) in the my-app directory:

npm start -- --reset-cache

Can't we do better?

As you can see using local packages is poorly supported although it feels to me something which should have worked from day one. Event the first issue created here is relevant. There are many partial, half working articles as you can see below.

What is holding us back to implement a fix for this? Who, with know-how about the internals of metro which makes this a difficult problem can share some thoughts on this?

Relevant issues and articles