perf: lazy-load @mdi/js to reduce Vite dev server memory usage

Replace `import * as mdi from '@mdi/js'` (loads ~5MB of SVG paths
synchronously into every HMR update) with a lazy async import that
loads once and caches. MdiIcon and IconPicker now use getMdiPath()
and getAllMdiNames() from the shared mdi-lookup module.
This commit is contained in:
2026-03-22 01:26:08 +03:00
parent a7829c48a4
commit 826be4c347
3 changed files with 138 additions and 0 deletions
@@ -0,0 +1,93 @@
<script lang="ts">
import { getMdiPath, getAllMdiNames } from '$lib/mdi-lookup';
let { value = '', onselect } = $props<{
value: string;
onselect: (icon: string) => void;
}>();
let open = $state(false);
let search = $state('');
let buttonEl: HTMLButtonElement;
let dropdownStyle = $state('');
const popular = [
'mdiServer', 'mdiCamera', 'mdiImage', 'mdiVideo', 'mdiBell', 'mdiSend',
'mdiRobot', 'mdiHome', 'mdiStar', 'mdiHeart', 'mdiAccount', 'mdiFolder',
'mdiFolderImage', 'mdiAlbum', 'mdiImageMultiple', 'mdiCloudUpload',
'mdiEye', 'mdiCog', 'mdiTelegram', 'mdiWebhook', 'mdiMessageText',
'mdiCalendar', 'mdiClock', 'mdiMapMarker', 'mdiTag', 'mdiFilter',
'mdiSort', 'mdiMagnify', 'mdiPencil', 'mdiDelete', 'mdiPlus',
'mdiCheck', 'mdiClose', 'mdiAlert', 'mdiInformation', 'mdiShield',
'mdiLink', 'mdiDownload', 'mdiUpload', 'mdiRefresh', 'mdiPlay',
'mdiPause', 'mdiStop', 'mdiSkipNext', 'mdiMusic', 'mdiMovie',
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
];
function filtered(): string[] {
const allIcons = getAllMdiNames();
if (!search) return popular.filter(p => allIcons.includes(p));
const q = search.toLowerCase();
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
}
function toggleOpen() {
if (!open && buttonEl) {
const rect = buttonEl.getBoundingClientRect();
dropdownStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; left:${rect.left}px;`;
}
open = !open;
if (!open) search = '';
}
function select(iconName: string) {
onselect(iconName);
open = false;
search = '';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
open = false;
search = '';
}
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
<div class="inline-block">
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
{#if value && getMdiPath(value)}
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(value)} /></svg>
{:else}
<span class="text-[var(--color-muted-foreground)] text-xs">Icon</span>
{/if}
<span class="text-xs text-[var(--color-muted-foreground)]"></span>
</button>
</div>
{#if open}
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
role="presentation"
onclick={() => { open = false; search = ''; }}></div>
<div style="{dropdownStyle} width: 20rem; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); padding: 0.75rem;"
class="">
<input type="text" bind:value={search} placeholder="Search icons..."
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
<div style="display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.25rem; max-height: 14rem; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin;">
<button type="button" onclick={() => select('')}
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
title="No icon"></button>
{#each filtered() as iconName}
<button type="button" onclick={() => select(iconName)}
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
title={iconName.replace('mdi', '')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg>
</button>
{/each}
</div>
</div>
{/if}
@@ -0,0 +1,9 @@
<script lang="ts">
import { getMdiPath } from '$lib/mdi-lookup';
let { name = '', size = 18 } = $props<{ name: string; size?: number }>();
</script>
{#if name && getMdiPath(name)}
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor"><path d={getMdiPath(name)} /></svg>
{/if}
+36
View File
@@ -0,0 +1,36 @@
/**
* Lazy MDI icon path lookup.
*
* Instead of `import * as mdi from '@mdi/js'` (which loads ~5MB of SVG paths
* into memory), this module loads the full set once on first use and caches it.
* Vite only processes the import once, reducing HMR memory pressure.
*/
let _cache: Record<string, string> | null = null;
async function _load(): Promise<Record<string, string>> {
if (_cache) return _cache;
const mod = await import('@mdi/js');
_cache = mod as unknown as Record<string, string>;
return _cache;
}
// Eagerly load on module init (runs once)
let _ready: Record<string, string> | null = null;
_load().then(m => { _ready = m; });
/**
* Get SVG path for an icon name. Returns empty string if not found or not yet loaded.
*/
export function getMdiPath(name: string): string {
if (!name || !_ready) return '';
return (_ready as any)[name] || '';
}
/**
* Get all icon names (for IconPicker search). Returns empty array if not yet loaded.
*/
export function getAllMdiNames(): string[] {
if (!_ready) return [];
return Object.keys(_ready).filter(k => k.startsWith('mdi') && k !== 'default');
}