Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build react package for use in nextjs 13 #835

Closed
mnzsss opened this issue Feb 13, 2023 · 28 comments · Fixed by #925
Closed

Build react package for use in nextjs 13 #835

mnzsss opened this issue Feb 13, 2023 · 28 comments · Fixed by #925
Labels

Comments

@mnzsss
Copy link

mnzsss commented Feb 13, 2023

I tried create a package with ui components for use in Nextjs 13 web app, but I can't build components with "use client" in the beginning of code, like that:

"use client"

import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"

const AspectRatio = AspectRatioPrimitive.Root

export { AspectRatio }

So when I build this code, the "use client" has removed:
image

Error on import component of package in app:
image
Obs.: The component needs to be imported into a server side file, in which case it would be the layout.tsx

Have a workround or option for this?

I use:

  • turbo: ^1.7.4
  • next: ^13.1.6
  • tsup: ^6.1.3
  • typescript: ^4.9.4

tsconfig.json of ui package:

{
  "compilerOptions": {
    "composite": false,
    "declaration": true,
    "declarationMap": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "inlineSources": false,
    "isolatedModules": true,
    "moduleResolution": "node",
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "preserveWatchOutput": true,
    "skipLibCheck": true,
    "strict": true,
    "lib": ["ES2015", "DOM"],
    "module": "ESNext",
    "target": "ES6",
    "jsx": "react-jsx",
    "baseUrl": "./src",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["."],
  "exclude": ["dist", "build", "node_modules"]
}

tsup.config.ts
image

@zeakd
Copy link

zeakd commented Feb 19, 2023

I'm not maintainer, but I think this is out of tsup role scope and you should create wrapping component of it outside of app directory.

// components/AspectRatio.js
export { AspectRatio } from 'your-pkg'

// or inside app directory,
// app/.../AspectRatio.js
"use client"
export { AspectRatio } from 'your-pkg'

But if you have to, how about to use esbuild inject option?

export default defineConfig({
  ...
  esbuildOptions(options, context) {
    options.inject?.push('./inject.js');
  },
})
// inject.js
"use client"

@mnzsss
Copy link
Author

mnzsss commented Feb 21, 2023

I'm not maintainer, but I think this is out of tsup role scope and you should create wrapping component of it outside of app directory.

// components/AspectRatio.js
export { AspectRatio } from 'your-pkg'

// or inside app directory,
// app/.../AspectRatio.js
"use client"
export { AspectRatio } from 'your-pkg'

But if you have to, how about to use esbuild inject option?

export default defineConfig({
  ...
  esbuildOptions(options, context) {
    options.inject?.push('./inject.js');
  },
})
// inject.js
"use client"

I partial solved this problem using a wrapper too, but I guess using inject script is better. I will try that.

@mnzsss mnzsss closed this as completed Feb 22, 2023
@syjung-cookapps
Copy link

@mnzsss
Did you clear your problem? I still have issues with use inject option..

@michael-land
Copy link

export default defineConfig({
  ...
  esbuildOptions(options, context) {
    options.inject?.push('./inject.js');
  },
})

does not work for me. any suggestion?

@mnzsss mnzsss reopened this Mar 23, 2023
@mnzsss
Copy link
Author

mnzsss commented Mar 23, 2023

I'm not maintainer, but I think this is out of tsup role scope and you should create wrapping component of it outside of app directory.

// components/AspectRatio.js
export { AspectRatio } from 'your-pkg'

// or inside app directory,
// app/.../AspectRatio.js
"use client"
export { AspectRatio } from 'your-pkg'

But if you have to, how about to use esbuild inject option?

export default defineConfig({
  ...
  esbuildOptions(options, context) {
    options.inject?.push('./inject.js');
  },
})
// inject.js
"use client"

I partial solved this problem using a wrapper too, but I guess using inject script is better. I will try that.

@michael-land
@syjung-cookapps

This way not works for me too, I hadn't time to test it and today I found another way to made that.

// tsup.config.ts

import { defineConfig } from "tsup"

export default defineConfig((options) => ({
  entry: ["src/index.tsx"],
  format: ["esm", "cjs"],
  treeshake: true,
  splitting: true,
  dts: true,
  minify: true,
  clean: true,
  external: ["react"],
  onSuccess: "./scripts/post-build.sh",
  ...options,
}))
# ./scripts/post-build.sh

#!/bin/bash

sed -i '1,1s/^/"use client"; /' ./dist/index.js
sed -i '1,1s/^/"use client"; /' ./dist/index.mjs

This script runs on index.js and index.mjs files.

If you use Next.js we can use the transpilePackages option in next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  transpilePackages: ["ui"],
}

