The
Ninja Squad
Blog

What's new in Angular 21.0?

Angular 21.0.0 is here!

Angular logo

The highlights? Signal Forms and zoneless change detection by default. The CLI, with Vitest becoming the default testing framework, also delivers on a long due improvement.

Let's dive in!

Signal Forms (experimental)

Angular 21 introduces a new experimental way to build forms: Signal Forms, in the @angular/forms/signals package. This signal-based approach provides better type safety and easier validation compared to traditional reactive and template-driven forms.

private readonly credentials = signal({ email: '', password: '' });
protected readonly loginForm = form(
  this.credentials,
  // πŸ‘‡ add validators to the fields, with their error messages
  form => {
    // email is mandatory
    required(form.email, { message: 'Email is required' });
    // must be a valid email
    email(form.email, { message: 'Email is not valid' });
    // password is mandatory
    required(form.password, { message: 'Password is required' });
    // should have at least 6 characters
    minLength(form.password, 6, {
      // can also be a function, if you need to access the current value/state of the field
      message: password => `Password should have at least 6 characters but has only ${password.value().length}`
    });
  }
);

We wrote a two-part series to introduce you all the concepts of Signal Forms. The first one covers the basics, while the second one dives into more advanced topics like custom validation, custom form components, etc.

Check them out!

πŸ‘‰ Angular Signal Forms - Part 1

πŸ‘‰ Angular Signal Forms - Part 2

There is also an effort to make the migration to signal forms more progressive, with the introduction of the @angular/forms/signals/compat package. This package offers a compatForm function, which is a version of the form function designed for backwards compatibility with reactive forms by accepting reactive controls as a part of the data. I'm not sure that this will save us from completely rewriting our forms, but we appreciate the effort 🫑.

Zoneless by default πŸš€

Angular applications are now zoneless by default! provideZonelessChangeDetection() is no longer needed in new applications, as Angular will use zoneless change detection automatically. If your application relies on Zone.js, you will need to explicitly add provideZoneChangeDetection() in the root providers. A migration will automatically add it for you if necessary when updating to Angular v21.

The CLI now generates zoneless applications by default, and you'll note that the component tests use await fixture.whenStable() instead of fixture.detectChanges().

Core

The SimpleChanges type that we use for the ngOnChanges parameter is now generic and can be type safe, if you add the type of your component as a type parameter. To keep the backward compatibility, it defaults to any when no type parameter is provided.

export class User implements OnChanges {
  readonly name = input.required<string>();

  // πŸ‘‡ ngOnChanges is now type safe
  ngOnChanges(changes: SimpleChanges<User>): void {
    const nameChange = changes.name;
    // typed as `SimpleChange<string> | undefined`
    if (nameChange) {
      console.log(`Name changed from ${nameChange.previousValue} to ${nameChange.currentValue}`);
    }
  }

Forms

In "classic" forms, a new FormArrayDirective directive has been added, allowing to have an array as the top element of a form. Until now, you had to wrap it in a FormGroup and use the FormGroupDirective.

Http

The big change is that HttpClient is now provided in the root injector by default. We no longer need to use provideHttpClient() in our applications, except when we want to customize the HTTP client (for example, to register interceptors). It also simplifies the testing setup, where it was a bit verbose to have to use both provideHttpClient() and provideHttpClientTesting(). We can now only use the latter.

The HttpClient also gained a few options in its Fetch version:

  • responseType has been added (corresponding to the response type property in the native fetch API, see the MDN docs)
  • referrerPolicy which can be used to specify the referrer information (see the MDN docs) has been added to the client and in the HttpResource options.

Router

The router gained a new scroll option that can be used when navigating to define the scrolling behaviour (allowing to override the scroll restoration behaviour that you can enable in provideRouter thanks to withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })).

The scroll option can be 'manual' (no scrolling even if enabled globally) or 'after-transition' (following the global behaviour). So, using router.navigateByUrl('/', { scroll: 'manual' }) will prevent scrolling even if scroll restoration is enabled globally.

