From ec4b9286a7ff62b1751970e8d12e6e9e6bc83aa6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:08 +0200 Subject: [PATCH] perf(web): provide form-field defaults per component, not at the root (#1928) * perf(web): provide form-field defaults per component, not at the root app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root injector. Importing the token kept @angular/material/form-field in main.js, and Material 22.2's form field imports @angular/forms/signals, which would add @angular/forms to startup as well (the reason the Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every form field renders in a lazy chunk, so the outline + dynamic defaults now come from provideFormFieldDefaults() (@iptvnator/ui/components/form-field-defaults) on each component that renders . tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate, run in CI) reports a component that renders a form field without the provider, and any use of the token outside the shared provider. The Xtream import form test asserts outlined, dynamically sized fields with no root provider; without the component provider they render as fill. renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local production build; the baseline is lowered to the measured value. Co-Authored-By: Claude Opus 5.5 * perf(web): cite #1928 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 * test(ui): check form-field providers per component on the TypeScript AST The guard matched templates and providers with regular expressions over the whole file: a quoted inline template went unread, one component's provider covered another in the same file, an earlier array-valued provider hid the call, and provideFormFieldDefaults() in app.config or a route passed although it loads Material's form field on startup again. It now reads each @Component decorator's own template and providers array, and reports any provideFormFieldDefaults() call outside one. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance-lazy-form-field-defaults.md | 7 + .github/workflows/ci.yml | 6 + apps/web/src/app/app.config.ts | 5 - .../settings-about-section.component.ts | 2 + .../settings-epg-section.component.ts | 2 + .../settings-general-section.component.ts | 2 + .../settings-parental-section.component.ts | 2 + .../settings-playback-section.component.ts | 2 + ...ttings-remote-control-section.component.ts | 2 + .../settings-tmdb-section.component.ts | 2 + apps/web/src/m3-theme.scss | 5 +- docs/architecture/iptvnator-ui-guidelines.md | 7 + .../lib/auto-import/auto-import.component.ts | 2 + .../stalker-portal-import.component.ts | 2 + .../lib/text-import/text-import.component.ts | 2 + .../lib/url-upload/url-upload.component.ts | 2 + .../xtream-code-import.component.spec.ts | 20 ++ .../xtream-code-import.component.ts | 2 + .../playlist-info/playlist-info.component.ts | 3 +- libs/ui/components/src/index.ts | 1 + .../epg-mapping-dialog.component.ts | 2 + .../form-field-defaults.ts | 25 +++ .../parental-lock-pin-dialog.component.ts | 2 + package.json | 3 + tools/nx/check-form-field-defaults.mjs | 192 +++++++++++++++++ tools/nx/check-form-field-defaults.test.mjs | 200 ++++++++++++++++++ tools/performance/journey-baselines.json | 6 +- tsconfig.base.json | 3 + 28 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 .changes/performance-lazy-form-field-defaults.md create mode 100644 libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts create mode 100644 tools/nx/check-form-field-defaults.mjs create mode 100644 tools/nx/check-form-field-defaults.test.mjs diff --git a/.changes/performance-lazy-form-field-defaults.md b/.changes/performance-lazy-form-field-defaults.md new file mode 100644 index 000000000..d1ae58790 --- /dev/null +++ b/.changes/performance-lazy-form-field-defaults.md @@ -0,0 +1,7 @@ +--- +type: perf +area: performance +--- + +The app loads less code at startup: the text field component and its form +helpers now load with the screens that show a form instead of with the app. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5a581c80..d25580c26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,6 +430,12 @@ jobs: - name: Validate icon-only button names run: pnpm run a11y:icon-buttons:validate + # Form-field defaults are provided per lazy component: the root + # token import would load Material form-field and @angular/forms + # on startup (tools/nx/check-form-field-defaults.mjs). + - name: Validate form-field defaults + run: pnpm run ui:form-field-defaults:validate + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 6a5b072bb..27e447a27 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -9,7 +9,6 @@ import { OverlayContainer, } from '@angular/cdk/overlay'; import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; -import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; @@ -162,9 +161,5 @@ export const appConfig: ApplicationConfig = { ...provideParentalLockPrompt(), provideParentalLockPlaylistCleanup(), ...provideXtreamDataSource(), - { - provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, - useValue: { appearance: 'outline', subscriptSizing: 'dynamic' }, - }, ], }; diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 511206c62..d48af2add 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -20,6 +20,7 @@ import { } from '@iptvnator/shared/interfaces'; import { UpdateChannelOption } from './settings.models'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; interface SettingsUpdateStatusChip { readonly labelKey: string; @@ -28,6 +29,7 @@ interface SettingsUpdateStatusChip { @Component({ selector: 'app-settings-about-section', + providers: [provideFormFieldDefaults()], imports: [ MatButtonModule, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index d4ec29556..431a94796 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -23,9 +23,11 @@ import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { TranslateModule } from '@ngx-translate/core'; import { EpgViewModeOption } from './settings.models'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-settings-epg-section', + providers: [provideFormFieldDefaults()], imports: [ EpgSourceStatusComponent, MatButtonModule, diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index 7bb7b4f33..92582b33c 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -20,9 +20,11 @@ import { ThemeOption, } from './settings.models'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-settings-general-section', + providers: [provideFormFieldDefaults()], imports: [ CommonModule, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-parental-section.component.ts b/apps/web/src/app/settings/settings-parental-section.component.ts index 7e874c010..50ced0a82 100644 --- a/apps/web/src/app/settings/settings-parental-section.component.ts +++ b/apps/web/src/app/settings/settings-parental-section.component.ts @@ -9,9 +9,11 @@ import { } from '@angular/material/slide-toggle'; import { TranslateModule } from '@ngx-translate/core'; import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-settings-parental-lock-section', + providers: [provideFormFieldDefaults()], imports: [ MatButtonModule, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5fd306029..b806917b2 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -22,9 +22,11 @@ import { } from '@iptvnator/shared/interfaces'; import { SettingsPlayerOption } from './settings.models'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-settings-playback-section', + providers: [provideFormFieldDefaults()], imports: [ CommonModule, MatButtonModule, diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 3b69c0636..9e99ac088 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -15,9 +15,11 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-settings-remote-control-section', + providers: [provideFormFieldDefaults()], imports: [ MatButtonModule, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 428893053..5a751e376 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -17,11 +17,13 @@ import { TranslateModule } from '@ngx-translate/core'; import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @Component({ selector: 'app-settings-tmdb-section', + providers: [provideFormFieldDefaults()], imports: [ MatButtonModule, MatFormFieldModule, diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index acd62acd0..ccf773f13 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -279,8 +279,9 @@ html { // ─── Global refined outlined inputs ────────────────────────────────────────── // Applied app-wide AFTER mat.all-component-themes so these overrides win the -// cascade. Pairs with MAT_FORM_FIELD_DEFAULT_OPTIONS (outline + dynamic) set -// in app.config.ts. +// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS +// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each +// component rendering a form field. $app-refined-input-density: mat.define-theme(( density: (scale: -2), )); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5886a24ae..da7192356 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1033,6 +1033,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`. ## Forms +Every `` is outlined with a dynamic subscript. A component +that renders one adds `providers: [provideFormFieldDefaults()]` +(`@iptvnator/ui/components/form-field-defaults`); the root injector does not +provide the defaults, because the token import there would load Material's +form field and `@angular/forms` on startup. `pnpm run +ui:form-field-defaults:validate` (in CI) reports a missing provider. + The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one vocabulary, so a field reads the same wherever it appears: diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 0b86cb5af..4283d80fd 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -20,6 +20,7 @@ import { ProviderImportKind, SOURCE_TYPE_ICONS, } from '@iptvnator/shared/interfaces'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; interface CandidateSummaryRow { labelKey: string; @@ -145,6 +146,7 @@ function maskUrlQueryPasswords(url: string): string { */ @Component({ selector: 'app-auto-import', + providers: [provideFormFieldDefaults()], templateUrl: './auto-import.component.html', styleUrl: './auto-import.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 1a19d0403..f4e93c8c2 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -50,6 +50,7 @@ import { toStalkerImportFeedback, toStalkerPlaylistIdentityFields, } from './stalker-import-identity'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; /** * A MAC and the device IDs that belong to exactly it. Kept together because @@ -75,6 +76,7 @@ interface StalkerSettledIdentity { TranslatePipe, ], selector: 'app-stalker-portal-import', + providers: [provideFormFieldDefaults()], templateUrl: './stalker-portal-import.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styles: [ diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index f767cffb7..f5751e4f6 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -12,9 +12,11 @@ import { } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-text-import', + providers: [provideFormFieldDefaults()], templateUrl: './text-import.component.html', styleUrls: ['./text-import.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 59338b8bf..0df1ae15e 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -15,9 +15,11 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; @Component({ selector: 'app-url-upload', + providers: [provideFormFieldDefaults()], templateUrl: './url-upload.component.html', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts index 72f4e8099..1c72b0334 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts @@ -228,6 +228,26 @@ describe('XtreamCodeImportComponent form', () => { ); } + it('outlines its fields with a dynamic subscript without a root provider', () => { + // The root injector no longer provides MAT_FORM_FIELD_DEFAULT_OPTIONS + // (it pulled Material's form field and @angular/forms into startup); + // the form carries provideFormFieldDefaults() itself. + const { root } = render(); + const fields = Array.from(root.querySelectorAll('mat-form-field')); + + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field.classList).toContain( + 'mat-form-field-appearance-outline' + ); + expect( + field.querySelector( + '.mat-mdc-form-field-subscript-dynamic-size' + ) + ).not.toBeNull(); + } + }); + it('labels the name field like the other add-source forms', () => { const { root } = render(); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index ca7c148f9..ac9d137a4 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -30,6 +30,7 @@ import { normalizeXtreamServerUrl, Playlist, } from '@iptvnator/shared/interfaces'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; function xtreamServerUrlValidator( control: AbstractControl @@ -59,6 +60,7 @@ function xtreamServerUrlValidator( TranslatePipe, ], selector: 'app-xtream-code-import', + providers: [provideFormFieldDefaults()], templateUrl: './xtream-code-import.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styles: [ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 0f68e88ba..12e0399fe 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -62,6 +62,7 @@ import { STALKER_PLAYLIST_CONNECTION_EDITOR, STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS, } from './stalker-playlist-connection-editor.token'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; type DesktopFileSaveBridge = Pick< typeof window.electron, @@ -165,7 +166,7 @@ type DesktopFileSaveBridge = Pick< } `, ], - providers: [DatePipe], + providers: [provideFormFieldDefaults(), DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index df0b14ba3..cd83984b6 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/confirm-dialog/confirm-dialog.component'; export * from './lib/confirm-dialog/dialog.service'; export * from './lib/content-hero/content-hero.component'; export * from './lib/expandable-text/expandable-text.component'; +export * from './lib/form-field-defaults/form-field-defaults'; export * from './lib/external-playback-dock/external-playback-dock.component'; export * from './lib/portal-detail-shell/content-about.component'; export * from './lib/portal-detail-shell/detail-template.directives'; diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts index bc0e423c8..51d88ed55 100644 --- a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts @@ -22,6 +22,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { debounceTime, from, Subject, switchMap } from 'rxjs'; +import { provideFormFieldDefaults } from '../../form-field-defaults/form-field-defaults'; export interface EpgMappingDialogData { channelKey: string; @@ -39,6 +40,7 @@ const SEARCH_MIN_CHARS = 2; @Component({ selector: 'app-epg-mapping-dialog', + providers: [provideFormFieldDefaults()], templateUrl: './epg-mapping-dialog.component.html', styleUrl: './epg-mapping-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts b/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts new file mode 100644 index 000000000..c29c8996e --- /dev/null +++ b/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts @@ -0,0 +1,25 @@ +import { Provider } from '@angular/core'; +import { + MAT_FORM_FIELD_DEFAULT_OPTIONS, + MatFormFieldDefaultOptions, +} from '@angular/material/form-field'; + +/** The app's outlined, dynamically sized form fields (see m3-theme.scss). */ +export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = { + appearance: 'outline', + subscriptSizing: 'dynamic', +}; + +/** + * Provide the app's form-field defaults on a component that renders + * ``. They are not provided in the root injector: importing + * the token there loads `@angular/material/form-field` (and, since Material + * 22.2, `@angular/forms`) on startup, while every form field lives in a lazy + * chunk. `tools/nx/check-form-field-defaults.mjs` enforces this. + */ +export function provideFormFieldDefaults(): Provider { + return { + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, + useValue: APP_FORM_FIELD_DEFAULTS, + }; +} diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index 42fb878d9..486e1d8e8 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -27,6 +27,7 @@ import { PARENTAL_LOCK_PIN_MAX_LENGTH, PARENTAL_LOCK_PIN_MIN_LENGTH, } from '@iptvnator/shared/interfaces'; +import { provideFormFieldDefaults } from '../form-field-defaults/form-field-defaults'; export type ParentalLockPinDialogMode = 'unlock' | 'set'; @@ -59,6 +60,7 @@ export interface ParentalLockPinDialogData { */ @Component({ selector: 'app-parental-lock-pin-dialog', + providers: [provideFormFieldDefaults()], templateUrl: './parental-lock-pin-dialog.component.html', styleUrl: './parental-lock-pin-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/package.json b/package.json index 023cb8546..8bd8d804f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs", "a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs", "a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check", + "ui:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs", + "ui:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs", + "ui:form-field-defaults:validate": "pnpm run ui:form-field-defaults:test && pnpm run ui:form-field-defaults:check", "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", diff --git a/tools/nx/check-form-field-defaults.mjs b/tools/nx/check-form-field-defaults.mjs new file mode 100644 index 000000000..624d66be9 --- /dev/null +++ b/tools/nx/check-form-field-defaults.mjs @@ -0,0 +1,192 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +/** + * The app's `` defaults (outline, dynamic subscript) are + * provided per component, not in the root injector: importing + * MAT_FORM_FIELD_DEFAULT_OPTIONS in app.config.ts loads + * `@angular/material/form-field` and, since Material 22.2, `@angular/forms` + * on startup, although every form field lives in a lazy chunk. A component + * that renders a form field without the provider silently falls back to the + * `fill` appearance, so this guard requires it on each such component, and + * allows the provider and the token nowhere else. + */ +export const DEFAULTS_FILE = + 'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts'; +const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS'; +const PROVIDER = 'provideFormFieldDefaults'; + +const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts']; + +function isScanned(file) { + return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); +} + +/** The object literal of an `@Component({...})` decorator, if `node` is one. */ +function componentMetadata(node) { + if ( + !ts.isDecorator(node) || + !ts.isCallExpression(node.expression) || + !ts.isIdentifier(node.expression.expression) || + node.expression.expression.text !== 'Component' + ) { + return undefined; + } + const [metadata] = node.expression.arguments; + return metadata && ts.isObjectLiteralExpression(metadata) + ? metadata + : undefined; +} + +function property(metadata, name) { + return metadata.properties.find( + (entry) => + ts.isPropertyAssignment(entry) && + (ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) && + entry.name.text === name + )?.initializer; +} + +/** Literal text of a quoted, backticked or interpolated template. */ +function literalText(node) { + if (ts.isStringLiteralLike(node)) return node.text; + if (ts.isTemplateExpression(node)) { + return [ + node.head.text, + ...node.templateSpans.map((span) => span.literal.text), + ].join(''); + } + return undefined; +} + +function isProviderCall(node) { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === PROVIDER + ); +} + +/** + * Findings for one TypeScript source. `readTemplate` resolves a + * `templateUrl` relative to the component and returns its text. + */ +export async function checkComponentSource(file, source, readTemplate) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true + ); + const lineOf = (node) => + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + .line + 1; + const findings = []; + const components = []; + const componentProviderCalls = new Set(); + + const visit = (node) => { + const metadata = componentMetadata(node); + if (metadata) { + const providers = property(metadata, 'providers'); + const calls = + providers && ts.isArrayLiteralExpression(providers) + ? providers.elements.filter(isProviderCall) + : []; + calls.forEach((call) => componentProviderCalls.add(call)); + components.push({ node, metadata, provides: calls.length > 0 }); + } + if (ts.isIdentifier(node) && node.text === TOKEN) { + if (file !== DEFAULTS_FILE) { + findings.push({ + file, + line: lineOf(node), + reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`, + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const visitCalls = (node) => { + if (isProviderCall(node) && !componentProviderCalls.has(node)) { + findings.push({ + file, + line: lineOf(node), + reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`, + }); + } + ts.forEachChild(node, visitCalls); + }; + visitCalls(sourceFile); + + for (const { node, metadata, provides } of components) { + const inline = property(metadata, 'template'); + const url = property(metadata, 'templateUrl'); + let template = inline ? literalText(inline) : undefined; + if (template === undefined && url && ts.isStringLiteralLike(url)) { + template = await readTemplate( + path.posix.join(path.posix.dirname(file), url.text) + ); + } + if (provides || !/ without providers: [${PROVIDER}()]`, + }); + } + return findings.sort((a, b) => a.line - b.line); +} + +/** Tracked renderer TypeScript files the guard reads, at any depth. */ +export function listScannedFiles(rootDir) { + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean) + .filter(isScanned); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const files = listScannedFiles(rootDir); + const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8'); + + const findings = []; + for (const file of files) { + const source = await readFile(path.join(rootDir, file), 'utf8'); + findings.push( + ...(await checkComponentSource(file, source, readTemplate)) + ); + } + + if (findings.length > 0) { + console.error( + 'Form-field defaults must be provided per component, not in the root injector:' + ); + for (const { file, line, reason } of findings) { + console.error(`- ${file}:${line} ${reason}`); + } + process.exitCode = 1; + } else { + console.log( + `Checked ${files.length} renderer files; every component provides the app defaults.` + ); + } +} diff --git a/tools/nx/check-form-field-defaults.test.mjs b/tools/nx/check-form-field-defaults.test.mjs new file mode 100644 index 000000000..b3301622a --- /dev/null +++ b/tools/nx/check-form-field-defaults.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + DEFAULTS_FILE, + checkComponentSource, + listScannedFiles, +} from './check-form-field-defaults.mjs'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); +const templates = { + 'libs/a/field.component.html': + '', + 'libs/a/plain.component.html': '', +}; +const readTemplate = async (file) => templates[file]; +const check = async (file, source) => + (await checkComponentSource(file, source, readTemplate)).map( + ({ line, reason }) => ({ line, reason }) + ); + +test('accepts a templateUrl form field with the provider', async () => { + const source = [ + '@Component({', + " selector: 'app-field',", + ' providers: [provideFormFieldDefaults()],', + " templateUrl: './field.component.html',", + '})', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), []); +}); + +test('accepts the provider next to other providers', async () => { + const source = [ + '@Component({', + ' providers: [DatePipe, provideFormFieldDefaults()],', + " templateUrl: './field.component.html',", + '})', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), []); +}); + +test('reports a templateUrl form field without the provider', async () => { + const source = [ + '@Component({', + " selector: 'app-field',", + " templateUrl: './field.component.html',", + '})', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), [ + { + line: 1, + reason: 'renders without providers: [provideFormFieldDefaults()]', + }, + ]); +}); + +test('reports an inline template form field without the provider', async () => { + const source = [ + '@Component({', + ' template: `', + ' ', + ' `,', + '})', + ].join('\n'); + + assert.equal((await check('libs/a/inline.component.ts', source)).length, 1); +}); + +test('reads quoted inline templates', async () => { + const source = [ + '@Component({', + " template: '',", + '})', + 'class Single {}', + '@Component({', + ' template: "",', + '})', + 'class Double {}', + ].join('\n'); + + assert.deepEqual( + (await check('libs/a/quoted.component.ts', source)).map( + ({ line }) => line + ), + [1, 5] + ); +}); + +test('checks each component of a file on its own providers', async () => { + const source = [ + '@Component({', + ' providers: [provideFormFieldDefaults()],', + " templateUrl: './field.component.html',", + '})', + 'class WithDefaults {}', + '@Component({', + " templateUrl: './field.component.html',", + '})', + 'class WithoutDefaults {}', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), [ + { + line: 6, + reason: 'renders without providers: [provideFormFieldDefaults()]', + }, + ]); +}); + +test('finds the provider after an earlier array-valued provider', async () => { + const source = [ + '@Component({', + ' providers: [', + ' { provide: LIST, useValue: [] },', + ' provideFormFieldDefaults(),', + ' ],', + " templateUrl: './field.component.html',", + '})', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), []); +}); + +test('reports the provider outside a component', async () => { + const appConfig = [ + 'export const appConfig = {', + ' providers: [provideRouter(routes), provideFormFieldDefaults()],', + '};', + ].join('\n'); + const routes = [ + 'export const routes = [', + " { path: 'settings', providers: [provideFormFieldDefaults()] },", + '];', + ].join('\n'); + + assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [ + { + line: 2, + reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup", + }, + ]); + assert.equal( + (await check('apps/web/src/app/app.routes.ts', routes)).length, + 1 + ); +}); + +test('ignores templates without a form field', async () => { + const source = [ + '@Component({', + " templateUrl: './plain.component.html',", + '})', + '@Component({ template: `add` })', + ].join('\n'); + + assert.deepEqual(await check('libs/a/plain.component.ts', source), []); +}); + +test('reports the token outside the shared provider', async () => { + const source = [ + "import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';", + 'export const appConfig = {', + ' providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {} }],', + '};', + ].join('\n'); + + assert.deepEqual( + (await check('apps/web/src/app/app.config.ts', source)).map( + ({ line }) => line + ), + [1, 3] + ); + assert.deepEqual(await check(DEFAULTS_FILE, source), []); +}); + +test('the shared provider supplies the outline, dynamic defaults', async () => { + const source = await readFile(path.join(repoRoot, DEFAULTS_FILE), 'utf8'); + + assert.match(source, /appearance: 'outline'/); + assert.match(source, /subscriptSizing: 'dynamic'/); + assert.match(source, /provide: MAT_FORM_FIELD_DEFAULT_OPTIONS/); +}); + +test('scans renderer TypeScript and skips specs', () => { + const files = listScannedFiles(repoRoot); + + assert.ok(files.includes(DEFAULTS_FILE)); + assert.ok(files.includes('apps/web/src/app/app.config.ts')); + assert.ok(files.every((file) => !/\.spec\.ts$/.test(file))); +}); diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index ee8841485..d3814bfb3 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1595777, + "value": 1534604, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-07", - "evidencePr": 1810, + "updatedAt": "2026-10-13", + "evidencePr": 1928, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, "renderer.ipcCallsToFirstCard": { diff --git a/tsconfig.base.json b/tsconfig.base.json index 01a5e5b29..de5cbeead 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -105,6 +105,9 @@ "@iptvnator/ui/components/confirm-dialog": [ "libs/ui/components/src/lib/confirm-dialog/dialog.service.ts" ], + "@iptvnator/ui/components/form-field-defaults": [ + "libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts" + ], "@iptvnator/ui/components/parental-lock-pin-dialog": [ "libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts" ],