export default nextConfig

@jlarmstrongiv
Copy link

jlarmstrongiv commented Mar 28, 2023

Another option may be using these packages:

To ensure that a clear error is thrown, such as:

throw new Error(
  "This module cannot be imported from a Server Component module. " +
    "It should only be used from a Client Component."
);

Those packages do have side effects though https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free


Though, I agree that the best way would be to keep those annotations with the component. The problem is that all files that import client components must always be marked with the "use client" directive, including the root index file.

Perhaps multiple exports would help to separate shared, client, and server components:

After more research, I also found:

The solution I will go with today is the multiple exports + custom esbuild plugin (using the original file naming conventions, related)

@oalexdoda
Copy link

The Custom ESBuild plugin won't work because of this: Module level directives cause errors when bundled, "use client" in "dist/index.js" was ignored. - any clue how to fix it?

@mnzsss
Copy link
Author

mnzsss commented May 25, 2023

The Custom ESBuild plugin won't work because of this: Module level directives cause errors when bundled, "use client" in "dist/index.js" was ignored. - any clue how to fix it?

@altechzilla I think this lib can help you https://github.com/Ephem/rollup-plugin-preserve-directives

@tcolinpa
Copy link

tcolinpa commented Jun 14, 2023

NextJS docs gives two examples on how to inject directives as wrapper.

https://nextjs.org/docs/getting-started/react-essentials#library-authors
https://github.com/shuding/react-wrap-balancer/blob/main/tsup.config.ts#L10-L13

esbuildOptions: (options) => {
      options.banner = {
        js: '"use client"',
      };
    },

EDIT: It didn't work for me.

@teobler
Copy link
Contributor

teobler commented Jun 15, 2023

I faced the same issue with @tcolinpa , after some spike, I think the reason is tsup still using esbuild 0.17.6, and in 0.17.9 esbuild removed the filter of directives.
To resolve this issue, we may need to upgrade esbuild version.

cc: @egoist

@teobler
Copy link
Contributor

teobler commented Jun 15, 2023

I raised a PR for this fix, after PR merged and use config from @tcolinpa should be fine.

@tcolinpa
Copy link

tcolinpa commented Jun 16, 2023

@teobler nice, I was just going to post that the directive was being ignored

Module level directives cause errors when bundled, "use client" in "dist/index.js" was ignored.

@github-actions
Copy link

🎉 This issue has been resolved in version 7.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@tcolinpa
Copy link

Just updated to v7.0.0 and I still get the same error message.

react dev: Module level directives cause errors when bundled, "use client" in "dist/index.js" was ignored.
react dev: Module level directives cause errors when bundled, "use client" in "dist/index.mjs" was ignored.

Am I missing something?

@Gomah
Copy link

Gomah commented Jun 16, 2023

@tcolinpa Same here, it works fine when removing treeshaking & splitting from my tsup config tho.

I believe treeshaking is using rollup, hence the error

@tcolinpa
Copy link

Good catch, I've disabled treeshaking from my config and it worked. Thanks @Gomah

@MohJaber93
Copy link

But what if I want to keep the treeshake option enabled, I am still facing the same issue

@JohnGrisham
Copy link

JohnGrisham commented Sep 14, 2023

I ended up using the banner solution but I went the extra mile because I wanted my packages to be "dynamic" and use the "use client" directive whenever the environment is the browser and not use it when the environment is the server. I ended up essentially creating two packages one for the client and one for the server. Inside my next application it's smart enough to know which package to import depending on the environment. This effectively achieves what I want but doubles my build time for any packages that I want to be "dynamic". At least this way I can define "use client" inside my app and know that the package that is imported will use the correct bundle.

