diff --git a/packages/lucide-react/package.json b/packages/lucide-react/package.json index d4276e633..7f72eb3ae 100644 --- a/packages/lucide-react/package.json +++ b/packages/lucide-react/package.json @@ -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": { diff --git a/packages/lucide-react/src/DynamicIcon.ts b/packages/lucide-react/src/DynamicIcon.ts index b6777d60c..20daf468b 100644 --- a/packages/lucide-react/src/DynamicIcon.ts +++ b/packages/lucide-react/src/DynamicIcon.ts @@ -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(); + 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( ({ name, fallback: Fallback, ...props }, ref) => { - const [iconData, setIconData] = useState(); + 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( return () => { ignore = true; }; - }, [name]); + }, [name, loadedIcon]); if (iconData == null) { if (Fallback == null) { diff --git a/packages/lucide-react/src/Icon.ts b/packages/lucide-react/src/Icon.ts index cf52b9688..ab946c2b0 100644 --- a/packages/lucide-react/src/Icon.ts +++ b/packages/lucide-react/src/Icon.ts @@ -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(); +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; +}; + /** * Lucide icon component * @@ -43,51 +117,56 @@ const Icon = forwardRef( 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, ); }, ); diff --git a/packages/lucide-react/src/createLucideIcon.ts b/packages/lucide-react/src/createLucideIcon.ts index 13e77a3e6..c20162fd8 100644 --- a/packages/lucide-react/src/createLucideIcon.ts +++ b/packages/lucide-react/src/createLucideIcon.ts @@ -35,13 +35,8 @@ function createLucideIcon( ? toLucideIconData(iconDataOrName, iconNode, aliases) : iconDataOrName; - const Component = forwardRef(({ className, ...props }, ref) => - createElement(Icon, { - ref, - icon: iconData, - className, - ...props, - }), + const Component = forwardRef((props, ref) => + createElement(Icon, { ...props, ref, icon: iconData }), ); if (iconData.name) { diff --git a/packages/lucide-react/tests/DynamicIcon.spec.tsx b/packages/lucide-react/tests/DynamicIcon.spec.tsx index d7c5a584f..0b5577573 100644 --- a/packages/lucide-react/tests/DynamicIcon.spec.tsx +++ b/packages/lucide-react/tests/DynamicIcon.spec.tsx @@ -81,7 +81,7 @@ describe('Using DynamicIcon Component', () => { const FallbackComponent = () => Loading...; const { getByTestId } = render( , ); @@ -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 = () => Loading...; + const first = render(); + + await waitFor(() => { + expect(first.container.firstChild).toHaveClass('lucide-anchor'); + }); + + const second = render( + , + ); + + 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(); + + await waitFor(() => { + expect(container.firstChild).toHaveClass('lucide-anchor'); + }); + + rerender(); + + expect(container.firstChild).toHaveClass('lucide-smile'); + }); + it('should forward ref to the rendered svg element', async () => { const ref = { current: null as SVGSVGElement | null }; diff --git a/packages/lucide-react/tests/Icon.bench.tsx b/packages/lucide-react/tests/Icon.bench.tsx new file mode 100644 index 000000000..5255c659e --- /dev/null +++ b/packages/lucide-react/tests/Icon.bench.tsx @@ -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 = {}) => + 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(); + }); +}); diff --git a/packages/lucide-react/tests/Icon.spec.tsx b/packages/lucide-react/tests/Icon.spec.tsx index 612b58e7d..75472b41f 100644 --- a/packages/lucide-react/tests/Icon.spec.tsx +++ b/packages/lucide-react/tests/Icon.spec.tsx @@ -32,6 +32,99 @@ describe('Using Icon Component', () => { }); }); +describe('Icon Component static icon data', () => { + it('should render nested icon nodes', () => { + const { container } = render( + , + ); + + 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(); + const getVectorEffects = () => + Array.from(container.querySelectorAll('svg > *')).map((child) => + child.getAttribute('vector-effect'), + ); + + expect(getVectorEffects()).toEqual([null, null, null, null]); + + rerender( + , + ); + expect(getVectorEffects()).toEqual(Array(4).fill('non-scaling-stroke')); + + rerender(); + expect(getVectorEffects()).toEqual([null, null, null, null]); + }); + + it('should keep the rendered elements when props change', () => { + const { container, rerender } = render(); + const svg = container.firstChild; + const paths = Array.from(container.querySelectorAll('path')); + + rerender( + , + ); + + 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( + <> + + + , + ); + 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( + + Air vent + + , + ); + + 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( diff --git a/packages/shared/src/build/buildLucideIconNode.ts b/packages/shared/src/build/buildLucideIconNode.ts index a88f323e7..9bd52fed8 100644 --- a/packages/shared/src/build/buildLucideIconNode.ts +++ b/packages/shared/src/build/buildLucideIconNode.ts @@ -13,22 +13,29 @@ 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 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]; diff --git a/packages/shared/src/utils/mergeClasses.ts b/packages/shared/src/utils/mergeClasses.ts index 9dfc54cdd..d9770924f 100644 --- a/packages/shared/src/utils/mergeClasses.ts +++ b/packages/shared/src/utils/mergeClasses.ts @@ -4,14 +4,18 @@ * @param {array} classes * @returns {string} A string of classes */ -export const mergeClasses = (...classes: ClassType[]) => - classes - .filter((className, index, array) => { - return ( - Boolean(className) && - (className as string).trim() !== '' && - array.indexOf(className) === index - ); - }) - .join(' ') - .trim(); +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(); +};