~9 min de lecture
linkedSignal({ set }) : Angular 22.1 branche enfin l'écriture sur la source
Tu as un objet order dans un store. Tu veux exposer sa méthode de livraison dans un <select>.
Réflexe signals correct : linkedSignal(), parce qu'il te faut une valeur dérivée de order mais
modifiable par l'utilisateur.
protected readonly shippingMethod = linkedSignal(() => this.order().shippingMethod);
L'utilisateur choisit "Aérien". Le <select> affiche "Aérien". Tu passes à l'écran suivant, tu
reviens, et la commande est toujours en livraison terrestre. Ton écriture n'est jamais arrivée
jusqu'à order.
Ce n'est pas un bug. C'est le contrat de linkedSignal() depuis Angular 19 : l'écriture est
locale, elle vit jusqu'au prochain recalcul et disparaît avec lui. Angular 22.1 ajoute l'option
set, qui casse enfin cette asymétrie.
TL;DR
| Le cas | Ce qui se passe |
|---|---|
linkedSignal(() => src()), sans option |
Tu écris, la valeur saute au prochain recalcul |
linkedSignal(() => src(), { set }) |
Ton écriture part où le callback set l'envoie |
Un set qui n'écrit nulle part |
L'écriture disparaît, sans erreur ni warning |
rawSet(value) dans le callback (la porte de sortie) |
Écriture locale, comme avant la 22.1 |
Ton set ne défait pas ce que ta fonction de calcul a fait |
Tu écris 5, tu relis 6 |
Le comportement historique, en un test
Voici ce que fait un linkedSignal() sans option. Ce test passe au vert sur Angular 22.1 comme sur
Angular 19 :
it('garde la valeur ecrite jusqu au prochain recalcul, puis la perd', () => {
const orderTotal = signal(100);
const discounted = linkedSignal(() => orderTotal() * 0.9);
expect(discounted()).toBe(90);
discounted.set(50);
expect(discounted()).toBe(50);
expect(orderTotal()).toBe(100); // la source n a pas bouge
orderTotal.set(200);
expect(discounted()).toBe(180); // l ecriture de 50 est perdue
});
Deux choses à retenir de ces quatre assertions. D'abord, discounted.set(50) ne touche jamais
orderTotal : la source ignore l'écriture. Ensuite, la valeur 50 survit jusqu'au moment où la
source bouge, et elle est écrasée sans avertissement.
C'est le comportement voulu pour le cas d'usage d'origine : une sélection qui se réinitialise quand
le filtre change, une pagination qui revient à la page 1, deux patterns détaillés dans
le guide complet de linkedSignal(). Mais dès que tu
veux un aller-retour, ce contrat te trahit en silence.
L'option set : la signature
Angular 22.1 ajoute une propriété aux options de linkedSignal(), à côté de equal et debugName.
Voici la déclaration exacte, telle qu'elle apparaît dans les types publiés du paquet :
set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;
D, c'est le type de la valeur dérivée, celle que renvoie ta fonction de calcul. NoInfer empêche
TypeScript de déduire ce type depuis l'argument de set : c'est le calcul qui fixe le type, pas
l'écriture.
Quand tu fournis ce callback, Angular ne fait plus rien lui-même au moment d'un set() : il te passe
la valeur demandée et te laisse décider où elle va. Une conversion Celsius / Fahrenheit dans les deux
sens tient en trois lignes :
const tempC = signal(0);
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
set: (valF) => tempC.set(((valF - 32) * 5) / 9),
});
tempF.set(212);
// tempC() === 100, tempF() === 212
tempF.set(212) écrit 100 dans tempC, le calcul se relance et redonne 212. Les deux signaux
restent cohérents sans un seul effect().
Les quatre règles de set
Le contrat tient en quatre points, dont trois contre-intuitifs. Les assertions qui suivent tournent toutes au vert sur Angular 22.1.0.
1. Ton callback remplace l'écriture, il ne s'y ajoute pas
C'est le point le plus important, et le plus facile à rater : si ton callback n'écrit nulle part, l'écriture est avalée en silence. Pas d'erreur, pas de warning, la valeur ne bouge pas.
const source = signal(1);
const mirror = linkedSignal(() => source(), { set: () => {} });
mirror.set(99);
expect(mirror()).toBe(1); // pas 99
expect(source()).toBe(1);
Une garde mal placée (if (!isValid) return;) et tu as un champ de formulaire qui refuse les saisies
sans le dire. Si ton set peut refuser une valeur, rends le refus visible : lève une exception,
logue, ou écris dans un signal d'erreur.
2. rawSet te rend l'ancien comportement
Le deuxième argument du callback écrit dans le linkedSignal lui-même, exactement comme avant la
22.1. Donc valeur locale, écrasée au prochain recalcul.
const source = signal(10);
const mirror = linkedSignal(() => source(), {
set: (value, rawSet) => rawSet(value),
});
mirror.set(5);
expect(mirror()).toBe(5);
expect(source()).toBe(10); // la source reste intacte
source.set(11);
expect(mirror()).toBe(11); // le recalcul reprend la main
L'intérêt est de pouvoir router conditionnellement. Un champ qui ne remonte au modèle qu'une fois la valeur valide, et garde la frappe en cours en local le reste du temps, tient en un callback :
const committed = signal('');
const draft = linkedSignal(() => committed(), {
set: (value, rawSet) => {
if (value.length >= 3) {
committed.set(value);
} else {
rawSet(value);
}
},
});
draft.set('ab');
expect(draft()).toBe('ab');
expect(committed()).toBe(''); // trop court, reste local
draft.set('abc');
expect(committed()).toBe('abc'); // valide, remonte a la source
Cette logique demandait auparavant deux signaux et un effect() de synchronisation. Ici, la règle de
propagation vit au même endroit que la dérivation.
3. update() passe aussi par set
Pas de chemin dérobé. update() lit la valeur courante, applique ta fonction, et passe le résultat à
ton callback.
const count = signal(1);
const doubled = linkedSignal(() => count() * 2, {
set: (value) => count.set(value / 2),
});
doubled.update((current) => current + 2);
expect(count()).toBe(2);
expect(doubled()).toBe(4);
4. set marche aussi sur la forme longue
L'option est acceptée sur les deux formes : la forme courte, et la forme longue
{ source, computation }. Le callback y reçoit les deux mêmes arguments.
const user = signal({ id: 1, displayName: 'Ada' });
const displayName = linkedSignal({
source: user,
computation: (source) => source.displayName,
set: (name) => user.update((current) => ({ ...current, displayName: name })),
});
displayName.set('Grace');
expect(user().displayName).toBe('Grace');
expect(displayName()).toBe('Grace');
Le piège : ton set doit être l'inverse exact de ta fonction de calcul
Rien ne vérifie que ton set défait ce que ta fonction de calcul a fait. Si les deux ne se répondent
pas exactement, tu écris une valeur et tu en relis une autre.
const page = signal(0); // index 0-based
const displayedPage = linkedSignal(() => page() + 1, { // affichage 1-based
set: (value) => page.set(value), // oubli du -1
});
displayedPage.set(5);
expect(page()).toBe(5);
expect(displayedPage()).toBe(6); // pas 5
Tu demandes la page 5, tu obtiens la 6. Sur un décalage d'index, c'est visible tout de suite ; sur
un arrondi monétaire, ça passe en production. La règle est mécanique : pour toute valeur v,
set(v) suivi d'une lecture doit redonner v. Cet invariant s'écrit en une assertion, alors
écris-la.
En composant : l'enfant édite une propriété, le parent reçoit l'objet
Le cas réel qui motive tout ça : un composant enfant qui reçoit un objet via model() et n'expose
qu'une propriété à l'édition, sans muter l'objet parent.
import { ChangeDetectionStrategy, Component, linkedSignal, model } from '@angular/core';
export type Order = { id: number; shippingMethod: 'Ground' | 'Air' };
@Component({
selector: 'app-shipping-picker',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<select [value]="shippingMethod()" (change)="shippingMethod.set(readMethod($event))">
<option value="Ground">Terrestre</option>
<option value="Air">Aérien</option>
</select>
<p>Livraison : {{ shippingMethod() }}</p>
`,
})
export class ShippingPicker {
readonly order = model.required<Order>();
protected readonly shippingMethod = linkedSignal(() => this.order().shippingMethod, {
set: (method) => this.order.update((current) => ({ ...current, shippingMethod: method })),
});
protected readMethod(event: Event): Order['shippingMethod'] {
return (event.target as HTMLSelectElement).value as Order['shippingMethod'];
}
}
Le set remonte jusqu'au parent via model(), en recréant l'objet plutôt qu'en le mutant. Les deux
sens fonctionnent :
it('remonte la modification du select jusqu au model order', async () => {
const fixture = TestBed.createComponent(ShippingPicker);
fixture.componentRef.setInput('order', { id: 42, shippingMethod: 'Ground' } satisfies Order);
await fixture.whenStable();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
select.value = 'Air';
select.dispatchEvent(new Event('change'));
await fixture.whenStable();
expect(fixture.componentInstance.order()).toEqual({ id: 42, shippingMethod: 'Air' });
});
Avant la 22.1, ce composant demandait soit un output() et une remontée manuelle, soit un effect()
qui surveille le signal local pour repousser vers le parent. Ce second montage est précisément
l'anti-pattern que linkedSignal() était censé supprimer.
Quand ne pas utiliser set
L'option est neuve, donc elle va être sur-utilisée. Trois cas où elle n'est pas la bonne réponse.
Ton calcul n'est pas inversible. Si ta dérivation agrège, filtre ou réduit (items().length,
une somme, un find()), il n'existe pas de valeur source unique à réécrire : un set ne peut être
qu'une devinette. Reste sur computed().
Tu exposes toute la valeur. Si l'enfant édite exactement la donnée que le parent lui passe,
model() seul suffit. linkedSignal({ set }) ne se justifie que quand la valeur exposée est une
projection de la source : une propriété d'un objet, une conversion d'unité, un format
d'affichage.
La source est un store. L'écriture doit passer par une méthode du store. L'appeler depuis le
callback est très bien ; faire du patchState (NgRx Signal Store) en douce depuis un composant, non.
Le reste d'Angular 22.1
Cache de transfert SSR : deux verrous deviennent ouvrables
HttpTransferCacheOptions gagne includeRequestsWithCredentials et includeNonCacheableRequests.
Contexte : le cache de transfert s'est verrouillé au fil de la série 22.0. Depuis la 22.0.2, il
exclut d'office les requêtes dont le mode credentials vaut include ou same-origin (le
withCredentials classique, lui, était déjà exclu), celles qui portent un Cache-Control: no-store,
no-cache ou private, et celles dont le mode cache vaut no-store ou no-cache ; depuis la
22.0.3, les réponses qui portent un Set-Cookie. Ces exclusions étaient sans recours : l'option
filter ne peut que restreindre la liste, jamais y réintégrer une requête exclue, comme expliqué dans
les 4 erreurs qui font fetcher ton API deux fois.
La 22.1 ajoute l'opt-in qui manquait : la correction côté backend que cet article recommandait reste
la plus sûre, mais elle n'est plus la seule issue.
Ne confonds pas avec includeRequestsWithAuthHeaders, plus ancienne, qui couvre Authorization,
Proxy-Authorization et Cookie : ce n'est pas elle que la 22.1 débloque.
provideClientHydration(
withHttpTransferCacheOptions({
includeRequestsWithCredentials: true,
includeNonCacheableRequests: true,
}),
)
À manier avec prudence : ces réponses sont sérialisées en clair dans le HTML envoyé au
navigateur. Un Cache-Control: private sur un endpoint de profil est ce qui empêche la donnée d'un
utilisateur de finir dans le HTML servi à un autre. N'ouvre ces options que sur des endpoints dont tu
sais ce qu'ils renvoient, et à qui.
Migrer @Injectable vers @Service : le schematic est là
Le décorateur @Service de la 22.0 a maintenant sa migration automatique :
ng generate @angular/core:service-migration
Sur un @Injectable({ providedIn: 'root' }), il produit @Service(). Sur un @Injectable() nu,
@Service({ autoProvided: false }), qui préserve le comportement : le service reste à fournir
explicitement. L'import est nettoyé au passage. La 22.1 n'apporte que l'outil de migration, pas un
remplacement : pour savoir quand @Injectable reste le bon choix, va voir
@Service ou @Injectable.
JSONP est déprécié
HttpClient.jsonp, HttpClientJsonpModule et les classes associées sont dépréciés en 22.1. Le
message de dépréciation donne le motif : JSONP ouvre la porte à des failles XSS. Le retrait est
annoncé sans calendrier : c'est une dette, pas une urgence.
Isoler les variables CSS par application
Utile si tu embarques plusieurs apps Angular dans la même page : leurs variables CSS peuvent enfin
être cloisonnées. provideCssVarNamespacing(), exporté par @angular/platform-browser, les préfixe
avec le namespace que tu passes, ou par défaut avec l'APP_ID de l'application, le token Angular qui
l'identifie (ng si tu n'y as jamais touché).
providers: [provideCssVarNamespacing('eak')]
Une variable --accent déclarée dans un composant est alors rendue --eak_accent, références
comprises. Pour en soustraire une au préfixage, il faut deux tirets après global :
--global--brand ressort en --brand. Attention au piège : --global-brand, avec un seul tiret,
n'est pas une échappatoire, c'est une variable ordinaire qui sera préfixée. Angular prévoit une
erreur de compilation pour cette faute de frappe, mais elle n'est active que dans le monorepo interne
de Google.
Le préfixe --global-- marche aussi côté lecture : il dit de ne pas préfixer ce nom-là. Et c'est là
qu'est le piège d'adoption, parce que styles.css n'est pas préfixé. Ton
:root { --brand: ... } global reste --brand, mais un composant qui écrit var(--brand) cherche
désormais --eak_brand, qui n'existe pas. Aucune erreur, juste un style qui ne s'applique plus. Si tes
tokens de design system vivent dans une feuille globale, chaque var() écrit dans un composant doit
passer en var(--global--brand).
À connaître même sans opt-in : le compilateur réécrit les variables CSS de composant en --%NS%nom
dans le bundle, sauf celles échappées par --global--. %NS% est remplacé au rendu, par une chaîne
vide si tu n'actives rien. Un test qui lit le CSS émis n'y trouvera plus tes noms de variables.
Build : l'optimisation de chunks passe à Rolldown
Côté @angular/build, l'étape qui regroupe les fichiers de sortie bascule sur Rolldown par défaut,
et s'étend aux builds serveur, qui en étaient exclus. Les plugins Babel d'optimisation avancée
s'appuient sur oxc-parser, un parser JavaScript écrit en Rust. Le cache de build persistant est
désormais partagé entre
worktrees git : deux branches ouvertes en parallèle ne
recompilent plus chacune de leur côté. Le changelog ne chiffre aucun de ces gains : si le temps de
build est un sujet chez toi, mesure avant et après.
Récap actionnable
| Ce que tu veux | Ce que tu écris |
|---|---|
| Valeur dérivée en lecture seule | computed() |
| Valeur dérivée, modifiable, reset au changement de source | linkedSignal(() => ...) |
| Valeur dérivée dont l'écriture doit remonter à la source | linkedSignal(..., { set }) |
| Écriture locale conditionnelle | rawSet dans le callback set |
Avant de pousser ton premier set en production :
- Un
setqui n'écrit nulle part avale l'écriture sans rien dire. Si ton callback peut refuser une valeur, rends le refus observable. - Teste l'aller-retour.
set(v)puis lecture doit redonnerv. update()passe parset. Ton callback doit encaisser toutes les valeurs, pas seulement celles d'unset()explicite.
Et si tu es encore en 22.0, la mise à jour est un ng update @angular/core @angular/cli : la 22.1
n'introduit aucun breaking change, seulement des options en plus et deux dépréciations à noter, dont
JSONP.