Le blog
de
Ninja Squad

What's new in Angular 22.0?

Angular 22.0.0 is here!

Angular logo

This release continues the modernization work started in the previous versions: Signal Forms and resources are now stable, OnPush is now the default change detection strategy for components, and the HTTP client now uses Fetch by default.

There are also a new decorator, and quite a few additions around AI tooling.

Let's dive in!

TypeScript v6 and Node v22 required

Angular v22 now requires TypeScript v6. Older versions of TypeScript, including v5.9, are not supported anymore.

Angular v22 also drops support for Node v20, and Node v26 is supported.

OnPush by default

Angular offers two change detection strategies:

  • Eager (which was previously named Default),
  • OnPush.

Until Angular v21, the default strategy was Eager. Since Angular v22, the default strategy is now OnPush!

This means that a component that does not explicitly specify a change detection strategy will now use OnPush instead of Eager. Of course, there is a migration available to automatically add changeDetection: ChangeDetectionStrategy.Eager to all components that do not specify a strategy yet, so that they keep using the Eager strategy when you upgrade to Angular v22. It also replaces Default by Eager in the component decorators if needed.

If a component is already using OnPush, then the migration keeps the changeDetection property from the component decorator, even if it is not needed anymore.

Signal Forms are stable!

One of the biggest changes in Angular v22 is that the signal forms APIs are no longer experimental! They graduated to stable and are now available for production use.

👉 To learn more about signal forms, check out our dedicated articles.

A few changes landed before the stabilization though, so let's start with the breaking ones before going into the new features.

touch/touched

Custom form components used to be able to bind to the touched model, but after reflecting on the API, the Angular team realized that this was not the best approach, as it was allowing a control to mark the field as no longer touched. Instead, we now have a touched input to know if the field is touched or not, and a touch() output to mark the field as touched. You'll have to manually make this change in your codebase if you were using the touched model before.

markAsTouched()

markAsTouched() now marks a field and all its descendants as touched, instead of only the field itself. This behavior can be overridden by passing { skipDescendants: true } as an argument to the method.

Consistent when

All validators and dynamic behavior functions (disabled, readonly, hidden, etc.), now have a when option to specify when they should be applied, instead of passing the reactive function directly as an argument. The old signature is still supported for backward compatibility, but is deprecated:

// before
disabled(form.age, ({ valueOf }) => valueOf(form.isAdmin));
// 👇after
disabled(form.age, { when: ({ valueOf }) => valueOf(form.isAdmin) });

minDate() and maxDate() validators

Two new validators minDate() and maxDate() were added, allowing you to easily validate date inputs:

protected readonly userForm = form(
  model,
  form => {
    // ...
    // 👇date should be between January 1st, 1900 and today
    minDate(form.birthDate, new Date('1900-01-01'));
    maxDate(form.birthDate, new Date())
  }
);

This adds minDate and maxDate errors to the field if the date is out of range.

debounce a field on blur

Signal forms already supported debouncing the value changes of a field, by using the debounce function and specifying a delay in milliseconds or a function that returns a promise for fine-grained control. It is now also possible to debounce the value changes on blur, by using the 'blur' option:

form => {
  // ...
  // 👇debounce the password field on blur
  debounce(form.password, 'blur')
}

debounce async validators

As you sometimes want to debounce only the asynchronous validators of a field, we now have a debounce option in validateAsync() and validateHttp():

validateHttp(form.email, {
  // 👇 httpResource is triggered when the email signal changes
  request: email => `/api/users/check?email=${email.value()}`,
  // 👇 specify a debounce duration to avoid sending a request on every keystroke
  debounce: 400,
  // ...
});

reloadValidation()

Since we are talking about asynchronous validators, a new reloadValidation() method was added to allow you to easily re-run the asynchronous validators of a field.

When called on a field, it will re-run the asynchronous validators of this field and all its descendants, by calling the reload() method of the underlying resources. This matches what we could do in legacy forms with updateValueAndValidity().

getError()

Another method has been added to a field: getError(). It allows you to easily get the error of a specific validator, by passing the error kind as an argument, and removes the need to iterate over the errors object of the field to find the error you are looking for:

<input id="login" class="form-control" [formField]="userForm.login" />
@let login = userForm.login();
@if (login.touched() && login.invalid()) {
  @if (login.getError('required')) {
    <div>Login is required</div>
  }
  <!-- 👇 getError() narrows the type of the error to minLength error -->
  @if (login.getError('minLength'); as minLengthError) {
    <div>Login should be {{ minLengthError.minLength }} characters</div>
  }
}

ControlValueAccessor compatibility

Custom form components have been possible in Angular since the very beginning, by implementing the ControlValueAccessor interface. In Signal Forms, we now have the new FormControlValue interface, which is more powerful and easier to use than ControlValueAccessor.

To simplify the migration to signal forms, the Angular team made legacy custom form components based on ControlValueAccessor compatible with signal forms. One thing was missing though: ControlValueAccessor components were able to define validators inside themselves (using the NG_VALIDATORS provider), but there was no way to use the errors added by these validators in signal forms. This is now fixed, as these validation errors are propagated to signal forms fields!

FormControlValue compatibility

