perf(web): load Angular date locales lazily per language (#1695)

Plan thread C1, journey **J1 `launch`**, counter **`renderer.initialBytes`**. Stacked on #1694 → #1693 → #1692; merge in order.

`apps/web/src/app/app-date-locales.ts` imported the locale data of all 18 supported languages eagerly, so every user shipped and parsed all of it at startup. Each locale is now a dynamic import keyed by the Angular locale id that `normalizeDateLocale()` derives from the app language (`by` → `be`, `ary` → `ar-MA`, `zhtw` → `zh-Hant`); English needs no data.

Ordering is preserved so no template renders a locale whose data has not arrived (Angular throws in that case):
- `main.ts` awaits the initial language's data (from `getInitialLanguage()`) before `bootstrapApplication`.
- Both `TranslateService.use()` call sites, `AppComponent.initSettings()` and `SettingsFormFacade.applySavedSettings()`, register the data first through the new `AppDateLocaleService`.
- A failed load never leaves the locale without data: English formatting is registered under the requested id (eager 1.1 KB `@angular/common/locales/en`), so `DatePipe` renders instead of throwing; the locale is not marked registered, so the next call retries and a success replaces the fallback (review follow-up).
- `AppDateLocaleService.use()` orders switches by request, not by completion: a switch whose data arrives after a newer request is dropped, so the language chosen last wins (review follow-up).

No kill switch: behavior is identical once the locale resolves, and the only new failure mode (a same-origin chunk failing to load) is shared with every lazy route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-26 13:44:29 +02:00
1 parent d3b6e548cc
commit 512e9787a8
10 files changed
+284 -59

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: perf
area: web
---
The app now loads date and time formatting data only for the language you
use instead of shipping all 18 languages in the startup bundle, so it has
less to download and parse on every launch; English needs none at all.
+123
View File
@@ -0,0 +1,123 @@
import { formatDate } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { Language } from '@iptvnator/shared/interfaces';
import {
APP_DATE_LOCALE_LOADERS,
AppDateLocaleService,
registerAppDateLocale,
} from './app-date-locales';
const sampleDate = new Date(2024, 0, 15, 13, 5);
describe('registerAppDateLocale', () => {
it('resolves for English without loading anything', async () => {
const loader = jest.fn(() => import('@angular/common/locales/de'));
await registerAppDateLocale('en', { en: loader });
await registerAppDateLocale('', { en: loader });
await registerAppDateLocale(undefined, { en: loader });
expect(loader).not.toHaveBeenCalled();
});
it('loads the locale once and shares the in-flight import', async () => {
const loader = jest.fn(() => import('@angular/common/locales/fr'));
await Promise.all([
registerAppDateLocale('fr', { fr: loader }),
registerAppDateLocale('fr', { fr: loader }),
]);
await registerAppDateLocale('fr', { fr: loader });
expect(loader).toHaveBeenCalledTimes(1);
expect(formatDate(sampleDate, 'MMMM', 'fr')).toBe('janvier');
});
it('maps app language aliases to Angular locale ids before loading', async () => {
const loader = jest.fn(() => import('@angular/common/locales/be'));
await registerAppDateLocale('by', { be: loader });
expect(loader).toHaveBeenCalledTimes(1);
expect(formatDate(sampleDate, 'MMMM', 'be')).toBe('студзеня');
});
it('falls back to English formatting on a failed import and retries on the next call', async () => {
const failing = jest.fn(() => Promise.reject(new Error('offline')));
const working = jest.fn(() => import('@angular/common/locales/pl'));
await expect(
registerAppDateLocale('pl', { pl: failing })
).resolves.toBeUndefined();
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('January');
await registerAppDateLocale('pl', { pl: working });
expect(working).toHaveBeenCalledTimes(1);
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('stycznia');
});
it('resolves for a language without bundled data instead of rejecting', async () => {
await expect(registerAppDateLocale('xx', {})).resolves.toBeUndefined();
});
it('bundles a loader for every supported language except English', () => {
const expectedLocales = Object.values(Language)
.filter((language) => language !== Language.ENGLISH)
.map((language) => normalizeDateLocale(language))
.sort();
expect(Object.keys(APP_DATE_LOCALE_LOADERS).sort()).toEqual(
expectedLocales
);
});
it('registers working data for every supported language', async () => {
for (const language of Object.values(Language)) {
await registerAppDateLocale(language);
const locale = normalizeDateLocale(language);
expect(formatDate(sampleDate, 'MMMM', locale)).not.toBe('');
}
expect(formatDate(sampleDate, 'MMMM', 'ar-MA')).toBe('يناير');
expect(formatDate(sampleDate, 'MMMM', 'zh-Hant')).toBe('1月');
});
});
describe('AppDateLocaleService', () => {
let translate: { use: jest.Mock };
let service: AppDateLocaleService;
beforeEach(() => {
translate = { use: jest.fn() };
TestBed.configureTestingModule({
providers: [{ provide: TranslateService, useValue: translate }],
});
service = TestBed.inject(AppDateLocaleService);
});
it('delegates register to registerAppDateLocale', async () => {
await expect(
service.register(Language.GERMAN)
).resolves.toBeUndefined();
expect(formatDate(sampleDate, 'MMMM', 'de')).toBe('Januar');
});
it('registers the locale data before switching the language', async () => {
await service.use(Language.HUNGARIAN);
expect(translate.use).toHaveBeenCalledWith(Language.HUNGARIAN);
expect(formatDate(sampleDate, 'MMMM', 'hu')).toBe('január');
});
it('applies only the language requested last when switches overlap', async () => {
await Promise.all([
service.use(Language.ITALIAN),
service.use(Language.TURKISH),
]);
expect(translate.use).toHaveBeenCalledTimes(1);
expect(translate.use).toHaveBeenCalledWith(Language.TURKISH);
});
});
+117 -42
View File
@@ -1,48 +1,123 @@
import { registerLocaleData } from '@angular/common';
import localeAr from '@angular/common/locales/ar';
import localeArMa from '@angular/common/locales/ar-MA';
import localeBe from '@angular/common/locales/be';
import localeDe from '@angular/common/locales/de';
import localeEl from '@angular/common/locales/el';
import localeEs from '@angular/common/locales/es';
import localeFr from '@angular/common/locales/fr';
import localeHu from '@angular/common/locales/hu';
import localeIt from '@angular/common/locales/it';
import localeJa from '@angular/common/locales/ja';
import localeKo from '@angular/common/locales/ko';
import localeNl from '@angular/common/locales/nl';
import localePl from '@angular/common/locales/pl';
import localePt from '@angular/common/locales/pt';
import localeRu from '@angular/common/locales/ru';
import localeTr from '@angular/common/locales/tr';
import localeZh from '@angular/common/locales/zh';
import localeZhHant from '@angular/common/locales/zh-Hant';
import localeEn from '@angular/common/locales/en';
import { inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { createDevLogger } from '@iptvnator/shared/interfaces';
let localesRegistered = false;
type LocaleDataModule = { default: unknown };
export type AppDateLocaleLoader = () => Promise<LocaleDataModule>;
export function registerAppDateLocales(): void {
if (localesRegistered) {
return;
const debug = createDevLogger('AppDateLocales');
/**
* Angular ships only English locale data; every other UI language needs its
* data registered before `DatePipe` can format with it. Each entry is a
* separate lazy chunk, keyed by the Angular locale id that
* `normalizeDateLocale()` produces for an app language (`by` -> `be`,
* `ary` -> `ar-MA`, `zhtw` -> `zh-Hant`). Importing all of them eagerly put
* the data for 18 languages into the initial bundle of every user.
*/
export const APP_DATE_LOCALE_LOADERS: Readonly<
Record<string, AppDateLocaleLoader>
> = {
ar: () => import('@angular/common/locales/ar'),
'ar-MA': () => import('@angular/common/locales/ar-MA'),
be: () => import('@angular/common/locales/be'),
de: () => import('@angular/common/locales/de'),
el: () => import('@angular/common/locales/el'),
es: () => import('@angular/common/locales/es'),
fr: () => import('@angular/common/locales/fr'),
hu: () => import('@angular/common/locales/hu'),
it: () => import('@angular/common/locales/it'),
ja: () => import('@angular/common/locales/ja'),
ko: () => import('@angular/common/locales/ko'),
nl: () => import('@angular/common/locales/nl'),
pl: () => import('@angular/common/locales/pl'),
pt: () => import('@angular/common/locales/pt'),
ru: () => import('@angular/common/locales/ru'),
tr: () => import('@angular/common/locales/tr'),
zh: () => import('@angular/common/locales/zh'),
'zh-Hant': () => import('@angular/common/locales/zh-Hant'),
};
const registeredLocales = new Set<string>(['en']);
const pendingLocales = new Map<string, Promise<void>>();
/**
* Registers the Angular locale data for an app language, loading it on first
* use. Resolves once `DatePipe` can format with that locale, so call it before
* `TranslateService.use()`: the language switch re-renders every date with the
* new locale, and a locale whose data has not arrived throws. English resolves
* immediately, as does a locale that is already registered or in flight.
*
* A failed load resolves rather than rejects, but never leaves the locale
* without data: English formatting is registered under the requested id so
* `DatePipe` keeps rendering instead of throwing in every template that passes
* the locale. The locale is not marked registered, so the next call retries
* the import and a success replaces the fallback.
*/
export function registerAppDateLocale(
language: string | null | undefined,
loaders: Readonly<
Record<string, AppDateLocaleLoader>
> = APP_DATE_LOCALE_LOADERS
): Promise<void> {
const locale = normalizeDateLocale(language);
if (registeredLocales.has(locale)) {
return Promise.resolve();
}
const inFlight = pendingLocales.get(locale);
if (inFlight) {
return inFlight;
}
const loader = loaders[locale];
if (!loader) {
debug('No bundled Angular locale data for', locale);
return Promise.resolve();
}
registerLocaleData(localeAr, 'ar');
registerLocaleData(localeArMa, 'ar-MA');
registerLocaleData(localeBe, 'be');
registerLocaleData(localeDe, 'de');
registerLocaleData(localeEl, 'el');
registerLocaleData(localeEs, 'es');
registerLocaleData(localeFr, 'fr');
registerLocaleData(localeHu, 'hu');
registerLocaleData(localeIt, 'it');
registerLocaleData(localeJa, 'ja');
registerLocaleData(localeKo, 'ko');
registerLocaleData(localeNl, 'nl');
registerLocaleData(localePl, 'pl');
registerLocaleData(localePt, 'pt');
registerLocaleData(localeRu, 'ru');
registerLocaleData(localeTr, 'tr');
registerLocaleData(localeZh, 'zh');
registerLocaleData(localeZhHant, 'zh-Hant');
localesRegistered = true;
const load = loader()
.then((module) => {
registerLocaleData(module.default, locale);
registeredLocales.add(locale);
})
.catch((error: unknown) => {
debug(
'Loading Angular locale data failed; using English formatting for',
locale,
error
);
registerLocaleData(localeEn, locale);
})
.finally(() => {
pendingLocales.delete(locale);
});
pendingLocales.set(locale, load);
return load;
}
/** Gates UI language switches on the locale data they render with. */
@Injectable({ providedIn: 'root' })
export class AppDateLocaleService {
private readonly translate = inject(TranslateService);
private latestRequest = 0;
register(language: string | null | undefined): Promise<void> {
return registerAppDateLocale(language);
}
/**
* Registers the locale data, then switches the UI language. Switches are
* ordered by request, not by completion: a switch whose data arrives
* after a newer request was made is dropped, so the language chosen last
* is the one that ends up active.
*/
async use(language: string): Promise<void> {
const request = ++this.latestRequest;
await registerAppDateLocale(language);
if (request === this.latestRequest) {
this.translate.use(language);
}
}
}
+8 -2
View File
@@ -31,6 +31,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { AppComponent } from './app.component';
import { AppDateLocaleService } from './app-date-locales';
import { ElectronServiceStub } from './services/electron.service.stub';
import { SettingsService } from './services/settings.service';
@@ -136,6 +137,11 @@ describe('AppComponent', () => {
setDefaultLang: jest.fn(),
use: jest.fn(),
}),
// The real service imports locale chunks; the language switch
// is gated on it, so it must resolve deterministically here.
MockProvider(AppDateLocaleService, {
use: jest.fn().mockResolvedValue(undefined),
}),
{
provide: WORKSPACE_SHELL_ACTIONS,
useValue: {
@@ -223,12 +229,12 @@ describe('AppComponent', () => {
});
settingsService.getValueFromLocalStorage.mockReturnValue(of(settings));
jest.spyOn(settingsService, 'changeTheme');
jest.spyOn(translateService, 'use');
const dateLocales = TestBed.inject(AppDateLocaleService);
component.initSettings();
await fixture.whenStable();
expect(translateService.use).toHaveBeenCalledWith(Language.SPANISH);
expect(dateLocales.use).toHaveBeenCalledWith(Language.SPANISH);
expect(settingsService.changeTheme).toHaveBeenCalledWith(
Theme.DarkTheme
);
+5 -1
View File
@@ -38,6 +38,7 @@ import {
Theme,
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { AppDateLocaleService } from './app-date-locales';
import { SettingsService } from './services/settings.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
@@ -69,6 +70,7 @@ export class AppComponent implements OnInit {
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private readonly dateLocales = inject(AppDateLocaleService);
private epgBridge = inject(EpgRuntimeBridgeService);
private epgService = inject(EpgService);
private snackBar = inject(MatSnackBar);
@@ -149,7 +151,9 @@ export class AppComponent implements OnInit {
// Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component
const resolvedLang = settings.language ?? this.DEFAULT_LANG;
this.translate.use(resolvedLang);
// The switch re-renders every date with the new locale;
// its data is a lazy chunk that must be registered first.
void this.dateLocales.use(resolvedLang);
// Mirror the active language to localStorage so the next
// cold start can read it synchronously in app.config.ts's
// getInitialLanguage() and avoid the English-then-localized
@@ -12,10 +12,10 @@ import {
Language,
Theme,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { SettingsSnackbarService } from './settings-snackbar.service';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
import { AppDateLocaleService } from '../app-date-locales';
import {
applyEpgUrlsToFormArray,
createEpgUrlControl,
@@ -32,6 +32,7 @@ type SettingsFormPatch = Parameters<SettingsForm['patchValue']>[0];
*/
@Injectable()
export class SettingsFormFacade {
private readonly dateLocales = inject(AppDateLocaleService);
private readonly destroyRef = inject(DestroyRef);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly formBuilder = inject(FormBuilder);
@@ -39,7 +40,6 @@ export class SettingsFormFacade {
private readonly settingsService = inject(SettingsService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly settingsStore = inject(SettingsStore);
private readonly translate = inject(TranslateService);
readonly supportsEpg =
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
@@ -192,7 +192,10 @@ export class SettingsFormFacade {
/** Applies the saved language/theme and resets the dirty state */
applySavedSettings(): void {
this.form.markAsPristine();
this.translate.use(this.form.value.language ?? Language.ENGLISH);
// The switch re-renders every date with the new locale; its data is
// a lazy chunk that must be registered first, and a newer choice
// must win over an older one whose data arrives later.
void this.dateLocales.use(this.form.value.language ?? Language.ENGLISH);
this.settingsService.changeTheme(
this.form.value.theme ?? Theme.SystemTheme
);
+8 -5
View File
@@ -1,10 +1,8 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces';
import { registerAppDateLocales } from './app/app-date-locales';
import { registerAppDateLocale } from './app/app-date-locales';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
registerAppDateLocales();
import { appConfig, getInitialLanguage } from './app/app.config';
// A reloaded packaged renderer arrives on index.html with the route it was
// on carried in the query string (the Electron main process recovers the
@@ -18,7 +16,12 @@ if (restoredHref !== null) {
window.history.replaceState(window.history.state, '', restoredHref);
}
bootstrapApplication(AppComponent, appConfig)
// Angular ships only English locale data; the preferred language's data is a
// lazy chunk. It is awaited before bootstrapping because the first route
// renders dates with that locale, and a locale without data throws. English
// (the default) resolves immediately.
registerAppDateLocale(getInitialLanguage())
.then(() => bootstrapApplication(AppComponent, appConfig))
.then(() => {
// Splash is rendered eagerly by index.html so the user sees something
// immediately instead of a blank Material-grey background. Once Angular
+1 -1
View File
@@ -12,7 +12,7 @@
- Date display should follow the user-selected app language from `TranslateService`.
- When a template renders localized month or weekday names, pass the normalized app locale explicitly to `DatePipe`.
- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts).
- Angular locale data is loaded lazily per language by `registerAppDateLocale()` in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts): `main.ts` awaits the initial language's data before bootstrapping, and every `TranslateService.use()` call site registers the data first (through `AppDateLocaleService`), because the switch re-renders dates with the new locale and a locale without data throws. English needs no data. `AppDateLocaleService.use()` applies only the language requested last when switches overlap, and a locale whose chunk fails to load falls back to English formatting under that locale id until a later call loads it.
- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/libs/ui/pipes/src/lib/date-format.util.ts):
- `ary` -> `ar-MA`
- `by` -> `be`
+6 -3
View File
@@ -31,9 +31,12 @@ The bytes a browser fetches before Angular can bootstrap, read from the built
- every `<link rel="modulepreload">` chunk.
Manifest, icons, external URLs, commented-out tags and lazy chunks are not
counted. A file that `index.html` references but the build did not emit is an
error, never zero bytes. The value is raw (uncompressed) size, which is what the
renderer parses. It is Angular's "Initial total" plus `index.html` and
counted, so the value is the same for every language: a non-English launch
additionally fetches that language's Angular locale chunk (about 2 KB), which
belongs to the per-profile J1 benchmark rather than to this counter. A file
that `index.html` references but the build did not emit is an error, never
zero bytes. The value is raw (uncompressed) size, which is what the renderer
parses. It is Angular's "Initial total" plus `index.html` and
`assets/app-config.js` (about 4 KB together), so it sits slightly above the
rounded figure the build prints; never copy that figure into a baseline, use
the script's output. The bundle embeds only the app version from
+2 -2
View File
@@ -3,10 +3,10 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2739510,
"value": 2715087,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1693,
"evidencePr": 1695,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}