egoist / tsup

The simplest and fastest way to bundle your TypeScript libraries.
https://tsup.egoist.dev
MIT License
9.25k stars 218 forks source link

Build react package for use in nextjs 13 #835

Closed mnzsss closed 1 year ago

mnzsss commented 1 year ago

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:

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 commented 1 year ago

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 commented 1 year ago

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.

syjung-cookapps commented 1 year ago

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

michael-land commented 1 year ago
export default defineConfig({
  ...
  esbuildOptions(options, context) {
    options.inject?.push('./inject.js');
  },
})

does not work for me. any suggestion?

mnzsss commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

tcolinpa commented 1 year ago

@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[bot] commented 1 year ago

:tada: This issue has been resolved in version 7.0.0 :tada:

The release is available on:

Your semantic-release bot :package::rocket:

tcolinpa commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

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

MohJaber93 commented 1 year ago

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

JohnGrisham commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

MiroslavPetrik commented 1 year ago

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 commented 12 months ago

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 commented 12 months ago

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 commented 11 months ago

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 commented 11 months ago

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 commented 11 months ago

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 commented 8 months ago

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

arthur-overlord commented 2 months ago

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.

That was for me the simple and easiest way to do it. Thank you !

Seojunhwan commented 1 month ago

Hello everyone,

I wanted to follow up on this issue as I've developed a plugin that might help address this problem: esbuild-plugin-preserve-directives

This plugin is specifically designed to preserve directives like "use client" in esbuild output. I created it as a potential solution for those who need this functionality before it's natively implemented in esbuild.

Key features of the plugin:

Preserves "use client" and other directives in the output Works with both JavaScript and TypeScript files Configurable to preserve specific directives I'd love to get your feedback on this approach. Has anyone else been working on similar solutions? Do you see any potential issues or improvements?

If you try it out, please let me know how it works for you. I'm open to suggestions and contributions to make it more robust and useful for the community.

This could serve as a temporary solution for users who need to preserve the "use client" directive in their RSC builds while we wait for native support in esbuild.

oalexdoda commented 1 month ago

Hello everyone,

I wanted to follow up on this issue as I've developed a plugin that might help address this problem: esbuild-plugin-preserve-directives

This plugin is specifically designed to preserve directives like "use client" in esbuild output. I created it as a potential solution for those who need this functionality before it's natively implemented in esbuild.

Key features of the plugin:

Preserves "use client" and other directives in the output Works with both JavaScript and TypeScript files Configurable to preserve specific directives I'd love to get your feedback on this approach. Has anyone else been working on similar solutions? Do you see any potential issues or improvements?

If you try it out, please let me know how it works for you. I'm open to suggestions and contributions to make it more robust and useful for the community.

This could serve as a temporary solution for users who need to preserve the "use client" directive in their RSC builds while we wait for native support in esbuild.

Does it work with chunked components (i.e. if a use client component gets split by tsup in multiple chunks) or does it only work with the entry point exports?

Seojunhwan commented 1 month ago

@oalexdoda

Does it work with chunked components (i.e. if a use client component gets split by tsup in multiple chunks) or does it only work with the entry point exports?

The plugin supports both scenarios: it works with chunked components where 'use client' components are split into multiple chunks by tsup, as well as with entry point exports. However, most users adopt this plugin specifically to handle the first case (chunked components), as that's the more common use case where directive preservation becomes critical.

You can check out the src structure and built chunks in this repository: https://github.com/Seojunhwan/esbuild-plugin-result-example to see a practical example of how the plugin handles directive preservation across chunks.

thevuong commented 4 weeks ago

I have developed a solution that I have integrated into the @codefast/ui library.

addUseClientDirective is a function that selectively adds the "use client" directive to code chunks. It checks if the content includes client libraries or React hooks and only adds the directive when necessary, ensuring that only the required files are updated. This function works as an tsup plugin, using the renderChunk hook, so it processes code very quickly without reading directly from files. Here's the summary:

  1. Imports: Includes relative from node:path and Options from tsup.
  2. Tracked Imports: Keeps track of imports in each code chunk.
  3. Directive: Defines the "use client" directive.
  4. Regex: Detects React hooks and event handlers, excluding comments.
  5. Functions:
    • containsClientLibsOrHooks: Checks for client libraries or React hooks in the content.
    • buildClientLibsRegex: Builds a regex for matching client libraries.
    • addUseClientDirective: Adds the "use client" directive based on specified client libraries.

