diff --git a/docs/.vitepress/sidebar/react-native.ts b/docs/.vitepress/sidebar/react-native.ts index 152a4adc5..7d9a69a5e 100644 --- a/docs/.vitepress/sidebar/react-native.ts +++ b/docs/.vitepress/sidebar/react-native.ts @@ -72,6 +72,11 @@ export const reactNativeSidebar = [ link: '/guide/react-native/advanced/combining-icons', desc: 'Combine multiple icons into one', }, + { + text: 'Optimizations', + link: '/guide/react-native/advanced/optimizations', + desc: 'Optimizing your app for performance', + }, ], }, { diff --git a/docs/guide/react-native/advanced/optimizations.md b/docs/guide/react-native/advanced/optimizations.md new file mode 100644 index 000000000..01c1ec6ab --- /dev/null +++ b/docs/guide/react-native/advanced/optimizations.md @@ -0,0 +1,56 @@ +--- +title: Optimizations - React Native +description: Learn how to optimize the bundle size of your React Native and Expo web exports by tree-shaking Lucide icons. +--- + +# Optimizations + +By default you import icons from the `lucide-react-native` entry point: + +```jsx +import { Camera } from 'lucide-react-native'; +``` + +This is convenient, but it relies on the bundler to _tree-shake_ the entry point (a "barrel" file that re-exports every icon) so that only the icons you actually use end up in your bundle. + +On native platforms this is usually fine. On **web exports** it can be a problem, because the Metro bundler is currently not very good at tree-shaking barrel imports and may include **all** icons in your final bundle. + +## The Metro tree-shaking limitation + +When you build for web with Expo (`expo export --platform web`), Metro is used to bundle your JavaScript. Metro bundles modules on-demand and, by default, does not remove unused exports from a barrel file. + +This means an import like the one below can pull in the entire icon set, even though only a single icon is rendered: + +```jsx +// May bundle every Lucide icon on web exports +import { Camera } from 'lucide-react-native'; +``` + +Expo documents an experimental optimization for [React Native web imports](https://docs.expo.dev/guides/tree-shaking/#react-native-web-imports) and for [removing unused imports and exports](https://docs.expo.dev/guides/tree-shaking/#remove-unused-imports-and-exports). In practice this tree-shaking does **not** currently work reliably for `lucide-react-native` barrel imports, so the unused icons are not removed from web exports. + +## Recommended: import icons individually + +To guarantee that only the icons you use are bundled — regardless of the bundler's tree-shaking capabilities — import each icon directly from its own module: + +```jsx +import Camera from 'lucide-react-native/icons/camera'; + +// Usage +const App = () => { + return ; +}; + +export default App; +``` + +Because each icon lives in its own file, the bundler only ever includes the icons you explicitly import. This keeps your web export small without depending on Metro's experimental tree-shaking. + +The icon module name is the [kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case) version of the icon name. For example, the `ArrowRight` icon is imported from `lucide-react-native/icons/arrow-right`: + +```jsx +import ArrowRight from 'lucide-react-native/icons/arrow-right'; +``` + +::: tip +Individual icon imports work identically on native and web, so you can safely use this style across your whole project to keep bundles as small as possible on every platform. +::: diff --git a/packages/lucide-react-native/package.json b/packages/lucide-react-native/package.json index a36826bf5..4adad09ff 100644 --- a/packages/lucide-react-native/package.json +++ b/packages/lucide-react-native/package.json @@ -25,20 +25,29 @@ "amdName": "lucide-react-native", "main": "dist/cjs/lucide-react-native.js", "module": "dist/esm/lucide-react-native.mjs", - "typings": "dist/lucide-react-native.d.ts", + "typings": "dist/types/lucide-react-native.d.ts", "react-native": "dist/esm/lucide-react-native.mjs", "exports": { ".": { - "types": "./dist/lucide-react-native.d.ts", + "types": "./dist/types/lucide-react-native.d.ts", + "react-native": "./dist/esm/lucide-react-native.mjs", "import": "./dist/esm/lucide-react-native.mjs", "browser": "./dist/esm/lucide-react-native.mjs", "require": "./dist/cjs/lucide-react-native.js" }, "./icons": { - "types": "./dist/icons.d.ts", + "types": "./dist/types/icons.d.ts", + "react-native": "./dist/esm/icons/index.mjs", "import": "./dist/esm/icons/index.mjs", "browser": "./dist/esm/icons/index.mjs", "require": "./dist/cjs/icons/index.js" + }, + "./icons/*": { + "types": "./dist/types/icons/*.d.ts", + "react-native": "./dist/esm/icons/*.mjs", + "import": "./dist/esm/icons/*.mjs", + "browser": "./dist/esm/icons/*.mjs", + "require": "./dist/cjs/icons/*.js" } }, "sideEffects": false, @@ -49,13 +58,14 @@ "build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm build:bundles", "copy:license": "cp ../../LICENSE ./LICENSE", "clean": "rm -rf dist && rm -rf stats && rm -rf ./src/icons/*.ts", - "build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --iconFileExtension=.ts --exportFileName=index.ts --withAliases --aliasesFileExtension=.ts", + "build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --iconFileExtension=.ts --exportFileName=index.ts --withAliases --separateAliasesFile --separateAliasesFileIgnore=fingerprint --aliasesFileExtension=.ts", "build:bundles": "rollup -c ./rollup.config.mjs", "test": "pnpm build:icons && vitest run", "test:watch": "vitest watch" }, "devDependencies": { "@lucide/build-icons": "workspace:*", + "@lucide/helpers": "workspace:*", "@lucide/rollup-plugins": "workspace:*", "@lucide/shared": "workspace:*", "@testing-library/jest-dom": "^6.8.0", diff --git a/packages/lucide-react-native/rollup.config.mjs b/packages/lucide-react-native/rollup.config.mjs index 6cd2b3ca9..7bc643d76 100644 --- a/packages/lucide-react-native/rollup.config.mjs +++ b/packages/lucide-react-native/rollup.config.mjs @@ -1,6 +1,7 @@ import plugins from '@lucide/rollup-plugins'; import dts from 'rollup-plugin-dts'; import pkg from './package.json' with { type: 'json' }; +import getIconEntryNamesAndAliases from './scripts/getIconEntryNamesAndAliases.mts'; const packageName = 'LucideReact'; const outputFileName = 'lucide-react-native'; @@ -22,6 +23,9 @@ const bundles = [ }, ]; +const iconAndAliasEntries = await getIconEntryNamesAndAliases(); +const typesModuleMatcher = /[/\\]src[/\\]types\.(d\.)?ts$/; + const configs = bundles.map(({ inputs, outputDir, format, preserveModules, extension = 'js' }) => ({ input: inputs, plugins: plugins({ pkg }), @@ -52,7 +56,7 @@ export default [ input: inputs[0], output: [ { - file: `dist/${outputFileName}.d.ts`, + file: `dist/types/${outputFileName}.d.ts`, format: 'es', }, ], @@ -62,17 +66,32 @@ export default [ input: 'src/icons/index.ts', output: [ { - file: `dist/icons.d.ts`, + file: `dist/types/icons.d.ts`, format: 'es', }, ], plugins: [dts()], }, + { + input: iconAndAliasEntries, + // Treat `src/types.ts` as external so `LucideProps` isn't bundled into a shared + // hashed chunk; `output.paths` then rewrites the import to the main entry. + external: [typesModuleMatcher], + output: [ + { + format: 'es', + dir: 'dist/types/icons', + entryFileNames: '[name].d.ts', + paths: (id) => (typesModuleMatcher.test(id) ? `../${outputFileName}.js` : id), + }, + ], + plugins: [dts()], + }, { input: `src/${outputFileName}.suffixed.ts`, output: [ { - file: `dist/${outputFileName}.suffixed.d.ts`, + file: `dist/types/${outputFileName}.suffixed.d.ts`, format: 'es', }, ], @@ -82,7 +101,7 @@ export default [ input: `src/${outputFileName}.prefixed.ts`, output: [ { - file: `dist/${outputFileName}.prefixed.d.ts`, + file: `dist/types/${outputFileName}.prefixed.d.ts`, format: 'es', }, ], diff --git a/packages/lucide-react-native/scripts/getIconEntryNamesAndAliases.mts b/packages/lucide-react-native/scripts/getIconEntryNamesAndAliases.mts new file mode 100644 index 000000000..5c080cb4e --- /dev/null +++ b/packages/lucide-react-native/scripts/getIconEntryNamesAndAliases.mts @@ -0,0 +1,27 @@ +import path from 'path'; +import { readSvgDirectory } from '@lucide/helpers'; +import getIconMetaData from '@lucide/build-icons/utils/getIconMetaData'; + +const ICONS_DIR = path.resolve(process.cwd(), '../../icons'); + +const ignoreAliases = ['fingerprint']; + +export default async function getIconEntryNamesAndAliases() { + const svgFiles = await readSvgDirectory(ICONS_DIR); + const iconNames = svgFiles.map((file) => path.basename(file, '.svg')); + + const metaJsonFiles = await getIconMetaData(ICONS_DIR); + const iconWithAliases = Object.values(metaJsonFiles).filter(({ aliases }) => aliases != null); + + const aliases = iconWithAliases.flatMap(({ aliases }) => aliases ?? []); + const aliasNames = aliases + .filter((alias) => !ignoreAliases.includes(alias.name)) + .map((alias) => alias.name); + + // Return an object mapping the entry name to its source path so Rollup uses the + // key as the `[name]` placeholder and emits declaration files flat in the output + // directory instead of preserving the `src/icons/` directory structure. + return Object.fromEntries( + [...iconNames, ...aliasNames].map((name) => [name, path.join('src/icons', `${name}.ts`)]), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a67288bb1..8b04829e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,9 @@ importers: '@lucide/build-icons': specifier: workspace:* version: link:../../tools/build-icons + '@lucide/helpers': + specifier: workspace:* + version: link:../../tools/build-helpers '@lucide/rollup-plugins': specifier: workspace:* version: link:../../tools/rollup-plugins diff --git a/tools/build-helpers/tsconfig.json b/tools/build-helpers/tsconfig.json index 6df24e8cd..5c5b4fcff 100644 --- a/tools/build-helpers/tsconfig.json +++ b/tools/build-helpers/tsconfig.json @@ -4,8 +4,8 @@ "declaration": true, "noEmitOnError": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "module": "ESNext", + "moduleResolution": "nodenext", + "module": "NodeNext", "target": "ESNext", "esModuleInterop": true, "lib": ["esnext"], diff --git a/tools/build-icons/package.json b/tools/build-icons/package.json index d7fd03c3b..f9813b351 100644 --- a/tools/build-icons/package.json +++ b/tools/build-icons/package.json @@ -25,6 +25,10 @@ "./utils/*": { "import": "./utils/*.ts", "require": "./utils/*.ts" + }, + "./types": { + "types": "./types.ts", + "import": "./types.ts" } }, "keywords": [], diff --git a/tools/build-icons/tsconfig.json b/tools/build-icons/tsconfig.json index b786f8c4a..d8155044b 100644 --- a/tools/build-icons/tsconfig.json +++ b/tools/build-icons/tsconfig.json @@ -4,8 +4,8 @@ "declaration": true, "noEmitOnError": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "module": "ESNext", + "moduleResolution": "nodenext", + "module": "NodeNext", "target": "ESNext", "esModuleInterop": true, "lib": ["esnext"], diff --git a/tools/build-icons/types.ts b/tools/build-icons/types.ts index d1521975f..1806eb346 100644 --- a/tools/build-icons/types.ts +++ b/tools/build-icons/types.ts @@ -40,7 +40,7 @@ export type IconDeprecationReason = 'icon.renamed' | ''; export type IconMetadataBase = { toBeRemovedInVersion?: string; categories: string[]; - aliases?: (string | AliasDeprecation)[]; + aliases?: AliasDeprecation[]; tags: string[]; deprecationReason?: IconDeprecationReason; deprecated?: boolean;