feat(angular): a template's event bindings call the component's methods (#2114)

An Angular handler's only caller is its template: `(click)="toggleFavorite()"`,
`(ngSubmit)="submitForm()"`. With no edge, the method showed no callers,
impact stopped at it, and the Steps picture of a screen reached its
handlers only through class containment, with nothing to say what fires
them.

The template pass now reads each `(event)="…"` binding — not
`[(ngModel)]`'s two-way half — and links the component to every method of
its own the statement calls (`save()`, `this.toggle(x)`; not
`closed.emit()` or `form.reset()`), as a `calls` edge
(`synthesizedBy: 'angular-event'`) carrying the binding as
`metadata.trigger`: `{ kind: 'prop', name: '(click)', of: 'button' }`.
The binding is in the template, so it cannot be read back from the source
at the edge's line; Steps now takes a trigger an edge carries before
reading one at the site.

angular-realworld: 21 binding sites → 17 edges (3 repeats of a method and
event, 1 `delete.emit(true)`); Steps for /article/:slug draws
deleteArticle on (click) · <button> → DELETE and back to /, addComment on
(ngSubmit) · <form>, the favorite button's toggle → POST/DELETE or
/register. Ghostfolio 225 edges of 302 sites, ngx-admin 75 of 100 — the
rest are `$event.stopPropagation()`, output emits, assignments and
repeats. Controls (koel, proshop) byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 02:51:30 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 761611c18e
commit c3efbbf4e7
6 changed files with 108 additions and 4 deletions
+29 -1
View File
@@ -15,6 +15,7 @@ import * as path from 'path';
import { CodeGraph } from '../src';
import { commandsHref, localizeDefault, parseAngularRoutes, staticString } from '../src/resolution/frameworks/angular-router';
import { buildScreens } from '../src/ui-server/api/screens';
import { buildSteps } from '../src/ui-server/api/steps';
describe('parseAngularRoutes', () => {
const file = (body: string) => `import { Routes } from '@angular/router';\n${body}`;
@@ -189,7 +190,13 @@ export class AdminRoutingModule {}
<a [routerLink]="['/account', 'access', 7]">Access</a>
<a routerLink="relative">Relative</a>
`,
'src/app/shared/card.component.ts': component('CardComponent', 'app-card', `<button (click)="save()">Save</button>`, ` save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`).replace(
'src/app/shared/card.component.ts': component(
'CardComponent',
'app-card',
`<button (click)="save()">Save</button><input [(ngModel)]="name" /><a (click)="closed.emit(true)">x</a>`,
` name = '';
save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`
).replace(
"import { Router } from '@angular/router';",
"import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"
),
@@ -270,6 +277,27 @@ export class AdminRoutingModule {}
expect(renders).toEqual(['HomeComponent -> CardComponent <app-card>']);
});
it("links a template's event bindings to the component's own methods, the binding as the trigger", async () => {
const bindings = cg
.getNodesByKind('class')
.flatMap((c) =>
cg
.getOutgoingEdgesFrom([c.id], ['calls'])
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'angular-event')
.map((e) => {
const meta = e.metadata as Record<string, unknown>;
return `${c.name} -> ${cg.getNode(e.target)?.name} ${JSON.stringify(meta.trigger)}`;
})
);
// `[(ngModel)]` is a two-way binding and `closed.emit()` raises the component's own output: neither calls a method.
expect(bindings).toEqual(['CardComponent -> save {"kind":"prop","name":"(click)","of":"button"}']);
const home = cg.getNodesByKind('route').find((r) => r.name === '/home')!;
const payload = await buildSteps(cg, root, new URLSearchParams({ anchor: home.id }));
const save = payload.steps.find((st) => st.label === 'save');
expect(save?.trigger).toMatchObject({ kind: 'prop', name: '(click)', of: 'button', in: 'CardComponent' });
});
it("draws the Screens picture: a child component's navigation on its screen, a layout's on each screen inside it", async () => {
const payload = await buildScreens(cg, root);
expect(payload.routed).toBe(true);