mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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 <mat-form-field>. 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ebb1ab3467
commit
8d01ae5f75
28 files changed
+355
-10
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
|
||||
@@ -1033,6 +1033,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
|
||||
|
||||
## Forms
|
||||
|
||||
Every `<mat-form-field>` 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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+20
@@ -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();
|
||||
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
* `<mat-form-field>`. 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,
|
||||
};
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<mat-form-field>` 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 (!/<mat-form-field\b/.test(text) || providesDefaults(source)) {
|
||||
continue;
|
||||
}
|
||||
findings.push({
|
||||
file,
|
||||
line: lineOf(source, template.index),
|
||||
reason: 'renders <mat-form-field> 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 <mat-form-field> component provides the app defaults.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
'<mat-form-field><input matInput /></mat-form-field>',
|
||||
'libs/a/plain.component.html': '<button mat-button>Save</button>',
|
||||
};
|
||||
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 <mat-form-field> without providers: [provideFormFieldDefaults()]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports an inline template form field without the provider', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' template: `',
|
||||
' <mat-form-field appearance="outline"></mat-form-field>',
|
||||
' `,',
|
||||
'})',
|
||||
].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: `<mat-icon>add</mat-icon>` })',
|
||||
].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)));
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user