Files
1802f33f6c 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>
2026-10-26 11:05:25 +01:00

67 lines
3.0 KiB
TypeScript

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');
});