pnpm과 @vitejs/plugin-react @vitejs/plugin-react-swc를 사용할 때의 문제

.
├── projects/
│   ├── projectA/ vite 번들러를 사용, packageA를 사용
│   └── projectB/ vite 번들러를 사용, packageA를 사용
└── packages/
    └── packageA/

위와 같은 폴더 구조의 pnpm 모노레포를 사용하는 프로젝트를 진행하는 중 projectB를 실행했더니 아래와 같은 런타임 에러가 발생했습니다.

Uncaught TypeError: Cannot read properties of null (reading 'useEffect')

React 인스턴스가 두 개여서 에러가 발생했던 것입니다. pnpm.lock.yaml을 살펴보니 packageA에 설치된 react 버전은 19.1.0이고 projectB에 설치된 React 버전은 19.0.0이었습니다. 그런데 projectB와 같이 packageA를 사용하는 projectA에서는 에러가 발생하지 않았습니다.

열심히 찾아본 결과, 단 네 글자 차이 때문에 에러가 발생했습니다. 바로 @vitejs/plugin-react@vitejs/plugin-react-swc의 사용 여부입니다. 원인을 찾기 위해 재현할 수 있는 프로젝트를 만들었습니다. (youngkyo0504/vite-plugin-react-dedupe-bug) @vitejs/plugin-react를 사용하면 에러가 없고 @vitejs/plugin-react-swc를 사용하면 에러가 발생했습니다.

// vite.config.ts
import { defineConfig } from 'vite';
import type { UserConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
// NOTE: 아래 주석을 해제해서 플러그인을 @vitejs/plugin-react로 변경하면 오류가 사라짐
// import react from '@vitejs/plugin-react';
 
const config: UserConfig = {
  plugins: [react()],
  esbuild: false,
 
};
 
export default defineConfig(config);

큰 맥락에서는 같은 역할을 하는 라이브러리이지만 세부 기능이 달라서 문제가 발생했습니다. @vitejs/plugin-reactreactreact-dom의 중복을 자동으로 처리하는 기능이 내장되어 있지만, @vitejs/plugin-react-swc에는 해당 기능이 없었습니다. 결정적인 부분은 packages/plugin-react/src/index.ts 소스 코드에서 확인할 수 있습니다. 이 플러그인은 내부적으로 resolve.dedupe 설정을 주입합니다. (소스코드 4.5.2 버전 기준) resolve.dedupe 옵션은 동일한 의존성이 중복된다면 동일한 복사본을 사용할 수 있게 하는 Vite 설정입니다.

resolve: {
  dedupe: ['react', 'react-dom'],
},

라이브러리 사용자는 트랜스파일링에 어떤 도구(swc 혹은 babel)를 사용했는지 여부와 상관없이, 기대하는 결과값은 똑같을 것입니다. 하지만 기대한 결과가 서로 다를 때 디버깅이 더 어려워집니다. 실제로 메인테이너는 이런 어려움을 인지하고 다음 메이저 버전에는 dedupe 옵션을 삭제한다고 합니다. (Remove usage of Vite dedupe option · Issue #280 · vitejs/vite-plugin-react)

Vite 플러그인을 사용할 때 Config를 직접 변경할 수 있는 점을 조심해야 합니다. 의존성은 애초에 통일하는 것이 좋아서 pnpm catalog(Added in: v9.5.0)를 도입하는 것이 좋습니다. 플러그인을 만드는 사람이라면 디버깅을 어렵게 하는 Config는 수정하지 않는 것이 좋습니다.