It processes and potentially modifies code chunks to include the directive if certain conditions are met, making the directive addition more efficient and targeted. You can check the implementation here.

oalexdoda commented 3 weeks ago

@oalexdoda

Does it work with chunked components (i.e. if a use client component gets split by tsup in multiple chunks) or does it only work with the entry point exports?

The plugin supports both scenarios: it works with chunked components where 'use client' components are split into multiple chunks by tsup, as well as with entry point exports. However, most users adopt this plugin specifically to handle the first case (chunked components), as that's the more common use case where directive preservation becomes critical.

You can check out the src structure and built chunks in this repository: https://github.com/Seojunhwan/esbuild-plugin-result-example to see a practical example of how the plugin handles directive preservation across chunks.

Hey @Seojunhwan

image

Any clue what I'm doing wrong?

image

It works in dev, but when building & deploying the directive needs to be at the top of the file/chunk so it breaks.

Seojunhwan commented 3 weeks ago

@oalexdoda I think it's a bug. I'll check it out today.

oalexdoda commented 3 weeks ago

Thank you @Seojunhwan , really appreciate it. I'm stuck on getting this to build so I can push the newly repackaged dependencies to my platform - so I'll be on the lookout for your update 🙏

I think the directives need to be moved to the start of the file, like this:

image

Instead of like this:

image

Also, if you enable treeshake, the plugin seems to stop working entirely.

oalexdoda commented 3 weeks ago

@Seojunhwan I tried a bunch of different things but it seems like the plugin gets executed before whatever ends up inserting the use strict directive. Perhaps there's a different way to be explored on how we run the plugin to have it take the right "priority" or order?

oalexdoda commented 3 weeks ago

I have developed a solution that I have integrated into the @codefast/ui library.

addUseClientDirective is a function that selectively adds the "use client" directive to code chunks. It checks if the content includes client libraries or React hooks and only adds the directive when necessary, ensuring that only the required files are updated. This function works as an esbuild plugin, using the renderChunk hook, so it processes code very quickly without reading directly from files. Here's the summary:

  1. Imports: Includes relative from node:path and Options from tsup.
  2. Tracked Imports: Keeps track of imports in each code chunk.
  3. Directive: Defines the "use client" directive.
  4. Regex: Detects React hooks and event handlers, excluding comments.
  5. Functions:

    • containsClientLibsOrHooks: Checks for client libraries or React hooks in the content.
    • buildClientLibsRegex: Builds a regex for matching client libraries.
    • addUseClientDirective: Adds the "use client" directive based on specified client libraries.

It processes and potentially modifies code chunks to include the directive if certain conditions are met, making the directive addition more efficient and targeted. You can check the implementation here.

This one seems to work! Not with treeshake enabled, but it works!

Thank you @thevuong

Seojunhwan commented 3 weeks ago

@oalexdoda I’ve checked, and if you’re using tsup, it seems more appropriate to leverage the excellent solution by @thevuong

To elaborate, tsup builds using esbuild and then operates the renderChunk hook with JavaScript on the output, finally saving the file. As a result, directives in the file contents are preserved when the ESM flag is added, so this issue didn’t seem to arise.

In contrast, the onEnd hook available in esbuild plugins receives the output before the ESM flag is added, which seems to be the cause of this issue.

In summary, I’m not sure how to add directives after adding the ESM flag within an esbuild plugin—perhaps it’s even impossible.

If, like in the default settings of tsup, you’re not performing code splitting in CJS, then this issue likely wouldn’t arise. However, it seems that code splitting is essential in your case, so this may not be the best approach.

Additionally, since treeshaking isn’t handled by this plugin, it might be worth revisiting your configuration.

Thanks for helping to identify this plugin issue!

refs

thevuong commented 1 week ago

Thank you, @Seojunhwan, for your detailed feedback!

In the case where tsup uses treeshaking, tsup actually switches to Rollup instead of esbuild to handle this, especially for tree-shaking purposes. This opens up some other possibilities. While tsup doesn’t natively support custom Rollup configurations directly (though I’m not entirely certain), if there’s a way to add additional plugins for Rollup through tsup’s configuration, we could consider using the rollup-plugin-preserve-directives library. This plugin would help preserve directives like "use client" in the output during the build process.

This approach could fully address the issue of keeping the "use client" directive intact while applying tree-shaking and managing code-splitting effectively. It would ensure the directive is retained in the output without relying on the ESM flag or the default configurations of tsup and esbuild.