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;