// tsup.config.ts
import 'dotenv/config';
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: {
    index: 'src/index.ts',
  },
  external: ['react'],
  format: ['esm', 'cjs'],
  dts: 'src/index.ts',
  platform: 'browser',
  esbuildOptions(options) {
    if (options.platform === 'browser') {
      options.banner = {
        js: '"use client"',
      };
    }
  },
});
// package.json
{
  "name": "ui",
  "version": "0.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "node": "./dist/server.js",
      "import": "./dist/index.js",
      "module": "./dist/index.mjs",
      "default": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./styles.css": "./dist/styles.css"
  },
  "license": "MIT",
  "scripts": {
    "build:client": "tsup && tailwindcss -i ./src/styles.css -o ./dist/styles.css",
    "build:server": "tsup --entry.server src/index.ts --platform=node && tailwindcss -i ./src/styles.css -o ./dist/styles.css",
    "build": "yarn build:client && yarn build:server",
    "dev": "concurrently \"yarn build:client --watch\" \"yarn build:server --watch\" \"tailwindcss -i ./src/styles.css -o ./dist/styles.css --watch\"",
    "storybook": "storybook dev -s ./public -p 6006",
    "clean": "rm -rf dist",
    "build-storybook": "storybook build"
  },
...

@oalexdoda
Copy link

oalexdoda commented Oct 29, 2023

Any way to make this work at a component level? I don't want the ENTIRE library to have 'use client' directives (aka every single file). If you have 100 components and you only need 5 of them to have that banner, how would you go about it?

Because

esbuildOptions(options) {
   options.banner = {
      js: '"use client"'
   };
},

adds it to every single file.

@thevuong
Copy link

thevuong commented Nov 9, 2023

My temporary solution is to add the 'use client' directive at the beginning of each chunk file.

https://github.com/codefixlabs/codefixlabs/blob/1a74a9e5fc36fc1d950f5a0cd15b5b1d6c568dca/packages/ui/tsup.config.ts#L8

@gracefullight
Copy link

Is there no other way than post-processing? If so, shouldn't this ticket remain open?

@MiroslavPetrik
Copy link

MiroslavPetrik commented Nov 15, 2023

I was able to create a package which has both server & client code and works in next 14.
https://github.com/MiroslavPetrik/react-form-action

I simply grouped/exported all the client stuff into one client.ts and then in package json added export for ./client.
Maybe it helps somebody here.

@kyuumeitai
Copy link

I've ended doing like the following, a little bit ackward but works:

import { defineConfig, Format } from "tsup";

const cfg = {
  splitting: true, //error triggerer
  treeshake: true, //error triggerer
  sourcemap: true,
  clean: true,
  dts: true,
  format: ["esm"] as Format[],
  minify: true,
  bundle: false,
  external: ["react"],
};

export default defineConfig([
  {
    ...cfg, //in this part, I just used the non client components, but excluding the one that needed 'use client'
    entry: ["components/**/*.tsx", "!components/layout/header.tsx"],
    outDir: "dist/layout", 
  },
  {
    ...cfg, //and here I've added the esbuildOptions banner and disabling the error trigger options
    entry: ["components/layout/header.tsx"],
    outDir: "dist/layout",
    esbuildOptions: (options) => {
      options.banner = {
        js: '"use client";',
      };
    },
    splitting: false,
    treeshake: false,
  },
]);

@thevuong
Copy link

My temporary solution is to add the 'use client' directive at the beginning of each chunk file.

https://github.com/codefixlabs/codefixlabs/blob/1a74a9e5fc36fc1d950f5a0cd15b5b1d6c568dca/packages/ui/tsup.config.ts#L8

It worked for me.

@clearly-outsane
Copy link

I've ended doing like the following, a little bit ackward but works:

import { defineConfig, Format } from "tsup";

const cfg = {
  splitting: true, //error triggerer
  treeshake: true, //error triggerer
  sourcemap: true,
  clean: true,
  dts: true,
  format: ["esm"] as Format[],
  minify: true,
  bundle: false,
  external: ["react"],
};

export default defineConfig([
  {
    ...cfg, //in this part, I just used the non client components, but excluding the one that needed 'use client'
    entry: ["components/**/*.tsx", "!components/layout/header.tsx"],
    outDir: "dist/layout", 
  },
  {
    ...cfg, //and here I've added the esbuildOptions banner and disabling the error trigger options
    entry: ["components/layout/header.tsx"],
    outDir: "dist/layout",
    esbuildOptions: (options) => {
      options.banner = {
        js: '"use client";',
      };
    },
    splitting: false,
    treeshake: false,
  },
]);

