diff --git a/.changes/web-lazy-date-locales.md b/.changes/web-lazy-date-locales.md new file mode 100644 index 000000000..bcb1af618 --- /dev/null +++ b/.changes/web-lazy-date-locales.md @@ -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. diff --git a/.changes/web-version-only-from-package-json.md b/.changes/web-version-only-from-package-json.md new file mode 100644 index 000000000..3cecf7190 --- /dev/null +++ b/.changes/web-version-only-from-package-json.md @@ -0,0 +1,7 @@ +--- +type: perf +area: web +--- + +The app no longer ships its whole `package.json` inside the startup bundle, +only its version number, which trims about 11 KB from every launch. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0b1910c6..72be0299b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,85 @@ jobs: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + initial-bytes-ratchet: + name: Initial bytes ratchet + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install pnpm + uses: pnpm/action-setup@v6.0.10 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The ratchet below compares a measurement with the baselines file + # of the same commit, so it cannot see a change that grows the + # payload and raises the baseline to match. Compare the file with + # the revision this one is measured against instead: the target + # branch for a pull request, the previous head for a master push, + # master for a manual dispatch. Any raised limit, widened tolerance + # or removed entry fails. + - name: Refuse baseline increases against the previous revision + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + case "$EVENT_NAME" in + pull_request) + git fetch --no-tags --depth=1 origin "$BASE_REF" + ;; + push) + if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then + echo "No previous revision for this push; nothing to compare against." + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BEFORE_SHA" + ;; + *) + git fetch --no-tags --depth=1 origin master + ;; + esac + git show "FETCH_HEAD:tools/performance/journey-baselines.json" > /tmp/base-journey-baselines.json 2>/dev/null || + rm -f /tmp/base-journey-baselines.json + node tools/performance/check-baseline-direction.mjs \ + --base /tmp/base-journey-baselines.json \ + --head tools/performance/journey-baselines.json + + # The production configuration is what users download; measuring + # any other build would ratchet a number nobody ships. + - name: Build web app (production) + run: pnpm nx build web --skip-nx-cache + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + + # Fails when renderer.initialBytes exceeds the value committed in + # tools/performance/journey-baselines.json. Baselines only move + # down, with the printed measurement as evidence; the contract is + # docs/architecture/performance-journeys.md. + - name: Check renderer.initialBytes against the baseline + run: pnpm run perf:initial-bytes:check + + - name: Upload journey summary + if: always() + uses: actions/upload-artifact@v7 + with: + name: performance-journey-summary + path: dist/performance/ + retention-days: 14 + unit-and-typecheck: name: Unit Tests and Typechecks runs-on: ubuntu-latest diff --git a/README.md b/README.md index f9a69e950..fbec01873 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,17 @@ To run only the Angular app without Electron, use: $ pnpm run serve:frontend ``` +To see how many bytes the built web app puts on the initial load path (the +number the CI ratchet guards), build it and run the measurement: + +``` +$ pnpm nx build web +$ pnpm run perf:initial-bytes +``` + +The contract behind that number is in +[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). + To benchmark the "launch to usable" journey (fresh Electron process on a seeded profile, exact renderer counters plus wall-clock), run: diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png index 9b09f0443..906358a9d 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png index ae5559a5e..cd0c24610 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png index 9e4c06376..db81e79f1 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png index 34a05696c..0ad8614a3 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png index 18f7992d2..5d9426b3b 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png index b63a79e02..c3fa7f9ff 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png index 06cb9eb42..6b1ad7296 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png differ diff --git a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png index 9f075ac73..885f67d76 100644 Binary files a/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png and b/apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png differ diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 15277199e..6fb90a9a1 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -39,6 +39,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', + '^@package$': '/src/test-stubs/package.mjs', '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', diff --git a/apps/web/src/app/app-date-locales.spec.ts b/apps/web/src/app/app-date-locales.spec.ts new file mode 100644 index 000000000..54e68f255 --- /dev/null +++ b/apps/web/src/app/app-date-locales.spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/app/app-date-locales.ts b/apps/web/src/app/app-date-locales.ts index 1cc48aca2..df69e39cb 100644 --- a/apps/web/src/app/app-date-locales.ts +++ b/apps/web/src/app/app-date-locales.ts @@ -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; -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 +> = { + 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(['en']); +const pendingLocales = new Map>(); + +/** + * 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 + > = APP_DATE_LOCALE_LOADERS +): Promise { + 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 { + 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 { + const request = ++this.latestRequest; + await registerAppDateLocale(language); + if (request === this.latestRequest) { + this.translate.use(language); + } + } } diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 8a38ba1a1..99557c15b 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -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 ); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 07a5d6d77..3e4dc7ad0 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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 diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index 5713b7e28..e1c9d39a1 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -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[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 ); diff --git a/apps/web/src/environments/environment.dev.ts b/apps/web/src/environments/environment.dev.ts index f176f814a..24edf3e91 100644 --- a/apps/web/src/environments/environment.dev.ts +++ b/apps/web/src/environments/environment.dev.ts @@ -3,11 +3,11 @@ // `ng build --env=prod` then `index.prod.ts` will be used instead. // The list of which env maps to which file can be found in `.angular-cli.json`. -import packageJson from '@package'; +import { version as appVersion } from '@package'; export const AppConfig = { production: false, environment: 'DEV', - version: packageJson.version, + version: appVersion, BACKEND_URL: 'http://localhost:3000', }; diff --git a/apps/web/src/environments/environment.prod.ts b/apps/web/src/environments/environment.prod.ts index 87f4284ef..f0e2a57d0 100644 --- a/apps/web/src/environments/environment.prod.ts +++ b/apps/web/src/environments/environment.prod.ts @@ -1,8 +1,8 @@ -import packageJson from '@package'; +import { version as appVersion } from '@package'; export const AppConfig = { production: true, environment: 'PROD', - version: packageJson.version, + version: appVersion, BACKEND_URL: 'https://iptvnator-playlist-parser-api.vercel.app', }; diff --git a/apps/web/src/environments/environment.ts b/apps/web/src/environments/environment.ts index 0790b2105..92988b7ba 100644 --- a/apps/web/src/environments/environment.ts +++ b/apps/web/src/environments/environment.ts @@ -1,8 +1,8 @@ -import packageJson from '@package'; +import { version as appVersion } from '@package'; export const AppConfig = { production: false, environment: 'LOCAL', - version: packageJson.version, + version: appVersion, BACKEND_URL: 'http://localhost:3000', }; diff --git a/apps/web/src/environments/environment.web.ts b/apps/web/src/environments/environment.web.ts index 688520218..6b85ff494 100644 --- a/apps/web/src/environments/environment.web.ts +++ b/apps/web/src/environments/environment.web.ts @@ -1,8 +1,8 @@ -import packageJson from '@package'; +import { version as appVersion } from '@package'; export const AppConfig = { production: false, environment: 'WEB', - version: packageJson.version, + version: appVersion, BACKEND_URL: 'http://localhost:3333', }; diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index b19bf15d1..15ea472b4 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -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 diff --git a/apps/web/src/test-stubs/package.mjs b/apps/web/src/test-stubs/package.mjs new file mode 100644 index 000000000..04f30e1f5 --- /dev/null +++ b/apps/web/src/test-stubs/package.mjs @@ -0,0 +1,12 @@ +// Jest's ESM loader exposes a JSON module only as a default export, while the +// app imports `{ version }` from '@package' so esbuild can tree-shake the rest +// of package.json out of the bundle. This stub serves the real file's fields +// as named exports for tests. +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8') +); + +export const version = packageJson.version; +export default packageJson; diff --git a/docs/architecture/date-handling.md b/docs/architecture/date-handling.md index 363cccffa..03c70e243 100644 --- a/docs/architecture/date-handling.md +++ b/docs/architecture/date-handling.md @@ -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` diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index e4899b181..14ef47f4f 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -4,8 +4,8 @@ IPTVnator measures performance through a small set of everyday user journeys. Each journey has deterministic counters that are asserted exactly, and wall-clock timings that are recorded as evidence. Counters are ratcheted in CI: a committed baseline may only be lowered, and only with the measured output as -evidence. This document is the contract for that loop. The journey harness -lives in `apps/electron-backend-e2e/src/journeys` and +evidence. This document is the contract for that loop. The journey harness lives in +`apps/electron-backend-e2e/src/journeys` and `apps/electron-backend-e2e/src/performance/journey-*.ts`; the ratchet scripts live in `tools/performance/`. @@ -18,8 +18,9 @@ live in `tools/performance/`. | J3 `playback` | click on a channel | HTML5 `playing` event | | J4 `search` | six-character query typed into global search | results list settled | -J1 is instrumented by the launch benchmark below. J2 to J4 follow the plan in -`.plans/` and are added one thread at a time; each thread names its journey +J1 is instrumented today: `renderer.initialBytes` from the built output, and +the runtime counters of the launch benchmark below. J2 to J4 follow the plan +in `.plans/` and are added one thread at a time; each thread names its journey and counter in the PR description. ## Running the journeys @@ -161,6 +162,131 @@ numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with `tools/performance/journey-baselines.json`. A J1 baseline is added once the numbers are stable on the CI runner; until then the summary is evidence only. +## `renderer.initialBytes` + +The bytes a browser fetches before Angular can bootstrap, read from the built +`dist/apps/web/index.html`: + +- `index.html` itself, +- every same-origin ` + + +`; + +const BUILT_FILES = { + 'assets/app-config.js': 65, + 'styles-VDU4SQ5F.css': 311539, + 'chunk-B6uziQ1i.js': 1566, + 'chunk-Cn2Agfvf.js': 529624, + 'polyfills-EBB6HFCX.js': 35876, + 'main-EI6PCDGR.js': 1131437, +}; + +let workDir; + +async function writeDist( + name, + { indexHtml = BUILT_INDEX_HTML, files = BUILT_FILES } = {} +) { + const distDir = path.join(workDir, name); + await mkdir(distDir, { recursive: true }); + if (indexHtml !== null) { + await writeFile(path.join(distDir, 'index.html'), indexHtml); + } + for (const [file, bytes] of Object.entries(files)) { + const target = path.join(distDir, file); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, 'x'.repeat(bytes)); + } + return distDir; +} + +before(async () => { + workDir = await mkdtemp(path.join(os.tmpdir(), 'measure-initial-bytes-')); +}); + +after(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('extracts scripts, stylesheets and modulepreload chunks in document order', () => { + assert.deepEqual( + extractInitialResources(BUILT_INDEX_HTML).map(({ path, kind }) => ({ + path, + kind, + })), + [ + { path: 'assets/app-config.js', kind: 'script' }, + { path: 'styles-VDU4SQ5F.css', kind: 'stylesheet' }, + { path: 'chunk-B6uziQ1i.js', kind: 'modulepreload' }, + { path: 'chunk-Cn2Agfvf.js', kind: 'modulepreload' }, + { path: 'polyfills-EBB6HFCX.js', kind: 'script' }, + { path: 'main-EI6PCDGR.js', kind: 'script' }, + ] + ); +}); + +test('ignores manifest, icon and external references', () => { + const html = ` + + + + + + + + `; + assert.deepEqual(extractInitialResources(html), [ + { path: 'main.js', url: 'main.js', kind: 'script' }, + ]); +}); + +test('deduplicates by request URL, ignores fragments and normalizes relative URLs', () => { + const html = ` + + + + + + + `; + assert.deepEqual(extractInitialResources(html), [ + { path: 'chunk-a.js', url: 'chunk-a.js', kind: 'modulepreload' }, + { path: 'chunk-a.js', url: 'chunk-a.js?v=2', kind: 'modulepreload' }, + { path: 'chunk-b.js', url: 'chunk-b.js', kind: 'modulepreload' }, + { path: 'main.js', url: 'main.js', kind: 'script' }, + ]); +}); + +test('ignores commented-out tags and tag-like text inside inline scripts and styles', () => { + const html = ` + + + + + + `; + assert.deepEqual( + extractInitialResources(html).map((resource) => resource.url), + ['assets/app-config.js', 'main.js'] + ); + assert.deepEqual( + scanLiveTags('').map((t) => t.tag), + ['script', 'link'] + ); + // '', so + // the script after it is live, exactly as the HTML tokenizer sees it. + assert.deepEqual( + extractInitialResources( + '-- -->' + ).map((resource) => resource.url), + ['x.js'] + ); +}); + +test('a comment opener inside a script body does not swallow later live tags', () => { + const html = ``; + assert.deepEqual( + extractInitialResources(html).map((resource) => resource.url), + ['main.js', 'chunk.js'] + ); + const reverse = ``; + assert.deepEqual( + extractInitialResources(reverse).map((resource) => resource.url), + ['live.js'] + ); + // Unterminated raw text swallows the rest, as it does in a browser. + assert.deepEqual( + extractInitialResources('`; + assert.deepEqual( + extractInitialResources(lookalike).map((resource) => resource.url), + ['real.js'] + ); + const fallback = `<script src="t.js"></script>`; + assert.deepEqual( + extractInitialResources(fallback).map((resource) => resource.url), + ['app.js'] + ); + assert.deepEqual( + extractInitialResources( + '' + ).map((r) => r.url), + ['y.js'] + ); +}); + +test('template contents are inert and character references are decoded', () => { + const html = ``; + assert.deepEqual( + extractInitialResources(html).map((resource) => resource.url), + ['chunk.js?a=1&b=2', 'main.js'] + ); +}); + +test('SVG script elements are not HTML scripts, HTML inside foreignObject is', () => { + const html = ``; + assert.deepEqual( + extractInitialResources(html).map((resource) => resource.url), + ['html-in-svg.js', 'main.js'] + ); +}); + +test('counts a file once per distinct request URL', async () => { + const distDir = await writeDist('cache-busted', { + indexHtml: ``, + files: { 'chunk-a.js': 100 }, + }); + const measurement = await measureInitialBytes({ distDir }); + assert.equal(measurement.resources.length, 2); + assert.equal(measurement.totals.modulepreload, 200); +}); + +test('sums index.html and every referenced file into the counter', async () => { + const distDir = await writeDist('built'); + const measurement = await measureInitialBytes({ distDir }); + + const indexBytes = Buffer.byteLength(BUILT_INDEX_HTML); + const script = 65 + 35876 + 1131437; + const stylesheet = 311539; + const modulepreload = 1566 + 529624; + + assert.deepEqual(measurement.indexHtml, { + path: 'index.html', + bytes: indexBytes, + }); + assert.equal(measurement.resources.length, 6); + assert.deepEqual(measurement.totals, { + indexHtml: indexBytes, + script, + stylesheet, + modulepreload, + initialBytes: indexBytes + script + stylesheet + modulepreload, + }); + assert.deepEqual(measurement.counters, { + [INITIAL_BYTES_COUNTER]: + indexBytes + script + stylesheet + modulepreload, + }); +}); + +test('fails when index.html is missing instead of reporting zero bytes', async () => { + const distDir = await writeDist('no-index', { indexHtml: null, files: {} }); + await assert.rejects( + measureInitialBytes({ distDir }), + /No index\.html under .*no-index.*pnpm nx build web/ + ); +}); + +test('fails and names every referenced file that is missing from the build', async () => { + const dropped = ['chunk-Cn2Agfvf.js', 'main-EI6PCDGR.js']; + const files = Object.fromEntries( + Object.entries(BUILT_FILES).filter(([file]) => !dropped.includes(file)) + ); + const distDir = await writeDist('missing-chunk', { files }); + await assert.rejects( + measureInitialBytes({ distDir }), + /not in .*missing-chunk: chunk-Cn2Agfvf\.js, main-EI6PCDGR\.js/ + ); +}); + +test('journey summary carries the counter under the launch journey', async () => { + const distDir = await writeDist('summary'); + const measurement = await measureInitialBytes({ distDir }); + const summary = toJourneySummary(measurement, { + measuredAt: new Date('2026-09-26T00:00:00.000Z'), + }); + assert.deepEqual(summary, { + version: 1, + measuredAt: '2026-09-26T00:00:00.000Z', + journeys: { + launch: { + counters: { + [INITIAL_BYTES_COUNTER]: measurement.totals.initialBytes, + }, + }, + }, + }); +}); + +test('report lists the largest files first and ends with the counter', async () => { + const distDir = await writeDist('report'); + const report = formatReport(await measureInitialBytes({ distDir })); + const lines = report.split('\n'); + const mainLine = lines.findIndex((line) => + line.startsWith('main-EI6PCDGR.js') + ); + const configLine = lines.findIndex((line) => + line.startsWith('assets/app-config.js') + ); + assert.ok(mainLine > 0 && mainLine < configLine); + assert.match( + lines.at(-1), + /^renderer\.initialBytes = [\d,]+ bytes \(6 files \+ index\.html\)$/ + ); +}); + +test('parses CLI arguments and rejects unknown ones', () => { + assert.deepEqual(parseArgs([]), { + distDir: DEFAULT_DIST_DIR, + json: false, + summary: null, + }); + assert.deepEqual( + parseArgs(['--', '--dist', 'out', '--json', '--summary=s.json']), + { + distDir: 'out', + json: true, + summary: 's.json', + } + ); + assert.deepEqual( + parseArgs(['--dist=out/web', '--summary', 'dist/s.json']).distDir, + 'out/web' + ); + assert.throws( + () => parseArgs(['--verbose']), + /Unknown argument: --verbose/ + ); + assert.throws(() => parseArgs(['--dist']), /Missing value for --dist/); +}); + +test('CLI writes the journey summary and exits 0 on a complete build', async () => { + const distDir = await writeDist('cli'); + const summaryPath = path.join(workDir, 'out', 'journey-summary.json'); + const stdout = execFileSync( + process.execPath, + [scriptPath, '--dist', distDir, '--summary', summaryPath], + { encoding: 'utf8' } + ); + assert.match(stdout, /renderer\.initialBytes = [\d,]+ bytes/); + const summary = JSON.parse(await readFile(summaryPath, 'utf8')); + assert.equal( + summary.journeys.launch.counters[INITIAL_BYTES_COUNTER], + Buffer.byteLength(BUILT_INDEX_HTML) + + Object.values(BUILT_FILES).reduce((sum, bytes) => sum + bytes, 0) + ); +}); + +test('CLI exits 1 with a readable message when the build is missing', () => { + const result = spawnSync( + process.execPath, + [scriptPath, '--dist', path.join(workDir, 'does-not-exist')], + { encoding: 'utf8' } + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /measure-initial-bytes: No index\.html under/); +}); diff --git a/tools/performance/project.json b/tools/performance/project.json new file mode 100644 index 000000000..ac5ff1f95 --- /dev/null +++ b/tools/performance/project.json @@ -0,0 +1,31 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "performance-tools", + "projectType": "library", + "sourceRoot": "tools/performance", + "tags": ["scope:tools", "domain:performance", "type:tool"], + "targets": { + "test": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{projectRoot}/*.mjs", + "{projectRoot}/*.json", + { "externalDependencies": ["parse5"] } + ], + "options": { + "command": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "inputs": [ + "default", + "{workspaceRoot}/eslint.config.mjs", + "{workspaceRoot}/tools/eslint-rules/**/*", + "{workspaceRoot}/tools/eslint/**/*" + ], + "command": "eslint \"tools/performance/*.mjs\"" + } + } +}