Refine: icon loading

This commit is contained in:
aleksey-hoffman committed 2026-07-12 14:11:40 +03:00
1 parent 93167d7952
commit 54499cda27
2 files changed
+254 -87

No files matched your search

+172 -7
View File
@@ -2,9 +2,41 @@
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import { CircleIcon } from '@lucide/vue';
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { getLucideIcon } from '@/utils/lucide-icons';
import { describe, expect, it, vi } from 'vitest';
import {
defineComponent,
h,
type Component,
} from 'vue';
import {
createLucideIconResolver,
getLucideIcon,
} from '@/utils/lucide-icons';
function createDeferred<Value>() {
let resolvePromise!: (value: Value) => void;
const promise = new Promise<Value>((resolve) => {
resolvePromise = resolve;
});
return {
promise,
resolve: resolvePromise,
};
}
function mountIcon(
icon: Component,
attrs: Record<string, unknown> = {},
) {
return mount(defineComponent({
setup() {
return () => h(icon, attrs);
},
}));
}
async function waitForIconHtml(
getHtml: () => string,
@@ -24,6 +56,140 @@ async function waitForIconHtml(
return getHtml();
}
describe('createLucideIconResolver', () => {
it('deduplicates concurrent mounts and renders resolved remounts immediately', async () => {
const deferredModule = createDeferred<{ default: Component }>();
const loader = vi.fn(() => deferredModule.promise);
const resolveIcon = createLucideIconResolver(new Map([
['circle', loader],
]));
const Icon = resolveIcon('Circle');
expect(Icon).toBeDefined();
expect(resolveIcon('CircleIcon')).toBe(Icon);
const firstWrapper = mountIcon(Icon!);
const secondWrapper = mountIcon(Icon!);
expect(firstWrapper.html()).not.toContain('lucide-blocks');
expect(secondWrapper.html()).not.toContain('lucide-blocks');
expect(loader).toHaveBeenCalledTimes(1);
deferredModule.resolve({ default: CircleIcon });
await flushPromises();
expect(firstWrapper.html()).toContain('lucide-circle');
expect(secondWrapper.html()).toContain('lucide-circle');
firstWrapper.unmount();
secondWrapper.unmount();
const remountedWrapper = mountIcon(Icon!);
expect(remountedWrapper.html()).toContain('lucide-circle');
expect(remountedWrapper.html()).not.toContain('visibility: hidden');
});
it('forwards attributes and listeners through loading and resolved states', async () => {
const deferredModule = createDeferred<{ default: Component }>();
const loader = vi.fn(() => deferredModule.promise);
const handleClick = vi.fn();
const resolveIcon = createLucideIconResolver(new Map([
['circle', loader],
]));
const Icon = resolveIcon('Circle');
const wrapper = mountIcon(Icon!, {
'aria-label': 'Circle icon',
'class': 'navigation-icon',
'onClick': handleClick,
'size': 18,
});
const loadingSvg = wrapper.get('svg');
const loadingClasses = loadingSvg.classes()
.filter(className => className === 'navigation-icon');
expect(loadingClasses).toHaveLength(1);
expect(loadingSvg.attributes('aria-label')).toBe('Circle icon');
expect(loadingSvg.attributes('width')).toBe('18');
expect(loadingSvg.attributes('height')).toBe('18');
expect(loadingSvg.attributes('style')).toContain('visibility: hidden');
await loadingSvg.trigger('click');
expect(handleClick).toHaveBeenCalledTimes(1);
deferredModule.resolve({ default: CircleIcon });
await flushPromises();
const resolvedSvg = wrapper.get('svg');
const resolvedClasses = resolvedSvg.classes()
.filter(className => className === 'navigation-icon');
expect(resolvedClasses).toHaveLength(1);
expect(resolvedSvg.attributes('aria-label')).toBe('Circle icon');
expect(resolvedSvg.attributes('width')).toBe('18');
expect(resolvedSvg.attributes('height')).toBe('18');
await resolvedSvg.trigger('click');
expect(handleClick).toHaveBeenCalledTimes(2);
});
it('settles rejected loaders on Blocks without retrying after remount', async () => {
const loader = vi.fn(() => Promise.reject(new Error('Forced icon load failure')));
const handleClick = vi.fn();
const resolveIcon = createLucideIconResolver(new Map([
['failed', loader],
]));
const Icon = resolveIcon('FailedIcon');
const firstWrapper = mountIcon(Icon!, {
'aria-label': 'Unavailable icon',
'class': 'failed-icon',
'onClick': handleClick,
'size': 16,
});
await flushPromises();
const fallbackSvg = firstWrapper.get('svg');
const fallbackClasses = fallbackSvg.classes()
.filter(className => className === 'failed-icon');
expect(firstWrapper.html()).toContain('lucide-blocks');
expect(fallbackClasses).toHaveLength(1);
expect(fallbackSvg.attributes('aria-label')).toBe('Unavailable icon');
expect(fallbackSvg.attributes('width')).toBe('16');
await fallbackSvg.trigger('click');
expect(handleClick).toHaveBeenCalledTimes(1);
firstWrapper.unmount();
const remountedWrapper = mountIcon(Icon!);
expect(remountedWrapper.html()).toContain('lucide-blocks');
expect(loader).toHaveBeenCalledTimes(1);
});
it('retains a resolved icon after its first wrapper unmounts during loading', async () => {
const deferredModule = createDeferred<{ default: Component }>();
const loader = vi.fn(() => deferredModule.promise);
const resolveIcon = createLucideIconResolver(new Map([
['circle', loader],
]));
const Icon = resolveIcon('Circle');
const wrapper = mountIcon(Icon!);
wrapper.unmount();
deferredModule.resolve({ default: CircleIcon });
await flushPromises();
const remountedWrapper = mountIcon(Icon!);
expect(remountedWrapper.html()).toContain('lucide-circle');
expect(loader).toHaveBeenCalledTimes(1);
});
});
describe('getLucideIcon', () => {
it('resolves known lucide icons to a stable component', () => {
const first = getLucideIcon('PencilRuler');
@@ -37,7 +203,7 @@ describe('getLucideIcon', () => {
const Icon = getLucideIcon('PencilRuler');
expect(Icon).toBeDefined();
const wrapper = mount(Icon!);
const wrapper = mountIcon(Icon!);
const html = await waitForIconHtml(() => wrapper.html(), 'lucide-pencil-ruler');
expect(html).toContain('lucide-pencil-ruler');
@@ -50,17 +216,16 @@ describe('getLucideIcon', () => {
expect(getLucideIcon(' ')).toBeUndefined();
});
it('renders Blocks through a stable fallback wrapper for unknown icon names', async () => {
it('renders Blocks immediately for unknown icon names', () => {
const first = getLucideIcon('DefinitelyNotARealLucideIcon');
const second = getLucideIcon('DefinitelyNotARealLucideIcon');
expect(first).toBeDefined();
expect(second).toBe(first);
const wrapper = mount(first!);
const html = await waitForIconHtml(() => wrapper.html(), 'lucide-blocks');
const wrapper = mountIcon(first!);
expect(html).toContain('lucide-blocks');
expect(wrapper.html()).toContain('lucide-blocks');
expect(getLucideIcon('DefinitelyNotARealLucideIcon')).toBe(first);
});
});
+82 -80
View File
@@ -3,24 +3,59 @@
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import {
defineAsyncComponent,
defineComponent,
h,
onMounted,
shallowRef,
useAttrs,
type Component,
type VNode,
} from 'vue';
import { Blocks } from '@lucide/vue';
const iconModules = import.meta.glob<{ default: Component }>([
interface IconModule {
default: Component;
}
type IconModuleLoader = () => Promise<IconModule>;
const iconModules = import.meta.glob<IconModule>([
'../../node_modules/@lucide/vue/dist/esm/icons/*.mjs',
'../../node_modules/@lucide/vue/dist/esm/icons/*.js',
]);
const iconCache = new Map<string, Component>();
const iconLoadPromises = new Map<string, Promise<Component>>();
const iconWrapperCache = new Map<string, Component>();
const iconModuleLoaders = new Map<string, IconModuleLoader>();
for (const [modulePath, loader] of Object.entries(iconModules)) {
const normalizedPath = modulePath.replace(/\\/g, '/');
const fileName = normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1);
const kebabName = fileName.replace(/\.(?:mjs|js)$/, '');
if (!iconModuleLoaders.has(kebabName)) {
iconModuleLoaders.set(kebabName, loader);
}
}
const LucideIconLoading = defineComponent({
name: 'LucideIconLoading',
inheritAttrs: false,
setup(_, { attrs }) {
return () => {
const {
size = 24,
style,
...svgAttrs
} = attrs;
return h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
...svgAttrs,
width: size,
height: size,
style: [style, { visibility: 'hidden' }],
});
};
},
});
function toKebabCase(name: string): string {
const withoutIcon = name.trim().replace(/Icon$/i, '');
@@ -30,86 +65,53 @@ function toKebabCase(name: string): string {
.toLowerCase();
}
function getIconModulePath(kebabName: string): string {
const paths = Object.keys(iconModules);
return paths.find((path) => {
const normalizedPath = path.replace(/\\/g, '/');
return normalizedPath.endsWith(`/${kebabName}.mjs`)
|| normalizedPath.endsWith(`/${kebabName}.js`);
}) ?? '';
}
export function createLucideIconResolver(
moduleLoaders: ReadonlyMap<string, IconModuleLoader>,
): (name: string) => Component | undefined {
const iconWrappers = new Map<string, Component>();
function loadIcon(kebabName: string): Promise<Component> {
const cached = iconCache.get(kebabName);
return (name: string): Component | undefined => {
if (!name || typeof name !== 'string') {
return undefined;
}
if (cached) {
return Promise.resolve(cached);
}
const kebabName = toKebabCase(name);
const pendingLoad = iconLoadPromises.get(kebabName);
if (!kebabName) {
return undefined;
}
if (pendingLoad) {
return pendingLoad;
}
const cachedWrapper = iconWrappers.get(kebabName);
const modulePath = getIconModulePath(kebabName);
const loader = modulePath ? iconModules[modulePath] : undefined;
if (cachedWrapper) {
return cachedWrapper;
}
if (!loader) {
return Promise.resolve(Blocks);
}
const loader = moduleLoaders.get(kebabName);
const loadPromise = loader()
.then((module) => {
const iconComponent = module.default as Component;
iconCache.set(kebabName, iconComponent);
return iconComponent;
})
.catch(() => Blocks)
.finally(() => {
iconLoadPromises.delete(kebabName);
if (!loader) {
iconWrappers.set(kebabName, Blocks);
return Blocks;
}
const wrapper = defineAsyncComponent({
loader: async () => {
try {
const iconModule = await loader();
return iconModule.default;
}
catch {
return Blocks;
}
},
loadingComponent: LucideIconLoading,
delay: 0,
suspensible: false,
});
iconLoadPromises.set(kebabName, loadPromise);
return loadPromise;
iconWrappers.set(kebabName, wrapper);
return wrapper;
};
}
export function getLucideIcon(name: string): Component | undefined {
if (!name || typeof name !== 'string') {
return undefined;
}
const kebabName = toKebabCase(name);
if (!kebabName || kebabName.length === 0) {
return undefined;
}
const cachedWrapper = iconWrapperCache.get(kebabName);
if (cachedWrapper) {
return cachedWrapper;
}
const wrapper = defineComponent({
name: `LucideIcon_${kebabName}`,
setup() {
const attrs = useAttrs();
const LoadedIcon = shallowRef<Component | null>(null);
onMounted(() => {
loadIcon(kebabName).then((component) => {
LoadedIcon.value = component;
});
});
return () => {
const component = LoadedIcon.value ?? Blocks;
return h(component as Component, attrs) as VNode;
};
},
});
iconWrapperCache.set(kebabName, wrapper);
return wrapper;
}
export const getLucideIcon = createLucideIconResolver(iconModuleLoaders);