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:
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).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.react-hook-form
directly in my consuming app but as soon as I'm using any component from the library.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>
);
}
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?
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.
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.
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.
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.
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, /* ... */ };
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
.
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.
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.