Skip to content

Conversation

@psyrenpark
Copy link

Summary

This PR adds support for pnpm workspace monorepos in Granite's Metro bundler configuration, resolving critical issues with module resolution and bundling in pnpm-based projects.

Problem Statement

When using Granite in a pnpm workspace monorepo with multiple apps using different React Native versions, the following issues occur:

  1. SHA-1 Computation Failure: Metro's HasteFS cannot compute SHA-1 hashes for files in pnpm's .pnpm virtual directory due to symlink handling
  2. React Native Version Conflicts: Metro incorrectly resolves to the wrong React Native version when multiple versions coexist in the monorepo
  3. Peer Dependency Resolution Issues: Metro's hierarchical lookup can resolve dependencies from parent directories, causing version mismatches

Example Error Scenario

Monorepo Structure:

monorepo/
├── apps/
│   ├── app-a/  # React Native 0.81.5
│   └── app-b/  # React Native 0.72.6
└── node_modules/
    └── .pnpm/  # pnpm virtual store

Errors encountered:

ReferenceError: SHA-1 for file .../node_modules/.pnpm/@granite-js+react-native@.../index.ts is not computed.
SyntaxError: .../[email protected]/.../EventEmitter.js: Unexpected token (46:5)

Changes Made

1. Enhanced Module Resolver (enhancedResolver.ts)

Added alias configuration to force local React/React Native versions:

const resolver = enhancedResolve.create.sync({
  extensions: finalExtensions,
  mainFields: context.mainFields,
  conditionNames: options?.conditionNames ?? [...RESOLVER_EXPORTS_MAP_CONDITIONS, 'require', 'node', 'default'],
  mainFiles: ['index'],
  modules: ['node_modules', path.join(rootPath, 'src')],
  alias: {
    'react-native': path.join(rootPath, 'node_modules', 'react-native'),
    'react': path.join(rootPath, 'node_modules', 'react'),
  },
});

Why this works:

  • Forces Metro to resolve react and react-native from each app's local node_modules
  • Prevents Metro from picking up wrong versions from monorepo root or sibling apps
  • Maintains peer dependency resolution by keeping modules: ['node_modules', ...]

2. Dependency Graph SHA-1 Fallback (DependencyGraph.js)

Added 2-tier fallback logic for SHA-1 computation:

getSha1(filename) {
  const splitIndex = filename.indexOf(".zip/");
  const containerName = splitIndex !== -1 ? filename.slice(0, splitIndex + 4) : filename;
  const realpath = fs.realpathSync(containerName);
  const resolvedPath = (pnpapi ? pnpapi.resolveVirtual(realpath) : realpath) ?? realpath;
  let sha1 = this._hasteFS.getSha1(resolvedPath);

  // Tier 1: Try original filename if resolvedPath fails
  if (!sha1 && resolvedPath !== filename) {
    sha1 = this._hasteFS.getSha1(filename);
  }

  // Tier 2: Compute SHA-1 directly from file content
  if (!sha1) {
    try {
      const crypto = require('crypto');
      const content = fs.readFileSync(resolvedPath);
      sha1 = crypto.createHash('sha1').update(content).digest('hex');
    } catch (error) {
      throw new ReferenceError(
        `SHA-1 for file ${filename} (${resolvedPath}) is not computed.
         Potential causes:
           1) You have symlinks in your project - watchman does not follow symlinks.
           2) Check \`blockList\` in your metro.config.js and make sure it isn't excluding the file path.
         Error: ${error.message}`
      );
    }
  }
  return sha1;
}

Why this works:

  • Handles pnpm's symlinked file structure gracefully
  • Provides fallback when HasteFS cache doesn't contain the file
  • Computes SHA-1 on-demand for symlinked files

3. Metro Configuration Optimization (getMetroConfig.ts)

Changes:

  1. Set disableHierarchicalLookup: true as default
  2. Removed monorepo root from watchFolders
return mergeConfig(defaultConfig, {
  projectRoot: additionalConfig?.projectRoot || rootPath,
  watchFolders: [packageRootPath, ...additionalConfig?.watchFolders || []],  // Removed resolvedRootPath
  resolver: {
    // ... other config
    disableHierarchicalLookup: additionalConfig?.resolver?.disableHierarchicalLookup ?? true,  // Default true
  }
});

Why this works:

  • Prevents Metro from searching parent directories for modules
  • Reduces unnecessary file watching overhead
  • Ensures each app uses its own dependencies independently

Testing

Environment:

  • pnpm workspace monorepo
  • Multiple apps with different React Native versions (0.72.6 and 0.81.5)
  • @granite-js/mpack: 0.1.31

Test Results:

  • ✅ SHA-1 computation succeeds for all pnpm symlinked files
  • ✅ Each app correctly resolves its own React Native version
  • ✅ Peer dependencies resolve correctly from local node_modules
  • ✅ Metro bundler completes without errors

Patch File for Users

For users who want to apply this fix immediately without waiting for a release, here's a pnpm patch:

1. Create patch:

pnpm patch @granite-js/[email protected]

2. Apply changes from this PR to the temporary directory

3. Commit patch:

pnpm patch-commit <temp-directory-path>

4. The patch will be saved to patches/@[email protected] and automatically added to package.json:

{
  "pnpm": {
    "patchedDependencies": {
      "@granite-js/[email protected]": "patches/@[email protected]"
    }
  }
}

Breaking Changes

