OnPush Becomes the Standard Change Detection

This is the most impactful alteration, affecting the largest volume of code, so it leads off. Starting in Angular 22, any component that doesn't explicitly define its changeDetection property defaults to ChangeDetectionStrategy.OnPush, replacing the previous "check always" behavior. This shift is the logical culmination of the framework's movement toward zoneless operation and signal-based reactivity. Instead of checking the entire component tree after every event, Angular only checks a view when it's explicitly marked as dirty—for instance, when a signal it depends on changes, an input value updates, an event occurs, or you manually invoke markForCheck.

To ensure a smooth transition, the team implemented two crucial safeguards. First, they introduced a new ChangeDetectionStrategy.Eager enum value that precisely replicates the old default (“check always”) behavior. Second, they shipped an automatic migration that inserts ChangeDetectionStrategy.Eager into your existing components wherever the legacy behavior is required. This guarantees that an upgraded application continues to function identically until you choose to refactor each component individually.

// New default in Angular 22 (no changeDetection needed):
@Component({
  selector: 'app-counter',
  template: `{{ count() }}`
})
export class Counter {
  count = signal(0); // OnPush + signals: updates just work
}

// The migration adds this to existing components to keep the old behavior:
@Component({
  selector: 'app-legacy',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: ``
})
export class Legacy {}

Essential takeaways:

  • All new components are OnPush by default. If you build these components using signals, you typically won't need to concern yourself with change detection at all.
  • ChangeDetectionStrategy.Eager is the new designation for the previous default. Post-migration, scanning for Eager in your codebase is an effective way to assess how much of your application is not yet OnPush-compatible. Each instance represents a potential candidate for optimization.
  • The automatic migration streamlines the upgrade process. It adds Eager where the old behavior is critical, and a subsequent fix ensures it doesn't generate invalid code in unusual edge cases.
  • This feature operates independently of, but synergistically with, zoneless. Zoneless eliminates zone.js as the event trigger, while OnPush dictates which views undergo checking. For optimal performance, you'll want to adopt both, but they are conceptually distinct.

In essence, new code receives the high-performance default automatically, while existing applications continue to operate without disruption. The Eager markers serve as a transparent checklist for incremental modernization efforts.

Angular 22: Key Features and Changes — figure 1

Signal Forms Achieve Stability

Introduced as experimental in Angular 21, Signal Forms are now officially stable in Angular 22. The associated experimental warnings have been removed. If you adopted these forms during the v21 cycle, the API you're familiar with remains unchanged:

import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';

interface LoginData {
  email: string;
  password: string;
}

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `
    <form (submit)="onSubmit($event)">
      <input type="email" [formField]="loginForm.email" />
      @if (loginForm.email().touched() && loginForm.email().invalid()) {
        @for (error of loginForm.email().errors(); track error) {
          <p class="error">{{ error.message }}</p>
        }
      }
      <input type="password" [formField]="loginForm.password" />
      <button type="submit" [disabled]="loginForm().invalid()">Log In</button>
    </form>
  `
})
export class LoginComponent {
  loginModel = signal<LoginData>({ email: '', password: '' });

  loginForm = form(this.loginModel, (f) => {
    required(f.email, { message: 'Email is required' });
    email(f.email, { message: 'Please enter a valid email' });
    required(f.password, { message: 'Password is required' });
  });

  onSubmit(event: Event) {
    event.preventDefault();
    if (this.loginForm().valid()) {
      console.log(this.loginModel());
    }
  }
}

The forms package also received several incremental enhancements:

  • Expanded public API: The date and limit validators, along with their related helper functions, are now part of the stable public contract.
  • Improved error typing: Built-in errors are now fully typed, offering genuine autocomplete support instead of forcing you to treat validation state as any.
  • Field metadata documentation: A new guide documents patterns that were previously considered undocumented knowledge.
  • Performance enhancements: FormField.parseErrors no longer performs redundant recalculations, and unnecessary invalidations in the parser-errors signal have been eliminated. This reduces wasted computation on every keystroke in larger forms.
  • Better support for plain-object models: Clearer documentation and assistance make it easier to develop custom controls.

Stable Release for resource() and httpResource

The other key component of this update involves reactive asynchronous data handling. Both the resource() and httpResource() APIs are achieving stable status. These functions enable you to manage asynchronous fetching within the signal graph itself, avoiding the need to constantly switch in and out of RxJS for every request:

