mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
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:
@@ -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')],
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user