Search code examples
node.jsnext.jsvitees6-modulesreact-hook-form

Import errors from 3rd party dependencies (`react-hook-form`) in Vite library


I'm currently writing a reusable UI library with Vite which is a simple wrapper around ShadCN Uis components. ATM I'm only using the library in a couple of NextJSs apps and everything is working as expected when only using the library in pages router components but I'm getting import errors when using it in app router components.

The errors are caused by imports (& re-exports) from third party dependency react-hook-form in the library. In detail I'm getting the following when running npm run build:

./node_modules/shadcn-ui-lib/dist/index6.js Attempted import error: 'FormProvider' is not exported from 'react-hook-form' (imported as 'u').

Import trace for requested module: ./node_modules/shadcn-ui-lib/dist/index6.js ./node_modules/shadcn-ui-lib/dist/index.js ./src/app/rsc/page.tsx

./node_modules/shadcn-ui-lib/dist/index6.js Attempted import error: 'Controller' is not exported from 'react-hook-form' (imported as 'p')

Here's some excerpt from index6.js within node_modules of the consuming app which is mentioned in the error:

import { Label as f } from "./index5.js";
import { Slot as F } from "@radix-ui/react-slot";
import * as e from "react";
import { FormProvider as u, Controller as p, useFormContext as x } from "react-hook-form";
import { useForm as M } from "react-hook-form";
const R = u;

// ...

export { R as Form, C as FormControl, w as FormDescription, $ as FormField, I as FormItem, g as FormLabel, E as FormMessage, M as useForm, a as useFormField };

There are a few interesting things for which I don't really have an explanation:

  • The error only occurs when using the library in app router components. When using it in pages router components everything works as expected.
  • The import of Slot from @radix-ui/react-slot is working as expected even though I'm treating the dependencies to @radix-ui/react-slot and react-hook-form the same way (see package.json later).
  • When looking at the node_modules folder in my consuming app I can see that react-hook-form is installed and the FormProvider is exported from react-hook-form as expected.
  • The errors occur even when I'm not importing anything from react-hook-form directly in my consuming app but as soon as I'm using any component from the library.

Implementation details

library/vite.config.ts

// @ts-ignore Not sure how to solve this but not worth the time to figure it out...
import * as packageJson from './package.json';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
    react({
      // Not really required but seems to make the bundle a bit smaller
      jsxRuntime: 'classic',
    }),
    dts({
      include: ['src/**/*'],
    }),
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'src', 'index.ts'),
      formats: ['es'],
      fileName: 'index',
    },
    rollupOptions: {
      // Do not include the deps and peerDeps in the build.
      external: [...Object.keys(packageJson.peerDependencies || {}), ...Object.keys(packageJson.dependencies)],
      // `preserveModules` makes the lib tree shakable (in combination with `sideEffects: false` in `package.json`).
      output: { preserveModules: true, exports: 'named' },
    },

    target: 'esnext',
    sourcemap: true,
  },
});

library/package.json

{
  "name": "shadcn-ui-lib",
  // ...
  "sideEffects": false,
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./styles.css": "./dist/styles.css",
    "./tailwind-config": "./dist/tailwind.config.ts"
  },
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsc && vite build && npm run build:styles && npm run copy:files-to-dist",
    "build:styles": "postcss ./src/index.css -o ./dist/styles.css && node ./build-scripts/inject-tw-directives",
    "copy:files-to-dist": "copyfiles -f ./tailwind.config.ts dist",
    "dev": "vite",
  },
  "dependencies": {
    // ...
    "@radix-ui/react-label": "^2.0.2",
    "@radix-ui/react-slot": "^1.0.2",
    // ...
    "react-hook-form": "^7.50.1",
    // ...
  },
  "devDependencies": {
    // ...
    "typescript": "^5.2.2",
    "vite": "^5.0.8",
    "vite-plugin-dts": "^3.7.2"
  },
  "peerDependencies": {
    "lucide-react": "^0.323.0",
    "postcss": "^8.4.34",
    "react": "18.2.0",
    "tailwindcss": "^3.4.1"
  },
  "engines": {
    "node": ">=16"
  }
}

library/src/components/form.tsx The Source which holds the troublesome imports & re-expors from react-hook-form):

'use client';

import { cn } from '../lib/css.utils.js';
import { Label } from './label.js';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import { Controller, type ControllerProps, type FieldError, type FieldPath, type FieldValues, FormProvider, useForm, useFormContext } from 'react-hook-form';

const Form = FormProvider;

// Definitions of form components, hooks etc. (`FormItem`, `FormField`, `useFormField`, ...)

export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField, useForm };

consuming-app/src/app/rsc/page.tsx

import { Card } from 'shadcn-ui-lib';

export default function Home() {
  return (
    <main>
      <Card>RSC test page</Card>
    </main>
  );
}

Some wild guesses I have

I explicitly set my library to "ESM only" as trying to export both CJS & ESM was causing issues in the consuming apps which I wasn't able to solve otherwise. Maybe this is causing the issue? When comparing @radix-ui/react-slot/package.json (which is working fine) & react-hook-form/package.json (which causes issues) I saw that react-slot doesn't provide any cjs files whilst react-hook-form does provide cjs & esm files.

Are my consuming apps (or the library itself) maybe trying to import the cjs files from react-hook-form instead of the esm files? If so, how can I fix this?

Reproduction

You can download both, a minimal example of the library and a test consuming app here:

The issues can reproduced by running npm i && npm run dev or npm run build in the consuming app.

Conclusion

I'm rather new to library authoring and am still having trouble understanding the ins- & outs of ESM & CJS and all implications of choosing one and/or the other.

Also, I'm not sure if the issue is caused by my library, the consuming apps or the third party dependencies.

Anyhow, I'd really appreciate some help with this and would be happy to provide more information if needed.

Update

I did some fiddling around directly with react-hook-form/package.json in my consuming app and found the following:

When removing the react-server condition from the conditional exports, everything is working as expected (at least on first glance as I get no more build errors...):

"exports": {
  "./package.json": "./package.json",
  ".": {
    "types": "./dist/index.d.ts",
    "react-server": "./dist/react-server.esm.mjs", // <-- Remove this
    "import": "./dist/index.esm.mjs",
    "require": "./dist/index.cjs.js"
  }
},

So it seems that the errors are not caused by ESM/CJS but somehow by this react-server export.

Question

This obviously raises the question of what I can change in my consuming app or library to make this work?

FYI, this is not an issue when directly importing react-hook-form in an Next JS app router app but only when imported "through" my library.


Solution

  • Re-declare useForm which is imported from react-hook-form before re-exporting it from the libs form.tsx:

    'use client';
    
    // ...
    
    import {
      // ...
      useForm as useFormImport,
    } from 'react-hook-form';
    
    // ...
    
    const useForm = useFormImport;
    
    export { useForm, /* ... */ };
    

    Why does this help?

    The original export of useForm from react-hook-form does not have a use client directive and I guess that the server component which imports from my lib does not consider the use client directive from form.tsx when simple passing useForm through like I initially did (i.e. when re-exporting useFormImport directly).

    However, it seems like re-declaring it before export, somehow "attaches" the use client directive to the re-declared useForm.

    Why was this an issue when not importing anything from form.tsx in my server component?

    Because my lib is exporting all its components from 1 barrel file (index.ts). So even when only importing Card from index.ts in a server component, React seems to be processing all exports from that barrel file.

    This answer from @morganney provides more details on this & actually lead me to this conclusion.

    Also important

    Make sure to have rollup-plugin-preserve-directives installed & activated in your Vite config as the 'use client' directives in the lib files would otherwise not exist in the final bundle.