import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
  selector: 'app-user',
  template: `
    @if (user.isLoading()) {
      <p>Loading…</p>
    } @else if (user.hasValue()) {
      <h1>{{ user.value().name }}</h1>
    }
  `
})
export class UserComponent {
  userId = signal(1);
  // Re-fetches automatically whenever userId changes.
  user = httpResource<User>(() => `/api/users/${this.userId()}`);
}

Two fixes are particularly relevant for long-running applications:

  • The rxResource function no longer leaks a subscription, and httpResource has received an identical fix. This resolves a slow memory leak observed in sessions that generated numerous short-lived resources.
  • The resource URL sanitizer lookup is now case-insensitive. This patch closes a security gap where differently-cased schemes could potentially bypass the anticipated checks.

WebMCP: Transforming Apps and Forms into AI Tools (Experimental)

This represents the genuinely novel feature in Angular 22, and the most captivating aspect for teams exploring AI integration in their applications. It is strictly experimental. Given that the specification is in its early, evolving stages, anticipate changes in this section as well.

Currently, AI interactions with web applications are largely confined to what the model can interpret from the rendered DOM. This approach is both fragile and superficial because the underlying business logic within your services and signals remains inaccessible to the agent. WebMCP, or Web Model Context Protocol, is an emerging web standard that addresses this limitation. Your application registers structured tools on a browser-level object (navigator.modelContext and document.modelContext), enabling an AI agent integrated into the browser to discover and invoke them directly. This eliminates the need for DOM scripting or a separate server.

It's important to clarify a potential point of confusion due to similar naming. The Angular CLI MCP server assists coding agents in understanding your project during the build phase. WebMCP is distinct. It operates within the browser, on the live page, and exposes the runtime capabilities of the active application to an in-browser agent. While they belong to the same protocol family, they serve opposite purposes.

Angular's role here is to integrate WebMCP with dependency injection and the component lifecycle, ensuring that tools are registered and unregistered automatically. There are three distinct methods for utilizing this feature.

Application-wide tool registration. You can utilize provideExperimentalWebMcpTools in your application configuration. The execute callback operates within the injection context of the matching injector, allowing you to directly inject services:

import { Service, inject, provideExperimentalWebMcpTools } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppRoot } from './app-root';

@Service()
class Greeter {
  sayHello(): string { return 'Hello agent!'; }
}

bootstrapApplication(AppRoot, {
  providers: [
    provideExperimentalWebMcpTools([
      {
        name: 'greet',
        description: 'Greets the agent.',
        inputSchema: { type: 'object', properties: {} },
        execute: () => {
          const greeter = inject(Greeter);
          return { content: [{ type: 'text', text: greeter.sayHello() }] };
        }
      }
    ])
  ]
});

Route-scoped tool registration. You have the option to register tools within a route's providers, ensuring they exist only while that specific route is active. One cautionary note: ensure you pair this with withExperimentalAutoCleanupInjectors() on the router. Otherwise, tools will remain registered after the user navigates away, and the agent will continue to see capabilities that don't align with the current view.

import { provideRouter, withExperimentalAutoCleanupInjectors } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withExperimentalAutoCleanupInjectors())
  ]
};

Implicit tools derived from Signal Forms. This is where the feature truly comes together, and it's the reason why Signal Forms stabilizing and WebMCP arriving are concurrent in this release. Add provideExperimentalWebMcpForms() to your providers and then pass an experimentalWebMcpTool option to a form(). Angular reads the form's data model, automatically generates a JSON schema, and connects the tool to the form's validators and submission handler. This eliminates any need for manual schema writing or event handling.

import { Component, signal } from '@angular/core';
import { form, required } from '@angular/forms/signals';

@Component({ /* ... */ })
export class UserRegistration {
  private readonly model = signal({
    firstName: '',
    lastName: '',
    age: 0,
    hobbies: ['Web Development']
  });

  readonly userForm = form(
    this.model,
    (f) => {
      required(f.firstName, { message: 'First name is mandatory.' });
      required(f.lastName, { message: 'Last name is mandatory.' });
    },
    {
      experimentalWebMcpTool: {
        name: 'registerUser',
        description: 'Registers a new user.'
      },
      submission: {
        action: async (formValue) => {
          console.log('Submitting user:', formValue);
        }
      }
    }
  );
}

