@dsivd/prestations-ng 19.0.7 → 19.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/ESLINT_PLUGIN.md +131 -17
- package/INTRODUCTION_ANGULAR_SIGNALS.md +503 -0
- package/dsivd-prestations-ng-19.0.8.tgz +0 -0
- package/eslint/component-path.mjs +15 -0
- package/eslint/rules/no-direct-signal-mutation.mjs +370 -142
- package/eslint/rules/no-uninvoked-signal-in-template.mjs +1 -12
- package/eslint/signal-kinds.mjs +57 -0
- package/eslint/signal-names.mjs +43 -49
- package/fesm2022/dsivd-prestations-ng.mjs +487 -487
- package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
- package/package.json +1 -1
- package/src/eslint/component-path.mjs +15 -0
- package/src/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +365 -128
- package/src/eslint/rules/no-direct-signal-mutation.mjs +370 -142
- package/src/eslint/rules/no-uninvoked-signal-in-template.mjs +1 -12
- package/src/eslint/signal-kinds.mjs +57 -0
- package/src/eslint/signal-names.mjs +43 -49
- package/types/dsivd-prestations-ng.d.ts +1 -2
- package/dsivd-prestations-ng-19.0.7.tgz +0 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# Introduction aux Signaux Angular
|
|
2
|
+
|
|
3
|
+
**Objectif** : compréhension solide de `signal()`, `computed()`, `effect()`, `input()`/`model()` et `linkedSignal()`, ainsi que des réflexes pour éviter les pièges les plus fréquents (mutation vs réassignation, sur-tracking dans `effect()`).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Plan de session
|
|
8
|
+
|
|
9
|
+
| # | Bloc |
|
|
10
|
+
| --- | ----------------------------------------------------------- |
|
|
11
|
+
| 1. | Pourquoi les signaux ? |
|
|
12
|
+
| 2. | Les 3 primitives : `signal()`, `computed()`, `effect()` |
|
|
13
|
+
| 3. | `input()` et `model()` : le nouveau contrat des composants |
|
|
14
|
+
| 4. | `linkedSignal()` : l'état local qui se resynchronise |
|
|
15
|
+
| 5. | `untracked()` : maîtriser les dépendances d'un `effect()` |
|
|
16
|
+
| 6. | RxJS interop : signaux **et** RxJS, pas l'un contre l'autre |
|
|
17
|
+
| 7. | Le piège n°1 : mutation vs réassignation |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Pourquoi les signaux ?
|
|
22
|
+
|
|
23
|
+
**Avant** : Angular détecte les changements en réexécutant (potentiellement) **tout l'arbre de composants** à chaque événement asynchrone intercepté par **Zone.js** (clic, timer, XHR...). Efficace historiquement, mais coûteux et peu prévisible à grande échelle.
|
|
24
|
+
|
|
25
|
+
**Avec les signaux** : chaque valeur réactive **déclare explicitement** qui en dépend. Angular ne recalcule/ré-affiche que ce qui a réellement changé. C'est un modèle **fine-grained** et **déclaratif**.
|
|
26
|
+
|
|
27
|
+
> 🎤 on ne "déclenche" plus la détection de changement, on **décrit un flux de données**. Le framework se charge de propager.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. Les 3 primitives de base
|
|
32
|
+
|
|
33
|
+
### `signal()` — une valeur réactive modifiable
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
readonly count = signal(0);
|
|
37
|
+
|
|
38
|
+
increment(): void {
|
|
39
|
+
this.count.update(v => v + 1);
|
|
40
|
+
// ou, pour remplacer complètement la valeur :
|
|
41
|
+
this.count.set(5);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
⚠️ **il n'y a pas de `.mutate()`**. C'était dans la proposition initiale, retiré avant la version stable. On ne modifie jamais la valeur en place — on refait toujours passer par `.set()` / `.update()` avec une nouvelle référence (on y revient en détail au bloc 6).
|
|
46
|
+
|
|
47
|
+
### `computed()` — une valeur dérivée, en lecture seule
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Pure et synchrone, jamais de `.set()` à l'intérieur
|
|
54
|
+
- Paresseux et mémoïsé : ne se recalcule que si une dépendance change
|
|
55
|
+
- Les dépendances sont détectées **automatiquement** — pas besoin de les déclarer
|
|
56
|
+
|
|
57
|
+
### `effect()` — un effet de bord
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
effect(() => {
|
|
61
|
+
localStorage.setItem('theme', this.theme());
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- S'exécute au moins une fois à l'initialisation
|
|
66
|
+
- Se nettoie automatiquement à la destruction du composant
|
|
67
|
+
- **Tout signal lu de façon synchrone dans le corps devient une dépendance** — y compris ceux qu'on ne voulait pas "trigger" (on y revient au bloc 5)
|
|
68
|
+
|
|
69
|
+
**Exemple concret — les trois primitives ensemble :**
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
@Component({
|
|
73
|
+
selector: 'app-counter',
|
|
74
|
+
template: `
|
|
75
|
+
<p>Count : {{ count() }} ({{ isEven() ? 'pair' : 'impair' }})</p>
|
|
76
|
+
<button (click)="increment()">+1</button>
|
|
77
|
+
`,
|
|
78
|
+
})
|
|
79
|
+
export class CounterComponent {
|
|
80
|
+
readonly count = signal(0);
|
|
81
|
+
readonly isEven = computed(() => this.count() % 2 === 0);
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
// se déclenche à l'init, puis à chaque changement de `count`
|
|
85
|
+
effect(() => console.log(`count = ${this.count()}`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
increment(): void {
|
|
89
|
+
this.count.update((v) => v + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Remarque : `isEven` ne se recalcule que quand `count` change (mémoïsation), et le template ne se ré-affiche que sur la portion concernée — pas de re-render de tout le composant.
|
|
95
|
+
|
|
96
|
+
### Mental model à donner à l'équipe
|
|
97
|
+
|
|
98
|
+
| Je veux... | J'utilise... |
|
|
99
|
+
| ---------------------------------------------------- | ------------ |
|
|
100
|
+
| Transformer / dériver une donnée | `computed()` |
|
|
101
|
+
| Synchroniser avec le DOM, un service externe, logger | `effect()` |
|
|
102
|
+
|
|
103
|
+
🚩 **Le red flag à apprendre à repérer** : un `effect()` qui fait `.set()` sur un autre signal est presque toujours un `computed()` déguisé.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// 🔴 À corriger dès que vu en review
|
|
107
|
+
effect(() => {
|
|
108
|
+
this.minYear.set(this.minDate()?.[0] ?? dayjs().year() - 100);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 🟢 Ce que c'est vraiment
|
|
112
|
+
readonly minYear = computed(() => this.minDate()?.[0] ?? dayjs().year() - 100);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 3. `input()` et `model()` : le nouveau contrat des composants
|
|
118
|
+
|
|
119
|
+
### `input()` remplace `@Input()` (binding parent → enfant, une direction)
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// Avant
|
|
123
|
+
@Input() closeable = true;
|
|
124
|
+
|
|
125
|
+
// Après
|
|
126
|
+
readonly closeable = input(true);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `model()` remplace la paire `@Input()` + `@Output()` (binding bidirectionnel)
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// Avant
|
|
133
|
+
@Input() isVisible = false;
|
|
134
|
+
@Output() isVisibleChange = new EventEmitter<boolean>();
|
|
135
|
+
|
|
136
|
+
// Après
|
|
137
|
+
readonly isVisible = model(false);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```html
|
|
141
|
+
<!-- deux sens -->
|
|
142
|
+
<my-component [(isVisible)]="open" />
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Scénario | Outil |
|
|
146
|
+
| ------------------------------------------------------- | ---------------- |
|
|
147
|
+
| Le parent écrit, l'enfant lit seulement | `input()` |
|
|
148
|
+
| Le parent écrit, l'enfant réécrit, le parent lit | `model()` |
|
|
149
|
+
| Le parent écrit, l'enfant réécrit, le parent ne lit pas | `linkedSignal()` |
|
|
150
|
+
| Dérivé d'autres inputs | `computed()` |
|
|
151
|
+
|
|
152
|
+
**Exemple concret — avant/après sur un composant type modale :**
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// 🔴 Avant : @Input()/@Output() + synchronisation manuelle
|
|
156
|
+
@Input() isVisible = false;
|
|
157
|
+
@Output() isVisibleChange = new EventEmitter<boolean>();
|
|
158
|
+
|
|
159
|
+
close(): void {
|
|
160
|
+
this.isVisible = false;
|
|
161
|
+
this.isVisibleChange.emit(false);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// 🟢 Après : model()
|
|
167
|
+
readonly isVisible = model(false);
|
|
168
|
+
|
|
169
|
+
close(): void {
|
|
170
|
+
this.isVisible.set(false);
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```html
|
|
175
|
+
<!-- Côté parent : aucun code de synchronisation à écrire -->
|
|
176
|
+
<app-modal [(isVisible)]="showModal" />
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 4. `linkedSignal()` : l'état local qui se resynchronise
|
|
182
|
+
|
|
183
|
+
Le chaînon manquant entre `computed()` (lecture seule) et `signal()` (aucune synchro automatique) : une valeur **modifiable localement**, mais qui **se réinitialise** quand une source change.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
readonly options = input<string[]>([]);
|
|
187
|
+
readonly selectedOption = linkedSignal(() => this.options()[0]);
|
|
188
|
+
|
|
189
|
+
// L'utilisateur peut toujours écrire dessus...
|
|
190
|
+
this.selectedOption.set('other value');
|
|
191
|
+
// ...jusqu'à ce que `options` change, auquel cas ça se réinitialise.
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
🚩 **Red flag à apprendre** : un `effect()` qui reset un `signal()` classique quand une autre valeur change est un `linkedSignal()` qui s'ignore.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// 🔴
|
|
198
|
+
readonly selectedOption = signal('');
|
|
199
|
+
effect(() => this.selectedOption.set(this.options()[0]));
|
|
200
|
+
|
|
201
|
+
// 🟢
|
|
202
|
+
readonly selectedOption = linkedSignal(() => this.options()[0]);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Exemple concret — un `select` dont les options viennent du parent :**
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
@Component({
|
|
209
|
+
selector: 'app-branch-select',
|
|
210
|
+
template: `
|
|
211
|
+
<select
|
|
212
|
+
[ngModel]="selectedBranch()"
|
|
213
|
+
(ngModelChange)="selectedBranch.set($event)"
|
|
214
|
+
>
|
|
215
|
+
@for (branch of branches(); track branch) {
|
|
216
|
+
<option [value]="branch">{{ branch }}</option>
|
|
217
|
+
}
|
|
218
|
+
</select>
|
|
219
|
+
`,
|
|
220
|
+
})
|
|
221
|
+
export class BranchSelectComponent {
|
|
222
|
+
readonly branches = input<string[]>([]);
|
|
223
|
+
|
|
224
|
+
// se réinitialise sur le premier élément à chaque changement de `branches`,
|
|
225
|
+
// mais reste librement modifiable par l'utilisateur entre deux changements
|
|
226
|
+
readonly selectedBranch = linkedSignal(() => this.branches()[0]);
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Sans `linkedSignal()`, il faudrait un `effect()` qui fait `.set()` sur un `signal()` classique à chaque changement de `branches` — exactement le red flag vu plus haut.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 5. `untracked()` : maîtriser les dépendances d'un `effect()`
|
|
235
|
+
|
|
236
|
+
Un `effect()` s'abonne à **tout** signal lu de façon synchrone dans son corps — pas seulement à ceux qu'on avait en tête comme déclencheurs. `untracked()` permet de lire une valeur **sans** créer cette dépendance.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
effect(() => {
|
|
240
|
+
const id = this.userId(); // tracké
|
|
241
|
+
const config = untracked(() => this.appConfig()); // lu, mais pas tracké
|
|
242
|
+
this.analytics.logUserView(id, config);
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Cas d'usage le plus fréquent dans nos composants : un `effect()` qui lit **et** réécrit potentiellement le même signal (ex. corriger un `model()` de texte selon un préfixe).
|
|
247
|
+
|
|
248
|
+
⚠️ **Piège à ne pas commettre** : ici, il ne faut **pas** faire `untracked(() => this.value())` sur le signal principal qu'on veut corriger — si on l'untrack, l'effect ne sera plus jamais redéclenché quand `value` change ailleurs (ex. la frappe de l'utilisateur), donc plus aucune correction ne se produira après le premier passage. `untracked()` sert à exclure des **signaux secondaires** qui ne doivent pas déclencher l'effect (un flag de config par exemple) — pas le signal principal qu'on observe.
|
|
249
|
+
|
|
250
|
+
La sécurité contre la boucle infinie ne vient pas d'`untracked()` dans ce cas, mais de la **convergence** : l'effet ne réécrit que si une condition change réellement, donc le second passage (déclenché par sa propre écriture) devient un no-op.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
private previousPrefix = DEFAULT_PREFIX; // simple champ de classe, pas un signal : pas besoin de réactivité dessus
|
|
254
|
+
|
|
255
|
+
effect(() => {
|
|
256
|
+
let valueCopy = this.value(); // tracké : l'effect DOIT réagir aux changements de value
|
|
257
|
+
const prefix = this.prefix(); // tracké : l'effect DOIT réagir aux changements de prefix
|
|
258
|
+
|
|
259
|
+
// signal secondaire, hors sujet pour le déclenchement de cet effect : untracked() légitime
|
|
260
|
+
if (untracked(() => this.trimOnBlur())) {
|
|
261
|
+
valueCopy = valueCopy?.trim() ?? null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Le prefix a changé : on corrige value et on notifie le parent
|
|
265
|
+
if (!valueCopy?.includes(prefix)) {
|
|
266
|
+
const withoutOldPrefix = valueCopy?.includes(this.previousPrefix)
|
|
267
|
+
? valueCopy.slice(this.previousPrefix.length)
|
|
268
|
+
: valueCopy;
|
|
269
|
+
|
|
270
|
+
this.value.set(withoutOldPrefix); // re-déclenche cet effect (voulu, cf. commentaire ci-dessous)
|
|
271
|
+
this.onUserInput(withoutOldPrefix); // vrai effet de bord (ex: notifier le parent)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ⚠️ Ce .set() ci-dessus re-déclenche cet effect une 2e fois (attendu) :
|
|
275
|
+
// au 2e passage, valueCopy.includes(prefix) devient vrai → plus d'écriture → convergence.
|
|
276
|
+
this.previousPrefix = prefix;
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Ce qui rend ce `.set()` sur `value` légitime malgré le red flag de la section 2 : l'effect fait aussi `onUserInput()`, un vrai effet de bord (notifier le parent) — ce n'est pas une dérivation pure, donc pas un `computed()` déguisé.
|
|
281
|
+
|
|
282
|
+
⚠️ `untracked()` ne remplace pas une garde de convergence. Il contrôle **ce qui déclenche** l'effet (signaux secondaires à exclure), pas le fait qu'une écriture sur le signal principal doive être conditionnelle pour éviter une boucle infinie — ce sont deux problèmes différents, qui se combinent souvent dans le même effect comme ci-dessus.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## 6. RxJS interop : signaux **et** RxJS, pas l'un contre l'autre
|
|
287
|
+
|
|
288
|
+
> 🎤 **on ne migre pas tout le RxJS existant vers des signaux**. Les deux modèles répondent à des besoins différents, et le mélanger au mauvais endroit est aussi un piège.
|
|
289
|
+
|
|
290
|
+
| | Signal | Observable (RxJS) |
|
|
291
|
+
| --------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
292
|
+
| Modèle | **État** — une valeur à un instant T | **Flux** — une séquence de valeurs dans le temps |
|
|
293
|
+
| Mode | Pull (on lit `signal()` quand on veut) | Push (on s'abonne, on reçoit) |
|
|
294
|
+
| Fort pour | État de composant, dérivations synchrones, template | Async (HTTP, WebSocket), opérateurs (`debounceTime`, `switchMap`, `retry`, `combineLatest`...), annulation de requêtes |
|
|
295
|
+
|
|
296
|
+
**Règle simple à donner à l'équipe** : si la donnée représente "un état actuel", signal. Si elle représente "une séquence d'événements dans le temps" avec de la composition/annulation à gérer, RxJS reste le bon outil — un appel HTTP en est l'exemple typique.
|
|
297
|
+
|
|
298
|
+
### `toSignal()` — exposer un Observable comme un Signal
|
|
299
|
+
|
|
300
|
+
Le cas le plus courant : un service qui expose un appel HTTP en `Observable`, consommé comme un `Signal` côté composant pour éviter le `| async` dans le template.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
304
|
+
|
|
305
|
+
private readonly userService = inject(UserService);
|
|
306
|
+
|
|
307
|
+
// userService.getUser() retourne un Observable<User> (appel HTTP RxJS, inchangé)
|
|
308
|
+
readonly user = toSignal(this.userService.getUser(), { initialValue: null });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
- Se comporte comme le `| async` du template, mais utilisable partout (pas seulement en template)
|
|
312
|
+
- Si l'Observable complète, le signal garde la dernière valeur émise
|
|
313
|
+
- Si l'Observable erreur, l'erreur est relancée à la lecture du signal
|
|
314
|
+
|
|
315
|
+
### `toObservable()` — exposer un Signal comme un Observable
|
|
316
|
+
|
|
317
|
+
Le sens inverse : utile quand on veut piper un signal (ex. une valeur de recherche tapée par l'utilisateur) dans des opérateurs RxJS avant de déclencher un appel HTTP.
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
321
|
+
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
|
|
322
|
+
|
|
323
|
+
readonly query = signal('');
|
|
324
|
+
|
|
325
|
+
// on garde RxJS pour ce que RxJS fait bien : debounce + annulation de requêtes en vol
|
|
326
|
+
private readonly results$ = toObservable(this.query).pipe(
|
|
327
|
+
debounceTime(300),
|
|
328
|
+
distinctUntilChanged(),
|
|
329
|
+
switchMap(query => this.searchService.search(query)), // Observable<Result[]>
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
readonly results = toSignal(this.results$, { initialValue: [] });
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
C'est l'exemple le plus parlant à montrer à l'équipe : **le signal pilote l'input utilisateur, RxJS gère le debounce/switchMap/annulation, et on ressort un signal côté template.** Aucun des deux modèles n'est sacrifié.
|
|
336
|
+
|
|
337
|
+
⚠️ `toObservable()` doit être déclaré comme **champ de classe** (et non appelé à l'intérieur d'une méthode) car il a besoin d'un contexte d'injection au moment de sa création.
|
|
338
|
+
|
|
339
|
+
### `rxResource()` — appels asynchrones pilotés par un signal, avec état loading/error inclus
|
|
340
|
+
|
|
341
|
+
Alternative à `toSignal()` quand on veut aussi le suivi d'état (chargement, erreur, rechargement) sans le coder à la main. `rxResource()` accepte un `Observable` comme source de données, et se redéclenche automatiquement quand les signaux dont dépend la `request` changent.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { rxResource } from '@angular/core/rxjs-interop';
|
|
345
|
+
|
|
346
|
+
readonly userId = input.required<string>();
|
|
347
|
+
|
|
348
|
+
readonly userResource = rxResource({
|
|
349
|
+
request: () => this.userId(),
|
|
350
|
+
loader: ({ request: id }) => this.userService.getUser(id), // Observable<User>
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// dans le template : userResource.value(), userResource.isLoading(), userResource.error()
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Exemple concret — avant/après sur un appel HTTP géré à la main :**
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
// 🔴 Avant : subscribe() manuel + variables loading/error gérées à la main
|
|
360
|
+
readonly user = signal<User | null>(null);
|
|
361
|
+
readonly loading = signal(false);
|
|
362
|
+
readonly error = signal<unknown>(null);
|
|
363
|
+
|
|
364
|
+
ngOnInit(): void {
|
|
365
|
+
this.loading.set(true);
|
|
366
|
+
this.userService.getUser(this.userId()).subscribe({
|
|
367
|
+
next: user => { this.user.set(user); this.loading.set(false); },
|
|
368
|
+
error: err => { this.error.set(err); this.loading.set(false); },
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// 🟢 Après : rxResource(), loading/error inclus, re-fetch automatique si userId() change
|
|
375
|
+
readonly userResource = rxResource({
|
|
376
|
+
request: () => this.userId(),
|
|
377
|
+
loader: ({ request: id }) => this.userService.getUser(id),
|
|
378
|
+
});
|
|
379
|
+
// userResource.value(), userResource.isLoading(), userResource.error()
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Doit-on abandonner le pipe `async` ?
|
|
383
|
+
|
|
384
|
+
Non, il n'est pas déprécié. Mais la tendance recommandée pour du code neuf est de préférer `toSignal()` dans la plupart des cas :
|
|
385
|
+
|
|
386
|
+
- `toSignal()` se comporte comme le pipe `async`, mais la valeur obtenue est utilisable **partout** — pas seulement dans le template (dans un `computed()`, un `effect()`, en `input` d'un enfant...)
|
|
387
|
+
- Une **seule souscription** même si la valeur est lue plusieurs fois dans le template (le pipe `async` utilisé deux fois sur le même flux crée deux souscriptions distinctes, sauf `shareReplay`)
|
|
388
|
+
- Lectures cohérentes avec le reste du graphe de signaux (important avec `OnPush`/zoneless)
|
|
389
|
+
|
|
390
|
+
Le pipe `async` garde toute sa légitimité quand l'`Observable` n'est consommé **que dans le template**, à un seul endroit, sans que la valeur ait besoin de sortir vers le reste de la classe — convertir en signal dans ce cas n'apporte rien.
|
|
391
|
+
|
|
392
|
+
> 🎤 si la valeur ne sert qu'à l'affichage à un endroit précis, `| async` reste légitime. Si elle sert ailleurs dans le composant (calcul dérivé, passage à un enfant, logique dans un effet), `toSignal()`.
|
|
393
|
+
|
|
394
|
+
### Ce qu'on ne migre **pas**
|
|
395
|
+
|
|
396
|
+
> 🎤 afin d'éviter la sur-correction après cette session :
|
|
397
|
+
|
|
398
|
+
- Les appels HTTP restent des `Observable` au niveau service (`HttpClient` retourne du RxJS, on ne change rien ici)
|
|
399
|
+
- Les flux avec opérateurs de composition/temporisation (`debounceTime`, `combineLatest`, `retry`, `race`...) restent en RxJS
|
|
400
|
+
- On ne convertit un `Observable` en `Signal` (via `toSignal()`) **qu'au bord** — c'est-à-dire au tout dernier moment, là où la donnée quitte le monde RxJS pour devenir un état à afficher, jamais au milieu d'une chaîne d'opérateurs
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
// 🔴 Conversion prématurée en plein milieu du pipeline : un Signal
|
|
404
|
+
// n'est pas "pipeable", il faut repasser par toObservable() pour
|
|
405
|
+
// continuer la composition — aller-retour inutile
|
|
406
|
+
private readonly debounced = toSignal(
|
|
407
|
+
toObservable(this.query).pipe(debounceTime(300)),
|
|
408
|
+
);
|
|
409
|
+
private readonly results$ = toObservable(this.debounced).pipe(
|
|
410
|
+
distinctUntilChanged(),
|
|
411
|
+
switchMap(query => this.searchService.search(query)),
|
|
412
|
+
);
|
|
413
|
+
readonly results = toSignal(this.results$, { initialValue: [] });
|
|
414
|
+
|
|
415
|
+
// 🟢 Un seul point de conversion, à la toute fin de la chaîne
|
|
416
|
+
private readonly results$ = toObservable(this.query).pipe(
|
|
417
|
+
debounceTime(300),
|
|
418
|
+
distinctUntilChanged(),
|
|
419
|
+
switchMap(query => this.searchService.search(query)),
|
|
420
|
+
);
|
|
421
|
+
readonly results = toSignal(this.results$, { initialValue: [] });
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Ça vaut aussi entre les couches : un service peut composer entièrement en RxJS (`debounceTime`, `retry`, `combineLatest`...) et rester testable indépendamment de tout composant — c'est le composant consommateur, "au bord", qui convertit en signal au moment de l'affichage.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## 7. Le piège n°1 : mutation vs réassignation
|
|
429
|
+
|
|
430
|
+
C'est **le** réflexe à perdre lorsqu'on vient de l'ère Zone.js : avant, muter un tableau ou un objet imbriqué "finissait par marcher" parce que la détection de changement repassait régulièrement sur tout. **Avec les signaux, ce n'est plus vrai** : seul `.set()` / `.update()` notifie les abonnés. Une mutation en place est invisible pour le signal, le template, et tout `computed()`/`effect()` qui en dépend.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// 🔴 No-op silencieux du point de vue du signal
|
|
434
|
+
this.address().locality.name = 'newName';
|
|
435
|
+
|
|
436
|
+
// 🟢 Nouvelle référence de premier niveau
|
|
437
|
+
this.address.update((current) => ({
|
|
438
|
+
...current,
|
|
439
|
+
locality: { ...current.locality, name: 'newName' },
|
|
440
|
+
}));
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Même logique pour les méthodes mutantes des tableaux :
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
// 🔴 push/splice/pop/shift/unshift/sort/reverse mutent en place
|
|
447
|
+
this.model().push({ name: null, address: null });
|
|
448
|
+
|
|
449
|
+
// 🟢 Toujours une nouvelle référence
|
|
450
|
+
this.model.update((current) => [...current, { name: null, address: null }]);
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
...et pour `Map` / `Set` (piège sournois : `Map.prototype.set` porte le même nom que le `.set()` du signal) :
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
// 🔴 Ceci est Map.prototype.set, pas celui du signal — mute la même instance
|
|
457
|
+
this.cache().set(key, value);
|
|
458
|
+
|
|
459
|
+
// 🟢
|
|
460
|
+
this.cache.update((current) => new Map(current).set(key, value));
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
> 💡 si une méthode modifie "en place" et ne renvoie pas un objet/tableau/Map/Set flambant neuf, elle mute — on la wrap dans une copie. `map`, `filter`, `slice`, `concat`, le spread : toujours sûrs.
|
|
464
|
+
|
|
465
|
+
**Exemple concret — le plus percutant à montrer à l'équipe :**
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
@Component({
|
|
469
|
+
selector: 'app-contact-list',
|
|
470
|
+
template: `
|
|
471
|
+
@for (contact of contacts(); track contact.id) {
|
|
472
|
+
<p>{{ contact.name }}</p>
|
|
473
|
+
}
|
|
474
|
+
<button (click)="addBroken()">Ajouter (cassé)</button>
|
|
475
|
+
<button (click)="addFixed()">Ajouter (correct)</button>
|
|
476
|
+
`,
|
|
477
|
+
})
|
|
478
|
+
export class ContactListComponent {
|
|
479
|
+
readonly contacts = signal<Contact[]>([{ id: 1, name: 'Alice' }]);
|
|
480
|
+
|
|
481
|
+
addBroken(): void {
|
|
482
|
+
// 🔴 push() mute le tableau en place : le signal ne détecte rien,
|
|
483
|
+
// RIEN ne s'affiche à l'écran malgré la donnée bien présente en mémoire
|
|
484
|
+
this.contacts().push({ id: 2, name: 'Bob' });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
addFixed(): void {
|
|
488
|
+
// 🟢 nouvelle référence : le @for se met à jour normalement
|
|
489
|
+
this.contacts.update((current) => [...current, { id: 2, name: 'Bob' }]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Checklist de fin de session
|
|
497
|
+
|
|
498
|
+
- Je sais choisir entre `input()`, `model()`, `computed()`, `linkedSignal()` et `signal()`
|
|
499
|
+
- Je sais repérer un `effect()` qui devrait être un `computed()`
|
|
500
|
+
- Je sais repérer un `effect()` qui devrait être un `linkedSignal()`
|
|
501
|
+
- Je ne mute plus jamais une valeur de signal en place — toujours `.set()`/`.update()` avec une nouvelle référence
|
|
502
|
+
- Je sais quand utiliser `untracked()` dans un `effect()`
|
|
503
|
+
- Je ne cherche pas à remplacer tout mon RxJS par des signaux — je garde RxJS pour l'async composé (HTTP, debounce, switchMap...) et je ne convertis en signal qu'au bord, avec `toSignal()`/`toObservable()`/`rxResource()`
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Path of the component a linted template belongs to, `undefined` when the file is not a
|
|
2
|
+
// template.
|
|
3
|
+
//
|
|
4
|
+
// An inline template is a virtual block whose path is derived from the `.ts`, and whose
|
|
5
|
+
// name ends with `.html` too: `foo.component.ts/1_inline-template-….component.html`.
|
|
6
|
+
// It has to be matched BEFORE the external template, otherwise it resolves to garbage.
|
|
7
|
+
export function resolveComponentPath(filename) {
|
|
8
|
+
const inlineMatch = filename.match(/^(.*\.ts)(?:[/\\]|$)/);
|
|
9
|
+
if (inlineMatch) {
|
|
10
|
+
return inlineMatch[1];
|
|
11
|
+
}
|
|
12
|
+
return filename.endsWith(".html")
|
|
13
|
+
? `${filename.slice(0, -".html".length)}.ts`
|
|
14
|
+
: undefined;
|
|
15
|
+
}
|