From 1802f33f6c270395fcd981067f22719140e2ffbc Mon Sep 17 00:00:00 2001 From: huuya Date: Mon, 26 Oct 2026 19:05:25 +0900 Subject: [PATCH] feat(lucide-react): add package exports for per-icon imports without changing how Node resolves the package (#4822) * feat(lucide-react): add package exports for per-icon imports without changing how Node resolves the package Makes `lucide-react/icons/` and `lucide-react/types` public so a single icon can be imported with a small shared declaration instead of the 2.2 MB `dist/lucide-react.d.ts`, and without loading the CJS barrel on the server. The root entry only lists `module` and `default`, so bundlers keep the ESM barrel and Node keeps resolving to `dist/cjs/lucide-react.js` as it does today through `main`. The map reverted in #2814 sent Node to the ESM barrel, which is what #2743 hit. `./dist/*` keeps existing deep imports working. * Modify getting started guide for icon imports Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(lucide-react): add node, import and require conditions after module and keep the extension-specific and src subpaths exported Resolvers that are called with a custom condition list drop `module` and fell through to `default`, the CJS bundle; esbuild with `conditions: ['workerd', 'worker']` bundled 1.3 MB instead of the 58 KB tree-shaken output it gets on main. The root and `./icons` entries now list `node`, `import` and `require` after `module`, with `node` ahead of `import` so Node keeps resolving to `dist/cjs/lucide-react.js` for both `import` and `require`. `lucide-react/dynamic.mjs`, `lucide-react/dynamic.js` and `lucide-react/dynamicIconImports.mjs` are imported by consumers and threw ERR_PACKAGE_PATH_NOT_EXPORTED; they have explicit entries again. The docs API routes import `lucide-react/src/createLucideIcon` and `lucide-react/src/Icon`, which Nitro left as external imports without a `./src/*` entry, so the entry from the 0.470.0 map is back. * test(lucide-react): resolve the package entry points with Node itself in the React Router fixture The browser suite resolves lucide-react through Vite, so nothing checked the `exports` map the way a server runtime does. `tests/exports.node.test.ts` runs under `node --test` from the fixture's `check` script and asserts where `import.meta.resolve` and `require.resolve` land for the root, `./icons`, a single icon, the dynamic entries with and without an extension, the `dist/*` passthrough and `package.json`, that `./types` is declaration-only, and that the resolved modules load. Vitest is told to leave that file to Node. * docs(lucide-react): note that per-icon imports are ESM only `require()` of `lucide-react/icons/` needs Node.js 20.19 or 22.12; 20.18 throws ERR_REQUIRE_ESM. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Eric Fennis --- docs/guide/react/getting-started.md | 11 ++++ integrations/lucide-react/README.md | 2 + .../react-router/app/IconShowcase.tsx | 3 +- .../lucide-react/react-router/package.json | 3 +- .../react-router/tests/exports.node.test.ts | 66 +++++++++++++++++++ .../react-router/vitest.config.ts | 3 +- .../lucide-react/vite/src/IconShowcase.tsx | 4 +- .../vite/tests/icons.browser.spec.tsx | 8 ++- packages/lucide-react/dynamic.js | 2 +- packages/lucide-react/package.json | 53 +++++++++++++++ packages/lucide-react/rollup.config.mjs | 10 +++ packages/lucide-react/src/lucide-icon.d.ts | 5 ++ 12 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 integrations/lucide-react/react-router/tests/exports.node.test.ts create mode 100644 packages/lucide-react/src/lucide-icon.d.ts diff --git a/docs/guide/react/getting-started.md b/docs/guide/react/getting-started.md index ee6e5270a..e941a8486 100644 --- a/docs/guide/react/getting-started.md +++ b/docs/guide/react/getting-started.md @@ -50,6 +50,17 @@ const App = () => { export default App; ``` +### Importing a single icon + +You can also import one icon at a time. This skips the icon barrel, which matters when modules are resolved at runtime (server-side rendering, for example), and only loads a small type declaration instead of the full one. + +```tsx +import Camera from 'lucide-react/icons/camera'; +import type { LucideProps } from 'lucide-react/types'; +``` + +These per-icon entry points are ES modules only. `require()` of one works on Node.js 20.19 and 22.12 or newer; on older versions, import the icon from the package root instead. + ## Props To customize the appearance of an icon, you can use the following props: diff --git a/integrations/lucide-react/README.md b/integrations/lucide-react/README.md index f6fa56e8a..f38443ff5 100644 --- a/integrations/lucide-react/README.md +++ b/integrations/lucide-react/README.md @@ -8,6 +8,8 @@ Every fixture runs three checks: - TypeScript with `noEmit`; - Vitest Browser Mode in Chromium. +The React Router fixture also resolves the package entry points with Node's own resolver (`node --test`), since the browser suite only sees them through Vite. + From the repository root, install Chromium once and run the complete suite: ```sh diff --git a/integrations/lucide-react/react-router/app/IconShowcase.tsx b/integrations/lucide-react/react-router/app/IconShowcase.tsx index f9b1da045..3f2427ec9 100644 --- a/integrations/lucide-react/react-router/app/IconShowcase.tsx +++ b/integrations/lucide-react/react-router/app/IconShowcase.tsx @@ -1,4 +1,5 @@ -import { Camera, Droplet, Edit2, House, LucideProvider, Pen } from 'lucide-react'; +import { Camera, Edit2, House, LucideProvider, Pen } from 'lucide-react'; +import Droplet from 'lucide-react/icons/droplet'; import { DynamicIcon } from 'lucide-react/dynamic'; export default function IconShowcase() { diff --git a/integrations/lucide-react/react-router/package.json b/integrations/lucide-react/react-router/package.json index c5901621d..b1ead0a09 100644 --- a/integrations/lucide-react/react-router/package.json +++ b/integrations/lucide-react/react-router/package.json @@ -7,7 +7,8 @@ "build": "react-router build", "typecheck": "react-router typegen && tsc --noEmit", "test:browser": "vitest run", - "check": "pnpm build && pnpm typecheck && pnpm test:browser" + "test:node": "node --test tests/exports.node.test.ts", + "check": "pnpm build && pnpm typecheck && pnpm test:node && pnpm test:browser" }, "dependencies": { "@react-router/node": "8.3.1", diff --git a/integrations/lucide-react/react-router/tests/exports.node.test.ts b/integrations/lucide-react/react-router/tests/exports.node.test.ts new file mode 100644 index 000000000..dc63ff434 --- /dev/null +++ b/integrations/lucide-react/react-router/tests/exports.node.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// The browser suite resolves lucide-react through Vite. These checks use Node's own resolver, +// which is what a server runtime sees, so the `exports` map is verified the way #2743 hit it. + +const require = createRequire(import.meta.url); +const packageRoot = fileURLToPath(new URL('./', import.meta.resolve('lucide-react/package.json'))); + +const inPackage = (file: string) => { + assert.ok(file.startsWith(packageRoot), `${file} is outside ${packageRoot}`); + assert.ok(existsSync(file), `${file} does not exist`); + return file.slice(packageRoot.length); +}; + +const resolveImport = (specifier: string) => + inPackage(fileURLToPath(import.meta.resolve(specifier))); +const resolveRequire = (specifier: string) => inPackage(require.resolve(specifier)); + +test('the root entry resolves to the CommonJS bundle for both import and require', () => { + assert.equal(resolveImport('lucide-react'), 'dist/cjs/lucide-react.js'); + assert.equal(resolveRequire('lucide-react'), 'dist/cjs/lucide-react.js'); + assert.equal(resolveImport('lucide-react/icons'), 'dist/cjs/lucide-react.js'); + assert.equal(resolveRequire('lucide-react/icons'), 'dist/cjs/lucide-react.js'); +}); + +test('a single icon resolves to its ESM module', () => { + assert.equal(resolveImport('lucide-react/icons/camera'), 'dist/esm/icons/camera.mjs'); + assert.equal(resolveRequire('lucide-react/icons/camera'), 'dist/esm/icons/camera.mjs'); +}); + +test('the dynamic entries resolve with and without an extension', () => { + assert.equal(resolveImport('lucide-react/dynamic'), 'dynamic.mjs'); + assert.equal(resolveRequire('lucide-react/dynamic'), 'dynamic.js'); + assert.equal(resolveImport('lucide-react/dynamic.mjs'), 'dynamic.mjs'); + assert.equal(resolveImport('lucide-react/dynamic.js'), 'dynamic.js'); + assert.equal(resolveImport('lucide-react/dynamicIconImports'), 'dynamicIconImports.mjs'); + assert.equal(resolveImport('lucide-react/dynamicIconImports.mjs'), 'dynamicIconImports.mjs'); +}); + +test('deep imports into dist and package.json keep resolving', () => { + assert.equal( + resolveImport('lucide-react/dist/esm/icons/camera.mjs'), + 'dist/esm/icons/camera.mjs', + ); + assert.equal(resolveRequire('lucide-react/dist/cjs/lucide-react.js'), 'dist/cjs/lucide-react.js'); + assert.equal(resolveImport('lucide-react/package.json'), 'package.json'); +}); + +test('the types entry is declaration-only', () => { + assert.throws(() => import.meta.resolve('lucide-react/types'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); +}); + +test('the resolved entries load and export icon components', async () => { + const root = await import('lucide-react'); + const camera = await import('lucide-react/icons/camera'); + + assert.equal(root.Camera.displayName, 'Camera'); + assert.equal(camera.default.displayName, 'Camera'); + assert.equal(require('lucide-react').Camera.displayName, 'Camera'); +}); diff --git a/integrations/lucide-react/react-router/vitest.config.ts b/integrations/lucide-react/react-router/vitest.config.ts index f30d4b90d..b3dd8197d 100644 --- a/integrations/lucide-react/react-router/vitest.config.ts +++ b/integrations/lucide-react/react-router/vitest.config.ts @@ -1,10 +1,11 @@ import react from '@vitejs/plugin-react'; import { playwright } from '@vitest/browser-playwright'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], test: { + exclude: [...configDefaults.exclude, 'tests/*.node.test.ts'], browser: { enabled: true, headless: true, diff --git a/integrations/lucide-react/vite/src/IconShowcase.tsx b/integrations/lucide-react/vite/src/IconShowcase.tsx index f9b1da045..a9c9dba16 100644 --- a/integrations/lucide-react/vite/src/IconShowcase.tsx +++ b/integrations/lucide-react/vite/src/IconShowcase.tsx @@ -1,4 +1,6 @@ -import { Camera, Droplet, Edit2, House, LucideProvider, Pen } from 'lucide-react'; +import { Camera, House, LucideProvider, Pen } from 'lucide-react'; +import Droplet from 'lucide-react/icons/droplet'; +import Edit2 from 'lucide-react/dist/esm/icons/edit-2.mjs'; import { DynamicIcon } from 'lucide-react/dynamic'; export default function IconShowcase() { diff --git a/integrations/lucide-react/vite/tests/icons.browser.spec.tsx b/integrations/lucide-react/vite/tests/icons.browser.spec.tsx index a2f9982ab..4e0371060 100644 --- a/integrations/lucide-react/vite/tests/icons.browser.spec.tsx +++ b/integrations/lucide-react/vite/tests/icons.browser.spec.tsx @@ -1,8 +1,14 @@ -import { expect, test } from 'vitest'; +import Edit2 from 'lucide-react/dist/esm/icons/edit-2.mjs'; +import Droplet from 'lucide-react/icons/droplet'; +import type { LucideIcon } from 'lucide-react/types'; +import { expect, expectTypeOf, test } from 'vitest'; import { render } from 'vitest-browser-react'; import App from '../src/App'; test('renders Lucide public APIs in a real browser', async () => { + expectTypeOf(Droplet).toEqualTypeOf(); + expectTypeOf(Edit2).toEqualTypeOf(); + const screen = await render(); const staticIcon = screen.getByTestId('static-icon'); diff --git a/packages/lucide-react/dynamic.js b/packages/lucide-react/dynamic.js index 66b682491..526556df6 100644 --- a/packages/lucide-react/dynamic.js +++ b/packages/lucide-react/dynamic.js @@ -1,4 +1,4 @@ // eslint-disable // This file is make sure that we can import from "lucide-react/dynamic" without having to specify the extension. This is a workaround for the fact that some bundlers (like Webpack) don't support importing from "lucide-react/dynamic.mjs" without specifying the extension. -// Unfortunately, we can't use exports field in package.json because it breaks some runtime environments. See https://github.com/lucide-icons/lucide/issues/2743#issuecomment-2626784300 +// It is also the "default" target of the "./dynamic" entry in the package.json exports map, and it stays in place for resolvers that ignore "exports". export * from './dynamic.mjs'; diff --git a/packages/lucide-react/package.json b/packages/lucide-react/package.json index 85e552a63..bba6c98f4 100644 --- a/packages/lucide-react/package.json +++ b/packages/lucide-react/package.json @@ -26,6 +26,59 @@ "main": "dist/cjs/lucide-react.js", "module": "dist/esm/lucide-react.mjs", "typings": "dist/lucide-react.d.ts", + "exports": { + ".": { + "types": "./dist/lucide-react.d.ts", + "module": "./dist/esm/lucide-react.mjs", + "node": "./dist/cjs/lucide-react.js", + "import": "./dist/esm/lucide-react.mjs", + "require": "./dist/cjs/lucide-react.js", + "default": "./dist/cjs/lucide-react.js" + }, + "./icons": { + "types": "./dist/lucide-react.d.ts", + "module": "./dist/esm/lucide-react.mjs", + "node": "./dist/cjs/lucide-react.js", + "import": "./dist/esm/lucide-react.mjs", + "require": "./dist/cjs/lucide-react.js", + "default": "./dist/cjs/lucide-react.js" + }, + "./icons/*": { + "types": "./dist/lucide-icon.d.mts", + "default": "./dist/esm/icons/*.mjs" + }, + "./types": { + "types": "./dist/lucide-types.d.ts" + }, + "./dynamic": { + "types": "./dynamic.d.ts", + "import": "./dynamic.mjs", + "default": "./dynamic.js" + }, + "./dynamic.mjs": { + "types": "./dynamic.d.mts", + "default": "./dynamic.mjs" + }, + "./dynamic.js": { + "types": "./dynamic.d.ts", + "default": "./dynamic.js" + }, + "./dynamicIconImports": { + "types": "./dynamicIconImports.d.ts", + "default": "./dynamicIconImports.mjs" + }, + "./dynamicIconImports.mjs": { + "types": "./dynamicIconImports.d.mts", + "default": "./dynamicIconImports.mjs" + }, + "./dist/esm/icons/*.mjs": { + "types": "./dist/lucide-icon.d.mts", + "default": "./dist/esm/icons/*.mjs" + }, + "./dist/*": "./dist/*", + "./src/*": "./src/*.ts", + "./package.json": "./package.json" + }, "sideEffects": false, "files": [ "dist", diff --git a/packages/lucide-react/rollup.config.mjs b/packages/lucide-react/rollup.config.mjs index ee3a9766e..dffb93ee7 100644 --- a/packages/lucide-react/rollup.config.mjs +++ b/packages/lucide-react/rollup.config.mjs @@ -109,6 +109,16 @@ const configs = bundles .flat(); export default [ + { + input: 'src/types.ts', + output: { file: 'dist/lucide-types.d.ts', format: 'es' }, + plugins: [dts(dtsOptions)], + }, + { + input: 'src/lucide-icon.d.ts', + output: { file: 'dist/lucide-icon.d.mts', format: 'es' }, + plugins: [dts(dtsOptions)], + }, { input: 'src/dynamicIconImports.ts', output: [ diff --git a/packages/lucide-react/src/lucide-icon.d.ts b/packages/lucide-react/src/lucide-icon.d.ts new file mode 100644 index 000000000..a2d088c3a --- /dev/null +++ b/packages/lucide-react/src/lucide-icon.d.ts @@ -0,0 +1,5 @@ +import type { LucideIcon } from './types'; + +declare const icon: LucideIcon; + +export default icon;