Angular

Angular applications — subscription teardown, change detection, injection sinks, guards.

What this is

Angular applications — subscription teardown, change detection, injection sinks, guards.

How to onboard it

A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:

terminal
$ npx redlinegate init  # detects the profile
$ npx redlinegate init --profile web-angular  # or name one

One profile pulls these rules in: web-angular.

Once onboarded, your files match this stack when they fit any of these globs:

  • **/*.component.ts
  • **/*.component.html
  • **/*.service.ts
  • **/*.directive.ts
  • **/*.pipe.ts
  • **/*.guard.ts
  • **/*.interceptor.ts
  • src/app/**
  • apps/**/src/app/**
  • packages/**/src/app/**

How to use it — 32 rules

Nothing to run. Once your profile includes Angular, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.

Rule idSeverityCatches
angular/bypass-security-trustBLOCKER`DomSanitizer.bypassSecurityTrust*` on a value derived from external input. The call exists to switch Angular's sanitiser off; passing a URL, a query param or an API field through it is an injection sink with the guard removed.
angular/open-redirect-navigationBLOCKER`router.navigateByUrl`, `location.href` or `[routerLink]` fed a user-controlled URL. An `?next=` param that reaches navigation without an allow-list is an open redirect, and on `location.href` a `javascript:` URL is script execution.
angular/dynamic-template-compilationBLOCKERA component, template or `NgModule` compiled from a string that came from outside. There is no sanitiser on this path — the string is code.
angular/guard-as-only-authBLOCKERA route guard treated as the authorisation check. Guards decide what gets rendered, never what gets served: every endpoint behind the route re-checks, or the data is one `curl` away.
angular/secret-in-environment-fileBLOCKERA secret in `environment.ts` / `environment.prod.ts`. These files are compiled into the browser bundle. Anything in them is public, whatever the filename suggests.
angular/unsubscribed-subscriptionBLOCKER`.subscribe()` with no teardown. Use the `async` pipe, `takeUntilDestroyed()`, or unsubscribe in `ngOnDestroy` — a long-lived stream holds the component, its template and everything they close over.
angular/uncancelled-request-raceBLOCKERA request per keystroke or per navigation that is not cancelled when it is superseded. With `mergeMap` the responses land out of order and the slower, older one wins; with `concatMap` they stay ordered but queue, so the view shows an answer to a query the user has already moved on from. `switchMap` cancels the superseded request.
angular/timer-not-clearedBLOCKER`setInterval`, `setTimeout` or a manual event listener not torn down in `ngOnDestroy`. It keeps firing against a destroyed view.
angular/function-call-in-templateHIGHMethod or getter invoked from a template binding (`{{ total() }}`, `*ngIf="isReady()"`) — it re-runs on every change-detection cycle, including ones triggered by unrelated events. Precompute, or use a signal or `computed`.
angular/default-change-detection-hot-componentHIGHA list, table or frequently-updated component left on default change detection. Require `ChangeDetectionStrategy.OnPush` where the inputs are immutable.
angular/manual-change-detectionHIGH`detectChanges()` / `markForCheck()` called to make the view update. It is a symptom: the state was mutated in place, or the work escaped the zone. Fix the source.
angular/nested-subscribeHIGH`subscribe()` inside `subscribe()` — no cancellation, no error propagation, no ordering guarantee. Flatten with `switchMap`/`concatMap`.
angular/duplicate-http-subscriptionHIGHThe same cold `HttpClient` observable subscribed twice (two `async` pipes, or a `subscribe` plus a pipe) — that is two identical requests. `shareReplay({ bufferSize: 1, refCount: true })`.
angular/input-object-mutationHIGHA child mutating an object it received as `@Input()`. The parent owns it; with `OnPush` the parent never learns it changed.
angular/service-scope-mismatchHIGH`providedIn: 'root'` for state that must be per-component, or component `providers` for state that must be shared — one gives every consumer the same instance, the other silently gives each a fresh one.
angular/effect-writes-own-signalHIGHAn `effect()` writing a signal it also reads, or reaching for `allowSignalWrites` to make that legal. That is a cycle; `computed()` is what derived state is for.
angular/missing-trackbyHIGH`*ngFor` / `@for` over a list that reorders or re-fetches without `trackBy` (or `track`) — Angular destroys and rebuilds every row, losing focus and element state.
angular/interceptor-swallows-errorHIGHAn `HttpInterceptor` or `catchError` returning `of(null)` / `EMPTY` so the caller sees a successful empty response. The failure is now indistinguishable from no data.
angular/unvalidated-route-paramHIGHA route param or query param consumed as a typed value (`+id`, `as Status`) with no validation — it is external input and a user types what they like.
angular/uncalled-signalHIGHA signal used in a condition, comparison or logical expression without being called — `if (this.loading)`, `@if (isAdmin)`, `a === this.id`. A signal is a function, always truthy and never equal to a value, so `if (this.isAdmin)` shows admin actions to everyone. Call it: `this.isAdmin()`.
angular/computed-reads-no-signalHIGHA `computed()`, `linkedSignal()` or `effect()` that reads no signal, or a `computed` with no `return`. With no reactive dependency it runs once and never updates — `computed(() => this.first + ' ' + this.last)` over plain fields never reflects an edit. Read the signals it depends on, or make it a constant.
angular/async-lifecycle-hookHIGH`async ngOnInit` / `ngOnDestroy` / another lifecycle hook. Angular never awaits it, so the template renders the unloaded state (`Cannot read properties of undefined`), a rejection floats, and awaited teardown runs after the view is gone. Load through the `async` pipe, a resolver or `resource()`, or call a handled async method from a synchronous hook.
angular/injection-context-api-outside-contextHIGH`takeUntilDestroyed()` with no argument, `inject()`, `toSignal()` or an injector-less `effect()` called from a lifecycle hook, method or callback. These need an injection context and throw `NG0203` elsewhere, so the component fails to initialise. Pass `inject(DestroyRef)` / `{ injector }` captured in a field.
angular/output-native-event-nameHIGHAn `@Output()` / `output()` named after a bubbling DOM event — `click`, `change`, `input`, `submit`. The parent's `(change)` binds both the output and the native event bubbling out of the component, so the handler fires twice, once with an `Event`. Name it for the domain: `itemSelected`, `valueChange`.
angular/banana-out-of-boxHIGHTwo-way binding written `([x])="v"` instead of `[(x)]="v"`. The inverted form parses as an event binding named `[x]`, so the field never syncs from what the user types. Use `[(ngModel)]`.
angular/manual-lifecycle-callHIGH`this.ngOnInit()` or another hook called by hand to "refresh". Each call re-runs every subscription the hook sets up, so polling multiplies per click, and an early `ngOnDestroy` tears down a live view. Extract the shared work into a method. `super.ngOnInit()` is fine.
angular/impure-pipeHIGHA pipe declared `pure: false`. It re-runs on every change-detection cycle for every binding using it — hundreds of calls per keystroke in a 100-row table. Use a pure pipe over immutable inputs, or a `computed` signal.
angular/prefer-async-pipeSUGGESTION`async` pipe over a manual subscribe with a component field; subscription teardown comes free.
angular/prefer-signal-over-behaviorsubjectSUGGESTIONSignals for synchronous component state in new code; keep RxJS for streams that are genuinely asynchronous.
angular/prefer-standalone-componentSUGGESTIONStandalone components for new work rather than a new `NgModule`.
angular/prefer-inject-functionSUGGESTION`inject()` over constructor parameter injection in new code — it composes inside functions and base classes.
angular/oversized-componentSUGGESTIONComponent beyond ~300 lines, or a template beyond ~150 — suggest a split.

Expected output

A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:

a finding from this file
Redline/BLOCKER [angular/bypass-security-trust]: <one-line problem>

Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 32 rules here, 8 BLOCKER, 19 HIGH and 5 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.

If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.

How to edit it

Rules are edited in standards/stacks/angular.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.

  1. Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
  2. node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
  3. node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
  4. Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
  5. Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
  6. node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.

Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.

The full file

standards/stacks/angular.md · 86 lines · 8.2 KB
# Angular Review Rules

**Scope:** Angular 16+ applications and libraries — components, services, guards,
interceptors and templates. Where a rule exists in both the core standard and here,
the core rule wins and this file names only the Angular-specific shape of it.

Templates are reviewable code. A `.component.html` file is where change detection,
injection sinks and dead subscriptions actually become visible.

## BLOCKER — request changes

- `angular/bypass-security-trust` — **`DomSanitizer.bypassSecurityTrust*` on a value derived from external input.** The call exists to switch Angular's sanitiser off; passing a URL, a query param or an API field through it is an injection sink with the guard removed.

  ```ts
  // WRONG
  this.html = this.sanitizer.bypassSecurityTrustHtml(comment.body);
  // RIGHT — leave sanitisation on; bind the raw string
  this.html = comment.body; // [innerHTML] sanitises it
  ```

- `angular/open-redirect-navigation` — **`router.navigateByUrl`, `location.href` or `[routerLink]` fed a user-controlled URL.** An `?next=` param that reaches navigation without an allow-list is an open redirect, and on `location.href` a `javascript:` URL is script execution.
- `angular/dynamic-template-compilation` — **A component, template or `NgModule` compiled from a string that came from outside.** There is no sanitiser on this path — the string is code.
- `angular/guard-as-only-auth` — **A route guard treated as the authorisation check.** Guards decide what gets rendered, never what gets served: every endpoint behind the route re-checks, or the data is one `curl` away.
- `angular/secret-in-environment-file` — **A secret in `environment.ts` / `environment.prod.ts`.** These files are compiled into the browser bundle. Anything in them is public, whatever the filename suggests.
- `angular/unsubscribed-subscription` — **`.subscribe()` with no teardown.** Use the `async` pipe, `takeUntilDestroyed()`, or unsubscribe in `ngOnDestroy` — a long-lived stream holds the component, its template and everything they close over.

  ```ts
  // WRONG
  ngOnInit() { this.socket.messages$.subscribe((m) => this.messages.push(m)); }
  // RIGHT
  private readonly messages$ = this.socket.messages$.pipe(takeUntilDestroyed());
  ```

- `angular/uncancelled-request-race` — **A request per keystroke or per navigation that is not cancelled when it is superseded.** With `mergeMap` the responses land out of order and the slower, older one wins; with `concatMap` they stay ordered but queue, so the view shows an answer to a query the user has already moved on from. `switchMap` cancels the superseded request.
- `angular/timer-not-cleared` — **`setInterval`, `setTimeout` or a manual event listener not torn down in `ngOnDestroy`.** It keeps firing against a destroyed view.

## HIGH

- `angular/function-call-in-template` — Method or getter invoked from a template binding (`{{ total() }}`, `*ngIf="isReady()"`) — it re-runs on every change-detection cycle, including ones triggered by unrelated events. Precompute, or use a signal or `computed`.
- `angular/default-change-detection-hot-component` — A list, table or frequently-updated component left on default change detection. Require `ChangeDetectionStrategy.OnPush` where the inputs are immutable.
- `angular/manual-change-detection` — `detectChanges()` / `markForCheck()` called to make the view update. It is a symptom: the state was mutated in place, or the work escaped the zone. Fix the source.
- `angular/nested-subscribe` — `subscribe()` inside `subscribe()` — no cancellation, no error propagation, no ordering guarantee. Flatten with `switchMap`/`concatMap`.
- `angular/duplicate-http-subscription` — The same cold `HttpClient` observable subscribed twice (two `async` pipes, or a `subscribe` plus a pipe) — that is two identical requests. `shareReplay({ bufferSize: 1, refCount: true })`.
- `angular/input-object-mutation` — A child mutating an object it received as `@Input()`. The parent owns it; with `OnPush` the parent never learns it changed.
- `angular/service-scope-mismatch` — `providedIn: 'root'` for state that must be per-component, or component `providers` for state that must be shared — one gives every consumer the same instance, the other silently gives each a fresh one.
- `angular/effect-writes-own-signal` — An `effect()` writing a signal it also reads, or reaching for `allowSignalWrites` to make that legal. That is a cycle; `computed()` is what derived state is for.
- `angular/missing-trackby` — `*ngFor` / `@for` over a list that reorders or re-fetches without `trackBy` (or `track`) — Angular destroys and rebuilds every row, losing focus and element state.
- `angular/interceptor-swallows-error` — An `HttpInterceptor` or `catchError` returning `of(null)` / `EMPTY` so the caller sees a successful empty response. The failure is now indistinguishable from no data.
- `angular/unvalidated-route-param` — A route param or query param consumed as a typed value (`+id`, `as Status`) with no validation — it is external input and a user types what they like.
- `angular/uncalled-signal` — **A signal used in a condition, comparison or logical expression without being
  called** — `if (this.loading)`, `@if (isAdmin)`, `a === this.id`. A signal is a function, always truthy and
  never equal to a value, so `if (this.isAdmin)` shows admin actions to everyone. Call it: `this.isAdmin()`.
- `angular/computed-reads-no-signal` — **A `computed()`, `linkedSignal()` or `effect()` that reads no signal, or
  a `computed` with no `return`.** With no reactive dependency it runs once and never updates — `computed(() =>
  this.first + ' ' + this.last)` over plain fields never reflects an edit. Read the signals it depends on, or
  make it a constant.
- `angular/async-lifecycle-hook` — **`async ngOnInit` / `ngOnDestroy` / another lifecycle hook.** Angular never
  awaits it, so the template renders the unloaded state (`Cannot read properties of undefined`), a rejection
  floats, and awaited teardown runs after the view is gone. Load through the `async` pipe, a resolver or
  `resource()`, or call a handled async method from a synchronous hook.
- `angular/injection-context-api-outside-context` — **`takeUntilDestroyed()` with no argument, `inject()`,
  `toSignal()` or an injector-less `effect()` called from a lifecycle hook, method or callback.** These need an
  injection context and throw `NG0203` elsewhere, so the component fails to initialise. Pass
  `inject(DestroyRef)` / `{ injector }` captured in a field.
- `angular/output-native-event-name` — **An `@Output()` / `output()` named after a bubbling DOM event** —
  `click`, `change`, `input`, `submit`. The parent's `(change)` binds both the output and the native event
  bubbling out of the component, so the handler fires twice, once with an `Event`. Name it for the domain:
  `itemSelected`, `valueChange`.
- `angular/banana-out-of-box` — **Two-way binding written `([x])="v"` instead of `[(x)]="v"`.** The inverted
  form parses as an event binding named `[x]`, so the field never syncs from what the user types. Use
  `[(ngModel)]`.
- `angular/manual-lifecycle-call` — **`this.ngOnInit()` or another hook called by hand to "refresh".** Each call
  re-runs every subscription the hook sets up, so polling multiplies per click, and an early `ngOnDestroy` tears
  down a live view. Extract the shared work into a method. `super.ngOnInit()` is fine.
- `angular/impure-pipe` — **A pipe declared `pure: false`.** It re-runs on every change-detection cycle for
  every binding using it — hundreds of calls per keystroke in a 100-row table. Use a pure pipe over immutable
  inputs, or a `computed` signal.

## SUGGESTION

- `angular/prefer-async-pipe` — `async` pipe over a manual subscribe with a component field; subscription teardown comes free.
- `angular/prefer-signal-over-behaviorsubject` — Signals for synchronous component state in new code; keep RxJS for streams that are genuinely asynchronous.
- `angular/prefer-standalone-component` — Standalone components for new work rather than a new `NgModule`.
- `angular/prefer-inject-function` — `inject()` over constructor parameter injection in new code — it composes inside functions and base classes.
- `angular/oversized-component` — Component beyond ~300 lines, or a template beyond ~150 — suggest a split.