FormControlValue is now fully compatible with legacy forms, both reactive and template-driven. This means you can migrate your custom form components currently using ControlValueAccessor to use FormControlValue instead, without having to change the way you use them.

Let's say Rating is a custom form component based on FormControlValue. The following examples will work without any change after the migration to Angular v22:

<!-- legacy reactive form -->
<ns-rating formControlName="rating"></ns-rating>
<!-- legacy template-driven form -->
<ns-rating [(ngModel)]="rating"></ns-rating>

FormControlValue also gained a reset method that you can implement in your custom form components to support resetting the field as you want when the form field is reset.

That's all for forms, now we can go rewrite the thousands of forms in our codebase to use these new APIs! 😅

Debouncing signals

Forms have a few options to debounce the value changes of a field, but what if you want to debounce any signal in your application? Angular v22 now has a new (experimental) debounced() function that you can use.

It creates a debounced version of a signal, but it does not return another signal. It returns a Resource, because a debounced value has a state: it can be settled, waiting for a pending value, or in an error state if the source signal throws (a Resource is the read-only version of the ResourceRef returned by the resource() function).

For example, if we want to debounce a search query, we can write:

@Component({
  imports: [FormField],
  template: `
    <input type="search" placeholder="Filter users" [formField]="queryForm" />
    <p>Debounced query: {{ debouncedQuery.value() }}</p>
    @if (debouncedQuery.isLoading()) {
      <p role="status">Waiting for stable query...</p>
    }
    @if (filteredUsers.hasValue()) {
      <ul>
        @for (user of filteredUsers.value(); track user.id) {
          <li>{{ user.name }}</li>
        }
      </ul>
    }
  `
})
class SearchComponent {
  protected readonly query = signal('');
  protected readonly queryForm = form(this.query);
  // 👇 create a debounced version of the `query` signal
  protected readonly debouncedQuery = debounced(() => this.query(), 300);
  protected readonly filteredUsers = inject(UserService).search(this.debouncedQuery.value);
}

Here debouncedQuery.value() initially returns the current value of query. When query changes, debouncedQuery switches to a loading state, but its value still stays the previous settled value. If no new change happens during the next 300 milliseconds, the resource resolves with the new value.

This makes it a good fit when you want to drive another asynchronous operation from a stabilized value. For example, a resource or an HTTP resource can use debouncedQuery.value() as its request parameter, and the template can use debouncedQuery.isLoading() to display that the user input has not settled yet.

The second parameter can also be a function instead of a number. In that case, the function receives the new value and the previous resource snapshot, and must return a Promise that resolves when the new value should be published.

If you followed closely, there are now three variations of debouncing in Angular:

  • you can debounce a form field value on input with debounce(field, delay)
  • you can debounce an asynchronous validator with validateHttp(field, { debounce: delay })
  • or you can debounce any signal value with debounced(signal, delay)

Resources are stable!

resource(), rxResource(), and httpResource() are now stable and ready to use in production as well! This changes the recommended way to handle asynchronous data in Angular applications, and we encourage you to give them a try if you haven't already, as they provide a really nice developer experience.

Before stabilizing resources, the Angular team made a few improvements. It is now easier to chain resources, thanks to the chain() method exposed in the params of the request function:

const userResource = httpResource(() => `/api/users/${id()}`);

const postsResource = httpResource({
  params: ({ chain }) => {
    const user = chain(userResource);
    return { authorId: user.authorId };
  },
  loader: ({ authorId }) => fetchPostsByUserId(authorId)
});

chain(userResource) either returns userResource.value() when available, or automatically propagates the userResource state into postsResource:

  • if userResource is idle, postsResource becomes idle;
  • if userResource is loading/reloading, postsResource switches to loading;
  • if userResource errors, postsResource errors with a ResourceDependencyError;
  • if userResource is resolved, postsResource loads normally.

Improvements have also been made for SSR, as resource and rxResource can now be cached by specifying an id in the options. A cached resource will be immediately displayed without going through the loading state on the client after being rendered on the server.

New @Service decorator

It's been a while since we had a new decorator in Angular, but Angular v22 introduces the @Service decorator to define services in a more concise way.

It is basically the same as @Injectable({ providedIn: 'root' }), a shorter way to declare a service that should be provided in the root injector, with a nice name that makes it clear that this class is a service.

@Service()
class UserService {
  // ...
}

The limitation is that this service must use the inject function to inject its own dependencies, rather than constructor injection.

If you don't want to provide the service in the root injector, you can use the autoProvided: false option of the @Service decorator, and then provide the service in another fashion.

It is now recommended to use @Service() for services, and this is what the CLI generates with ng generate service by default in v22. You can still generate services with @Injectable() if you want to specify a different provider scope or other options, by using ng generate service --injectable.

There is no automatic migration available for this change (yet?).

injectAsync()

You all know the inject() function that allows you to inject a dependency. Angular v22 now also has an injectAsync() function. It lets you lazy-load a service only when you actually need it, while still creating the service instance with Angular dependency injection.

This can be useful for heavy services that are only needed after a user action, for example a reporting service used when exporting a PDF. The service must be auto-provided, either with @Service() or with @Injectable({ providedIn: 'root' }).

Then, instead of injecting it eagerly with inject(ReportService), you can use injectAsync with a dynamic import:

export class Admin {
  private readonly reportService = injectAsync(() => import('./report.service').then(m => m.ReportService));
  async exportPdf() {
   const reportService = await this.reportService();
   await reportService.exportPdf();
  }
}

You still need to call injectAsync from an injection context, for example in a component or service field initializer, just like inject.

You can also ask Angular to prefetch the service before it is requested. For example, onIdle starts loading the service when the browser is idle:

export class AdminWithPrefetch {
  private reportService = injectAsync(() => import('./report.service').then(m => m.ReportService), {
    prefetch: onIdle
  });

  async exportPdf() {
    const reportService = await this.reportService();
    await reportService.exportPdf();
  }
}

In that case, the bundle can be downloaded in the background after the application starts, but the service is still only used by the component when this.reportService() is called.

Note that if the service uses a default export, you can directly pass the dynamic import to injectAsync without the need for a then: injectAsync(() => import('./report.service')).

Http

The HTTP client now uses the Fetch API under the hood instead of XMLHttpRequest. It was already recommended to use withFetch() when using SSR, for better performance and compatibility. withFetch() is now the default, and is marked as deprecated, so you can remove it from your codebase if you were using it before.

A migration will automatically remove withFetch() from your codebase if you were using it, and add withXhr() if not, to keep using the old XMLHttpRequest implementation, as it is technically a breaking change. Note that if you removed provideHttpClient() from your codebase (as it is no longer necessary since Angular v21), the migration will do nothing.

It should be fairly transparent if you choose to switch over to the new Fetch API: the only thing unsupported by the Fetch implementation is the upload progress report. To add meaningful warnings, the reportProgress option is now deprecated, and reportUploadProgress and reportDownloadProgress must now be used instead.

Router

The router has an option called withComponentInputBinding() since Angular v16 that offers the possibility to bind parameters as inputs. The function now accepts configuration options.

The first option is queryParams, a boolean that lets you specify whether query parameters should be bound as inputs or not (default: true).

The second option is unmatchedInputBehavior to specify what should happen when an input does not match any parameter. The possible values are:

  • 'alwaysUndefined': always binds undefined to unmatched inputs (default);
  • 'undefinedIfStale': binds undefined only if the input was previously available in the router data for the active route. This avoids setting undefined for inputs that were never expected to be set by the router.

The RouterLink directive gained a new input browserUrl that allows you to specify the URL to update in the browser when the link is clicked (this option was introduced in Angular v18).

You can now write <a [routerLink]="['/users', user.id]" browserUrl="/my-profile">Profile</a> to navigate to /users/:id when the link is clicked, but update the browser URL to /my-profile.

To conclude the router section, note that this release has two breaking changes:

  • canMatch functions now have a mandatory third parameter currentSnapshot. An automatic migration is provided.
  • the paramsInheritanceStrategy option is now set to 'always' by default, ensuring that route parameters are inherited from parent routes by default. You'll need to explicitly set it to 'emptyOnly' if you want to keep the previous behavior of only inheriting parameters when the child route does not have any parameter of its own, as there is no migration for this change.

Templates

The compiler received a few changes as well.

strictTemplates by default

strictTemplates is now enabled by default, so you no longer need to specify it in your tsconfig.json to get the benefits of strict template type checking. A migration adds strictTemplates: false to your tsconfig.json if it is not already enabled, to keep the previous behavior when you upgrade.

Comments inside elements

It is now possible to add comments inside HTML elements:

<div /* comment */>...</div>
<div
  // comment
>...</div>

@default never

Angular v21.2 added the possibility to use @default never in switch statements to ensure that all cases are handled. The feature was limited, as it did not work on nested properties. It is now possible to use it with any type of switch statement, even if the discriminant is a nested property access, by specifying the type of the discriminant with @default never(entity).

For example:

@let data = chartData();
@switch (data.type) {
  @case ('line-chart') {
    <line-chart [data]="data" />
  }
  @case ('bar-chart') {
    <bar-chart [data]="data" />
  }
  @default never(data);
}

whereas this still works out of the box since v21.2:

@switch (status()) {
  @case ('idle') {
    <p>Idle</p>
  }
  @case ('loading') {
    <p>Loading</p>
  }
  // no need to specify the discriminant as it is a simple value
  @default never;
}

New compiler checks and errors

The compiler also has two new checks and errors. If an element is matched by multiple components in a template, then an error is thrown (this used to be caught at runtime before, but is now detected at compile time):

[ERROR] NG8023: Multiple components match node with tagname pr-menu: 'Menu', 'OtherMenu'. [plugin angular-compiler]

Another error is thrown if a component or directive exposes several inputs, outputs or models with the same name, for example via an alias:

readonly user = input('');
readonly user2 = input('', { alias: 'user' });

throws:

✘ [ERROR] NG1054: Input 'user' is bound to both 'user' and 'user2'. [plugin angular-compiler]

Another example is when a component exposes a model and an output with the same name:

readonly user = model('');
readonly userChange = output<string>();

This one is automatically fixed by the migration, by using an input and a linked signal:

readonly userInput = input('', { alias: 'user' });
readonly user = linkedSignal(this.userInput);
readonly userChange = output<string>();

Optional chaining

Another improvement is that the compiler now better supports ?. optional chaining in templates. For example, this code used to throw an error, as the compiler did not understand that {{ project.author }} was protected by the if guard and thought project could be null when trying to access author:

@if (project?.author) {
  {{ project.author }}
}

This is no longer the case and works properly in Angular v22. As a common workaround before this improvement, we had to write {{ project?.author }} instead of {{ project.author }}, and this is no longer necessary. More than that: the extended compiler diagnostics nullishCoalescingNotNullable and optionalChainNotNullable may now complain about such unnecessary workarounds and suggest removing them. To avoid having to fix all these when upgrading, a migration disables these diagnostics if they are enabled in your codebase, but you can re-enable them after the migration to fix the potential issues.

Another change in this area is that Angular semantics now align with TypeScript for optional chaining in templates. For example, project?.author returned null in Angular templates when project was null, while it returned undefined in TypeScript code. This is because Angular made this choice before TypeScript had its own semantics for optional chaining, but now that TypeScript does, Angular follows it and project?.author returns undefined when project is null or undefined.

As this might be fairly breaking, a migration wraps such optional chaining expressions in a $safeNavigationMigration() function to keep the previous behavior:

// 👇 after v22 update
{{ $safeNavigationMigration(project?.author) }}

You'll then have to go over these, and carefully check if you can remove the $safeNavigationMigration() wrapper or not.

@defer

The on idle trigger now accepts a timeout option to specify a timeout for the idle callback (as the underlying requestIdleCallback does), to avoid waiting indefinitely for the browser to be idle if it never happens. The callback will be called when the browser is idle, or when the timeout expires, whichever happens first.

@defer (on idle(500ms)) {
  <!-- content to defer -->
}

We can now also customize the idle behavior of @defer with the new provideIdleServiceWith(), to which we can pass a custom idle service that implements the IdleService interface:

@Service()
class CustomIdleService implements IdleService {
  requestOnIdle(callback: (deadline?: IdleDeadline) => void): number {}
  cancelOnIdle(id: number): void {}
}

The default implementation uses requestIdleCallback/cancelIdleCallback when available, and falls back to a setTimeout/cancelTimeout when it is not.

Testing

A new TestBed.getLastFixture() method has been added to easily get the last created fixture in your tests, without having to keep a reference to it in a variable.

beforeEach(() => TestBed.createComponent(User));
test('', () => {
  // 👇 get the fixture created in the beforeEach
  const fixture = TestBed.getLastFixture();
  //...
});

Another notable addition is that Zone.js tests now support the fakeAsync, flush, and waitForAsync utilities when using Vitest. This should simplify the migration from other test frameworks, as we were forced to rewrite these kinds of tests when migrating to Vitest before this improvement. Well, we'll still have to rewrite them at some point to remove the dependency on Zone.js, but at least we can do it in a second step now. You can simply add "zone.js/plugins/vitest-patch" to the polyfill entry of your angular.json test target to get this support.

SSR

Incremental hydration is now the default hydration strategy for server-side rendering. You can check out our article about Angular v19 to learn more about incremental hydration and how it works.

This means withIncrementalHydration() is now no longer necessary in provideClientHydration() and has been marked as deprecated. If you were not using it, a new withNoIncrementalHydration() call is added by the migration to keep using the old hydration strategy.

Another notable addition is that the provideServerRendering() function now accepts an options object as a first parameter. A single maxResponseBodySize property is available for now to specify the maximum size of the response body in bytes (default is 1 MB).

provideServerRendering(
  { maxResponseBodySize: 2 * 1024 * 1024 }, // 2 MB
  withRoutes(serverRoutes)
);

Angular ARIA

The @angular/aria package, introduced in Angular v21 to provide accessibility components, is no longer in developer preview and is now generally available for production use.

It has also been updated to work seamlessly with signal forms.

AI

Well, this section keeps growing with every release, so buckle up, as we have a lot of new AI features in Angular v22!

Building apps with AI

If you are a frequent reader of our blog, you know that the CLI has had an MCP server since Angular v20.1. This MCP helps your coding agent interact with your Angular project.

But practices evolve rather quickly in the AI world (lol), and skills are all the rage nowadays. Skills are a way to teach your agent how to perform specific tasks in a specific context, and the Angular team wrote a few skills to help your agent produce better code when working on an Angular project.

To install this set of Angular skills, you can run the following command:

npx skills add https://github.com/angular/skills

This adds two skills that your coding agent can use:

  • angular-new-app that contains guidelines for creating new Angular projects
  • angular-developer that contains guidelines for generating Angular code and providing architectural guidance.

The second one is the most interesting, as it covers a wide range of topics and provides best practices for working with Angular, up-to-date with the latest version of the framework and all its new features. You'll see your agent loading this skill when asked to generate Angular code, and it should generate state-of-the-art code.

As the skill now takes the lead for code generation, the MCP server no longer offers the find_examples and modernize tools.

Debugging apps with AI

When running in development, an Angular application using Angular v22 now exposes two tools to help your agent debug your application: the signal graph and the dependency injection graph, as you can see them in Angular DevTools. These tools are registered in Chrome DevTools as third-party tools. You may wonder why this is listed in the AI section then?

This is because you can then use the chrome-devtools-mcp to connect your agent to Chrome DevTools and let it call these Angular tools. This requires a bit of fiddling with the MCP flags to enable third-party tools, but then your agent gets insights about the application state and how to fix it when something is wrong.

Using WebMCP in Angular

As the rest of the AI features are based on WebMCP, let me just quickly introduce it before.

WebMCP is a proposed web standard that lets a website expose Model Context Protocol capabilities directly from a web page. So instead of asking an MCP server on your machine or in the cloud, an agent can directly use a website as a tool and interact with it to perform specific tasks. This is a very early experiment (you'll need a flag to enable it in Chrome Beta at the moment), but as Google is pushing it, the framework and CLI team decided to implement some capabilities.

WebMCP has two main parts:

  • it lets a website expose tools via an imperative API, and those tools can be called by an agent.
  • if a website adds some attributes to its forms, then these forms are automatically detected as tools that can be called by an agent (declarative API).

Angular v22 lets you leverage these capabilities.

A new declareExperimentalWebMcpTool() function (experimental, you guessed it) is available in @angular/core to declare a WebMCP tool imperatively in your Angular application.

So you can write this code in a component:

export class Users {
  constructor() {
    declareExperimentalWebMcpTool({
      name: 'list_users',
      description: 'List users with a specific status',
      inputSchema: {
        type: 'object',
        properties: {
          status: { type: 'string', enum: ['ADMINS', 'STUDENTS'] }
        },
        required: ['status'],
        additionalProperties: false,
      },
      execute: ({ status }) => {
        return inject(UserService).list(status);
      }
    })
  }
}

As you can see, it is possible to use inject in the execute function of the tool to leverage Angular dependency injection, and Angular destroys the tool when the component is destroyed.

Or you can declare the tool in the app providers (or in the providers of a route), using provideExperimentalWebMcpTool():

export const appConfig: ApplicationConfig = {
  providers: [provideExperimentalWebMcpTool([{
    name: 'list_users',
    // ...
  }])]
};

The other part of the WebMCP proposal is the declarative API for forms. This normally means that a developer must add attributes to their forms to make them detectable by agents as tools:

<form toolname="createUser" tooldescription="Creates a new user">

But if you use Signal Forms, then you can automatically register a tool from your forms! This is opt-in, by adding provideExperimentalWebMcpForms() from @angular/forms/signal to your app providers:

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalWebMcpForms(),
    // ...
  ]
};

Then you can define the metadata of the tool in the form() function, which now has an optional experimentalWebMcpTool key in its options:

protected readonly userForm = form(
  // ...
  {
    experimentalWebMcpTool: {
      name: 'user_creation',
      description: 'Form to create a new user'
    }
  }
)

This will then call declareExperimentalWebMcpTool() for you with the name and description you defined, and the input schema of the tool will be automatically generated from the form fields. The execute function of the tool will automatically fill the form with the input data and submit it.

Angular CLI

The CLI has received a few improvements as well, especially to ease the migration to Vitest.

Unit tests

As mentioned above, some work has been done on the CLI to ease the migration to Vitest. Zone.js can now be used with Vitest. A new migration migrate-karma-to-vitest is available to automatically migrate your test setup from Karma to Vitest, adding and removing the necessary dependencies and configuration to your project.

You can then run the existing refactor-jasmine-vitest migration to automatically convert your Jasmine tests to Vitest tests, which has been substantially improved to cover more cases and produce better code in v22. It gained a flag --fake-async to convert fakeAsync tests to Vitest fake timers utilities vi.useFakeTimers(), vi.advanceTimersByTimeAsync(n), etc. That's great if you don't want to keep using Zone.js for these migrated tests.

We are currently migrating fairly large projects to Vitest, and we adopted a dual setup, with ng test running the old Karma tests, and a new vitest task running the migrated Vitest tests. We can then migrate tests one by one, using:

ng g refactor-jasmine-vitest --browser-mode --add-imports --fake-async --include src/app/user/user.ts

For components, we usually rewrite the test to leverage all the great features of Vitest Browser Mode (check out our article about Angular tests with Vitest Browser Mode to learn more about it). For other entities, the migration produces a more straightforward conversion, just replacing Jasmine functions by their Vitest equivalents. A few things are not automatically converted, and you'll sometimes see a TODO appearing in the migrated tests when that's the case.

If you still use Karma in your current project, you'll see that ng update adds istanbul-lib-instrument as a dev dependency to your project. This library, used for code coverage, was a direct dependency of the CLI before, and it is now an optional peer dependency to avoid downloading it when you don't need it (for example if you are using Vitest instead of Karma).

Let's talk about the new features now: the unit-test builder gained two new options. The first one is a quiet flag to suppress the build summary and stats table of the test build in the console, which can be useful when running tests in watch mode and you only care about the running/failed tests. This option is true by default locally and false in CI, but you can override it in your angular.json configuration, or via the --quiet flag when running the tests. The second one is --isolate to run tests in isolated mode for Vitest only, using native isolation (running tests in separate threads or processes).

Dev server

It is now possible to set the dev server port via process.env.PORT: PORT=4203 ng serve. The environment variable takes precedence over the --port flag and the value in angular.json.

Build

The subresourceIntegrity option now generates an import map with the integrity hashes of the lazy-loading chunks, whereas it used to only add the integrity hashes as attributes of the script tags in the index.html file.

As we are talking about lazy-loading chunks, an important change is that the chunk optimization introduced in Angular v18.1 is now enabled by default in production builds.

It used to be an opt-in optimization that allowed you to reduce the number of your lazy-loading chunks, by using NG_BUILD_OPTIMIZE_CHUNKS=1 ng build. The NG_BUILD_OPTIMIZE_CHUNKS is still available, but it is now used to disable the optimization if needed (by passing 0), or to customize it by specifying the threshold of lazy chunks required to trigger optimization, with 3 as the default (for example NG_BUILD_OPTIMIZE_CHUNKS=4 to only optimize if there are at least 4 lazy chunks).

The optimizer switched back to Rollup by default (Angular v20.2 switched to the experimental Rolldown, written in Rust) but Rolldown is still available as an experimental optimizer if you want to give it a try, by using the NG_BUILD_CHUNKS_ROLLDOWN environment variable.

Summary

Angular v22 is a major step in the framework's current direction: more signal-friendly defaults, stable APIs for resources and Signal Forms.

The Angular team announced that they are working on a @boundary block in templates, to let us catch errors in the template and display a fallback UI instead of breaking the whole page. We may say see this lands in Angular v22.1 or v23, but it is definitely something to look forward to!

Stay tuned!

All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!

What's new in Angular 21.2?

Angular 21.2.0 is here!

Angular logo

Angular 21.2 is the second minor release of the v21 cycle. Let's dive in!

Templates

Arrow functions

One of the most requested template syntax improvements is finally here: we can now use arrow functions directly in templates.

<button (click)="count.update(n => n + 1)">+1</button>

Note that you can't use an arrow function in an event listener to call a method. (click)="() => doSomething()" will not work, and will throw the following error at compile time:

Arrow function will not be invoked in this event listener. Did you intend to call a method?

instanceof is now also supported in templates, but I don't think we ever missed this one 🤷.

Exhaustive @switch checks

Angular now supports exhaustive type-checking for control-flow @switch blocks. You can opt in by ending the switch with @default never;.

@switch (status()) {
  @case ('idle') { 
    <p>Idle</p> 
  }
  @case ('loading') { 
    <p>Loading</p>
  }
  @default never;
}

If status() can also be another value (for example 'error'), the template type-checker now reports it at compile time:

