~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
- Déclare
"tsConfig": "tsconfig.spec.json"sur ta cibletestdansangular.json. Les migrations voient alors tes specs et refusent de migrer plutôt que de casser ta CI. À défaut, lancenpx tsc -p tsconfig.spec.json --noEmitaprès chacune. - Relis ce que
router-testing-module-migrationa écrit. Elle produit unRouterModulenu dans deux cas,RouterTestingModuleseul etwithRoutes([])- et là, tes tests perdentActivatedRoute. - Utilise
--insert-todossursignal-input,signal-queriesetsignals. Coût nul, et tu récupères la liste de ce qui reste à faire, annotée dans le code. - Relis chaque
trackproduit parcontrol-flow. C'est une décision par défaut, pas une décision vérifiée. - Repasse ta route par défaut en
component:aprèsroute-lazy-loading, et ne te fie pas au compteur de routes converties. - Relance un build après
ngstyle-to-style. Dès la première conversion, elle retireNgStyledu composant et sonimportdu fichier entier - même s'il reste des[ngStyle]qu'elle n'a pas convertis. - Ne compte pas sur
cleanup-unused-importspour finir le travail des migrations de template. Ce qu'elle retire, c'est l'import dont personne ne s'est jamais servi. - Va au bout des trois
--modedestandalone. S'arrêter àconvert-to-standalonelaisse derrière toi lesNgModulequeprune-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.