Templates

The control flow migration will automatically be applied when updating to Angular v21, if you did not apply it yet.

Since the updated style guide written for Angular v20, it is now recommended to use class and style bindings instead of the NgClass and NgStyle directives.

Angular v21 offers automatic migrations (optional) to help you with this change!

ng g @angular/core:ngclass-to-class-migration
ng g @angular/core:ngstyle-to-style-migration

Another optional migration has been added to migrate CommonModule imports to the individual directives/pipes standalone imports:

ng g @angular/core:common-to-standalone

Speaking of changes in the templates, RegExp are now supported: you can now write something like <div>{{ /\\d+/.test(id()) }}</div> in your HTML files. I'm not sure why anyone would want to do that, but well...

The @defer trigger on viewport also gained a new option, allowing to define the options of the underlying IntersectionObserver. You can now write a @defer like this:

@defer (on viewport({ trigger, rootMargin: '50px' })) {

Compiler

In v21, the compiler now has its typeCheckHostBindings option enabled by default (see our blog post about v20 to learn more about what it does).

A new diagnostic has also been added to detect when a component reads a required input/model/viewChild/contentChild property before it is initialized. This was already throwing an error at runtime, but now you will get a compile-time error instead.

Another diagnostic has been added to detect unreachable or duplicated or inefficient @defer triggers.

Styles encapsulation

A new strategy for view encapsulation has been added: ExperimentalIsolatedShadowDom. As its name indicates, it is still experimental. It leverages Shadow DOM to provide true encapsulation of styles, but also isolates the component from the rest of the application. Unlike ShadowDom encapsulation, it prevents styles from leaking in or out of the component. This is useful when you want to ensure that the component's styles are completely isolated from the rest of the application, including global styles. There are still some issues with the implementation, so it is not recommended for production use yet.

Vitest is the new default

Big change in the unit testing setup: Vitest is now the default testing framework when creating new applications with the CLI! 😲

Karma/Jasmine has been the default for a long time, even though Karma has been deprecated for a while. The experimental @angular/build:unit-test is now stable and uses Vitest by default. Some efforts have been made to simplify the configuration as much as possible, and the minimal setup in angular.json is now:

"test": {
  "builder": "@angular/build:unit-test"
}

You can see the difference in a generated project on angular-cli-diff. This little project that we maintain proves to be really useful to see what changed between CLI versions when you want to update your projects.

Even if the configuration is simpler, you can still customize it a lot with the following options, either in angular.json or via CLI options when running ng test:

  • coverage (and all coverage related options, like coverageInclude, coverageExclude, coverageThresholds, etc.) to configure code coverage
  • browsers to define which browsers to use for testing. Note that Vitest stabilized the Browser Mode in v4, so you can now run tests in real browsers instead of jsdom/happy-dom if you want to. The browserViewport option allows to define the viewport size for browser tests.
  • reporters to define which reporters to use, and output-file to define where to output the report.
  • setupFiles to define setup files to be run before the tests and providersFile to define a file that provides Angular testing providers.
  • ui if you want to use Vitest's UI mode.
  • watch to enable/disable watch mode (true by default when running ng test, but false in CI environments).
  • filter to run only tests matching a specific pattern in their test suite or test name.
  • list-tests to list all the tests without running them (useful to check what the include/exclude options are doing).

The CLI team also added a runnerConfig option to allow using a custom Vitest configuration file, if you need to customize something not covered by the CLI options. For example, if you want to enable Vitest isolation mode (disabled by default in the CLI as it slows down tests), you can add a vitest-base.config.ts file to your project using: ng g config vitest.

Then customize it like this:

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    isolate: true,
  },
});

We really like Vitest and its Browser Mode that allows to use real browsers and write more expressive tests using its Locator API. We wrote a dedicated blog post to explain how to write more expressive than ever component unit tests 🀩:

πŸ‘‰ Angular tests with Vitest browser mode

It is still possible to use Karma if you want to, by setting the runner option to "karma". And you can generate a new project with Karma as the default by using ng new my-app --test-runner=karma.

A migration is also available to migrate existing tests from Jasmine to Vitest:

ng generate refactor-jasmine-vitest

This migration is fairly mechanical, so you'll probably have to manually adjust some tests afterward. But it should save you some time, as it replaces all Jasmine-specific functions, types, matchers and imports with their Vitest equivalents! It does not migrate the test setup or dependencies though, so you'll have to do that part manually.

The migration has some useful options:

  • --add-imports to automatically add the Vitest imports in your test files (if you don't want to use global imports, which is the default when generating a new application)
  • --file-suffix if you use a different suffix than .spec.ts for your test files
  • --include to target only specific files or folders

Note that fakeAsync tests need to be rewritten, as fakeAsync relies on a patched Zone.js for Jasmine, and there is no version of it for Vitest. The migration will also add helpful TODO comments in your tests to indicate where it couldn't automatically migrate them.

Vitest is definitely a great addition to Angular, and the way to go if you start a new project! The web-test-runner and jest experimental builders which were introduced as a possible alternative to Vitest will be removed in v22.

ng serve

It is now possible to define variables that will be replaced during the serve process, using the define option in the serve configuration. For example, you can define a VERSION variable that will be replaced with the version of your application from the command line (making it simple to use environment variables):

ng serve --define VERSION="'1.0.0'"

and then use it in your code:

// @ts-expect-error defined with --define flag
console.log(`App version: ${VERSION}`);

This was already possible since v17.2 in builds, but is now also available for ng serve.

Tailwind schematic

A new schematic has been added to easily set up Tailwind CSS in your Angular projects. You can use it when creating a new application:

ng new my-app --style tailwind

This sets up Tailwind CSS with a minimal .postcssrc.json configuration file and adds the required dependencies (tailwindcss, @tailwindcss/postcss, and postcss).

Interestingly, ng add now supports adding a package that is not a schematic and will fall back to installing the NPM package and then try to run its schematic if it has one. So, you can also add Tailwind CSS to an existing project with:

ng add tailwindcss

Style guide 2016

If you're nostalgic about the old Angular file naming conventions from 2016, you can now generate a project following its recommendations by using the --file-name-style-guide=2016 option when creating a new application.

This option adds a retro vibe to your project with file names like user.component.ts, user.pipe.ts, and user.service.ts πŸͺ©.

The component, directive, and services name will also follow the 2016 conventions, with a Component, Directive, or Service suffix in their class names.

ng version

The ng version command has been improved to provide more detailed information about the packages in your Angular project (showing the requested and installed versions). A --json option has also been added to output the information in JSON format, which can be useful for automation scripts.

Accessibility

A new @angular/aria package has been introduced to provide headless and accessible directives that implement common ARIA patterns. They handle keyboard interactions, ARIA attributes, focus management, and screen reader support. These directives are built on top of the CDK and developed by the Angular Material team. The package is in developer preview in v21 and currently contains the following directives: Accordion, Autocomplete, ComboBox, Grid, ListBox, Menu, Multiselect, Select, Tabs, Toolbar, and Tree. We haven't tested them yet, so we'll let you explore them on your own.

AI

The AI section is becoming a regular section of our release review! The CLI team worked hard on the MCP server. It now offers 7 tools:

  • list_projects: lists the Angular projects in the workspace
  • get_best_practices: provides the latest best practices
  • find_examples (now stable): finds code examples for a given topic
  • search_documentation: searches the Angular documentation
  • modernize (still experimental): suggests modernizations for your codebase
  • ai_tutor (new): provides interactive learning to build a "Smart Recipe Box" application
  • onpush_zoneless_migration (new): helps migrate to OnPush and zoneless change detection

find_examples is now stable, and has been improved to support filtering, weighted results, and searching for specific Angular packages and versions. The examples you add to your project can now have a frontmatter description, with a title, summary and keywords. It can also be marked as experimental if they use experimental APIs. By default, the database has just one example for a @if, but we can expect the default database to grow over time.

search_documentation has also been improved to search the documentation for the specific Angular version of your project.

modernize is still experimental,, and now can run migrations directly. So if you ask your favorite AI assistant to modernize a component, the MCP server will be able to run the necessary migrations to update your codebase (control-flow, signal migrations, etc.).

onpush_zoneless_migration is an interesting new tool that analyzes your codebase and provides a step-by-step plan to migrate your application to OnPush and zoneless. It targets components and their tests, helps you identify ZoneJS usages that need to be addressed and suggests how to migrate to zoneless.

Summary

The transition to zoneless by default, the introduction of Signal Forms and Vitest as the new default testing framework are big news for Angular developers.

Migration of existing applications to turn on zoneless change detection will require a big effort though. As well as for Vitest if you have thousands of Jasmine tests like we do.

Signal forms have still some rough edges as they are still experimental, but they look very promising, and are maybe a good choice for new projects? We will keep an eye on their evolution in the coming releases, and let you know what we think about them.

Stay tuned!

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

Angular tests with Vitest browser mode

Unit testing our Angular applications is something we take seriously.

The TestBed helps, but I've always found it very low-level. It looks to me like it was designed mainly to test the framework itself rather than applications using the framework. For example, there is nothing to help you interact with the UI elements (form inputs, selects, etc.) other than the low-level DOM APIs.

Recent versions, along with zoneless change detection, have improved the situation a little bit. We can now replace the many fixture.detectChanges() calls with await fixture.whenStable(), and let the framework detect the changes automatically, making the code under test behave more like it would behave in real-life. You can check out this conference talk by CΓ©dric Exbrayat (in French) if you want to learn more about this topic.

To make tests easier to write and maintain, we have developed, and still heavily use ngx-speculoos. It's a thin layer on top of the TestBed, but it offers several interesting advantages:

  • it makes it easier to interact with elements, in particular form elements
  • it offers frequently used jasmine assertions to check the state of form and other DOM elements
  • it drastically reduces the number of fixture.detectChanges() / fixture.whenStable() calls necessary
  • with the page object pattern, it allows defining the selectors to access the elements to test once, and use them across all the tests of a suite

Angular 21 changes everything for the better. Change detection is zoneless by default, and Vitest is now the default testing framework instead of Jasmine. In particular, the Browser mode of Vitest makes unit testing awesome.

Instead of using a Node-based DOM implementation like Jest (and Vitest by default) do, the Browser mode allows the code under test to run in the real production environment: the browser(s) (Chromium, Firefox and Webkit). So we keep this feature that Karma offered.

But it goes much further. Vitest comes with two killer features.

Retried assertions

Retried assertions eliminate, most of the time, the need to call fixture.detectChanges() or await fixture.whenStable(). If you've used Playwright or Cypress for end-to-end tests, you should know the principle already.

Let's say you want to check that the title of the page contains "Hello". If you write:

expect(title).toHaveTextContent('Hello');

that will just make that check, immediately, and only once.

In order for that test to pass, you need to make sure that the asynchronous task that sets the title is done, and that the framework has detected the changes and updated the DOM. That's why we litter the code with fixture.detectChanges() or await fixture.whenStable(). But Vitest allows doing the following:

await expect.element(title).toHaveTextContent('Hello');

A small change, but with big implications! This time Vitest will execute the assertion, and retry it, again and again (every N milliseconds), until it passes or until the test times out. So if you haven't waited for the fixture to be stable, no problem: the assertion will fail once, but by the time it retries, the framework will have stabilized and updated the DOM, and the test will pass.

Imagine you're using a resource and you want to test that the page displays a spinner while the resource is loading, with Jasmine and the TestBed:

it('should display a spinner while loading', async () => {
  const fixture = TestBed.createComponent(MyComponent);
  await fixture.whenStable();
  expect(fixture.nativeElement.querySelector('[data-testid=spinner]')).toBeTruthy();
});

This looks simple enough, but it doesn't work: the application won't become stable until the resource has loaded. The fix is really not easy to figure out: you need to call TestBed.tick() in that case:

it('should display a spinner while loading', async () => {
  const fixture = TestBed.createComponent(MyComponent);
  TestBed.tick();
  expect(fixture.nativeElement.querySelector('#spinner')).toBeTruthy();
});

No need to deal with these low-level details thanks to retried assertions:

it('should display a spinner while loading', async () => {
  const fixture = TestBed.createComponent(MyComponent);
  await expect(page.getByText('Loading...')).toBeVisible();
});

Locators, their interactivity and assertion API

Instead of using the DOM API and interacting directly with DOM elements, in a Vitest browser test, you use locators. A locator is an object which allows getting zero, one or several elements on the page (or inside the element of another locator).

Every time you interact with the locator, it executes the query to find the element. This means that you can use the same locator throughout the tests of a component without worrying that it might be null, or stale, or not in the DOM anymore.

In fact, in my tests, I use the same page object pattern as with ngx-speculoos. Instead of using getters to query the element(s) every time I want to interact with them, check their presence or their state, I simply initialize locators (see the complete example below).

Vitest heavily promotes writing tests that work as if you were a user in front of the screen. So instead of locating elements by their ID or element names, you locate them by their text, role, label, etc.

Locators, in addition to querying components, also offer an API to test the state of their element(s) with rich (and extensible) assertions. And they also have an interaction API to manipulate them as a user would: click, fill, select, etc.

All this combined allows writing really expressive and simple tests suites like the following (we will not mock the service that it uses, but Vitest of course has a mocking API too):

class LoginTester {
  readonly fixture = TestBed.createComponent(LoginPage);
  readonly root = page.elementLocator(this.fixture.nativeElement);
  readonly login = page.getByLabelText('Login');
  readonly password = page.getByLabelText('Password');
  readonly signIn = page.getByRole('button', { name: 'Sign in' });
  readonly error = page.getByText('Wrong credentials, try again');
}

describe('LoginPage', () => {
  let tester: LoginTester;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideRouter([])]
    });
    tester = new LoginTester();
  });

  it('should display an empty form and no error initially', async () => {
    await expect.element(tester.login).toHaveDisplayValue('');
    await expect.element(tester.password).toHaveDisplayValue('');
    await expect.element(tester.error).not.toBeInTheDocument();
  });

  it('should validate', async () => {
    await tester.signIn.click();
    await expect.element(tester.root).toHaveTextContent('The login is required');
    await expect.element(tester.root).toHaveTextContent('The password is required');
  });

  it('should display an error when login fails', async () => {
    await tester.login.fill('john');
    await tester.password.fill('wrong-password');
    await tester.signIn.click();

    await expect.element(tester.error).toBeVisible();
  });

  it('should navigate away when login succeeds', async () => {
    const router = TestBed.inject(Router);
    vi.spyOn(router, 'navigate');

    await tester.login.fill('john');
    await tester.password.fill('correct-password');
    await tester.signIn.click();

    expect(router.navigate).toHaveBeenCalled();
  });
});

Isn't that beautiful?

Another cool thing? If you edit the component or its test and save, Vitest will reexecute just that test! Jest users had this for years, but we, poor Karma users, have been struggling with the whole test suite re-running on - every - single - save.

I still wonder how much time a very big test suite would take (the biggest app we're working on has more than 7500 tests), but given that Vitest can run tests in parallel, I hope it will scale reasonably well.

Anyway, we won't be able to rewrite all these tests any time soon.

For new projects, Vitest browser mode would be my preferred choice. What would be yours?