Marmicode
Blog Post
Younes Jaaidi

Versatile Angular Style brings Modern Dev Tools to Angular

by Younes Jaaidi โ€ข 
Dec 7, 2022 โ€ข 12 minutes
Versatile Angular Style brings Modern Dev Tools to Angular

TL;DR: As surprising as this might sound, Angular is one of the easiest frameworks to integrate with modern dev tools out of the box (e.g. Vite, Vitest, SWC, Playwright Component Testing)... but only if you follow the Versatile Angular Style rules:

  • ๐ŸŽ’ use standalone components only.
  • ๐Ÿ’‰ use the inject() function only.
  • ๐Ÿ—บ use inline template only.
  • ๐ŸŽจ use inline styles only.
  • ๐ŸŽจ do not use Sass or Tailwind features that need preprocessing in the inline styles.

Following these rules will instantly open the gates of tools like Vite, Vitest, SWC, Playwright Component Testing... and maybe Turbopack.

๐Ÿ‡ Dev Tools Evolve & Proliferate

While there are chances that some of you might not adhere to the Versatile Angular Style, I am sure that we will agree on one thing: the speed at which build & dev tools evolve and proliferate is as exciting as it is overwhelming.

Let's start with build tools, skip the Grunt & Gulp era and focus on the post-Webpack period.

โฉ esbuild & โšก๏ธ Vite

The first game changer was esbuild. In fact, why not use Go to transform code? Then why not mix esbuild and modern browser features to instantly start a dev server and serve files on-demand? Well, that's when Vite joins the party.

It became so popular that some very useful tools only support Vite right now (e.g. Playwright Component Testing) and others are simply built on top of it (e.g. Vitest).

๐Ÿค” ... but what about Angular support?

Since Angular 14, it is possible to opt-in to a new experimental esbuild builder.

{
  // Nx
  "executor": "@angular-devkit/build-angular:browser-esbuild",
  // Angular CLI
  "builder": "@angular-devkit/build-angular:browser-esbuild",
}

This might make it easier to add Angular support to build & dev tools like Vitest, Vitest, Vite-based Cypress Component Testing, Playwright Component Testing etc...

๐Ÿฆ€ mind the Rustaceans!

In the meantime, on some Rusty beach, Rust-based builders are starting to proliferate. It first started with SWC, and now, here comes Turbopack.

How can all dev tools catch up?

...and what about less popular tools like WMR?

...finally, while the last wave brought Rustaceans, how can we predict what the next wave is holding for us?

๐Ÿ‘ด๐Ÿผ Wait or Act ๐Ÿ’ช

There are different ways of handling this situation. We could just wait for official or community-driven initiatives to add Angular support on all these kinds of tools (like Brandon's pretty exciting Vite plugin), or we could try and figure out a way to make Angular work everywhere.

Faster Build Twitter Poll

As a starting point, I decided to run a little poll to compare the importance of development vs. production build speed. While of course, the importance of deployment (production build) is not neglectable and shouldn't be ignored when considering Developer eXperience, the local feedback loop (development build) seems to be more important.

This makes sense of course as the expected speed of the feedback is generally proportional to the frequency of the task.

Let's focus on development builds & dev tools then.

๐Ÿช„ JIT is the Golden Ticket

Thanks to JIT (Just-in-Time) mode, Angular is one of the most versatile frameworks around as it can be used in any context and with almost no code transformation (compared to Vue's SFC or JSX, even though the latter is supported in all the tools listed above). Cf. Plain JavaScript Angular Demo

As a matter of fact, the day the following ECMAScript propositions get adopted:

what would prevent us from running Angular code using JIT, without any transformer while keeping our code AOT compatible?

๐Ÿ“ Use inline template & styles

The first thing that all builders have to handle is resolving templateUrl and styleUrls as they can't be resolved at runtime. We can save some hassle for all tools by simply using inline template and styles.

@Component({ ... template: `<h1>Hello!</h1>`, styles: [ ` :host { background: purple; color: white; } ` ] }) class GreetingsComponent {}

Also, if you've already been seduced by SFAM = (SFC + Lars' SCAM), now that standalone components are here (and stable since Angular 15), it just makes even more sense.

๐Ÿ›‘ Avoid Style Preprocessors

As the styles will be discovered at runtime, we'll have to avoid any style preprocessors in component styles. This means no Sass, nor Tailwind directives (e.g. no @apply) at the component level.

That said, you can still use Sass & Tailwind directives at the global level as it is easier to set up with different build tools. For instance, we could use different entry points (i.e. multiple index.html or main.ts).

๐Ÿ’‰ Use inject() instead of Traditional DI

The last major issue is supporting Angular's traditional Dependency Injection:

@Component(...) class GreetingsComponent { constructor(greetings: GreetingsService) {} }

At runtime, Angular needs to access the GreetingsService symbol as it is the key to allowing the resolution of the dependency. For this to work, Angular requires TypeScript to emit some metadata (design:paramtypes to be more specific).

This metadata is only available when using the TypeScript compiler with the emitDecoratorMetadata option enabled.

The problem with this requirement is that emitDecoratorMetadata is not supported and will probably never be supported on some builders like esbuild for well-justified performance reasons (if you want to dig deeper, cf. esbuild docs & TS parser's source code).

Yes, we could implement an esbuild plugin that compiles TypeScript and emits decorator metadata but we would lose the whole performance benefit of using esbuild... and the lightness of having Angular working with esbuild out of the box.

Prefer inject() to @Inject()

The first workaround we can think of is using the @Inject() decorator.

@Component(...) class GreetingsComponent { constructor(@Inject(GreetingsService) greetings: GreetingsService) {} }

In fact, using @Inject(), Angular doesn't need to emit any decorator metadata... but this approach has two major drawbacks:

  • ๐Ÿž @Inject() is not type-safe, and makes DI error-prone,
  • ๐Ÿ”ฎ Parameter Decorators are not future-proof as they are not part of the ECMAScript Decorator Proposal.

The best alternative is the inject() function.

Since Angular 14, we can use the new inject() function.

๐ŸŒถ While some might argue that inject() is not an implementation of Dependency Injection but Dependency Inversion through a Service Locator... I'll say Potayto-Potahto, and that one should use TestBed (and similar tools) to override dependencies instead of manually passing dependencies in the right order when testing... or figure out a tradeoff like described here angular/issues#47606.

The great things about the inject() function is that:

  • it doesn't need any TypeScript metadata,
  • it is type-safe and provides powerful type-inference (cf. example below),
  • and it doesn't require us to implement constructors.
const GREETINGS_TOKEN = new InjectionToken<GreetingsService>('GREETINGS'); @Component(...) class GreetingsComponent { greetings = inject(GreetingsService); // GreetingsService greetingsWithToken = inject(GREETINGS_TOKEN); // GreetingsService greetingsOptional = inject(GreetingsService, {optional: true}); // GreetingsService | null }

๐Ÿค” Maybe @Inject() should be deprecated in favor of the inject() function... ๐Ÿ’ฃ

๐ŸŽ’ Use Standalone Components

While this is not mandatory for most tools, Standalone Components turn out to be easier to integrate with modern dev tools as we only need a single entry point to pinpoint a component (while we need both a reference to the module and the component when using modules).

For instance, Playwright Component Testing's mount() function experimental support for React, Solid, Svelte, Vue is expecting one parameter that can be either a component type symbol or JSX. To make it work with Angular modules, this would require a couple of hacks but with Standalone Components, this works almost out of the box without changing the way Playwright Component Testing works.

In other words, Standalone Components are more aligned with how other frameworks and libraries are integrated with modern dev tools.

๐Ÿคน Demo

A few months ago, my friend Edouard and I started experimenting Vite & Vitest support for Angular... then I think that we kind of got distracted by Angular 14's release ๐Ÿ˜…...

... until the first tweet about Versatile Angular, so I finally decided to take some time and set up a demo combining everything:

  • ๐Ÿ…ฐ๏ธ Angular CLI build
  • โšก๏ธ Vite
  • โœ… Vitest
  • ๐Ÿฆ€ Jest + SWC
  • ๐ŸŽญ ... and why not give a try to Playwright Component Testing?

Wanna try first? Check it out here: Versatile Angular Demo

โšก๏ธ Vite

Vite worked out of the box with Versatile Angular Style with nothing more than the configuration below. The only required configuration was the custom style Conditional Export that allows us to import prebuilt Angular Material styles (Cf. Angular Package Format managing assets in a library).

// vite.config.ts import { defineConfig } from 'vite'; export default defineConfig({ root: 'src', resolve: { conditions: ['style'], }, });

โœ… Vitest

Vitest has an issue with Zone.js... or should I say, Zone.js has an issue with Node.js & ES Modules dynamic imports (Cf. angular/issues#48359).

zoneless & fine

By the way, do we really need zones in tests even if our app is not totally zoneless yet? Well, in most cases, we don't:

  • change detection should only concern components & directives, so we don't have to worry about zones when testing services for example;
  • we will generally prefer testing components & directives with Cypress Component Testing or Playwright Component Testing;
  • if we are looking for performance and testing components and directives using a browserless option (Jest or Vitest), then we will probably prefer shallow tests (generally 5 to 10x faster) so we will have to trigger change detection manually in the tests, thus no need for zone.js;
  • ... or we might be using a wrapper like @testing-library/angular that will trigger change detection when necessary.

Sadly, there is no simple way of using TestBed without zone.js angular/issues#48198 but this should hopefully be fixed soon. Meanwhile, the workaround is using a NoopZone as a no-op alternative to Zone (not to be confused with NgNoopZone which is a no-op alternative to NgZone).

Excluding the NoopZone, it takes around 20 lines of configuration and no extra plugin or transformer to run Angular tests using Vitest.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['src/test-setup.ts']
  },
});
// test-setup.ts
import './app/testing/noop-zone';

import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
import { getTestBed } from '@angular/core/testing';

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);

๐Ÿฆ€ Jest + SWC

Thanks to @swc/jest, Jest + SWC works out of the box with this configuration:

// jest.config.ts
module.exports = {
  setupFilesAfterEnv: ["<rootDir>/src/test-setup.ts"],
  testEnvironment: "jsdom",
  transform: {
    "^.+\\.m?(t|j)sx?$": ["@swc/jest"],
  },
  /* We could get rid of this if we switch to ESM. */
  transformIgnorePatterns: ["/node_modules/(?!(@angular/)"],
};

๐ŸŽญ Playwright Component Testing

Vite, Vitest, and Jest + SWC integration went so fine that it gave me the courage to go a bit further.

๐Ÿค” What about adding Angular support to some dev tool where all other frameworks/libs support is still experimental too?

Yes! Playwright's experimental Component Testing!

The way Playwright Component Testing works is a bit different than how Jest, Vitest, or Cypress Component Testing work because the test's code is running in Node.js while the component is running in the browser. This is the reason why we still need a specific integration for Angular and that is how @jscutlery/playwright-ct-angular was born. We'll be sharing more about this in a future blog post, so stay tuned through our Newsletter to get notified when it is ready.

You can try Playwright Component Testing support for Angular right now!

Note that using Versatile Angular Style, Angular is the only framework/library, supported by Playwright Component Testing, that doesn't need any plugin, or transformer.

๐Ÿข Progressive Migration to Versatile Angular Style

Don't panic! You don't have to go through a big bang migration. You can progressively start migrating to Versatile Angular Style whenever you bring some changes to a component for example (scouts rule of "always leaving the campground cleaner than you found it")... or maybe someone could help the community with a Betterer test? ๐Ÿ˜‰

Also, if you are using Nx, you can easily provide different build & test configurations for your apps and libraries so you can migrate and focus on the most crucial parts first.

If for some reason, you are temporarily stuck using an Angular version older than 14, then you can use ษตษตdirectiveInject() as a replacement for inject() meanwhile you migrate to a more recent version.


๐Ÿ”— Links

๐Ÿ“ฐ Subscribe to Newsletter

๐Ÿ’ป Showcase Repository

๐Ÿ’ฌ Discuss this on github