react-nativenpmexpometro-bundler

Expo project reference sibling project


Is there a way to share (React Native) components, services, hooks etc. between 2 Expo projects?

Ideally all the common functionality would be in a 3rd project and both of these projects would reference it.

Is this possible without having to publish/upload the common project to npm, GitHub, etc.?

This question is regarding blank project that are created with create-expo-app and don't contain metro.config or babel.config.

# Create expo react typescript project
npx create-expo-app --template expo-template-blank-typescript
# Install react dom
npm install --save react-dom@19.0.0
# Install web component for react native
npm i --save react-native-web@^0.20.0 --force
# Install metro
npm i --save-dev @react-native/metro-config
npm i --save-dev @expo/metro-runtime@~5.0.4

Resulting project structure:

├─assets/
├─.gitignore
├─App.tsx
├─App.json
├─index.ts
├─package-lock.json
├─package.json
└─tsconfig.json

Solution

  • Warning

    This is a "working" solution for the current version of metro, that is otherwise having trouble reading sibling projects.

    If you're able to publish to npm and install the second project as a normal node module you are strongly encouraged to do that instead.

    This metro resolver override only works for typescript projects.

    Overriding metro config resolve

    1. Edit the common project tsconfig.json to something like:

    {
      "compilerOptions": {
        "jsx": "react-jsx",
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noFallthroughCasesInSwitch": true,
        "noEmit": false,
        "composite": true,
        "declarationMap": true,
        "moduleResolution": "node16",
        "module": "node16"
      }
    }
    

    2. Export all the common functionality to the main index

    Create an index.ts file in the root or the /src directory (of the common project). That exports all the sub modules. For example:

    export * from "./components";
    export * from "./hooks";
    export * from "./services";
    

    3. Set the common's project main

    Edit the package.json in the common project to point to the main index you just created

    "main": "index.ts",
    

    or

    "main": "src/index.ts",
    

    4. Install metro dependencies

    Install the metro dependencies on your main project

    npm install --save-dev metro-react-native-babel-preset
    npm install --save-dev metro-resolver
    

    5. "Install" the sibling project

    Install the sibling project using node on your main project

    npm install "../<SiblingDirectoryName>"
    

    6. Create a metro.config.js file in your root directory

    Don't forget to replace the respective directory names (<!CommonDirectoryNameHERE!>, <!CurrentDirectoryNameHERE!>, <!CommonProjectNameHere!>)

    const path = require("path");
    const fs = require("fs");
    const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
    const { resolve } = require("metro-resolver");
    
    const commonDirectoryName = "<!CommonDirectoryNameHERE!>";
    const currentDirectoryName = "<!CurrentDirectoryNameHERE!>";
    // these could also be derived by reading package.json and the current directory info
    
    const defaultConfig = getDefaultConfig(__dirname);
    /** @type {typeof defaultConfig} */
    const config = {
      transformer: {
        unstable_allowRequireContext: true,
        getTransformOptions: async () => ({
          transform: {
            experimentalImportSupport: false,
            inlineRequires: true,
          },
        }),
      },
      watchFolders: [
        __dirname,
        path.resolve(__dirname, path.join("..", commonDirectoryName)),
      ],
      resolver: {
        alias: {
          "<!CommonProjectNameHere!>": path.resolve(__dirname, path.join("..", commonDirectoryName, "src")),
        },
        resolveRequest: (context, moduleName, platform) => {
          const isCommonProjectReference = moduleName.includes(commonDirectoryName);
          const isFromCommonProjectReference = context.originModulePath.includes(commonDirectoryName);
          const isFromNodeModule = context.originModulePath.includes("node_modules");
          const isRelativePath = /(^\.\/)|(^\.\.\/)/.test(moduleName);
          const isLibraryModule = isFromNodeModule || !isRelativePath;
          const isCommonModule = isCommonProjectReference || (isFromCommonProjectReference && !isLibraryModule);
    
          if (isCommonModule) {
            const modulePath = path.resolve(context.originModulePath, path.join("..", moduleName));
            const filePath = getModuleFilePath(modulePath);
    
            return {
              type: "sourceFile",
              filePath: filePath,
            };
          } else {
    
            if (isFromCommonProjectReference && isLibraryModule) {
              const modulePath = context.originModulePath;
              const replacedPath = modulePath.replace(commonDirectoryName, currentDirectoryName);
              const redirectedPath = context.redirectModulePath(replacedPath);
    
              if (redirectedPath !== false) context.originModulePath = redirectedPath;
              else throw Error(`Failed to redirect module ${moduleName} (${modulePath})`);
            }
    
            return resolve(context, moduleName, platform);
          }
        },
      },
    };
    
    module.exports = mergeConfig(defaultConfig, config);
    
    /**
     * @param {string} modulePath 
     */
    function getModuleFilePath(modulePath) {
      const { dir: filePath, name: fileName } = path.parse(modulePath);
      const parentDirectoryFiles = fs.readdirSync(filePath);
    
      const matchingParentDirectoryFiles = parentDirectoryFiles.filter(name => name.replace(/\..+$/, "") === fileName);
      const hasJavaScriptFiles = matchingParentDirectoryFiles.some(name => /\.js(x?)$/.test(name));
      const matchingTypescriptFiles = matchingParentDirectoryFiles.filter(name => /(\.d)?\.ts(x?)$/.test(name));
    
      if (hasJavaScriptFiles) {
        throw Error(`The metro bundler can't read the javascript files for "${modulePath}"`);
      } else if (matchingTypescriptFiles.length > 1) {
        throw Error(`The metro bundler found too many source files for "${modulePath}"`);
      } else if (matchingTypescriptFiles.length === 1) {
        const [matchedFile] = matchingTypescriptFiles;
        return path.join(filePath, matchedFile);
      } else {
        return path.join(modulePath, "index.ts");
      }
    }
    

    Adding smaller packages

    Follow this step if you want to be able to have this structure

    import { Hook1, Hook2 } from "common-project/hooks";
    import { Service1 } from "common-project/services";
    

    over

    import { Hook1, Hook2, Service1 } from "common-project";
    

    Edit your package.json to include all your exported sub-modules:

      "exports": {
        ".": {
          "import": "./src/index.ts"
        },
        "./components": {
          "import": "./src/components/index.ts"
        },
        "./services": {
          "import": "./src/services/index.ts"
        },
        "./hooks": {
          "import": "./src/hooks/index.ts"
        }
      },
    

    Extra steps for expo-router

    If you're using expo-router with TypeScript you should also follow these steps:

    1. Install webpack types

    npm install --save-dev @types/webpack-env
    

    2. Manually register ExpoRoot in your index.tsx

    import { registerRootComponent } from "expo";
    import { ExpoRoot, RequireContext } from "expo-router";
    
    export default function App() {
      const appContext = require.context("./app") as RequireContext;
      // or ".src/app" depending on your structure
      return <ExpoRoot context={appContext} />;
    }
    
    registerRootComponent(App);
    

    3. Change the main script in package.json to your index.tsx

    "main": "index.tsx",
    

    or

    "main": "src/index.tsx",