mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
9e1116555c
commit
4317e6575d
4 changed files with 130 additions and 37 deletions
|
|
@ -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([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
35
src/app/ui/material-icons-loader.service.spec.ts
Normal file
35
src/app/ui/material-icons-loader.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
src/app/ui/material-icons-loader.service.ts
Normal file
31
src/app/ui/material-icons-loader.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue