Le blog
de
Ninja Squad

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!

What's new in Angular 21.1?

Angular 21.1.0 is here!

Angular logo

Two months after the huge v21 release, the Angular team delivers a new minor update.

Let's dive in!

Signal Forms

A meaningful change in Signal Forms is the renaming of the [field] directive to [formField]. The [field] selector was too generic and likely to cause naming collisions with existing components. Therefore, the Angular team decided to rename both the directive class (from Field to FormField) and the directive selector (from [field] to [formField]).

You should now use [formField] instead of [field] to bind form controls:

<input [formField]="form.name" />

The Field directive has been removed, so you'll have some renaming to do if you already use experimental Signal Forms.

Another name change in Signal Forms is the renaming of the field property to fieldTree in validation error interfaces and field contexts. If you're writing custom validators, you'll need to update your code to use fieldTree instead of field:

// Before
const error: ValidationError = {
  // 👇 targets the password field
  field: context.fieldTree.password,
  kind: 'password-different-from-email',
  message: 'Password should not be the same as email'
};

// After v21.1
const error: ValidationError = {
  // 👇 targets the password field
  fieldTree: context.fieldTree.password,
  kind: 'password-different-from-email',
  message: 'Password should not be the same as email'
}

This change affects the ValidationError interface and the FieldContext interface.

Signal Forms gained a new feature in v21.1: the ability to automatically apply CSS classes to fields based on their state 🚀.

One of the missing features of signal forms compared to the classic reactive/template-driven forms was the automatic CSS classes that were added to form controls (ng-valid, ng-dirty, ng-touched, etc.).

This is now possible using the provideSignalFormsConfig() function in your application configuration:

provideSignalFormsConfig({
  classes: {
    'is-invalid': field => field.state().invalid() && field.state().touched()
  }
})

In this example, the is-invalid CSS class will be automatically added to any field that is invalid and touched. You can define as many classes as you want, and use any property of the Field/FormField directive, like its state (invalid(), touched(), dirty(), etc.) or its host element (a new property added in this version) to determine when the class should be applied.

This makes it easier to style your forms, especially when using CSS frameworks like Bootstrap or Tailwind that rely on specific CSS classes to style form inputs.

Note that you can have the "old" behavior with ng-valid/ng-invalid/ng-dirty/ng-touched... classes by using:

provideSignalFormsConfig({
  classes: NG_STATUS_CLASSES,
});

Signal Forms now also support custom control directives! Previously, the [field] binding could only be used with components. Now, you can create directives that implement the FormValueControl or FormCheckboxControl interfaces and bind them with [formField].

<input [formField]="form.name" appCustomControl />

Signal Forms also gained a new focusBoundControl() method on the field state. This is particularly useful for accessibility or when you want to focus a specific field after a validation error.

This allows you to programmatically focus the input element associated with a form field, for example when a submission failed:

protected async register(event: SubmitEvent) {
  event.preventDefault();
  await submit(this.userForm, async form => {
    // ...
  });
  // 👇 automatically focus the first field with an error
  const firstError = this.userForm().errorSummary()[0];
  if (firstError?.fieldTree) {
    firstError.fieldTree().focusBoundControl();
  }

This will automatically focus the first input/select/textarea associated with a field that has an error, but the cool thing is that it can also work with custom control components! You just have to implement a focus() method in your custom control, and focusBoundControl() will automatically call it:

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

NOTE: We added a Signal Forms exercise to our online workshop, check it out!

Control flow

The @switch control flow now supports multiple consecutive @case blocks matching a single content block.

Previously, each @case required its own content. Now you can specify multiple conditions for a single block, similar to fall-through behavior in traditional switch statements:

@switch (status) {
  @case ('draft')
  @case ('pending') {
    <p>Your document is not yet published</p>
  }
  @case ('published') {
    <p>Your document is live</p>
  }
  @default {
    <p>Unknown status</p>
  }
}

In this example, both 'draft' and 'pending' statuses will display the same message, making the code more concise when multiple conditions should produce the same result.

Template spread operator

Angular templates now support the spread operator (...)! This allows you to spread an object into an object or an array into another array:

@let users = [currentUser, ...admins];

It also supports the spread syntax in function calls. This is particularly useful for functions that use rest parameters, a syntax that allows a function to accept an indefinite number of arguments as an array:

<button (click)="sum(...counters)">Sum all</button>

Spoiler for the next version: we will get arrow function support in templates in v21.2!

Router

The router introduces a new standalone isActive() function that returns a computed signal indicating whether a given URL is currently active.

This new function is a more tree-shakeable alternative to the existing Router.isActive() method, which is now deprecated in v21.1:

import { isActive } from '@angular/router';

const active = isActive(
  '/home',
  this.router, 
  { paths: 'exact', queryParams: 'exact', fragment: 'ignored', matrixParams: 'ignored' }
);

The function returns a signal that automatically updates when the router state changes, making it easier to reactively track active routes.

The router also gained better memory management capabilities in v21.1 if you use a custom RouteReuseStrategy implementation (which is not that common).

A new experimental feature withExperimentalAutoCleanupInjectors() automatically destroys unused route injectors after navigation, helping to prevent memory leaks in applications with many routes or long-lived sessions.

You can enable it when configuring your routes:

provideRouter(routes, withExperimentalAutoCleanupInjectors())

Additionally, a new destroyDetachedRouteHandle() function is available for manually cleaning up detached route handles in custom RouteReuseStrategy implementations.

Finally, the router introduces an experimental integration with the browser's Navigation API. This API is a modern alternative to the traditional History API, providing a more robust way to handle navigations.

By enabling this experimental feature, the Angular router can:

  • intercept navigations triggered outside the router and convert them to SPA navigations.
  • leverage native browser scroll and focus restoration.
  • communicate ongoing navigations to the browser for better accessibility and user experience (native loading progress, stop button, etc.).

You can enable it using the withExperimentalPlatformNavigation() feature:

provideRouter(routes, withExperimentalPlatformNavigation())

This feature is highly experimental and the native browser support is currently very limited. This won't be stabilized until the Navigation API is more widely supported. When this is done, it may not become a router feature (with...) but rather a different router provider, which would allow to tree-shake the current history-based classes.

Debugging stability

A new debugging utility provideStabilityDebugging() helps developers troubleshoot applications that fail to stabilize during hydration.

If your application doesn't reach a stable state within 9 seconds, this utility logs diagnostic information to the console, including pending tasks and their stack traces. This is particularly useful when debugging hydration timeouts in SSR applications.

import { provideStabilityDebugging } from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [provideStabilityDebugging()],
});

