mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
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/<name>` 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/<name>` 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 <eric.fennis@gmail.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
Eric Fennis
parent
05232756c0
commit
1802f33f6c
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<LucideIcon>();
|
||||
expectTypeOf(Edit2).toEqualTypeOf<LucideIcon>();
|
||||
|
||||
const screen = await render(<App />);
|
||||
|
||||
const staticIcon = screen.getByTestId('static-icon');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { LucideIcon } from './types';
|
||||
|
||||
declare const icon: LucideIcon;
|
||||
|
||||
export default icon;
|
||||
Reference in New Issue
Block a user