Based on this single declaration, Angular generates a tool with a schema that includes firstName, lastName, age, and hobbies (typed as a string array, inferred from the non-empty initial value). It designates firstName and lastName as required based on the validators and connects the agent to the form's validation and submission outcomes. If the agent submitss invalid data, it receives the validation error and can self-correct and retry.

The constraints arise from the inference mechanism. Angular derives types from the initial value of the model, so you must provide concrete initial values ('', 0, false, not null or undefined) and non-empty arrays (as it cannot infer the element type from an empty []).

Angular 22: Key Features and Changes — figure 2

Several points warrant attention:

  • This feature is experimental, and the specification changes frequently. The API can evolve outside of major version releases, and on the browser side, it currently requires Chrome with a specific flag plus a polyfill for development.
  • Tool names must be unique across the application. Registering a duplicate name triggers an error. It's best to use application providers, route providers, or root services. Avoid component constructors unless the component is guaranteed to appear on the page only once.
  • Validate the inputs. Angular does not ensure that an agent's arguments conform to the declared schema. Perform validation within execute before acting on the data.
  • Async validators are not triggered by the implicit forms tool. These should be handled within the submission action.

For enterprise Angular applications, this is arguably the most forward-thinking aspect of the release. It re-conceptualizes the form not merely as a UI element, but as a typed capability that can be utilized by both human users and AI agents.

Streamlined DI: Introducing @Service and injectAsync

Angular 22 introduces a more concise way to declare services and offers a native method for loading them on demand.

The @Service decorator (now stable). @Service() is a newly introduced decorator. By default, it functions identically to @Injectable({ providedIn: 'root' }), creating a tree-shakeable, application-wide singleton, but without the need for repetitive configuration. There are two key justifications for this. First, the most prevalent scenario is a root-provided singleton, so it makes sense for that to be the default rather than an opt-in through an options object. Second, the name more accurately describes the class's purpose. "Service" denotes the object itself, whereas "Injectable" describes a technical mechanism. @Injectable will continue to be supported, but for a standard service, @Service() offers a more intuitive choice.

// Angular 22
import { Service } from '@angular/core';

@Service()
export class UserStore {
  // Root-provided, tree-shakeable singleton. No { providedIn: 'root' } needed.
}

Adopting injectAsync for lazy-loading services. The new injectAsync helper enables you to load a service only when it's actually required. Your bundler will separate the service into its own chunk, which is downloaded upon first use. Once loaded, Angular resolves it through the standard DI system, allowing it to depend on other injectables and behave like any typical singleton.

import { Component, injectAsync } from '@angular/core';

@Component({
  selector: 'app-report',
  template: `<button (click)="export()">Export</button>`
})
export class Report {
  private exporter = injectAsync(() =>
    import('./report-exporter').then((m) => m.ReportExporter)
  );

  async export() {
    const exporter = await this.exporter();
    exporter.export();
  }
}

The initial call triggers the dynamic import and resolves the service via DI. Subsequent calls reuse the same promise, ensuring the chunk is fetched only once. A few helpful details:

  • Default exports are automatically unwrapped. You can pass the dynamic import directly without needing .then(m => m.X).
  • Prefetching is optional and controlled via a trigger. Angular provides onIdle, which waits until the browser is idle (and can accept a timeout to guarantee the prefetch occurs within a defined window): injectAsync(loader, { prefetch: () => onIdle({ timeout: 1_000 }) });
  • Custom triggers are simply functions that return a promise (a PrefetchTrigger), allowing you to bind prefetching to a hover event, a scheduler tick, or any other signal.

There's a single requirement that links these two features. For lazy loading to function correctly, the service must be automatically provided, which means it needs to be decorated with either @Injectable({ providedIn: 'root' }) or @Service(). Without this automatic provisioning, Angular lacks the necessary context to instantiate the service after it has been loaded.

Router Change: paramsInheritanceStrategy Now Defaults to ‘always’ (Breaking)

This minor default alteration removes a common source of frustration. Previously, paramsInheritanceStrategy defaulted to 'emptyOnly'. A child route would only inherit a parent's params and data if the child did not have its own component. This resulted in the well-known route.parent?.parent?.snapshot.params chains one had to write just to access a grandparent's :id.

