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

+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,