Note that this utility is provided by default in dev mode when using provideClientHydration().

Vitest

The Angular CLI's migration schematic for converting Jasmine tests to Vitest now supports a browserMode option (a contribution from my fellow ninja JB!).

If you're migrating your tests to Vitest and plan to use Vitest's browser mode (you should, read our blog post about Vitest browser mode), you can now use this option to preserve certain assertions that are natively supported in browser mode:

ng generate refactor-jasmine-vitest --browser-mode

When this option is enabled, the migration will keep the toHaveClass matcher in its original form instead of converting it to a different assertion, since Vitest's browser mode provides its own toHaveClass matcher.

This makes the migration smoother for projects that want to run their tests in a real browser environment, ensuring your tests work as expected without manual adjustments after the migration.

The schematic also now generates a detailed migration report by default, creating a markdown file that lists all the TODOs and manual tasks that need to be addressed after the automatic migration. This report includes precise file paths and line numbers, making it easier to quickly identify and fix any remaining migration tasks in large codebases. You can disable this with --no-report if you don't need it.

# Jasmine to Vitest Refactoring Report

Date: 2025-12-17T15:16:43.108Z

## Summary

|                   | Count |
|:------------------|------:|
| Files Scanned     |   159 |
| Files Transformed |   151 |
| Files Skipped     |     8 |
| Total TODOs       |     3 |

## TODO Overview

| Category    | Count |
|:------------|------:|
| addMatchers |     3 |

## Files Requiring Manual Attention
...

AI

The MCP server gained a few new (experimental) tools that enable AI assistants to control Angular projects more effectively:

  • build: compiles the Angular application
  • devserver.start: launches the development server
  • devserver.stop: terminates the running development server
  • devserver.wait_for_build: waits until the dev server completes its build process
  • test: runs unit tests via ng test
  • e2e: runs end-to-end tests via ng e2e

These tools allow AI assistants like Claude to programmatically build your application, manage the development server, and run your tests, making it easier to verify that everything compiles correctly and all tests pass.

You can now enable all experimental tools at once using the all group in your MCP configuration, making it easier to get started with the full suite of AI assistance features: ng mcp --experimental-tool=all.

The already existing ai_tutor tool continues to evolve. A new lesson has been added to the interactive learning experience: Signal Forms!

If you're curious about Signal Forms, the AI tutor can be a nice resource, but you should definitely check out our 2 part series on Signal Forms if you haven't already 😉

The Angular team continues to improve the infrastructure for AI assistance. Code examples are now embedded directly in Angular packages (starting with @angular/forms) using a SQLite database, making them easily accessible to the MCP server and other tooling. This builds upon the code examples feature introduced in earlier versions, improving the AI's ability to provide contextual code snippets and guidance, and we'll probably see more code examples added in future releases in the various packages.

Finally, setting up the MCP server is now easier than ever. New Angular workspaces now include a .vscode/mcp.json.template file with a pre-configured setup for the Angular CLI MCP server. This eliminates the need for manual configuration and makes it straightforward to start using AI assistance in your Angular projects. You can learn more about this on the Angular MCP documentation page.

Summary

That's all for this release! Signal Forms continue to improve with CSS class configuration, custom control directives support, the focusBoundControl() method, and naming improvements. The router gained an experimental integration with the Navigation API. The CLI team focused on AI features and the Vitest stabilization.

Stay tuned!

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