perf(web): take the form-field defaults off the initial path

app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS at the root, and that
one import put the whole 54 KB chunk of @angular/material/form-field into
the initial bundle, although every form field renders in a lazy route or a
dialog. The bundler places a module in one chunk, so one eager import of any
export carries the module's entire code.

The defaults now travel with the element: AppFormFieldModule
(@iptvnator/ui/components/form-field) exports MatFormFieldModule together
with a directive that matches mat-form-field and provides the options in
its element injector. That also reaches the dialogs that root services
open, which a route-level provider would miss. Every component that renders
a form field imports it, and tools/nx/check-form-field-defaults.mjs (CI)
rejects one that does not.

renderer.initialBytes: 1,596,201 -> 1,534,606 bytes (-61,595) on a local
production build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-13 09:49:55 +02:00
1 parent 8bf92f9d65
commit 73575ff092
26 files changed
+615 -35

No files matched your search

+6
View File
@@ -430,6 +430,12 @@ jobs:
- name: Validate icon-only button names
run: pnpm run a11y:icon-buttons:validate
# The form-field defaults travel with AppFormFieldModule, not a
# root provider: a component that renders <mat-form-field>
# without it silently gets Material's filled look.
- name: Validate form-field defaults
run: pnpm run styles: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.
-5
View File
@@ -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' },
},
],
};
@@ -8,10 +8,10 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import {
AppUpdateChannel,
APP_UPDATE_CHANNELS,
@@ -30,7 +30,7 @@ interface SettingsUpdateStatusChip {
selector: 'app-settings-about-section',
imports: [
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
@@ -12,12 +12,12 @@ import {
ReactiveFormsModule,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { EpgViewMode } from '@iptvnator/shared/interfaces';
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
@@ -29,7 +29,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
imports: [
EpgSourceStatusComponent,
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
@@ -7,11 +7,11 @@ import {
ChangeDetectionStrategy,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
import {
CoverSizeOption,
@@ -25,7 +25,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
selector: 'app-settings-general-section',
imports: [
CommonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
@@ -1,6 +1,5 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import {
@@ -8,13 +7,14 @@ import {
MatSlideToggleModule,
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-settings-parental-lock-section',
imports: [
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
@@ -8,13 +8,13 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import {
StreamFormat,
VideoPlayer,
@@ -28,7 +28,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
imports: [
CommonModule,
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
@@ -7,20 +7,20 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-remote-control-section',
imports: [
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatInputModule,
MatSlideToggleModule,
@@ -8,12 +8,12 @@ import {
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@@ -24,7 +24,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
selector: 'app-settings-tmdb-section',
imports: [
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
+3 -3
View File
@@ -278,9 +278,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.
// Applied app-wide AFTER the component themes so these overrides win the
// cascade. Pairs with the outline + dynamic defaults that AppFormFieldModule
// (libs/ui/components, form-field) gives every form field.
$app-refined-input-density: mat.define-theme((
density: (scale: -2),
));
@@ -9,10 +9,10 @@ import {
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import {
detectProviderImportCandidates,
ProviderImportCandidate,
@@ -150,7 +150,7 @@ function maskUrlQueryPasswords(url: string): string {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatFormFieldModule,
AppFormFieldModule,
MatIcon,
MatInputModule,
ReactiveFormsModule,
@@ -18,12 +18,12 @@ import {
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
legacyTransformStalkerPortalUrl,
@@ -66,7 +66,7 @@ interface StalkerSettledIdentity {
imports: [
FormsModule,
MatCheckboxModule,
MatFormFieldModule,
AppFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
@@ -3,6 +3,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { MockModule } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';
@@ -14,6 +15,9 @@ describe('TextImportComponent', () => {
TestBed.configureTestingModule({
imports: [
TextImportComponent,
// The form field and its input are mocked together: a real
// MatFormField would look for the mocked input's state.
MockModule(AppFormFieldModule),
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
@@ -12,13 +12,19 @@ import {
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
@Component({
selector: 'app-text-import',
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
imports: [
AppFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();
@@ -11,9 +11,9 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
@Component({
@@ -21,7 +21,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services';
templateUrl: './url-upload.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatFormFieldModule,
AppFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
@@ -16,11 +16,11 @@ import {
Validators,
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { createXtreamConnectionTestState } from '@iptvnator/services';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
@@ -50,7 +50,7 @@ function xtreamServerUrlValidator(
@Component({
imports: [
FormsModule,
MatFormFieldModule,
AppFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
@@ -28,6 +28,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { firstValueFrom } from 'rxjs';
@@ -168,6 +169,7 @@ type DesktopFileSaveBridge = Pick<
providers: [DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AppFormFieldModule,
ClipboardModule,
MatButton,
MatCheckboxModule,
+1
View File
@@ -10,6 +10,7 @@ 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/external-playback-dock/external-playback-dock.component';
export * from './lib/form-field/app-form-field.module';
export * from './lib/portal-detail-shell/content-about.component';
export * from './lib/portal-detail-shell/detail-template.directives';
export * from './lib/portal-detail-shell/portal-detail-shell.component';
@@ -14,11 +14,11 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AppFormFieldModule } from '../../form-field/app-form-field.module';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
@@ -46,7 +46,7 @@ const SEARCH_MIN_CHARS = 2;
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
@@ -0,0 +1,54 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { AppFormFieldModule } from './app-form-field.module';
@Component({
imports: [AppFormFieldModule, MatInputModule],
template: `
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput />
<mat-hint>Short</mat-hint>
</mat-form-field>
`,
})
class WithDefaultsComponent {}
@Component({
imports: [MatFormFieldModule, MatInputModule],
template: `
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput />
</mat-form-field>
`,
})
class WithoutDefaultsComponent {}
function formField(component: typeof WithDefaultsComponent): HTMLElement {
const fixture = TestBed.createComponent(component);
fixture.detectChanges();
return fixture.nativeElement.querySelector('mat-form-field');
}
describe('AppFormFieldModule', () => {
it('gives a form field the outlined appearance and dynamic subscript', () => {
const field = formField(WithDefaultsComponent);
expect(field.classList).toContain('mat-form-field-appearance-outline');
expect(field.querySelector('.mdc-notched-outline')).not.toBeNull();
// Dynamic sizing reserves no empty subscript row below the field.
expect(
field.querySelector('.mat-mdc-form-field-subscript-dynamic-size')
).not.toBeNull();
});
it('is the only source of those defaults: Material alone renders filled', () => {
const field = formField(WithoutDefaultsComponent);
expect(field.classList).toContain('mat-form-field-appearance-fill');
expect(field.querySelector('.mdc-notched-outline')).toBeNull();
});
});
@@ -0,0 +1,49 @@
import { Directive, NgModule } from '@angular/core';
import {
MAT_FORM_FIELD_DEFAULT_OPTIONS,
MatFormFieldDefaultOptions,
MatFormFieldModule,
} from '@angular/material/form-field';
/** The app-wide form field: outlined, with hints and errors sized as needed. */
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
appearance: 'outline',
subscriptSizing: 'dynamic',
};
/**
* Gives every `<mat-form-field>` the app defaults through its own element
* injector, which `MatFormField` reads before any environment injector.
*
* Provided at the root, the defaults were a static import of
* `@angular/material/form-field` in `app.config.ts`, and one eager import of
* any export puts a module's whole bundler chunk on the initial path: 54 KB
* of form-field code for an app whose every form field renders in a lazy
* route or dialog. A route-level provider would miss the dialogs that root
* services open, so the defaults travel with the element instead.
*/
@Directive({
// The directive must match the Material element itself: an attribute
// would have to be added to every form field by hand.
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'mat-form-field',
providers: [
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: APP_FORM_FIELD_DEFAULTS,
},
],
})
export class AppFormFieldDefaultsDirective {}
/**
* Import this, not `MatFormFieldModule`, wherever a template renders
* `<mat-form-field>`; it exports Material's form field together with the
* defaults directive. `pnpm run styles:form-field-defaults:validate` (CI)
* rejects a component that renders a form field without it.
*/
@NgModule({
imports: [MatFormFieldModule, AppFormFieldDefaultsDirective],
exports: [MatFormFieldModule, AppFormFieldDefaultsDirective],
})
export class AppFormFieldModule {}
@@ -16,10 +16,10 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { AppFormFieldModule } from '../form-field/app-form-field.module';
import {
createParentalLockPinThrottle,
isValidParentalLockPin,
@@ -66,7 +66,7 @@ export interface ParentalLockPinDialogData {
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
AppFormFieldModule,
MatIconModule,
MatInputModule,
TranslatePipe,
+3
View File
@@ -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",
"styles:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
"styles:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
"styles:form-field-defaults:validate": "pnpm run styles:form-field-defaults:test && pnpm run styles: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",
+239
View File
@@ -0,0 +1,239 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseTemplate,
TmplAstRecursiveVisitor,
tmplAstVisitAll,
} from '@angular/compiler';
import ts from 'typescript';
import { isScanned } from './check-theme-references.mjs';
/**
* The app-wide form-field defaults (outlined, dynamic subscript) are not a
* root provider: that import put the whole form-field chunk on the initial
* path. `AppFormFieldModule` carries them on the element instead, so a
* component that renders `<mat-form-field>` without importing it silently
* gets Material's filled look. This guard requires the import.
*/
/** Angular renderer sources whose components can render a form field. */
const SCANNED_PATHSPECS = [
'apps/web/*.ts',
'apps/remote-control-web/*.ts',
'libs/*.ts',
];
export const DEFAULTS_MODULE = 'AppFormFieldModule';
export const DEFAULTS_ALIAS = '@iptvnator/ui/components/form-field';
const DEFAULTS_SOURCE = /(^|\/)app-form-field\.module$/;
const FORM_FIELD_ELEMENT = 'mat-form-field';
/** Identifiers listed in an `imports: [...]` array, nested arrays included. */
function importIdentifiers(expression) {
if (ts.isArrayLiteralExpression(expression)) {
return expression.elements.flatMap(importIdentifiers);
}
return ts.isIdentifier(expression) ? [expression.text] : [];
}
function propertyNamed(object, name) {
return object.properties.find(
(property) =>
ts.isPropertyAssignment(property) &&
(ts.isIdentifier(property.name) ||
ts.isStringLiteral(property.name)) &&
property.name.text === name
);
}
function stringValue(property) {
const value = property?.initializer;
return value &&
(ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
? value.text
: undefined;
}
/**
* The `@Component({...})` declarations of a TypeScript source: class name,
* the line its decorator starts on, inline `template` or `templateUrl`, and
* the identifiers of its `imports` array. Whether the source binds `AppFormFieldModule` from its
* module is reported alongside, since a same-named local class would not
* carry the defaults.
*/
export function componentDeclarations(source, file) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true
);
const components = [];
let bindsDefaultsModule = false;
const visit = (node) => {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
(node.moduleSpecifier.text === DEFAULTS_ALIAS ||
DEFAULTS_SOURCE.test(node.moduleSpecifier.text)) &&
node.importClause?.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings) &&
node.importClause.namedBindings.elements.some(
(element) => element.name.text === DEFAULTS_MODULE
)
) {
bindsDefaultsModule = true;
}
if (
ts.isDecorator(node) &&
ts.isCallExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'Component' &&
ts.isClassDeclaration(node.parent)
) {
const [metadata] = node.expression.arguments;
if (metadata && ts.isObjectLiteralExpression(metadata)) {
const importsProperty = propertyNamed(metadata, 'imports');
components.push({
className: node.parent.name?.text ?? '(anonymous)',
line:
sourceFile.getLineAndCharacterOfPosition(
node.parent.getStart(sourceFile)
).line + 1,
template: stringValue(propertyNamed(metadata, 'template')),
templateUrl: stringValue(
propertyNamed(metadata, 'templateUrl')
),
imports: importsProperty
? importIdentifiers(importsProperty.initializer)
: [],
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return { components, bindsDefaultsModule };
}
class FormFieldVisitor extends TmplAstRecursiveVisitor {
found = false;
visitElement(element) {
if (element.name === FORM_FIELD_ELEMENT) this.found = true;
super.visitElement(element);
}
}
/** Whether a template renders `<mat-form-field>` anywhere, control flow included. */
export function templateRendersFormField(template, file) {
const parsed = parseTemplate(template, file, {
preserveWhitespaces: true,
});
if (parsed.errors?.length) {
throw new Error(
`${file}: cannot parse template: ${parsed.errors[0].msg}`
);
}
const visitor = new FormFieldVisitor();
tmplAstVisitAll(visitor, parsed.nodes);
return visitor.found;
}
/**
* Components of one source that render a form field without importing the
* defaults module. `readTemplate` resolves a `templateUrl` to its text.
*/
export async function findFormFieldsWithoutDefaults(
file,
source,
readTemplate
) {
const { components, bindsDefaultsModule } = componentDeclarations(
source,
file
);
const findings = [];
for (const component of components) {
const template =
component.template ??
(component.templateUrl
? await readTemplate(component.templateUrl)
: undefined);
if (template === undefined) continue;
if (!templateRendersFormField(template, file)) continue;
if (
bindsDefaultsModule &&
component.imports.includes(DEFAULTS_MODULE)
) {
continue;
}
findings.push({
file,
line: component.line,
className: component.className,
});
}
return findings;
}
/** Tracked renderer sources 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 findings = [];
for (const file of files) {
const source = await readFile(path.join(rootDir, file), 'utf8');
if (
!source.includes(FORM_FIELD_ELEMENT) &&
!source.includes('templateUrl')
) {
continue;
}
findings.push(
...(await findFormFieldsWithoutDefaults(file, source, (url) =>
readFile(path.resolve(rootDir, path.dirname(file), url), 'utf8')
))
);
}
if (findings.length > 0) {
console.error(
`Components that render <mat-form-field> without ${DEFAULTS_MODULE} (from ${DEFAULTS_ALIAS}), so their fields lose the app defaults (outlined, dynamic subscript). Import it in place of MatFormFieldModule:`
);
for (const { file, line, className } of findings) {
console.error(`- ${file}:${line} ${className}`);
}
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} renderer sources; every component rendering <mat-form-field> imports ${DEFAULTS_MODULE}.`
);
}
}
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import {
componentDeclarations,
findFormFieldsWithoutDefaults,
listScannedFiles,
templateRendersFormField,
} from './check-form-field-defaults.mjs';
const lines = (...rows) => rows.join('\n');
const component = ({
name = 'FieldComponent',
imports = ['AppFormFieldModule'],
template,
templateUrl,
importLine = "import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';",
}) =>
lines(
"import { Component } from '@angular/core';",
importLine,
'',
'@Component({',
` selector: 'app-field',`,
` imports: [${imports.join(', ')}],`,
template !== undefined
? ` template: \`${template}\`,`
: ` templateUrl: '${templateUrl}',`,
'})',
`export class ${name} {}`
);
const noTemplates = () => {
throw new Error('no templateUrl expected');
};
test('accepts a form field in a component that imports the defaults module', async () => {
const source = component({
template: '<mat-form-field><input matInput /></mat-form-field>',
});
assert.deepEqual(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
),
[]
);
});
test('reports the line of the @Component decorator when the defaults module is missing', async () => {
const source = component({
imports: ['MatFormFieldModule'],
importLine:
"import { MatFormFieldModule } from '@angular/material/form-field';",
template: '<mat-form-field><input matInput /></mat-form-field>',
});
assert.deepEqual(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
),
[{ file: 'a.component.ts', line: 4, className: 'FieldComponent' }]
);
});
test('a same-named class from elsewhere does not count as the defaults module', async () => {
const source = component({
importLine: "import { AppFormFieldModule } from './local-module';",
template: '<mat-form-field></mat-form-field>',
});
assert.equal(
(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
)
).length,
1
);
});
test('a relative import of the module source counts inside its own library', async () => {
const source = component({
importLine:
"import { AppFormFieldModule } from '../../form-field/app-form-field.module';",
template: '<mat-form-field></mat-form-field>',
});
assert.deepEqual(
await findFormFieldsWithoutDefaults(
'a.component.ts',
source,
noTemplates
),
[]
);
});
test('reads templateUrl templates and sees form fields inside control flow', async () => {
const source = component({
imports: ['MatInputModule'],
importLine: "import { MatInputModule } from '@angular/material/input';",
templateUrl: './a.component.html',
});
const urls = [];
const findings = await findFormFieldsWithoutDefaults(
'dir/a.component.ts',
source,
async (url) => {
urls.push(url);
return lines(
'@if (editing()) {',
' <mat-form-field appearance="fill">',
' <input matInput />',
' </mat-form-field>',
'}'
);
}
);
assert.deepEqual(urls, ['./a.component.html']);
assert.equal(findings.length, 1);
});
test('ignores components without a form field and classes without a template', async () => {
const source = lines(
component({
imports: ['MatButtonModule'],
importLine:
"import { MatButtonModule } from '@angular/material/button';",
template: '<button mat-button>Save</button>',
}),
'@Injectable({ providedIn: "root" })',
'export class PlainService {}'
);
assert.deepEqual(
await findFormFieldsWithoutDefaults('a.ts', source, noTemplates),
[]
);
});
test('templateRendersFormField does not match a form field inside a comment', () => {
assert.equal(
templateRendersFormField(
'<!-- <mat-form-field></mat-form-field> -->',
'a.html'
),
false
);
assert.equal(
templateRendersFormField(
'<div><ng-template><mat-form-field /></ng-template></div>',
'a.html'
),
true
);
});
test('componentDeclarations flattens nested import arrays and keeps the template url', () => {
const { components, bindsDefaultsModule } = componentDeclarations(
lines(
"import { Component } from '@angular/core';",
"import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';",
'const SHARED = [AppFormFieldModule];',
'@Component({',
' imports: [[MatInputModule, SHARED], AppFormFieldModule],',
" templateUrl: './x.html',",
'})',
'export class XComponent {}'
),
'x.ts'
);
assert.equal(bindsDefaultsModule, true);
assert.deepEqual(components, [
{
className: 'XComponent',
line: 4,
template: undefined,
templateUrl: './x.html',
imports: ['MatInputModule', 'SHARED', 'AppFormFieldModule'],
},
]);
});
test('listScannedFiles returns tracked renderer sources and skips specs', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'form-field-guard-'));
try {
await mkdir(path.join(root, 'libs/ui/src'), { recursive: true });
await mkdir(path.join(root, 'apps/web/src'), { recursive: true });
await writeFile(path.join(root, 'libs/ui/src/a.component.ts'), '');
await writeFile(path.join(root, 'libs/ui/src/a.component.spec.ts'), '');
await writeFile(path.join(root, 'apps/web/src/b.component.ts'), '');
await writeFile(path.join(root, 'apps/web/src/untracked.ts'), '');
execFileSync('git', ['init', '-q'], { cwd: root });
execFileSync(
'git',
[
'add',
'libs/ui/src/a.component.ts',
'libs/ui/src/a.component.spec.ts',
'apps/web/src/b.component.ts',
],
{ cwd: root }
);
assert.deepEqual(listScannedFiles(root).sort(), [
'apps/web/src/b.component.ts',
'libs/ui/src/a.component.ts',
]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+3
View File
@@ -105,6 +105,9 @@
"@iptvnator/ui/components/confirm-dialog": [
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
],
"@iptvnator/ui/components/form-field": [
"libs/ui/components/src/lib/form-field/app-form-field.module.ts"
],
"@iptvnator/ui/components/parental-lock-pin-dialog": [
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
],