In Angular 22, the default is now 'always'. Route parameters and data are inherited from all parent routes by default, meaning a deeply nested route can directly read an ancestor's parameter. This constitutes a breaking change. If your application relied on the 'emptyOnly' behavior, you can explicitly restore it:

// Restore the previous behavior if you relied on it:
provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }));

For the majority of applications, this is a quality-of-life enhancement that reduces unnecessary ?.parent plumbing. However, because it changes what paramMap and data resolve to in nested routes, it's prudent to verify its impact deliberately rather than assume there won't be any.

Custom set Option for linkedSignal Anticipated in Version 22.1

linkedSignal represents a writable signal whose value gets determined and refreshed by a reactive computation. Angular 22 introduces a custom set option for it. Previously, configuring a linked signal involved specifying a source, a computation, and optionally an equal function or debugName. The newly added option allows interception of write operations via .set() or .update(). The callback takes the following form:

set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;

This callback receives the incoming value along with a rawSet function that persists the value into the linked signal's internal state. The purpose of set lies in maintaining synchronization across signals. Since a linked signal already pulls from a source, this hook provides a mechanism to direct writes back to that source within the same tick.

One might wonder what advantage this offers over directly invoking update on the source. For writes confined to a single component, the answer is none—using linkedSignal here would be unnecessary complexity. You would simply write this.task.update(t => ({ …t, status: ‘done’ })) at the call site and proceed. This feature demonstrates its value at boundaries, where you need to expose a portion of a larger state object externally as an independent WritableSignal.

The most straightforward illustration involves two-way binding to a generic child component. Consider a reusable <status-picker> that accepts status = model<string>() as input. It has no knowledge of your Task type, nor should it. You want [(status)] to function while keeping task as the sole source of truth:

import { Component, signal, linkedSignal } from '@angular/core';

@Component({
  template: `<status-picker [(status)]="status" />`
})
class TaskComponent {
  task = signal<Task>({ id: 42, status: 'todo' });

  status = linkedSignal(() => this.task().status, {
    set: (s) => this.task.update(t => ({ ...t, status: s }))
  });
}

Here, status serves as a writable projection of task().status. The child component interacts with a simple WritableSignal<string>, and each write gets channeled back to task, preserving the parent's state structure. Without this mechanism, two less appealing alternatives emerge. You could pass the entire task downward, exposing the Task shape to a generic component. Alternatively, you might resort to manual input/output pairs and hand-crafted propagation—precisely the imperative wiring signals aim to eliminate.

