fix: bundle and load only the icons the app uses

93 files import icons from the `lucide-react-native` barrel. Metro has
no tree shaking, so the barrel pulled all 1,694 icons into the bundle
and evaluated every one of them at startup.

A small Babel plugin (`scripts/babel-plugin-lucide-imports.js`, wired
up in the new `babel.config.js` on top of `babel-preset-expo`) rewrites
each named import to the per-icon module the barrel re-exports, e.g.
`import { Mail } from 'lucide-react-native'` becomes a default import of
`lucide-react-native/dist/esm/icons/mail.js`. The name table is read
from the package's own barrel, so aliases such as `MailIcon` or renamed
icons keep resolving; an unknown name fails the build. The package's
`exports` map only lists the barrel, so the new `metro.config.js`
resolves these deep paths as plain files instead of warning about each.
A test checks that every icon imported in the app maps to an existing
module and that no default or namespace import brings the barrel back.

Measured with `npx expo export --platform android`:
- modules in the bundle: 3,995 -> 2,469 (lucide: 1,701 -> 175)
- Hermes bundle: 9.34 MB -> 8.19 MB
- modules evaluated at startup: 3,564 -> 2,038

Metro does not key its transform cache on the Babel config, so a warm
dev cache keeps serving the old barrel imports until
`npx expo start --clear`.
This commit is contained in:
Linus Rath
2026-09-24 19:35:47 +02:00
parent 604eb39340
commit 063432bf28
4 changed files with 231 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
const path = require('node:path');
// babel-preset-expo ships inside `expo` rather than as a direct dependency, so
// resolve it from there; this is the preset Expo uses when no config exists.
const presetExpo = require.resolve('babel-preset-expo', {
paths: [path.dirname(require.resolve('expo/package.json'))],
});
module.exports = function babelConfig(api) {
api.cache(true);
return {
presets: [presetExpo],
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
};
};
+20
View File
@@ -0,0 +1,20 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// babel.config.js rewrites lucide-react-native imports to per-icon modules
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
// `exports` map only lists its barrel, so resolve those paths as plain files
// instead of letting Metro warn about every one of them.
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
const upstreamResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
const resolve = upstreamResolveRequest ?? context.resolveRequest;
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
}
return resolve(context, moduleName, platform);
};
module.exports = config;
+91
View File
@@ -0,0 +1,91 @@
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
// the per-icon modules they re-export, so the bundle only contains (and the
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
// in and run every icon at startup.
//
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
// becomes
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
//
// The name -> file table is read from the package's own ESM barrel, so icon
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
// through the barrel. metro.config.js resolves these deep paths, which the
// package's `exports` map does not list.
const fs = require('node:fs');
const path = require('node:path');
const PACKAGE = 'lucide-react-native';
const ESM_DIR = 'dist/esm';
/** Map every name the barrel exports to `{ source, imported }`. */
function loadExportMap() {
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
const map = new Map();
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
for (const spec of m[1].split(',')) {
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
if (imported) map.set(exported, { source, imported });
}
}
if (map.size === 0) {
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
}
return map;
}
let exportMap = null;
function getExportMap() {
if (!exportMap) exportMap = loadExportMap();
return exportMap;
}
// A value-less binding (only used as a type) is dropped by the TypeScript
// transform anyway; treat it the same way instead of failing the build.
function isOnlyUsedAsType(importPath, localName) {
const binding = importPath.scope.getBinding(localName);
if (!binding) return true;
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
}
function lucideImportsPlugin({ types: t }) {
return {
name: 'lucide-direct-imports',
visitor: {
ImportDeclaration(importPath) {
const { node } = importPath;
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
// Side-effect, default and namespace imports keep using the barrel.
if (node.specifiers.length === 0) return;
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
const map = getExportMap();
const replacements = [];
for (const spec of node.specifiers) {
if (spec.importKind === 'type') continue;
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
const target = map.get(name);
if (!target) {
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
}
const local = t.identifier(spec.local.name);
const specifier = target.imported === 'default'
? t.importDefaultSpecifier(local)
: t.importSpecifier(local, t.identifier(target.imported));
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
}
if (replacements.length === 0) importPath.remove();
else importPath.replaceWithMultiple(replacements);
},
},
};
}
module.exports = lucideImportsPlugin;
module.exports.loadExportMap = loadExportMap;
module.exports.PACKAGE = PACKAGE;
+105
View File
@@ -0,0 +1,105 @@
// The babel plugin in scripts/babel-plugin-lucide-imports.js rewrites
// `import { X } from 'lucide-react-native'` to per-icon modules. These tests
// make sure every icon the app imports maps to a file that exists, and that
// no import form slips back to the full barrel.
import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const require = createRequire(import.meta.url);
const plugin = require('../../../scripts/babel-plugin-lucide-imports.js');
const babel = require('@babel/core');
const ROOT = join(__dirname, '..', '..', '..');
const PACKAGE_DIR = join(require.resolve('lucide-react-native'), '..', '..', '..');
function sourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) {
if (entry !== '__tests__' && entry !== 'node_modules') sourceFiles(p, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(p);
}
}
return out;
}
const FILES = [...sourceFiles(join(ROOT, 'src')), join(ROOT, 'App.tsx'), join(ROOT, 'index.ts')];
const LUCIDE_IMPORT_RE = /import\s+(type\s+)?([^;]*?)\s+from\s+'lucide-react-native'/g;
function transform(code: string): string {
return babel.transformSync(code, {
babelrc: false,
configFile: false,
filename: 'fixture.tsx',
parserOpts: { plugins: ['typescript', 'jsx'] },
plugins: [plugin],
}).code as string;
}
describe('lucide-react-native imports', () => {
const exportMap: Map<string, { source: string; imported: string }> = plugin.loadExportMap();
it('reads the icon table from the package barrel', () => {
expect(exportMap.size).toBeGreaterThan(1000);
expect(exportMap.get('Mail')?.source).toBe('lucide-react-native/dist/esm/icons/mail.js');
// Aliases resolve to the icon they re-export.
expect(exportMap.get('MailIcon')?.source).toBe('lucide-react-native/dist/esm/icons/mail.js');
expect(exportMap.get('AlarmCheck')?.source).toBe('lucide-react-native/dist/esm/icons/alarm-clock-check.js');
});
it('maps every icon the app imports to an existing module', () => {
const problems: string[] = [];
let checked = 0;
for (const file of FILES) {
const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(LUCIDE_IMPORT_RE)) {
const where = relative(ROOT, file);
if (m[1]) continue; // `import type { … }` is erased
const clause = m[2].trim();
if (!clause.startsWith('{') || !clause.endsWith('}')) {
problems.push(`${where}: only named imports avoid the barrel (${clause})`);
continue;
}
for (const raw of clause.slice(1, -1).split(',')) {
const spec = raw.trim();
if (!spec || spec.startsWith('type ')) continue;
const name = spec.split(/\s+as\s+/)[0];
const target = exportMap.get(name);
if (!target) {
problems.push(`${where}: ${name} is not a lucide export (import types with \`type\`)`);
continue;
}
const file = join(PACKAGE_DIR, target.source.slice('lucide-react-native/'.length));
if (!existsSync(file)) problems.push(`${where}: ${name} -> missing ${target.source}`);
checked++;
}
}
}
expect(problems).toEqual([]);
expect(checked).toBeGreaterThan(100);
});
it('rewrites named imports to per-icon modules', () => {
const out = transform(
"import { Mail, Trash2 as Bin, type LucideIcon, LucideProvider } from 'lucide-react-native';\n"
+ 'const icons: LucideIcon[] = [Mail, Bin];\n'
+ 'export { icons, LucideProvider };\n',
);
expect(out).toContain("import Mail from \"lucide-react-native/dist/esm/icons/mail.js\"");
expect(out).toContain("import Bin from \"lucide-react-native/dist/esm/icons/trash-2.js\"");
expect(out).toContain("import { LucideProvider } from \"lucide-react-native/dist/esm/context.js\"");
expect(out).not.toMatch(/from "lucide-react-native"/);
});
it('drops type-only names and fails on unknown values', () => {
expect(transform(
"import { LucideProps } from 'lucide-react-native';\nexport function f(p: LucideProps) { return p; }\n",
)).not.toContain('lucide-react-native');
expect(() => transform(
"import { NotAnIcon } from 'lucide-react-native';\nexport const x = NotAnIcon;\n",
)).toThrow(/no export named "NotAnIcon"/);
});
});