mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
perf(web): provide form-field defaults per component, not at the root (#1928)
* perf(web): provide form-field defaults per component, not at the root app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root injector. Importing the token kept @angular/material/form-field in main.js, and Material 22.2's form field imports @angular/forms/signals, which would add @angular/forms to startup as well (the reason the Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every form field renders in a lazy chunk, so the outline + dynamic defaults now come from provideFormFieldDefaults() (@iptvnator/ui/components/form-field-defaults) on each component that renders <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> * perf(web): cite #1928 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ui): check form-field providers per component on the TypeScript AST The guard matched templates and providers with regular expressions over the whole file: a quoted inline template went unread, one component's provider covered another in the same file, an earlier array-valued provider hid the call, and provideFormFieldDefaults() in app.config or a route passed although it loads Material's form field on startup again. It now reads each @Component decorator's own template and providers array, and reports any provideFormFieldDefaults() call outside one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
28 files changed
+500
-11
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
/**
|
||||
* The app's `<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
|
||||
* allows the provider and the token nowhere else.
|
||||
*/
|
||||
export const DEFAULTS_FILE =
|
||||
'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts';
|
||||
const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS';
|
||||
const PROVIDER = 'provideFormFieldDefaults';
|
||||
|
||||
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
|
||||
|
||||
function isScanned(file) {
|
||||
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
|
||||
}
|
||||
|
||||
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
|
||||
function componentMetadata(node) {
|
||||
if (
|
||||
!ts.isDecorator(node) ||
|
||||
!ts.isCallExpression(node.expression) ||
|
||||
!ts.isIdentifier(node.expression.expression) ||
|
||||
node.expression.expression.text !== 'Component'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const [metadata] = node.expression.arguments;
|
||||
return metadata && ts.isObjectLiteralExpression(metadata)
|
||||
? metadata
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function property(metadata, name) {
|
||||
return metadata.properties.find(
|
||||
(entry) =>
|
||||
ts.isPropertyAssignment(entry) &&
|
||||
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
|
||||
entry.name.text === name
|
||||
)?.initializer;
|
||||
}
|
||||
|
||||
/** Literal text of a quoted, backticked or interpolated template. */
|
||||
function literalText(node) {
|
||||
if (ts.isStringLiteralLike(node)) return node.text;
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return [
|
||||
node.head.text,
|
||||
...node.templateSpans.map((span) => span.literal.text),
|
||||
].join('');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isProviderCall(node) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === PROVIDER
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings for one TypeScript source. `readTemplate` resolves a
|
||||
* `templateUrl` relative to the component and returns its text.
|
||||
*/
|
||||
export async function checkComponentSource(file, source, readTemplate) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
const lineOf = (node) =>
|
||||
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
.line + 1;
|
||||
const findings = [];
|
||||
const components = [];
|
||||
const componentProviderCalls = new Set();
|
||||
|
||||
const visit = (node) => {
|
||||
const metadata = componentMetadata(node);
|
||||
if (metadata) {
|
||||
const providers = property(metadata, 'providers');
|
||||
const calls =
|
||||
providers && ts.isArrayLiteralExpression(providers)
|
||||
? providers.elements.filter(isProviderCall)
|
||||
: [];
|
||||
calls.forEach((call) => componentProviderCalls.add(call));
|
||||
components.push({ node, metadata, provides: calls.length > 0 });
|
||||
}
|
||||
if (ts.isIdentifier(node) && node.text === TOKEN) {
|
||||
if (file !== DEFAULTS_FILE) {
|
||||
findings.push({
|
||||
file,
|
||||
line: lineOf(node),
|
||||
reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`,
|
||||
});
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
|
||||
const visitCalls = (node) => {
|
||||
if (isProviderCall(node) && !componentProviderCalls.has(node)) {
|
||||
findings.push({
|
||||
file,
|
||||
line: lineOf(node),
|
||||
reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`,
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visitCalls);
|
||||
};
|
||||
visitCalls(sourceFile);
|
||||
|
||||
for (const { node, metadata, provides } of components) {
|
||||
const inline = property(metadata, 'template');
|
||||
const url = property(metadata, 'templateUrl');
|
||||
let template = inline ? literalText(inline) : undefined;
|
||||
if (template === undefined && url && ts.isStringLiteralLike(url)) {
|
||||
template = await readTemplate(
|
||||
path.posix.join(path.posix.dirname(file), url.text)
|
||||
);
|
||||
}
|
||||
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
|
||||
findings.push({
|
||||
file,
|
||||
line: lineOf(node),
|
||||
reason: `renders <mat-form-field> without providers: [${PROVIDER}()]`,
|
||||
});
|
||||
}
|
||||
return findings.sort((a, b) => a.line - b.line);
|
||||
}
|
||||
|
||||
/** Tracked renderer TypeScript files the guard reads, at any depth. */
|
||||
export function listScannedFiles(rootDir) {
|
||||
// No shell: `cmd.exe` treats single quotes as literal characters, so a
|
||||
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
|
||||
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter(isScanned);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) ===
|
||||
path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
const rootDir = process.cwd();
|
||||
const files = listScannedFiles(rootDir);
|
||||
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
|
||||
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(path.join(rootDir, file), 'utf8');
|
||||
findings.push(
|
||||
...(await checkComponentSource(file, source, readTemplate))
|
||||
);
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error(
|
||||
'Form-field defaults must be provided per component, not in the root injector:'
|
||||
);
|
||||
for (const { file, line, reason } of findings) {
|
||||
console.error(`- ${file}:${line} ${reason}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Checked ${files.length} renderer files; every <mat-form-field> component provides the app defaults.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
DEFAULTS_FILE,
|
||||
checkComponentSource,
|
||||
listScannedFiles,
|
||||
} from './check-form-field-defaults.mjs';
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..'
|
||||
);
|
||||
const templates = {
|
||||
'libs/a/field.component.html':
|
||||
'<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: 1,
|
||||
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('reads quoted inline templates', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
" template: '<mat-form-field></mat-form-field>',",
|
||||
'})',
|
||||
'class Single {}',
|
||||
'@Component({',
|
||||
' template: "<mat-form-field></mat-form-field>",',
|
||||
'})',
|
||||
'class Double {}',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
(await check('libs/a/quoted.component.ts', source)).map(
|
||||
({ line }) => line
|
||||
),
|
||||
[1, 5]
|
||||
);
|
||||
});
|
||||
|
||||
test('checks each component of a file on its own providers', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' providers: [provideFormFieldDefaults()],',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
'class WithDefaults {}',
|
||||
'@Component({',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
'class WithoutDefaults {}',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), [
|
||||
{
|
||||
line: 6,
|
||||
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('finds the provider after an earlier array-valued provider', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' providers: [',
|
||||
' { provide: LIST, useValue: [] },',
|
||||
' provideFormFieldDefaults(),',
|
||||
' ],',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
|
||||
});
|
||||
|
||||
test('reports the provider outside a component', async () => {
|
||||
const appConfig = [
|
||||
'export const appConfig = {',
|
||||
' providers: [provideRouter(routes), provideFormFieldDefaults()],',
|
||||
'};',
|
||||
].join('\n');
|
||||
const routes = [
|
||||
'export const routes = [',
|
||||
" { path: 'settings', providers: [provideFormFieldDefaults()] },",
|
||||
'];',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [
|
||||
{
|
||||
line: 2,
|
||||
reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup",
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await check('apps/web/src/app/app.routes.ts', routes)).length,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores templates without a form field', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
" templateUrl: './plain.component.html',",
|
||||
'})',
|
||||
'@Component({ template: `<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,11 +3,11 @@
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 1595777,
|
||||
"value": 1534604,
|
||||
"unit": "bytes",
|
||||
"slack": 4096,
|
||||
"updatedAt": "2026-10-07",
|
||||
"evidencePr": 1810,
|
||||
"updatedAt": "2026-10-13",
|
||||
"evidencePr": 1928,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
},
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
|
||||
Reference in new issue
Block a user