From e8f86fc2d2b35d62c8f7848f89068586755c73e7 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Tue, 27 Oct 2026 08:45:01 +0200 Subject: [PATCH] fix(lucide-react): ignore stale DynamicIcon loads after the name changes (#5060) When the name prop changes while the previous icon is still loading, the earlier import could resolve last and overwrite the newer icon, leaving DynamicIcon showing the wrong icon. The effect now ignores results from a superseded name. --- packages/lucide-react/src/DynamicIcon.ts | 12 ++++- .../lucide-react/tests/DynamicIcon.spec.tsx | 52 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/lucide-react/src/DynamicIcon.ts b/packages/lucide-react/src/DynamicIcon.ts index 61cf04e76..b6777d60c 100644 --- a/packages/lucide-react/src/DynamicIcon.ts +++ b/packages/lucide-react/src/DynamicIcon.ts @@ -55,11 +55,21 @@ const DynamicIcon = forwardRef( const [iconData, setIconData] = useState(); useEffect(() => { + let ignore = false; + getIconData(name) - .then(setIconData) + .then((data) => { + if (!ignore) { + setIconData(data); + } + }) .catch((error) => { console.error(error); }); + + return () => { + ignore = true; + }; }, [name]); if (iconData == null) { diff --git a/packages/lucide-react/tests/DynamicIcon.spec.tsx b/packages/lucide-react/tests/DynamicIcon.spec.tsx index a99ea6c15..d7c5a584f 100644 --- a/packages/lucide-react/tests/DynamicIcon.spec.tsx +++ b/packages/lucide-react/tests/DynamicIcon.spec.tsx @@ -1,8 +1,32 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { act, render, waitFor, type RenderResult } from '@testing-library/react'; import DynamicIcon from '../src/DynamicIcon'; +const { delayedImports, pendingImports } = vi.hoisted(() => ({ + delayedImports: new Map>(), + pendingImports: new Map>(), +})); + +vi.mock('../src/dynamicIconImports', async (importOriginal) => { + const { default: dynamicIconImports } = + await importOriginal(); + + return { + default: Object.fromEntries( + Object.entries(dynamicIconImports).map(([name, importIcon]) => [ + name, + () => { + const pendingImport = (delayedImports.get(name) ?? Promise.resolve()).then(importIcon); + pendingImports.set(name, pendingImport); + + return pendingImport; + }, + ]), + ), + }; +}); + describe('Using DynamicIcon Component', () => { it('should render icon by given name', async () => { let container: RenderResult['container']; @@ -81,4 +105,30 @@ describe('Using DynamicIcon Component', () => { expect(ref.current).toBeInstanceOf(SVGSVGElement); }); }); + + it('should render the latest icon when a previous icon finishes loading later', async () => { + let resolveCameraImport = () => {}; + delayedImports.set( + 'camera', + new Promise((resolve) => { + resolveCameraImport = resolve; + }), + ); + + const { container, rerender } = render(); + rerender(); + + await waitFor(() => { + expect(container.firstChild).toHaveClass('lucide-smile'); + }); + + await act(async () => { + resolveCameraImport(); + await pendingImports.get('camera'); + await new Promise((resolve) => setTimeout(resolve)); + }); + + expect(container.firstChild).toHaveClass('lucide-smile'); + expect(container.firstChild).not.toHaveClass('lucide-camera'); + }); });