perf(icons): implement lazy loading for Material Icons to reduce bundle size

Implement lazy loading for material-icons.const.ts (69.5KB, 3800+ icons) to reduce initial bundle size by ~68KB.

Changes:
- Create MaterialIconsLoaderService with promise caching to prevent concurrent loads
- Update DialogCreateTagComponent to use lazy loader service
- Update IconInputComponent to use lazy loader service
- Add comprehensive unit tests for MaterialIconsLoaderService
- Convert icon input methods to async for lazy loading support

Expected impact: Main bundle reduced by ~69KB, icons loaded on-demand when user focuses icon input fields.
This commit is contained in:
Johannes Millan 2026-01-21 15:52:00 +01:00
parent 9e1116555c
commit 4317e6575d
4 changed files with 130 additions and 37 deletions

View file

@ -7,7 +7,7 @@ import {
signal,
} from '@angular/core';
import { FieldType } from '@ngx-formly/material';
import { MATERIAL_ICONS } from '../../../ui/material-icons.const';
import { MaterialIconsLoaderService } from '../../../ui/material-icons-loader.service';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { MatIcon } from '@angular/material/icon';
import { MatInput, MatSuffix } from '@angular/material/input';
@ -42,6 +42,7 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
filteredIcons = signal<string[]>([]);
isEmoji = signal(false);
private readonly _destroyRef = inject(DestroyRef);
private _iconLoader = inject(MaterialIconsLoaderService);
// Guards against duplicate processing when Windows emoji picker triggers multiple events
private _lastSetValue: string | null = null;
@ -64,53 +65,66 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
return i;
}
onFocus(): void {
async onFocus(): Promise<void> {
// Show initial icons when field is focused and no filter applied yet
if (this.filteredIcons().length === 0) {
const currentValue = this.formControl.value || '';
if (currentValue) {
// If there's a current value, filter by it
this.onInputValueChange(currentValue);
} else {
// Show first 50 icons when empty
this.filteredIcons.set(MATERIAL_ICONS.slice(0, 50));
try {
if (currentValue) {
// If there's a current value, filter by it
await this.onInputValueChange(currentValue);
} else {
// Show first 50 icons when empty
const icons = await this._iconLoader.loadIcons();
this.filteredIcons.set(icons.slice(0, 50));
}
} catch (error) {
console.error('Failed to load material icons:', error);
this.filteredIcons.set([]);
}
}
}
onInputValueChange(val: string): void {
async onInputValueChange(val: string): Promise<void> {
// Skip if this is the value we just set programmatically (prevents double processing)
if (val === this._lastSetValue) {
this._lastSetValue = null;
return;
}
const arr = MATERIAL_ICONS.filter(
(icoStr) => icoStr && icoStr.toLowerCase().includes(val.toLowerCase()),
);
arr.length = Math.min(150, arr.length);
this.filteredIcons.set(arr);
try {
const icons = await this._iconLoader.loadIcons();
const arr = icons.filter(
(icoStr) => icoStr && icoStr.toLowerCase().includes(val.toLowerCase()),
);
arr.length = Math.min(150, arr.length);
this.filteredIcons.set(arr);
const hasEmoji = containsEmoji(val);
const hasEmoji = containsEmoji(val);
if (hasEmoji) {
const firstEmoji = extractFirstEmoji(val);
if (hasEmoji) {
const firstEmoji = extractFirstEmoji(val);
if (firstEmoji) {
this._lastSetValue = firstEmoji;
this.formControl.setValue(firstEmoji);
this.isEmoji.set(true);
} else {
if (firstEmoji) {
this._lastSetValue = firstEmoji;
this.formControl.setValue(firstEmoji);
this.isEmoji.set(true);
} else {
this._lastSetValue = '';
this.formControl.setValue('');
this.isEmoji.set(false);
}
} else if (!val) {
this._lastSetValue = '';
this.formControl.setValue('');
this.isEmoji.set(false);
} else {
this.isEmoji.set(false);
}
} else if (!val) {
this._lastSetValue = '';
this.formControl.setValue('');
this.isEmoji.set(false);
} else {
this.isEmoji.set(false);
} catch (error) {
console.error('Failed to filter icons:', error);
this.filteredIcons.set([]);
}
}

View file

@ -21,7 +21,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { DEFAULT_TAG_COLOR } from '../../features/work-context/work-context.const';
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatOption } from '@angular/material/core';
import { MATERIAL_ICONS } from '../material-icons.const';
import { MaterialIconsLoaderService } from '../material-icons-loader.service';
export interface CreateTagData {
title?: string;
@ -51,6 +51,7 @@ export interface CreateTagData {
})
export class DialogCreateTagComponent {
private _matDialogRef = inject<MatDialogRef<DialogCreateTagComponent>>(MatDialogRef);
private _iconLoader = inject(MaterialIconsLoaderService);
data = inject(MAT_DIALOG_DATA);
T: typeof T = T;
@ -62,18 +63,30 @@ export class DialogCreateTagComponent {
// Get reference to autocomplete trigger for explicit cleanup
private _iconAutoTrigger = viewChild(MatAutocompleteTrigger);
onIconFocus(): void {
async onIconFocus(): Promise<void> {
if (this.filteredIcons().length === 0) {
this.filteredIcons.set(MATERIAL_ICONS.slice(0, 50));
try {
const icons = await this._iconLoader.loadIcons();
this.filteredIcons.set(icons.slice(0, 50));
} catch (error) {
console.error('Failed to load material icons:', error);
this.filteredIcons.set([]);
}
}
}
onIconInput(val: string): void {
const filtered = MATERIAL_ICONS.filter((ico) =>
ico.toLowerCase().includes(val.toLowerCase()),
);
filtered.length = Math.min(50, filtered.length);
this.filteredIcons.set(filtered);
async onIconInput(val: string): Promise<void> {
try {
const icons = await this._iconLoader.loadIcons();
const filtered = icons.filter((ico) =>
ico.toLowerCase().includes(val.toLowerCase()),
);
filtered.length = Math.min(50, filtered.length);
this.filteredIcons.set(filtered);
} catch (error) {
console.error('Failed to filter icons:', error);
this.filteredIcons.set([]);
}
}
close(isSave: boolean): void {

View file

@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { MaterialIconsLoaderService } from './material-icons-loader.service';
describe('MaterialIconsLoaderService', () => {
let service: MaterialIconsLoaderService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MaterialIconsLoaderService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should load icons on first call', async () => {
const icons = await service.loadIcons();
expect(icons).toBeDefined();
expect(icons.length).toBeGreaterThan(0);
});
it('should return cached icons on subsequent calls', async () => {
const icons1 = await service.loadIcons();
const icons2 = await service.loadIcons();
expect(icons1).toBe(icons2); // Same reference
});
it('should handle concurrent load requests', async () => {
const [icons1, icons2] = await Promise.all([
service.loadIcons(),
service.loadIcons(),
]);
expect(icons1).toBe(icons2);
});
});

View file

@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class MaterialIconsLoaderService {
private icons: string[] | null = null;
private loadingPromise: Promise<string[]> | null = null;
async loadIcons(): Promise<string[]> {
// Return cached icons if already loaded
if (this.icons) {
return this.icons;
}
// Return existing promise if currently loading (prevents race conditions)
if (this.loadingPromise) {
return this.loadingPromise;
}
// Start loading and cache promise
this.loadingPromise = this._loadModule();
return this.loadingPromise;
}
private async _loadModule(): Promise<string[]> {
const { MATERIAL_ICONS } = await import('./material-icons.const');
this.icons = MATERIAL_ICONS;
return MATERIAL_ICONS;
}
}