mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
Refine: icon loading
This commit is contained in:
1 parent
93167d7952
commit
54499cda27
2 files changed
+254
-87
No files matched your search
@@ -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
@@ -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);
|
||||
Reference in new issue
Block a user