does this work when you have nesting of client and server components?

@clearly-outsane
Copy link

clearly-outsane commented Dec 10, 2023

If I have a react package with both client and server components, I haven't been able to figure a way out to package it ( the chunks generated don't have the directives - they cant because some chunks have a mix of client and server components ).

@manavm1990
Copy link

This is what works for me, based upon Vercel Analytics example. It will generate multiple exports so that 1️⃣ can consolidate all of the 'use client' components or not. In my case, I was separating out components from a bunch of constants.

import { defineConfig, Options } from 'tsup';

const cfg: Options = {
  clean: false,
  dts: true,
  format: ['esm'],
  minify: true,
  sourcemap: false,
  splitting: false,
  target: 'es2022',
  treeshake: false,
};

export default defineConfig([
  {
    ...cfg,
    // These are client components. They will get the 'use client' at the top.
    entry: { index: 'src/components/index.ts' },
    esbuildOptions(options) {
      options.banner = {
        js: '"use client"',
      };
    },
    
    // However you like this.
    external: [
      '@twicpics/components',
      'autoprefixer',
      'postcss',
      'react',
      'react-dom',
      'tailwindcss',
    ],
    outDir: 'dist',
  },
  {
    ...cfg,
    
    // I was doing something else with another file, but this could be 'server components' or whatever
    entry: { index: 'src/types/constants.ts' },
    outDir: 'dist/constants',
  },
]);

You may need to also update your package.json so that a consuming app knows where stuff is:

  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./types": {
      "import": "./dist/constants/index.js",
      "types": "./dist/constants/index.d.ts"
    }
  },

@linhnvg
Copy link

linhnvg commented Mar 1, 2024

Wait for the stable release, this is my solution fix

Folder structure:

📦 ui
├─ src
│  └─ components
│     ├─ clients
│     │  └─ others
│     ├─ servers
│     │  └─ others
│     └─ index.ts
└─ tsup.config.ts
const ignoreBuilds = [
  '!src/_stories/**/*.{ts,tsx,js,jsx}', // ignore custom storybook config
  '!src/components/**/*.stories.{ts,tsx}', // ignore all file storybook
];

function readFilesRecursively(directory: string) {
  const files: string[] = [];

  function read(directory: string) {
    const entries = fs.readdirSync(directory);

    entries.forEach((entry) => {
      const fullPath = path.join(directory, entry);
      const stat = fs.statSync(fullPath);

      if (stat.isDirectory()) {
        read(fullPath);
      } else {
        files.push(fullPath);
      }
    });
  }

  read(directory);
  return files;
}

async function addDirectivesToChunkFiles(distPath = 'dist'): Promise<void> {
  try {
    const files = readFilesRecursively(distPath);

    for (const file of files) {
      /**
       * Skip chunk, sourcemap, other clients
       * */
      const isIgnoreFile =
        file.includes('chunk-') ||
        file.includes('.map') ||
        !file.includes('/clients/');

      if (isIgnoreFile) {
        console.log(`⏭️ Directive 'use client'; has been skipped for ${file}`);
        continue;
      }

      const filePath = path.join('', file);

      const data = await fsPromises.readFile(filePath, 'utf8');

      const updatedContent = `"use client";${data}`;

      await fsPromises.writeFile(filePath, updatedContent, 'utf8');

      console.log(`💚 Directive 'use client'; has been added to ${file}`);
    }
  } catch (err) {
    // eslint-disable-next-line no-console -- We need to log the error
    console.error('⚠️ Something error:', err);
  }
}

export default defineConfig((options: Options) => {
  return {
    entry: ['src/**/*.{ts,tsx}', ...ignoreBuilds],
    splitting: true,
    treeshake: true,
    sourcemap: true,
    clean: true,
    dts: true,
    format: ['esm', 'cjs'],
    target: 'es5',
    bundle: true,
    platform: 'browser',
    minify: true,
    minifyWhitespace: true,
    tsconfig: new URL('./tsconfig.build.json', import.meta.url).pathname,
    onSuccess: async () => {
      await addDirectivesToChunkFiles();
    },
    ...options,
  };
});

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.