None. These changes are backward compatible:

  • Default behavior improves pnpm support without affecting existing projects
  • disableHierarchicalLookup can still be overridden in config
  • Alias configuration doesn't interfere with standard module resolution

Benefits

  1. Full pnpm Workspace Support: Granite now works seamlessly in pnpm monorepos
  2. Version Isolation: Each app uses its correct React Native version
  3. Better Performance: Reduced file watching and faster module resolution
  4. Reliability: Robust SHA-1 computation with fallback mechanisms

Related Issues

This PR addresses the fundamental incompatibility between Granite's Metro bundler and pnpm workspace monorepos, particularly when multiple React Native versions coexist.


Tested with:

  • pnpm 10.20.0
  • React Native 0.72.6 & 0.81.5
  • @granite-js/mpack 0.1.31
  • Turbo monorepo setup

## 주요 변경사항

### 1. enhancedResolver.ts - React/React Native 버전 충돌 해결
- enhanced-resolve의 alias 기능을 추가하여 react-native와 react를 로컬 node_modules로 강제
- pnpm 모노레포에서 여러 React Native 버전이 공존할 때 발생하는 버전 충돌 문제 해결
- 각 앱이 자신의 package.json에 명시된 정확한 버전을 사용하도록 보장

### 2. DependencyGraph.js - pnpm symlink SHA-1 계산 지원
- pnpm의 .pnpm 가상 디렉토리 구조에서 발생하는 SHA-1 계산 실패 문제 해결
- 2단계 fallback 로직 추가:
  1. resolvedPath로 SHA-1 조회 실패 시 원본 filename으로 재시도
  2. 여전히 실패 시 파일 내용을 직접 읽어 SHA-1 계산
- Metro의 HasteFS가 pnpm symlink를 올바르게 처리할 수 있도록 개선

### 3. getMetroConfig.ts - 모노레포 모듈 해상도 최적화
- disableHierarchicalLookup 기본값을 true로 설정하여 상위 디렉토리 탐색 방지
- monorepo root를 watchFolders에서 제거하여 불필요한 파일 감시 감소
- 각 앱이 독립적으로 의존성을 해상하도록 개선

## 해결된 문제

1. **SHA-1 계산 실패**: pnpm symlink로 인한 "SHA-1 is not computed" 에러 해결
2. **React Native 버전 충돌**: 모노레포 내 서로 다른 RN 버전 사용 시 발생하는 SyntaxError 해결
3. **Peer dependency 해상도**: 로컬 node_modules의 올바른 peer dependency 해상 보장

## 테스트 환경

- pnpm workspace 모노레포
- apps/expo: React Native 0.81.5
- apps/toss: React Native 0.72.6
- @granite-js/mpack: 0.1.31

## 관련 이슈

이 변경사항은 pnpm 모노레포 환경에서 Granite 프레임워크를 사용할 때 발생하는
모듈 해상도 및 번들링 문제를 근본적으로 해결합니다.
@vercel
Copy link

vercel bot commented Nov 19, 2025

Someone is attempting to deploy a commit to the Toss Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link

changeset-bot bot commented Nov 19, 2025

⚠️ No Changeset found

Latest commit: e258623

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@psyrenpark
Copy link
Author

Additional Configuration for pnpm Monorepo Users

Optional: Project-level Metro Configuration Override

While the changes in this PR make Granite work seamlessly with pnpm monorepos, users may want additional control at the project level. Here's an example metro.config.js configuration that can be used in individual apps:

File: apps/your-app/metro.config.js

const path = require('path');
const { getMetroConfig } = require('@granite-js/mpack/dist/metro/getMetroConfig');

module.exports = (async () => {
  const config = await getMetroConfig(
    { rootPath: __dirname },
    {
      resolver: {
        // Force Metro to use the local react-native installation
        extraNodeModules: {
          'react-native': path.join(__dirname, 'node_modules', 'react-native'),
        },
        // Optional: Block specific versions if needed (e.g., wrong version from sibling apps)
        blockList: [/react-native@0\.81\.5/],
      },
    }
  );

  return config;
})();

When to Use This Configuration

You typically DON'T need this if:

  • ✅ This PR is merged and you're using the updated version
  • ✅ You're using pnpm patch with the changes from this PR
  • ✅ Your monorepo has only one React Native version

You MAY want this if:

  • 🔧 You need additional customization beyond the default behavior
  • 🔧 You want explicit control over module resolution in specific apps
  • 🔧 You need to block specific package versions explicitly

Configuration Explanation

1. extraNodeModules:

  • Explicitly tells Metro where to find react-native
  • Ensures the local version is always used
  • Redundant with the alias fix in this PR, but provides double guarantee

2. blockList:

  • Prevents Metro from accessing specific package versions
  • Useful when you have conflicting versions in the monorepo
  • Example: Block [email protected] when your app uses 0.72.6
  • Use regex patterns to match the exact versions you want to exclude

Note

With the fixes in this PR (especially the alias configuration in enhancedResolver.ts), the extraNodeModules override becomes redundant but harmless. The blockList can still be useful for additional safety in complex monorepo setups.

Real-world Example

In our testing monorepo with 2 apps:

  • App A: Uses React Native 0.81.5
  • App B: Uses React Native 0.72.6 (with Granite)

App B's metro.config.js:

blockList: [/react-native@0\.81\.5/]  // Explicitly blocks App A's version

This ensures App B never accidentally picks up App A's React Native version during Metro's module resolution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant