~10 min de lecture

Les 15 migrations Angular que tu n'as jamais lancées (et ce qu'elles ratent)

TL;DR

Les 15 migrations opt-in d'Angular vivent dans @angular/core/schematics/collection.json, de standalone (15.2) à service (22.1). Sans tsConfig sur ta cible test, 14 d'entre elles ne voient pas tes specs : ce qu'il fallait y changer reste à ta charge, et le compteur de succès ne compte que ce qu'elles ont ouvert. Cinq angles morts vérifiés sur un workspace 22.1 neuf, dont deux qui laissent une CI rouge - plus le piège ngstyle-to-style, qui casse le build.

Tu lances ng generate @angular/core:signals sur ton app. La console affiche Successfully migrated to signal inputs 🎉. Ton ng build passe. Tu commites, tu pousses.

Trois minutes plus tard, la CI est rouge sur une erreur que tu n'as jamais vue : TS2540: Cannot assign to 'amount' because it is a read-only property. Dans un fichier .spec.ts que la migration n'a jamais ouvert.

Ce n'est pas un bug de la migration : c'est ton angular.json qui décide. 14 des 15 analysent les tsConfig déclarés par les cibles de ton workspace, et la cible test d'un workspace v21 ou v22 neuf n'en déclare aucun :

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

Il ne reste donc que tsconfig.app.json, qui exclut src/**/*.spec.ts par défaut. Les migrations migrent ton code applicatif, comptent leurs succès dessus, et te félicitent. Tes specs, elles, référencent encore l'API d'avant.

Les 15 migrations et leur version d'apparition

Ces migrations opt-in vivent dans collection.json : il faut les appeler à la main. Le second jeu, celui que ng update exécute pendant une montée de version, vit dans migrations.json. En 22.1, les deux listes sont disjointes - et ça n'a rien d'une garantie.

De la 20.0.1 à la 21.2, control-flow figurait dans les deux fichiers, et en 21.x son entrée de migrations.json ne portait pas le drapeau optional : ng update @angular/core@21 la lançait sans rien demander. Si tes templates sont passés en @if sans que tu aies rien lancé, c'est là que ça s'est joué.

Le tableau vient du collection.json de chaque version publiée sur npm, de la 15.2.0 - la première à en embarquer un - à la 22.1.3. La plupart des migrations y portent un nom canonique long (signal-input-migration) doublé d'un alias court ; la deuxième colonne donne ce que tu tapes.

Migration ng generate @angular/core: Depuis
Convertir en standalone standalone 15.2
Control flow @if / @for / @switch control-flow 17.0
Injection par constructeur vers inject() inject 18.2
Routes en loadComponent route-lazy-loading 18.2
@Output vers output() outputs 19.0
@Input vers input() signal-input 19.0
@ViewChild / @ContentChild vers signal queries signal-queries 19.0
Les trois précédentes d'un coup signals 19.0
Retirer les imports inutilisés cleanup-unused-imports 19.1
Balises auto-fermantes self-closing-tag 19.2
CommonModule vers imports individuels common-to-standalone 21.0
[ngClass] vers [class] ngclass-to-class 21.0
[ngStyle] vers [style] ngstyle-to-style 21.0
RouterTestingModule vers RouterModule router-testing-module-migration 21.0
@Injectable vers @Service service 22.1

Trois migrations n'ont pas d'alias : signals, cleanup-unused-imports et router-testing-module-migration, dont le nom seul te fait taper 31 caractères. service ne convertit que les services qui rentrent dans le moule du nouveau décorateur : la frontière est dans @Service : le décorateur qui remplace @Injectable.

Angle mort 1 : tes specs ne sont pas dans le périmètre

Le plus coûteux du lot. Un composant trivial :

@Component({
  selector: 'app-price-tag',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ amount }} EUR</span>`,
})
export class PriceTag {
  @Input() amount = 0;
}

Et sa spec :

const fixture = TestBed.createComponent(PriceTag);
fixture.componentInstance.amount = 42;
fixture.detectChanges();

Tu lances la migration :

ng generate @angular/core:signal-input --path=src/app

Le composant est parfaitement migré, template compris :

export class PriceTag {
  readonly amount = input(0);
}

La première ligne de la sortie dit tout : Preparing analysis for: tsconfig.app.json.... Or ce fichier exclut src/**/*.spec.ts. La spec n'a jamais existé pour la migration. Le ng build passe, le typecheck des tests tombe :

src/app/legacy/price-tag.spec.ts(7,31): error TS2540: Cannot assign to 'amount' because it is a read-only property.

Le correctif n'est pas un renommage : l'affectation de propriété devient un setInput().

fixture.componentRef.setInput('amount', 42);

La version avec inputBinding() est détaillée dans Tester ses composants Angular avec inputBinding / outputBinding.

Le réflexe à prendre : supprimer l'angle mort à la source, en déclarant le tsConfig de tes tests dans angular.json :

"test": {
  "builder": "@angular/build:unit-test",
  "options": { "tsConfig": "tsconfig.spec.json" }
}

La migration voit alors tes specs, et tout change : sur le composant ci-dessus, elle annonce Migrated 0/1 inputs et ne touche à rien. Elle a vu que la spec écrivait dans l'input, et a refusé plutôt que de casser ta CI.

Une exception, et elle est heureuse : control-flow ne passe par aucun tsconfig, elle balaie les fichiers par chemin. Un composant à template inline déclaré dans un .spec.ts est migré quelle que soit ta configuration. Les quatre autres migrations de template - ngclass-to-class, ngstyle-to-style, self-closing-tag, common-to-standalone - ne le voient pas.

Angle mort 2 : router-testing-module-migration peut casser les tests qu'elle migre

Le mécanisme de l'angle mort 1 produit ici un cas comique : cette migration a un seul travail, remplacer RouterTestingModule dans tes tests - précisément les fichiers que son périmètre exclut. Tu la lances en l'état :

Successfully migrated RouterTestingModule to RouterModule 🎉
  -> Migrated 0 RouterTestingModule usages in 0 test files.
Nothing to be done.

Zéro fichier, et un emoji de victoire quand même. Déclare le tsConfig de ta cible test : elle trouve enfin de quoi travailler, et c'est là que le vrai problème commence.

Ce qu'elle écrit dépend de ce que tu lui donnes. Cinq cas, mesurés sur une suite verte :

Avant Après Test
RouterTestingModule RouterModule rouge
RouterTestingModule.withRoutes([]) RouterModule rouge
RouterTestingModule.withRoutes([], { useHash: true }) RouterModule.forRoot([], { useHash: true }) vert
RouterTestingModule.withRoutes(ROUTES), ROUTES vide RouterModule.forRoot(ROUTES) vert
RouterTestingModule.withRoutes([{ path: 'a', ... }]) RouterModule.forRoot([{ path: 'a', ... }]) vert

La bascule est syntaxique, pas sémantique. Elle écrit un forRoot() dès que tu lui passes des routes, sauf si tu les lui passes sous la forme d'un [] écrit sur place, sans second argument. Sans withRoutes du tout, il n'y a rien à passer : RouterModule nu. Et une variable de routes obtient son forRoot() même vide, parce que la migration ne lit pas son contenu.

Dans les deux cas rouges, ce RouterModule nu ne fournit aucun provider de routeur, et le test échoue à l'exécution :

ɵNotFound: NG0201: No provider found for `ActivatedRoute`. Source: DynamicTestModule.

Cette suite de cinq cas était verte. Après migration : 2 failed | 3 passed. Ce n'est donc pas une migration qu'on arme et qu'on oublie : partout où tu écrivais RouterTestingModule seul ou withRoutes([]), il te reste du travail. Sa description dans collection.json annonce un remplacement par provideRouter(), un nom absent de son code. C'est pourtant exactement ce que tu dois écrire, à la place du RouterModule nu : provideRouter([]).

Angle mort 3 : les inputs que signal-input refuse

Sur un composant à sept inputs, la migration en convertit cinq sans motiver ses refus. Relance-la avec --insert-todos et elle écrit ses raisons :

// TODO: Skipped for migration because:
//  Your application code writes to the input. This prevents migration.
@Input() title = '';

// TODO: Skipped for migration because:
//  Accessor inputs cannot be migrated as they are too complex.
@Input()
set soldOut(value: boolean) { /* ... */ }

Les deux refus sont légitimes. Un @Input que ton composant réassigne n'a pas d'équivalent direct, input() étant en lecture seule : il te faut un linkedSignal, fait pour ce cas, ou un model si la valeur doit remonter au parent. Un input accesseur cache dans son setter une logique arbitraire, que la migration ne sait pas traduire.

--best-effort-mode passe de 5 sur 7 à 6 sur 7, et cale toujours sur l'accesseur. Mais l'input gagné est justement celui que le mode normal refusait parce que le code y écrit. La migration le convertit quand même, réécrit l'affectation en this.title = this.title().trim(), et ton build tombe sur le même TS2540 qu'en ouverture - cette fois dans ton code applicatif, que lui, il compile. Ne l'active que sur un périmètre que tu relis : « best effort » veut dire exactement ça, du code qu'elle ne garantit pas.

Angle mort 4 : control-flow te fabrique un track que tu ne veux pas

control-flow est la plus impressionnante du lot, et gère les cas tordus :

<span *ngIf="badge as b; else noBadge">{{ b }}</span>
<ng-template #noBadge><em>aucun badge</em></ng-template>

devient

@if (badge; as b) {
  <span>{{ b }}</span>
} @else {
  <em>aucun badge</em>
}

Le piège est ailleurs. Sur un *ngFor sans trackBy, elle doit inventer une clé de tracking, et elle choisit la seule qu'elle peut deviner :

<span *ngFor="let item of items">{{ item }}</span>

devient

@for (item of items; track item) {
  <span>{{ item }}</span>
}

track item est précisément le pattern démonté dans Le track de @for : sur des objets rechargés par HTTP, la référence change et Angular recrée tout le DOM ; sur deux éléments qui produisent la même clé (même chaîne, ou même référence), Angular te sort un NG0955 en dev. Warning best effort : jamais au premier rendu, et pas toujours ensuite. La migration ne connaît pas tes données. Toi si. Chaque track item produit est un endroit à relire.

Côté imports, elle nettoie derrière elle, mais conditionnellement : imports: [NgIf, NgFor] ressort en imports: [], tandis que CommonModule n'est retiré que si plus rien dans le template ne le justifie - un | date qui reste suffit à le conserver.

Angle mort 5 : route-lazy-loading lazy-load aussi ton premier écran

Cette migration réécrit en loadComponent les routes qui pointaient vers un composant standalone importé statiquement. Sur une route par défaut et une route admin :

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home').then((m) => m.Home) },
  { path: 'admin', loadComponent: () => import('./admin').then((m) => m.Admin) },
];

La route admin est un gain net. La route '' est une régression : c'est le premier écran, et le chargement différé y ajoute un aller-retour réseau sur le chemin critique. C'est le raisonnement de « Le lazy loading Angular ça sert à rien ».

Elle a une limite : un composant déclaré dans le fichier de routes reste en component: sans apparaître dans le compteur skipped routes. Le rapport Number of updated routes ne suffit donc pas à conclure que toutes tes routes sont passées en lazy.

À sa décharge, elle affiche IMPORTANT! Please verify manually that your application builds and behaves as expected. Prends-la au mot : repasse ta route par défaut en import statique.

Le piège ngstyle-to-style, que l'ordre ne désamorce qu'à moitié

Les migrations de template s'enchaînent sans surprise : l'ordre ne change pas le HTML produit. Il change ce qui reste dans tes imports, et c'est là que tout se joue.

ng generate @angular/core:control-flow --path=src/app
ng generate @angular/core:ngclass-to-class --path=src/app
ng generate @angular/core:ngstyle-to-style --path=src/app
ng generate @angular/core:self-closing-tag --path=src/app
ng generate @angular/core:common-to-standalone --path=src/app

Une seule est dangereuse. ngstyle-to-style ne convertit que les [ngStyle] qui reçoivent un objet littéral. Dès qu'elle en a converti un seul, elle retire NgStyle du composant - y compris s'il reste dans le template des [ngStyle] qu'elle n'a pas su traiter :

  -> Migrated 1 ngStyle to style bindings in 1 files.

Le ng build suivant, lui, ne passe plus :

✘ [ERROR] NG8002: Can't bind to 'ngStyle' since it isn't a known property of 'li'.

Elle va plus loin que le tableau imports : l'instruction import { NgStyle } disparaît pour tout le fichier. Un second composant du même fichier qui en avait besoin tombe sur NG1010 et TS2304.

Contre-intuitif : CommonModule te protège. Elle ne le retire que si tous les [ngStyle] ont été convertis ; s'il en reste un, CommonModule survit et ton build passe. Le danger ne frappe que les composants qui importent NgStyle nommément - ceux que common-to-standalone vient de produire.

D'où l'ordre ci-dessus, common-to-standalone en dernier. Mais ne compte pas dessus : une codebase qui a déjà quitté CommonModule n'a plus ce filet.

--best-effort-mode élargit le périmètre aux expressions qui ne sont pas des objets littéraux : [ngStyle]="computed" devient [style]="computed". Deux formes lui résistent, exactement comme en mode normal, le raccourci ({ color }) et le spread ({ ...base }) : le binding reste en [ngStyle], et NgStyle disparaît quand même.

Il n'existe pas de garde-fou automatique : relance un build après cette migration.

Deux migrations qu'on lance mal

cleanup-unused-imports, qu'on ajoute en fin de chaîne en espérant qu'elle ramasse les miettes, ne travaille pas sur le même plan : les migrations de template nettoient déjà derrière elles, tu l'as vu deux fois. Sur un composant qui ne traînait rien d'autre, cleanup-unused-imports répond Schematic could not find unused imports in the project. Son vrai gibier, c'est le composant ou le pipe dont personne ne s'est jamais servi. Celui-là, elle le retire, chaîne ou pas. Elle n'accepte aucune option, pas même --path.

standalone, elle, n'est pas une migration mais trois, à enchaîner via --mode : convert-to-standalone, prune-ng-modules, standalone-bootstrap. La plupart des gens lancent la première et s'arrêtent là, laissant dans la codebase des NgModule devenus inutiles.

Le récap actionnable

  1. Déclare "tsConfig": "tsconfig.spec.json" sur ta cible test dans angular.json. Les migrations voient alors tes specs et refusent de migrer plutôt que de casser ta CI. À défaut, lance npx tsc -p tsconfig.spec.json --noEmit après chacune.
  2. Relis ce que router-testing-module-migration a écrit. Elle produit un RouterModule nu dans deux cas, RouterTestingModule seul et withRoutes([]) - et là, tes tests perdent ActivatedRoute.
  3. Utilise --insert-todos sur signal-input, signal-queries et signals. Coût nul, et tu récupères la liste de ce qui reste à faire, annotée dans le code.
  4. Relis chaque track produit par control-flow. C'est une décision par défaut, pas une décision vérifiée.
  5. Repasse ta route par défaut en component: après route-lazy-loading, et ne te fie pas au compteur de routes converties.
  6. Relance un build après ngstyle-to-style. Dès la première conversion, elle retire NgStyle du composant et son import du fichier entier - même s'il reste des [ngStyle] qu'elle n'a pas convertis.
  7. Ne compte pas sur cleanup-unused-imports pour finir le travail des migrations de template. Ce qu'elle retire, c'est l'import dont personne ne s'est jamais servi.
  8. Va au bout des trois --mode de standalone. S'arrêter à convert-to-standalone laisse derrière toi les NgModule que prune-ng-modules était là pour supprimer.

Aucune n'est irréversible : elles produisent un diff relisable, qu'un git checkout . annule. Ne les crois pas infaillibles : tu viens de voir ngstyle-to-style retirer une directive encore utilisée et casser le build en annonçant son succès. Leur diff se relit en dix minutes. Leur emoji de victoire, lui, te fait fermer l'onglet en croyant que le travail est fini.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.