From f15ccfcd358ed22787b2399b9b20b5b68d7c68b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 11:00:57 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01G9u8Fgx7hqgYx7gfUQgfY6 --- packages/lucide-react/src/Icon.ts | 152 +++++------------- .../shared/src/build/buildLucideIconNode.ts | 103 ++++++------ packages/shared/src/utils/mergeClasses.ts | 26 ++- 3 files changed, 97 insertions(+), 184 deletions(-) diff --git a/packages/lucide-react/src/Icon.ts b/packages/lucide-react/src/Icon.ts index ab946c2b0..694611fc3 100644 --- a/packages/lucide-react/src/Icon.ts +++ b/packages/lucide-react/src/Icon.ts @@ -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(); -const legacyIcons = new WeakMap(); - -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( 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); }, ); diff --git a/packages/shared/src/build/buildLucideIconNode.ts b/packages/shared/src/build/buildLucideIconNode.ts index 9bd52fed8..a88f323e7 100644 --- a/packages/shared/src/build/buildLucideIconNode.ts +++ b/packages/shared/src/build/buildLucideIconNode.ts @@ -13,29 +13,22 @@ function isDefined(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]; diff --git a/packages/shared/src/utils/mergeClasses.ts b/packages/shared/src/utils/mergeClasses.ts index d9770924f..9dfc54cdd 100644 --- a/packages/shared/src/utils/mergeClasses.ts +++ b/packages/shared/src/utils/mergeClasses.ts @@ -4,18 +4,14 @@ * @param {array} classes * @returns {string} A string of classes */ -export const mergeClasses = (...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 = (...classes: ClassType[]) => + classes + .filter((className, index, array) => { + return ( + Boolean(className) && + (className as string).trim() !== '' && + array.indexOf(className) === index + ); + }) + .join(' ') + .trim();