[ERROR] TS2322: Type '"error"' is not assignable to type 'never'. [plugin angular-compiler]

    src/app/live/live.html:7:13:
      7     @switch (status()) {

I'm super happy to see this landing, as I opened the original feature request when the control flow syntax was announced, back in 2023 😍.

ChangeDetectionStrategy.Eager

No, this is not a new change detection strategy, but an alias for ChangeDetectionStrategy.Default. This is part of the ongoing effort to change the default change detection strategy to OnPush, as you can read in the official RFC.

In the next major release, the default change detection strategy will be switched to OnPush, and the Eager alias will be the only way to explicitly opt in to the old default behavior (Default is marked as deprecated in v21.1). That means components with no specified change detection strategy will be using OnPush and we'll have to explicitly set ChangeDetectionStrategy.Eager for components that are not OnPush compatible. An automatic migration will take care of that for existing components.

Resource snapshot

Angular now exposes a resource state as a first-class value with ResourceSnapshot<T>. A resource has a new snapshot() signal, which is a simple object containing a status and either a value or an error. Angular also introduces resourceFromSnapshots(...) to turn a snapshot signal back into a Resource.

This is an advanced API that allows you to customize the behavior of resources. You can use normal signal composition (computed, linkedSignal) to transform resource states, then convert the result back to a resource. This pattern makes it much easier to build reusable resource utilities. For example, built-in resources set the value to undefined when loading. If you don't like this behavior, you can implement a resource that keeps the stale value while loading.

function withPreviousValue<T>(input: Resource<T>): Resource<T> {
  const derived = linkedSignal<ResourceSnapshot<T>, ResourceSnapshot<T>>({
    source: () => input.snapshot(),
    computation: (snap, previous) => {
      if (snap.status === 'loading' && previous?.value?.status === 'resolved') {
        // keep previous value while loading next one
        return { status: 'loading', value: previous.value.value };
      }
      return snap;
    }
  });

  return resourceFromSnapshots(derived);
}

Signal Forms

Signal Forms received several improvements in Angular v21.2.

FormRoot directive and submission option

One of the interesting Signal Forms additions in v21.2 is the FormRoot directive. Instead of manually handling (submit) and calling submit(...), you can now bind the form directly in the template:

<form [formRoot]="loginForm">
  <input [formField]="loginForm.login" />
  <button type="submit">Log in</button>
</form>

Submission logic can be declared in the form() configuration directly:

protected readonly loginForm = form(this.credentials, {
  submission: {
    action: async () => await this.userService.authenticate(this.credentials().login, this.credentials().password),
    onInvalid: () => this.invalidSubmission.set(true),
    ignoreValidators: 'none'
  }
});

As you can see, this submission option also allows to define an onInvalid callback, which is called when the form is submitted while invalid. The ignoreValidators option let you define whether the validation should be ignored when submitting the form or not:

  • pending (default value) ignore pending async validators, but not synchronous validators;
  • all ignore all validators;
  • none don't ignore any validator.

The FormRoot directive also automatically adds the novalidate attribute to the form element, preventing native browser validation from interfering with Signal Forms validation.

Note that the existing submit() function now uses these form-level defaults. So if action is defined in submission, you no longer need to pass it if you manually call submit(). Angular v21.2 in fact updated the submit() API to let you pass options as the second parameter (action, onInvalid, ignoreValidators) to override these defaults, and the submit function now returns a Promise<boolean>.

focus and registerAsBinding

As explained in our previous blog post, Angular v21.1 introduced the focusBoundControl method on a field to programmatically focus its form control in the DOM:

protected readonly loginForm = form(this.credentials, {
  submission: {
    action: // ...
    onInvalid: () => {
      // 👇 automatically focus the first field with an error
      const firstError = this.loginForm().errorSummary()[0];
      if (firstError?.fieldTree) {
        firstError.fieldTree().focusBoundControl();
      }
    }

A typical FormField will focus its host element (input, select, etc.) when focusBoundControl is called. For custom form controls that implements FormValueControl or FormCheckControl, you'll need to implement the focus method to define how the control should be focused when focusBoundControl is called:

@Component({
  // ...
})
export class BirthYearInput implements FormValueControl<number | null> {
  // ...
  // 👇
  focus() {
    this.birthYearInput().nativeElement.focus();
  }

But we can also have components or directives that don't implement FormValueControl or FormCheckControl, and instead receive the field as an input and bind it in the template. In this case, we can use the new registerAsBinding method to register the field as a binding, which will make focusBoundControl focus the chosen element:

@Component({
  // ...
  template: `
    <input type="password" [formField]="formField()" />
    <button #button (click)="generate()">Generate</button>
   `
})
export class Password {
  readonly formField = input.required<FieldTree<string>>();
  readonly button = viewChild.required<ElementRef<HTMLButtonElement>>('button');

  constructor() {
    // 👇 register the field as a binding, and specify to focus the button element
    inject(FormField).registerAsBinding({
      focus: () => this.button().nativeElement.focus()
    });
  }
}

If the custom control is a directive, the registerAsBinding call can be done without specifying the focus method, as the directive host element will be focused by default:

@Directive({
  // ...
})
export class PasswordDirective {
  readonly formField = input.required<FieldTree<string>>();
  
  constructor() {
    // 👇 the directive host element will be focused by default
    inject(FormField).registerAsBinding();
  }
}

Note: the focus API now accepts and propagates FocusOptions which allows control over focus behavior (for example, preventing scroll on focus with { preventScroll: true }).

transformedValue for custom controls

Another nice addition for custom controls is the new transformedValue utility. It helps synchronize a raw value entered by the user with the model value using parse and format functions, while automatically reporting parse errors to the parent form. The parse function can either return an object with a value property containing the parsed value, or an object with an errors property containing an array of errors if the value is invalid. In this example, the user types a duration like 20m or 1h, and the form model stores the duration in minutes. If the value is invalid, then a custom parse error is reported to the form, which can be used to display an error message in the UI. You can also return built-in validation errors from the parse function, for example to report that a negative duration is not allowed using a min error.

@Component({
  selector: 'duration-input',
  template: `<input [value]="rawValue()" (input)="onDurationInput($event)" />`
})
export class DurationInput implements FormValueControl<number | null> {
  // model value exposed to the form (in minutes)
  readonly value = model.required<number | null>();

  // 👇 raw UI value (string) <-> model value (minutes)
  readonly rawValue = transformedValue(this.value, {
    parse: (raw: string) => {
      // normalize user input
      const input = raw.trim().toLowerCase();
      if (input === '') return { value: null };

      // `20m` -> 20
      if (input.endsWith('m')) {
        const minutes = Number(input.slice(0, -1));
        if (Number.isNaN(minutes)) return { error: { kind: 'parse' } };
        return minutes < 0 ? { error: minError(0) } : { value: minutes };
      }

      // `1h` -> 60
      if (input.endsWith('h')) {
        const hours = Number(input.slice(0, -1));
        if (Number.isNaN(hours)) return { error: { kind: 'parse' } };
        const minutes = hours * 60;
        return minutes < 0 ? { error: minError(0) } : { value: minutes };
      }

      // anything else is a parse error
      return { error: { kind: 'parse' } };
    },
    // format minutes back to a user-facing value
    format: (value: number | null) => (value == null ? '' : `${value}m`)
  });

  onDurationInput(event: Event) {
    this.rawValue.set((event.target as HTMLInputElement).value);
  }
}

Nullable number inputs

Note that parse errors are now also integrated for native form inputs. For example, if a user types an unparsable value in a type="number" input, the model keeps its previous value and the field reports a parse error.

Signal Forms now better supports null with native number inputs: binding null clears the input, and when users clear the field, the model becomes null.

Our online workshop showcases a more complete example of transformedValue usage in the Signal Forms exercise 👀.

SignalFormControl for incremental migration

Angular v21.2 also introduces SignalFormControl in @angular/forms/signals/compat. It acts as a bridge between Signal Forms and classic Reactive Forms: you can define signal-form validation rules, while still plugging this new control into a FormGroup or FormArray.

protected readonly credentials = this.fb.group({
  login: ['', [Validators.required, Validators.minLength(3)]],
  // 👇 use a SignalFormControl in a classic FormGroup
  password: new SignalFormControl('', p => {
    required(p);
    minLength(p, 6);
  })
});

On the template side, you don't need to do anything special to bind a SignalFormControl with formControlName/formControl.

@let passwordCtrl = credentials.controls.password;
<input id="password" type="password" formControlName="password" />
@if (passwordCtrl.touched && passwordCtrl.hasError('required')) {
  <div>Password is required</div>
}

It also exposes a fieldTree so the control can be bound with [formField] in templates.

@let passwordCtrl = credentials.controls.password;
<input id="password" class="form-control" type="password" [formField]="passwordCtrl.fieldTree" />
@if (passwordCtrl.touched && passwordCtrl.hasError('required')) {
  <div id="password-required-error" class="invalid-feedback">Password is required</div>
}

This is particularly useful for bottom-up migration: you can migrate one control at a time instead of rewriting the whole form in one pass.

SignalFormControl would not be really "signal-friendly" if it did not use a signal: it in fact has a sourceValue property which is a signal of the control value, and can be used:

protected readonly passwordStrength = computed(() => {
  const password = this.passwordCtrl.sourceValue();
  // ...
});

SignalFormControl intentionally does not support some imperative AbstractControl APIs:

  • disable() / enable()
  • setValidators() / addValidators() / removeValidators() / clearValidators()
  • setAsyncValidators() / addAsyncValidators() / removeAsyncValidators() / clearAsyncValidators()
  • setErrors()
  • markAsPending()

State and validation are expected to be derived from signal rules instead.

Reactive validateStandardSchema()

validateStandardSchema() now supports signal-based schemas. You can pass a function that returns the schema, so validation can react to changing signals over time. For example, if minimumLength is a signal containing the minimum length, you can now write: validateStandardSchema(p, () => z.object({ name: z.string().min(minimumLength()) })).

Warning for rendered hidden fields

Angular now warns in dev mode when a field marked as hidden is still rendered in the DOM. In Signal Forms, hidden fields should be guarded in the template (typically with @if), so this warning helps catch accidental rendering of hidden controls:

NG01916: Field 'password' is hidden but is being rendered.
Hidden fields should be removed from the DOM using @if.

Animations

It is now possible to have nested animations, where a component with its own animations is used inside another component that also has animations. Previously, only the animations of the top-level component were working when components were destroyed, but with this change, animations defined in nested components will be properly triggered first.

Router

Angular now lets you configure trailing slash behavior through dedicated location strategies. You can choose to always write URLs with a trailing slash, or to always remove it:

bootstrapApplication(AppComponent, {
  providers: [
    // 👇 always write URLs with a trailing slash
    { provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy }
    // or never
    // { provide: LocationStrategy, useClass: NoTrailingSlashPathLocationStrategy }
  ]
});

canMatch now gets a partial route snapshot

Another useful router change is that canMatch now receives a third argument: a partial route snapshot (PartialMatchRouteSnapshot).

This addresses a long-standing limitation where canMatch had access to route and segments, but not to params like { userId: '123' }. With this snapshot argument, the guard can directly read params, queryParams, fragment, etc.

DevTools

Angular DevTools now visualizes resource() relationships in dedicated clusters in the signal graph. DevTools also adds dependency-path highlighting from the node details panel. You can now highlight:

  • upstream dependencies (what this node depends on)
  • downstream dependents (what depends on this node)

This can be helpful when debugging update propagation in large signal graphs.

Angular CLI

Unit tests

The CLI now supports ng add for Vitest browser providers:

  • @vitest/browser-playwright
  • @vitest/browser-webdriverio
  • @vitest/browser-preview

When used on a project already configured with the @angular/build:unit-test builder (and Vitest runner), the schematic installs the selected provider package plus required peer dependencies (like playwright or webdriverio).

So you can now run:

ng add @vitest/browser-playwright

The unit-test builder has a new headless option to force all configured browsers to run headless:

{
  "builder": "@angular/build:unit-test",
  "options": {
    "browsers": ["Chromium"],
    "headless": true
  }
}

This is an alternative to the hard-coded convention used until now, where you had to name the browser with a Headless suffix to run it headlessly (for example ChromiumHeadless).

Prettier

The CLI now has built-in Prettier integration!

New generated applications now include a .prettierrc file, with prettier a dev dependency. Files created/modified by schematics are automatically formatted when possible and ng update migrations now also run formatting on changed files.

This reduces formatting noise after ng generate and ng update.

Summary

This was a packed release for a minor version. Arrow functions and exhaustive @switch type-checking on the template side, the ChangeDetectionStrategy.Eager alias as the OnPush as default migration is coming, and a wave of Signal Forms improvements: FormRoot, transformedValue, SignalFormControl for incremental migration. Next version should be the v22 release.

Stay tuned!

All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!