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..ad8536f90 --- /dev/null +++ b/tools/nx/check-form-field-defaults.mjs @@ -0,0 +1,127 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * 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 + * keeps the token itself inside the shared provider. + */ +export const DEFAULTS_FILE = + 'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts'; +const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS'; + +const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts']; + +function isScanned(file) { + return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); +} + +function lineOf(source, index) { + return source.slice(0, index).split('\n').length; +} + +/** Inline template text, or the path of the `templateUrl` file. */ +export function componentTemplates(source) { + const inline = [...source.matchAll(/\btemplate:\s*`([\s\S]*?)`/g)].map( + (match) => ({ inline: match[1], index: match.index }) + ); + const urls = [...source.matchAll(/\btemplateUrl:\s*['"]([^'"]+)['"]/g)].map( + (match) => ({ url: match[1], index: match.index }) + ); + return [...inline, ...urls].sort((a, b) => a.index - b.index); +} + +function providesDefaults(source) { + return /\bproviders:\s*\[[^\]]*\bprovideFormFieldDefaults\(\)/.test(source); +} + +/** + * Findings for one component source. `readTemplate` resolves a + * `templateUrl` relative to the component and returns its text. + */ +export async function checkComponentSource(file, source, readTemplate) { + const findings = []; + if (file !== DEFAULTS_FILE) { + for (const match of source.matchAll( + new RegExp(`\\b${TOKEN}\\b`, 'g') + )) { + findings.push({ + file, + line: lineOf(source, match.index), + reason: `uses ${TOKEN}; provide the app defaults with provideFormFieldDefaults() from @iptvnator/ui/components/form-field-defaults`, + }); + } + } + + for (const template of componentTemplates(source)) { + const text = + template.inline ?? + (await readTemplate( + path.posix.join(path.posix.dirname(file), template.url) + )); + if (!/ without providers: [provideFormFieldDefaults()]', + }); + } + return findings; +} + +/** 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..8651be43b --- /dev/null +++ b/tools/nx/check-form-field-defaults.test.mjs @@ -0,0 +1,121 @@ +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: 3, + 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('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..261a288e2 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,10 +3,10 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1595777, + "value": 1534604, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-07", + "updatedAt": "2026-10-13", "evidencePr": 1810, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, 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" ],