The second application of this mechanism involves synchronizing with a state container, which drives much of the community conversation. Many observers frame this feature in terms of delegatedSignal, the API proposed in the NgRx RFC (@ngrx/platform issue #5121, “Add delegatedSignal API”). That RFC called for a primitive to sync state between a SignalStore slice and a Signal Form, structured as { computation, update }. Current workarounds combine linkedSignal with an effect that pushes values to the external source. The RFC itself acknowledges limitations: synchronization remains indirect, requires extra wiring, and effects fail to propagate updates synchronously. The new set option addresses this gap from the framework perspective:

readonly filter = linkedSignal({
  source: () => this.store.filter(),
  computation: (filter) => filter,
  set: (value) => this.store.updateFilter(value) // delegated synchronously, no effect
});

Whether NgRx still ships a dedicated delegatedSignal as a lightweight wrapper remains an open question, but the underlying functionality now exists in core.

One consideration: introducing the slice creates two read paths to the same value (status() and task().status). This remains acceptable as long as the write shape in set aligns with the read shape in computation. If transformations differ between write and read operations, the two paths diverge. Keep them aligned.

Template Enhancement: Comments Within Element Tags

A modest yet welcome improvement, long requested by the community, arrives in Angular 22: support for comments inside an HTML element—specifically, between attributes of an opening tag. You can now annotate or temporarily disable a single attribute on a multi-line element without restructuring the markup. Previously, this capability did not exist in Angular templates.

<input
  [value]="value"
  (input)="onInput($event)"
  <!-- (blur)="onBlur()"  temporarily disabled while we debug -->
  type="search"
/>

This change won't alter application design approaches, but it removes a persistent annoyance in attribute-heavy templates.

Security Hardening

Scanning the commit log, the most frequent term after "docs" relates to "sanitize" or "SSRF." Beneath the headline features, Angular 22 represents a substantial security-focused release. This carries particular significance for enterprise and SSR deployments.

Server-side request forgery (SSRF) protections. platform-server received fixes securing location and document initialization against SSRF and path hijacking, rejecting suspicious URLs, restricting protocol-relative URLs, and preventing SSRF bypasses through backslash URLs in HttpClient. The URL resolution utility was streamlined as part of these efforts, and a correction ensures only a literal /index.html suffix gets stripped from URLs.

Stricter sanitization. The framework now sanitizes dynamic href and xlink:href bindings on SVG <a> elements, sanitizes meta selectors, sanitizes placeholder values, and normalizes namespaced tag names in the DOM element schema registry and the runtime i18n attribute security-context lookup. Namespaced SVG <script> elements are removed during template compilation, while namespaced SVG <style> elements are preserved correctly. Additionally, a <script> element is now rejected as a dynamic component host.

TransferCache and credentials. One meaningful default shifts here. The HTTP transfer cache now skips requests carrying cookies by default and excludes withCredentials requests. This prevents authenticated, user-specific responses from being written into transferred state and accidentally shared. This represents the kind of subtle SSR data leak easily missed during review.

Other hardening. zone.js now validates __Zone_symbol_prefix to guard against DOM-clobbering attacks. LOCALE_DATA is constructed with Object.create(null) as protection against prototype pollution. A maximum buffer size for fetch requests on SSR also limits memory usage.

In summary, most of these require no code changes on your part. However, the TransferCache modification warrants deliberate review if you previously depended on caching responses bearing credentials.

Compiler and Type-Safety Improvements

Angular 22 pushes the compiler toward stricter template correctness.

  • Invalid @for loops now receive type-checking. The compiler identifies a range of mistakes at build time rather than runtime.
  • NgModules can compile under TypeScript's isolatedDeclarations, enabling faster, more parallel builds.
  • A documented typeCheckHostBindings option surfaces a category of host-binding errors that previously slipped through.
  • Event attribute bindings in host bindings are now universally disallowed, closing an existing inconsistency.
  • Deprecated shadow CSS encapsulation polyfills and legacy shadow DOM selector support were removed, with simplified :host and :host-context handling. If you depended on older emulated-encapsulation behavior, test your styles.

Language Service and DevTools

  • The language service now type-checks templates requiring inline type-check blocks, and it compiles non-exported classes when they're standalone. In practice, more templates receive accurate diagnostics and autocomplete in the editor, including components you haven't exported.
  • Angular DevTools advances to 1.15.0, featuring an improved signal graph (clearer cluster-to-cluster relationships) and early support for inspecting component trees containing non-Angular frameworks.
  • The VS Code extension becomes safer in untrusted workspaces. It prompts before loading a workspace TSDK, restricts JSDoc markdown trust, and disables the language server in untrusted workspaces. This represents a sensible response to the reality that opening a repository shouldn't execute its tooling automatically.

Angular 22: Key Features and Changes — figure 3

Conclusion

Angular 22 marks the convergence of numerous long-standing initiatives. The reactive, signal-first model solidifies as the default direction across change detection, forms, and async data. Simultaneously, the framework takes its initial substantial step toward AI agents operating within the web application itself.

Changes with the most significant impact:

  1. OnPush as the default. Free high-performance change detection for new components, with an automatic migration guiding existing applications through safely.
  2. Signal Forms stable. Production-ready forms on the signal-based API, no longer experimental.
  3. resource() and httpResource stable. Reactive async data, integrated within the signal graph, fully supported.
  4. paramsInheritanceStrategy defaults to 'always'. The end of route.parent?.parent?.snapshot.params—but review your nested routes.
  5. @Service and injectAsync. Simplified service declaration and first-class lazy-loaded services.
  6. Security hardening. SSRF protections, stricter sanitization, and safer TransferCache defaults, mostly without effort.
  7. WebMCP (experimental). The most forward-looking feature. Your forms and services can become typed tools that an in-browser agent can invoke directly.

For production work today, rely on the stable features, the new defaults, and the security fixes. For Angular's trajectory, monitor WebMCP and the foreign components groundwork closely. That shapes the coming releases.