refactor(lucide-react): use buildLucideIconForReact again in Icon

- Icon: drop the module-level icon cache and call the shared builder
  again; only the child elements are memoized with useMemo
- shared: restore the original mergeClasses and buildLucideIconNode

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-11 11:00:57 +00:00
parent f8d0e11faf
commit f15ccfcd35
3 changed files with 97 additions and 184 deletions
+38 -114
View File
@@ -1,7 +1,7 @@
'use client';
import { createElement, forwardRef, type ReactElement } from 'react';
import { defaultReactAttributes, hasA11yProp, mergeClasses } from '@lucide/shared';
import { createElement, forwardRef, useMemo, type ReactElement } from 'react';
import { buildLucideIconForReact, hasA11yProp, mergeClasses } from '@lucide/shared';
import { LucideIconData, LucideIconNode, LucideProps } from './types';
import { useLucideContext } from './context';
@@ -17,79 +17,10 @@ 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;
};
const renderNode = ([tag, attrs, children]: LucideIconNode): ReactElement =>
createElement(tag, attrs, children?.map(renderNode));
/**
* Lucide icon component
@@ -117,57 +48,50 @@ const Icon = forwardRef<SVGSVGElement, IconComponentProps>(
strokeWidth,
absoluteStrokeWidth,
nonScalingStroke,
className,
className = '',
children,
iconNode = EMPTY_ICON_NODE,
icon = getLegacyIcon(iconNode),
icon = {
node: iconNode,
aliases: [],
size: 24,
},
...rest
},
ref,
) => {
const context = useLucideContext();
const staticIcon = getStaticIcon(icon);
const {
size: contextSize = 24,
strokeWidth: contextStrokeWidth = 2,
absoluteStrokeWidth: contextAbsoluteStrokeWidth = false,
nonScalingStroke: contextNonScalingStroke = false,
color: contextColor = 'currentColor',
className: contextClass = '',
} = useLucideContext() ?? {};
const resolvedWidth = width ?? size ?? context.size ?? DEFAULT_SIZE;
const resolvedStrokeWidth =
strokeWidth ?? context.strokeWidth ?? defaultReactAttributes.strokeWidth;
const contextClassName = context.className;
const hasAccessibleProp = Boolean(children) || hasA11yProp(rest);
const resolvedNonScalingStroke = nonScalingStroke ?? contextNonScalingStroke;
let iconChildren = staticIcon.children;
const [name, svgAttributes, builtIconNode = []] = buildLucideIconForReact(icon, {
color: color ?? contextColor,
width: width ?? size ?? contextSize,
height: height ?? size ?? contextSize,
strokeWidth: strokeWidth ?? contextStrokeWidth,
absoluteStrokeWidth: absoluteStrokeWidth ?? contextAbsoluteStrokeWidth,
nonScalingStroke: resolvedNonScalingStroke,
className: mergeClasses(contextClass, className),
hasA11yProp: hasAccessibleProp,
attributes: rest,
});
if (nonScalingStroke ?? context.nonScalingStroke) {
iconChildren = staticIcon.nonScalingChildren ??= icon.node.map((node, index) =>
createNodeElement(node, index, NON_SCALING_STROKE),
);
}
return createElement(
'svg',
{
ref,
...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,
},
iconChildren,
children,
// The child elements only depend on the icon nodes and the non scaling stroke setting.
// Handing React the same elements again lets it skip them when the icon re-renders.
const iconChildren = useMemo(
() => builtIconNode.map(renderNode),
[icon.node, resolvedNonScalingStroke],
);
return createElement(name, { ref, ...svgAttributes }, iconChildren, children);
},
);
@@ -13,29 +13,22 @@ 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 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 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 calculatedStrokeWidth = params.absoluteStrokeWidth
? (Number(params.strokeWidth ?? defaultAttributes['stroke-width']) *
@@ -43,48 +36,48 @@ function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {
Number(params.size ?? params.width ?? defaultAttributes['width'])
: (params.strokeWidth ?? defaultAttributes['stroke-width']);
// 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;
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),
};
return [
'svg',
attributes,
icon.node.map((child): LucideIconNode => {
const [name, attrs, children] = child;
const nextAttrs = nonScalingStrokeAttributes
? { ...nonScalingStrokeAttributes, ...attrs }
const nextAttrs = params.nonScalingStroke
? { [getAttributeName('vector-effect')]: 'non-scaling-stroke', ...attrs }
: attrs;
return children ? [name, nextAttrs, children] : [name, nextAttrs];
+11 -15
View File
@@ -4,18 +4,14 @@
* @param {array} classes
* @returns {string} A string of classes
*/
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();
};
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();