perf(lucide-react): cache static icon work and speed up rendering

- Icon: build class names, view box and child elements once per icon
  data instead of on every render, and build the svg props directly
- Icon: render nested icon nodes, which were dropped before
- createLucideIcon: drop the redundant className destructuring
- DynamicIcon: remember loaded icon data so an icon that was loaded
  before renders right away, without the fallback
- shared: cheaper mergeClasses and buildLucideIconNode with identical
  output
- add a render benchmark (pnpm bench)

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G9u8Fgx7hqgYx7gfUQgfY6
This commit is contained in:
Claude
2026-11-10 22:50:10 +00:00
parent 03eae6069e
commit f8d0e11faf
9 changed files with 432 additions and 104 deletions
+1
View File
@@ -100,6 +100,7 @@
"typecheck": "pnpm build:icons && tsc --noEmit",
"typecheck:watch": "tsc -w",
"test": "pnpm build:icons && vitest run",
"bench": "NODE_ENV=production vitest bench --run --reporter=verbose",
"test:watch": "vitest watch"
},
"devDependencies": {
+18 -2
View File
@@ -19,6 +19,11 @@ interface DynamicIconComponentProps extends LucideProps {
fallback?: () => JSX.Element | null;
}
/**
* Icon data that has been loaded before, so an icon that is used again renders right away.
*/
const loadedIcons = new Map<IconName, LucideIconData>();
async function getIconData(name: IconName) {
if (!(name in dynamicIconImports)) {
throw new Error('[lucide-react]: Name in Lucide DynamicIcon not found');
@@ -28,6 +33,8 @@ async function getIconData(name: IconName) {
const icon = (await dynamicIconImports[name]()) as DynamicIconModule;
if (icon.__iconData != null) {
loadedIcons.set(name, icon.__iconData);
return icon.__iconData;
}
@@ -52,9 +59,18 @@ async function getIconData(name: IconName) {
*/
const DynamicIcon = forwardRef<SVGSVGElement, DynamicIconComponentProps>(
({ name, fallback: Fallback, ...props }, ref) => {
const [iconData, setIconData] = useState<LucideIconData>();
const loadedIcon = loadedIcons.get(name);
const [lastIconData, setIconData] = useState(loadedIcon);
// While another icon is loading, the previous one stays in place.
const iconData = loadedIcon ?? lastIconData;
useEffect(() => {
if (loadedIcon != null) {
setIconData(loadedIcon);
return;
}
let ignore = false;
getIconData(name)
@@ -70,7 +86,7 @@ const DynamicIcon = forwardRef<SVGSVGElement, DynamicIconComponentProps>(
return () => {
ignore = true;
};
}, [name]);
}, [name, loadedIcon]);
if (iconData == null) {
if (Fallback == null) {
+114 -35
View File
@@ -1,7 +1,7 @@
'use client';
import { createElement, forwardRef } from 'react';
import { buildLucideIconForReact, hasA11yProp, mergeClasses } from '@lucide/shared';
import { createElement, forwardRef, type ReactElement } from 'react';
import { defaultReactAttributes, hasA11yProp, mergeClasses } from '@lucide/shared';
import { LucideIconData, LucideIconNode, LucideProps } from './types';
import { useLucideContext } from './context';
@@ -17,6 +17,80 @@ type IconComponentProps = LucideProps &
}
);
/**
* Everything about an icon that doesn't depend on the props it is rendered with.
*/
type StaticIcon = {
viewBox: string;
classNames: string[];
className: string;
children: ReactElement[];
nonScalingChildren?: ReactElement[];
};
const DEFAULT_SIZE = defaultReactAttributes.width;
const EMPTY_ICON_NODE: LucideIconNode[] = [];
const NON_SCALING_STROKE = { vectorEffect: 'non-scaling-stroke' } as const;
const staticIcons = new WeakMap<LucideIconData, StaticIcon>();
const legacyIcons = new WeakMap<LucideIconNode[], LucideIconData>();
const createNodeElement = (
[tag, attrs, children]: LucideIconNode,
index: number,
extraAttrs?: object,
): ReactElement =>
createElement(
tag,
// The generated icons carry a key per node, custom icon nodes may not.
{ key: index, ...extraAttrs, ...attrs },
children?.map((child, childIndex) => createNodeElement(child, childIndex)),
);
/**
* Icon data is static, so the class names, the view box and the child elements are created once
* per icon instead of on every render. Handing React the very same child elements again also lets
* it skip them when an icon re-renders.
*/
const getStaticIcon = (icon: LucideIconData): StaticIcon => {
let staticIcon = staticIcons.get(icon);
if (staticIcon == null) {
const classNames = ['lucide'];
if (icon.name) {
classNames.push(`lucide-${icon.name}`);
}
for (const alias of icon.aliases ?? []) {
if (typeof alias === 'string' && alias.trim() !== '') {
classNames.push(`lucide-${alias}`);
}
}
staticIcon = {
viewBox: `0 0 ${icon.size ?? icon.width ?? DEFAULT_SIZE} ${icon.size ?? icon.height ?? DEFAULT_SIZE}`,
classNames,
className: mergeClasses(...classNames),
children: icon.node.map((node, index) => createNodeElement(node, index)),
};
staticIcons.set(icon, staticIcon);
}
return staticIcon;
};
const getLegacyIcon = (iconNode: LucideIconNode[]): LucideIconData => {
let icon = legacyIcons.get(iconNode);
if (icon == null) {
icon = { node: iconNode, size: DEFAULT_SIZE };
legacyIcons.set(iconNode, icon);
}
return icon;
};
/**
* Lucide icon component
*
@@ -43,51 +117,56 @@ const Icon = forwardRef<SVGSVGElement, IconComponentProps>(
strokeWidth,
absoluteStrokeWidth,
nonScalingStroke,
className = '',
className,
children,
iconNode = [],
icon = {
node: iconNode,
aliases: [],
size: 24,
},
iconNode = EMPTY_ICON_NODE,
icon = getLegacyIcon(iconNode),
...rest
},
ref,
) => {
const {
size: contextSize = 24,
strokeWidth: contextStrokeWidth = 2,
absoluteStrokeWidth: contextAbsoluteStrokeWidth = false,
nonScalingStroke: contextNonScalingStroke = false,
color: contextColor = 'currentColor',
className: contextClass = '',
} = useLucideContext() ?? {};
const context = useLucideContext();
const staticIcon = getStaticIcon(icon);
const hasAccessibleProp = Boolean(children) || hasA11yProp(rest);
const resolvedWidth = width ?? size ?? context.size ?? DEFAULT_SIZE;
const resolvedStrokeWidth =
strokeWidth ?? context.strokeWidth ?? defaultReactAttributes.strokeWidth;
const contextClassName = context.className;
const [name, svgAttributes, builtIconNode = []] = buildLucideIconForReact(icon, {
color: color ?? contextColor,
width: width ?? size ?? contextSize,
height: height ?? size ?? contextSize,
strokeWidth: strokeWidth ?? contextStrokeWidth,
absoluteStrokeWidth: absoluteStrokeWidth ?? contextAbsoluteStrokeWidth,
nonScalingStroke: nonScalingStroke ?? contextNonScalingStroke,
className: mergeClasses(contextClass, className),
hasA11yProp: hasAccessibleProp,
attributes: rest,
});
let iconChildren = staticIcon.children;
if (nonScalingStroke ?? context.nonScalingStroke) {
iconChildren = staticIcon.nonScalingChildren ??= icon.node.map((node, index) =>
createNodeElement(node, index, NON_SCALING_STROKE),
);
}
return createElement(
name,
'svg',
{
ref,
...svgAttributes,
...defaultReactAttributes,
width: resolvedWidth,
height: height ?? size ?? context.size ?? DEFAULT_SIZE,
viewBox: staticIcon.viewBox,
stroke: (color ?? context.color) || defaultReactAttributes.stroke,
strokeWidth:
(absoluteStrokeWidth ?? context.absoluteStrokeWidth)
? (Number(resolvedStrokeWidth) * Number(icon.size ?? icon.width ?? DEFAULT_SIZE)) /
Number(resolvedWidth)
: resolvedStrokeWidth,
className:
className || contextClassName
? mergeClasses(
...staticIcon.classNames,
...`${contextClassName ?? ''} ${className ?? ''}`.split(' '),
)
: staticIcon.className,
...(!children && !hasA11yProp(rest) && { 'aria-hidden': 'true' }),
...rest,
},
[
...builtIconNode.map(([tag, attrs]) => createElement(tag, attrs)),
...(Array.isArray(children) ? children : [children]),
],
iconChildren,
children,
);
},
);
@@ -35,13 +35,8 @@ function createLucideIcon(
? toLucideIconData(iconDataOrName, iconNode, aliases)
: iconDataOrName;
const Component = forwardRef<SVGSVGElement, LucideProps>(({ className, ...props }, ref) =>
createElement(Icon, {
ref,
icon: iconData,
className,
...props,
}),
const Component = forwardRef<SVGSVGElement, LucideProps>((props, ref) =>
createElement(Icon, { ...props, ref, icon: iconData }),
);
if (iconData.name) {
@@ -81,7 +81,7 @@ describe('Using DynamicIcon Component', () => {
const FallbackComponent = () => <span data-testid="fallback">Loading...</span>;
const { getByTestId } = render(
<DynamicIcon
name="smile"
name="airplay"
fallback={FallbackComponent}
/>,
);
@@ -89,6 +89,37 @@ describe('Using DynamicIcon Component', () => {
expect(getByTestId('fallback')).not.toBeNull();
});
it('should render an icon that was loaded before right away', async () => {
const FallbackComponent = () => <span data-testid="fallback">Loading...</span>;
const first = render(<DynamicIcon name="anchor" />);
await waitFor(() => {
expect(first.container.firstChild).toHaveClass('lucide-anchor');
});
const second = render(
<DynamicIcon
name="anchor"
fallback={FallbackComponent}
/>,
);
expect(second.queryByTestId('fallback')).toBeNull();
expect(second.container.firstChild).toHaveClass('lucide-anchor');
});
it('should switch to an icon that was loaded before right away', async () => {
const { container, rerender } = render(<DynamicIcon name="anchor" />);
await waitFor(() => {
expect(container.firstChild).toHaveClass('lucide-anchor');
});
rerender(<DynamicIcon name="smile" />);
expect(container.firstChild).toHaveClass('lucide-smile');
});
it('should forward ref to the rendered svg element', async () => {
const ref = { current: null as SVGSVGElement | null };
+102
View File
@@ -0,0 +1,102 @@
import { describe, test } from 'vitest';
import { createElement, type ReactElement } from 'react';
// @ts-ignore react-dom ships without type declarations
import { flushSync } from 'react-dom';
// @ts-ignore react-dom ships without type declarations
import { createRoot } from 'react-dom/client';
// @ts-ignore react-dom ships without type declarations
import { renderToString } from 'react-dom/server';
import { createLucideIcon, LucideProvider } from '../src/lucide-react';
import { airVent, coffee } from './testIconNodes';
// Benchmarks drive React directly, outside of act().
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
const COUNT = 1000;
const AirVent = createLucideIcon({ name: 'air-vent', size: 24, node: airVent });
const Coffee = createLucideIcon({ name: 'coffee', size: 24, node: coffee, aliases: ['cup'] });
const list = (props: Record<string, unknown> = {}) =>
createElement(
'div',
null,
Array.from({ length: COUNT }, (_, index) =>
createElement(index % 2 ? AirVent : Coffee, { key: index, ...props }),
),
);
const withProvider = (children: ReactElement) =>
createElement(LucideProvider, { size: 32, color: 'red', strokeWidth: 1.5, children });
const mount = (element: ReactElement) => {
const root = createRoot(document.createElement('div'));
flushSync(() => root.render(element));
root.unmount();
};
const rerenderer = (make: () => ReactElement) => {
const root = createRoot(document.createElement('div'));
flushSync(() => root.render(make()));
return () => flushSync(() => root.render(make()));
};
describe(`server render of ${COUNT} icons`, () => {
test('default props', async ({ bench }) => {
await bench('default props', () => {
renderToString(list());
}).run();
});
test('with props', async ({ bench }) => {
await bench('with props', () => {
renderToString(list({ size: 32, color: 'red', className: 'icon', strokeWidth: 1.5 }));
}).run();
});
});
describe(`client mount of ${COUNT} icons`, () => {
test('default props', async ({ bench }) => {
await bench('default props', () => {
mount(list());
}).run();
});
test('inside LucideProvider', async ({ bench }) => {
await bench('inside LucideProvider', () => {
mount(withProvider(list()));
}).run();
});
test('nonScalingStroke', async ({ bench }) => {
await bench('nonScalingStroke', () => {
mount(list({ nonScalingStroke: true }));
}).run();
});
});
describe(`client re-render of ${COUNT} icons`, () => {
const rerenderDefault = rerenderer(() => list());
const rerenderProps = rerenderer(() => list({ size: 32, color: 'red', className: 'icon' }));
const rerenderProvider = rerenderer(() => withProvider(list()));
test('default props', async ({ bench }) => {
await bench('default props', () => {
rerenderDefault();
}).run();
});
test('with props', async ({ bench }) => {
await bench('with props', () => {
rerenderProps();
}).run();
});
test('inside LucideProvider', async ({ bench }) => {
await bench('inside LucideProvider', () => {
rerenderProvider();
}).run();
});
});
+93
View File
@@ -32,6 +32,99 @@ describe('Using Icon Component', () => {
});
});
describe('Icon Component static icon data', () => {
it('should render nested icon nodes', () => {
const { container } = render(
<Icon
icon={{
name: 'nested',
size: 24,
node: [['g', { fill: 'red' }, [['path', { d: 'M6 8h12' }]]]],
}}
/>,
);
expect(container.querySelector('svg > g > path')).toHaveAttribute('d', 'M6 8h12');
});
it('should add and remove the non scaling stroke on the same icon', () => {
const { container, rerender } = render(<Icon iconNode={airVent} />);
const getVectorEffects = () =>
Array.from(container.querySelectorAll('svg > *')).map((child) =>
child.getAttribute('vector-effect'),
);
expect(getVectorEffects()).toEqual([null, null, null, null]);
rerender(
<Icon
iconNode={airVent}
nonScalingStroke
/>,
);
expect(getVectorEffects()).toEqual(Array(4).fill('non-scaling-stroke'));
rerender(<Icon iconNode={airVent} />);
expect(getVectorEffects()).toEqual([null, null, null, null]);
});
it('should keep the rendered elements when props change', () => {
const { container, rerender } = render(<Icon iconNode={airVent} />);
const svg = container.firstChild;
const paths = Array.from(container.querySelectorAll('path'));
rerender(
<Icon
iconNode={airVent}
size={48}
className="large"
/>,
);
expect(container.firstChild).toBe(svg);
expect(Array.from(container.querySelectorAll('path'))).toEqual(paths);
expect(svg).toHaveAttribute('width', '48');
expect(svg).toHaveClass('lucide', 'large');
});
it('should render the same icon data with different props independently', () => {
const { container } = render(
<>
<Icon
iconNode={airVent}
size={16}
className="small"
/>
<Icon
iconNode={airVent}
nonScalingStroke
/>
</>,
);
const [small, nonScaling] = Array.from(container.querySelectorAll('svg'));
expect(small).toHaveAttribute('width', '16');
expect(small).toHaveClass('small');
expect(small.querySelector('[vector-effect]')).toBeNull();
expect(nonScaling).toHaveAttribute('width', '24');
expect(nonScaling).not.toHaveClass('small');
expect(nonScaling.querySelectorAll('[vector-effect]')).toHaveLength(4);
});
it('should render user children after the icon nodes', () => {
const { container } = render(
<Icon iconNode={airVent}>
<title>Air vent</title>
<circle r="1" />
</Icon>,
);
expect(Array.from(container.querySelectorAll('svg > *')).map((child) => child.tagName)).toEqual(
['path', 'path', 'path', 'path', 'title', 'circle'],
);
});
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = render(
@@ -13,22 +13,29 @@ function isDefined<T>(value: T | null | undefined): value is T {
* @param params Additional build parameters.
*/
function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {}): LucideIconNode {
const attributeNames = params.attributeNames ?? {};
const attributeNames = params.attributeNames;
const getAttributeName = (attributeName: string) =>
attributeNames[attributeName] ?? attributeName;
attributeNames?.[attributeName] ?? attributeName;
const viewBoxWidth = icon.size ?? icon.width ?? defaultAttributes['width'];
const viewBoxHeight = icon.size ?? icon.height ?? defaultAttributes['height'];
const aliasClassNames =
icon.aliases
?.filter((alias: string): alias is string => typeof alias === 'string' && alias.trim() !== '')
.map((alias: string) => `lucide-${alias}`) ?? [];
const iconClassNames = [...(icon.name ? [`lucide-${icon.name}`] : []), ...aliasClassNames];
const classNamesFromClassName = params.className?.split(' ').filter(Boolean) ?? [];
const className =
params.includeDefaultClasses === false
? mergeClasses(...classNamesFromClassName)
: mergeClasses('lucide', ...iconClassNames, ...classNamesFromClassName);
const classNames: string[] = [];
if (params.includeDefaultClasses !== false) {
classNames.push('lucide');
if (icon.name) {
classNames.push(`lucide-${icon.name}`);
}
for (const alias of icon.aliases ?? []) {
if (typeof alias === 'string' && alias.trim() !== '') {
classNames.push(`lucide-${alias}`);
}
}
}
if (params.className) {
classNames.push(...params.className.split(' '));
}
const className = mergeClasses(...classNames);
const calculatedStrokeWidth = params.absoluteStrokeWidth
? (Number(params.strokeWidth ?? defaultAttributes['stroke-width']) *
@@ -36,48 +43,48 @@ function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {
Number(params.size ?? params.width ?? defaultAttributes['width'])
: (params.strokeWidth ?? defaultAttributes['stroke-width']);
const attributes = {
...Object.entries(defaultAttributes).reduce((attrs, [attrName, value]) => {
attrs[getAttributeName(attrName)] = value;
return attrs;
}, {} as SVGProps),
...('color' in params &&
params.color && {
[getAttributeName('stroke')]: params.color,
}),
...('size' in params &&
isDefined(params.size) && {
[getAttributeName('width')]: params.size,
[getAttributeName('height')]: params.size,
}),
...('width' in params &&
isDefined(params.width) && {
[getAttributeName('width')]: params.width,
}),
...('height' in params &&
isDefined(params.height) && {
[getAttributeName('height')]: params.height,
}),
[getAttributeName('stroke-width')]: calculatedStrokeWidth,
...(className && {
[getAttributeName('class')]: className,
}),
[getAttributeName('viewBox')]: `0 0 ${viewBoxWidth} ${viewBoxHeight}`,
...(params.hasA11yProp === false
? {
[getAttributeName('aria-hidden')]: 'true',
}
: {}),
...('attributes' in params && params.attributes),
};
// Keys are assigned in a fixed order, so the attributes are always emitted in the same order.
const attributes: SVGProps = {};
for (const attributeName in defaultAttributes) {
attributes[getAttributeName(attributeName)] =
defaultAttributes[attributeName as keyof typeof defaultAttributes];
}
if (params.color) {
attributes[getAttributeName('stroke')] = params.color;
}
if (isDefined(params.size)) {
attributes[getAttributeName('width')] = params.size;
attributes[getAttributeName('height')] = params.size;
}
if (isDefined(params.width)) {
attributes[getAttributeName('width')] = params.width;
}
if (isDefined(params.height)) {
attributes[getAttributeName('height')] = params.height;
}
attributes[getAttributeName('stroke-width')] = calculatedStrokeWidth;
if (className) {
attributes[getAttributeName('class')] = className;
}
attributes[getAttributeName('viewBox')] = `0 0 ${viewBoxWidth} ${viewBoxHeight}`;
if (params.hasA11yProp === false) {
attributes[getAttributeName('aria-hidden')] = 'true';
}
Object.assign(attributes, params.attributes);
const nonScalingStrokeAttributes = params.nonScalingStroke
? { [getAttributeName('vector-effect')]: 'non-scaling-stroke' }
: null;
return [
'svg',
attributes,
icon.node.map((child): LucideIconNode => {
const [name, attrs, children] = child;
const nextAttrs = params.nonScalingStroke
? { [getAttributeName('vector-effect')]: 'non-scaling-stroke', ...attrs }
const nextAttrs = nonScalingStrokeAttributes
? { ...nonScalingStrokeAttributes, ...attrs }
: attrs;
return children ? [name, nextAttrs, children] : [name, nextAttrs];
+15 -11
View File
@@ -4,14 +4,18 @@
* @param {array} classes
* @returns {string} A string of classes
*/
export const mergeClasses = <ClassType = string | undefined | null>(...classes: ClassType[]) =>
classes
.filter((className, index, array) => {
return (
Boolean(className) &&
(className as string).trim() !== '' &&
array.indexOf(className) === index
);
})
.join(' ')
.trim();
export const mergeClasses = <ClassType = string | undefined | null>(...classes: ClassType[]) => {
const uniqueClasses: ClassType[] = [];
for (const className of classes) {
if (
Boolean(className) &&
(className as string).trim() !== '' &&
!uniqueClasses.includes(className)
) {
uniqueClasses.push(className);
}
}
return uniqueClasses.join(' ').trim();
};