@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 CHANGED
@@ -6,6 +6,33 @@
6
6
 
7
7
  ---
8
8
 
9
+ ## [19.0.8]
10
+
11
+ ### Changed
12
+
13
+ - ESLint rule [`no-direct-signal-mutation`](ESLINT_PLUGIN.md#no-direct-signal-mutation) now
14
+ covers much more than the plain assignment it started with. 🚨 Expect new errors on
15
+ `npm update`; each can be turned off from your own `rules` block.
16
+ - **in-place mutators** are reported as well, since they notify nothing either: those of
17
+ `Array` (`splice`, `push`, `pop`, `shift`, `unshift`, `sort`, `reverse`, `fill`,
18
+ `copyWithin`) and of `Map` / `Set` (`set`, `add`, `delete`, `clear`)
19
+ - so are `delete model().users[0]` and `Object.assign(model(), …)`, and
20
+ `items()["push"](x)` counts as `items().push(x)`
21
+ - the value is **followed through the bindings that can be resolved exactly**: a
22
+ variable written once, the element parameter of an iteration callback, a `@for` item
23
+ and a `@let`. `@for (user of model().users)` then `user.done = true` is reported
24
+ - 🚨 **no signal is mutable**, whatever its kind: `signal()`, `model()`,
25
+ `linkedSignal()`, `computed()`, `input()` and `toSignal()` are all reported, each with
26
+ the fix that applies to it. A **view query** is the single exception —
27
+ `viewChild()`, `contentChildren()` and their `.required` variants hand out a DOM
28
+ element or a component instance, which is there to be mutated
29
+ - only the signals the class really declares are inspected, resolved through the whole
30
+ inheritance chain, so a plain method (`this.getConfig().items.push(x)`) is never
31
+ reported
32
+ - a signal inherited from a published package and known by its type only (`Signal<T>`)
33
+ is left alone: a declaration file no longer says whether it was a `computed()` or a
34
+ view query
35
+
9
36
  ## [19.0.7]
10
37
 
11
38
  ### Added
@@ -44,7 +71,6 @@
44
71
  - [recaptcha.service.ts](projects/prestations-ng/src/sdk-recaptcha/recaptcha.service.ts)
45
72
  - fixed recaptcha called infinitely when backend says that it must be revalidated
46
73
 
47
-
48
74
  ## [19.0.0] - should be aligned with prestations-be 19.0.x
49
75
 
50
76
  ### Added
package/ESLINT_PLUGIN.md CHANGED
@@ -50,8 +50,8 @@ from the config. Inline templates written in a `@Component` are covered too, thr
50
50
 
51
51
  | Config | Target | Contents |
52
52
  | ----------------------------- | ----------- | -------------------------------------------------------------- |
53
- | `configs.tsRecommended` | `**/*.ts` | the baselines, the house style, and `tsRules` |
54
- | `configs.templateRecommended` | `**/*.html` | the baselines, the house style, and `templateRules` |
53
+ | `configs.tsRecommended` | `**/*.ts` | the baselines, the house style, and `tsRules` |
54
+ | `configs.templateRecommended` | `**/*.html` | the baselines, the house style, and `templateRules` |
55
55
  | `configs.tsRules` | `**/*.ts` | `no-direct-signal-mutation` |
56
56
  | `configs.templateRules` | `**/*.html` | `no-direct-signal-mutation`, `no-uninvoked-signal-in-template` |
57
57
 
@@ -96,8 +96,9 @@ your own `rules` block. It is applied after the shared config, so it wins:
96
96
 
97
97
  ### `no-direct-signal-mutation`
98
98
 
99
- Reports an assignment into a member of a signal's value. Mutating the object held by a
100
- signal does not notify its dependents, so the view is not refreshed.
99
+ Reports a mutation of the value held by a signal, in either of its two forms: an
100
+ assignment into one of its members, or a call to an in-place mutator. Mutating that value
101
+ does not notify the signal's dependents, so the view is not refreshed.
101
102
 
102
103
  Applies to **TypeScript files and templates**, including the case where the mutation hides
103
104
  in a two-way binding.
@@ -105,14 +106,20 @@ in a two-way binding.
105
106
  ```ts
106
107
  // ✗ mutates the object inside the signal, no notification
107
108
  this.model().property = value;
109
+ this.model().items.splice(index, 1);
108
110
 
109
111
  // ✓
110
112
  this.model.update((current) => ({ ...current, property: value }));
113
+ this.model.update((current) => ({
114
+ ...current,
115
+ items: current.items.filter((_, i) => i !== index),
116
+ }));
111
117
  ```
112
118
 
113
119
  ```html
114
120
  <!-- ✗ -->
115
121
  <button (click)="model().property = value">…</button>
122
+ <button (click)="model().items.splice(index, 1)">…</button>
116
123
  <input [(ngModel)]="model().property" />
117
124
 
118
125
  <!-- ✓ -->
@@ -122,23 +129,130 @@ this.model.update((current) => ({ ...current, property: value }));
122
129
  All the assignment operators accepted by the Angular expression parser are covered: `=`,
123
130
  `+=`, `-=`, `*=`, `/=`, `%=`, `**=`, `&&=`, `||=`, `??=`.
124
131
 
125
- **Not reported**, because the call is not assumed to be a signal read:
132
+ The in-place mutators covered are those of `Array` `push`, `pop`, `shift`, `unshift`,
133
+ `splice`, `sort`, `reverse`, `fill`, `copyWithin` — and of `Map` / `Set` — `set`, `add`,
134
+ `delete`, `clear`. Their non-mutating counterparts, `toSorted`, `toReversed`, `filter`,
135
+ `concat`, `with`, are never reported: they return a new value. `items()["push"](x)` is the
136
+ same call as `items().push(x)` and is reported alike.
126
137
 
127
- | Expression | Why |
128
- | --------------------------------------------------- | ----------------------------------------------------------------- |
129
- | `map.get(key).files = []` | the call takes arguments |
130
- | `component.inputElement().nativeElement.value = ''` | reached through a foreign receiver, not `this` |
131
- | `obj.getter().property = value` | idem |
132
- | `viewChild(…)` / `viewChild.required()` accessors | a view query signal is read-only, there is no `.set()` to suggest |
138
+ Two other ways of writing into the value are covered, in TypeScript — the Angular template
139
+ parser accepts neither:
140
+
141
+ ```ts
142
+ delete this.model().users[0];
143
+ Object.assign(this.model(), { property: value });
144
+ ```
145
+
146
+ #### Names bound to the value
147
+
148
+ A mutation is rarely written on the signal read itself. The rule follows the value through
149
+ the bindings it can resolve exactly, and only those:
150
+
151
+ ```ts
152
+ // ✗ a variable written once
153
+ const users = this.model().users;
154
+ users.push(user);
155
+
156
+ // ✗ the element parameter of an iteration callback
157
+ this.model().users.forEach((user) => (user.done = true));
158
+ ```
159
+
160
+ ```angular-html
161
+ <!-- ✗ a @for item, bound only inside its own block -->
162
+ @for (user of model().users; track user.id) {
163
+ <input [(ngModel)]="user.done" />
164
+ <button (click)="user.tags.push(tag)">…</button>
165
+ }
166
+
167
+ <!-- ✗ a @let -->
168
+ @let users = model().users;
169
+ <button (click)="users.push(user)">…</button>
170
+ ```
171
+
172
+ In TypeScript these come from ESLint's scope analysis, in templates from the block that
173
+ introduces the name — nothing is inferred from a naming convention. A variable that is
174
+ **reassigned** is dropped: the name may hold something else by the time it is mutated. Same
175
+ for `reduce`, whose first parameter is the accumulator and not an element.
176
+
177
+ **What it cannot follow** is a value crossing a function boundary — handed to a method,
178
+ stored in a class field, returned and mutated elsewhere:
179
+
180
+ ```ts
181
+ // not reported: the rule cannot know what `sortInto` does
182
+ this.sortInto(this.model().users);
183
+ ```
184
+
185
+ That needs interprocedural analysis, which ESLint does not provide; a full type checker
186
+ would be required. It is the one remaining gap in detection.
187
+
188
+ #### Only real signals are inspected
189
+
190
+ The rule does not guess from the shape of a call. It reads the declaring class — the linted
191
+ file itself, or the component a template belongs to — and resolves its signal members
192
+ through the whole inheritance chain, the way TypeScript would: relative paths,
193
+ `node_modules`, `paths` mappings, and the flattened `.d.ts` of a published library. Nothing
194
+ has to be declared by hand.
195
+
196
+ **No signal's value is to be mutated, whatever its kind** — a mutation notifies none of its
197
+ dependents. The kinds differ only in the advice the message carries, since none but the
198
+ first has a `.set()`:
199
+
200
+ | Declared as | On a mutation of its value |
201
+ | --------------------------------------- | ----------------------------------------------------------- |
202
+ | `signal()`, `model()`, `linkedSignal()` | use `.set()` / `.update()` |
203
+ | `computed()` | the value is derived: mutate the signal it is computed from |
204
+ | `input()` | the value belongs to the parent: ask through an `output()` |
205
+ | `toSignal()` | the value comes from the observable: change it upstream |
206
+ | `viewChild()`, `contentChildren()`, … | **the one exception**, left alone: see below |
207
+
208
+ A view query is the exception because it hands out a DOM element or a component instance,
209
+ and mutating that is the reason for asking for it: `rows().nativeElement.hidden = true` is
210
+ the normal way to use one. `.required` variants included.
211
+
212
+ Anything that is not a declared signal is left alone as well:
213
+
214
+ | Expression | Why |
215
+ | ------------------------------------ | ------------------------------------------------------------ |
216
+ | `this.getConfig().items.push(item)` | `getConfig()` is a plain method, not a signal |
217
+ | `map.get(key).files.push(file)` | the call takes arguments, so it is not a signal read |
218
+ | `component.model().items.push(item)` | reached through a foreign receiver, not the component itself |
219
+
220
+ Because the rule knows a real signal from an ordinary method, `set` / `add` / `delete` are
221
+ safe to report: `byId().set(k, v)` mutates the `Map` inside the signal, while `byId.set(m)`
222
+ replaces the signal's value and is correct.
223
+
224
+ **One accepted false positive.** A `computed()` may legitimately hand out a live mutable
225
+ object, and no type information is available to tell that apart from a derived value:
226
+
227
+ ```ts
228
+ readonly items = computed(() => this.form.get('items') as FormArray);
229
+ this.items().push(control); // reported, though this is the FormArray API
230
+ ```
231
+
232
+ **One accepted gap, and it is forced.** A published declaration file spells a `computed()`,
233
+ a `toSignal()` and a view query alike, `Signal<T>` — the factory name is gone. A signal
234
+ **inherited from a package** and known by its type only is therefore left alone:
235
+
236
+ ```ts
237
+ // in the published .d.ts, indistinguishable from one another
238
+ readonly fieldId: Signal<string>; // was a computed()
239
+ readonly inputElement: Signal<ElementRef>; // was a viewChild()
240
+ ```
241
+
242
+ Reporting that group would break the lint of every project extending a component of this
243
+ library: `prestations-ng` publishes 54 view queries, `AbstractPageComponent` and
244
+ `FoehnInputComponent` among them, and touching the element they queried is normal code. So
245
+ a mutation of an inherited `computed()` goes unreported — the narrower harm of the two.
246
+ Signals declared in your own project, base classes included, are unaffected: their factory
247
+ call is right there to be read.
133
248
 
134
249
  The rule has no autofix: the correct rewrite depends on what you meant.
135
250
 
136
- **Known limitation.** The view query exclusion needs the component class, which a template
137
- AST does not carry. A mutation reached through a view query **from a template** is therefore
138
- still reported, with a message suggesting a `.update()` that does not exist on a read-only
139
- signal. It is rare mutating the DOM from a template expression but if you hit it, turn
140
- the rule off for that file. The same imprecision applies to `computed()` in TypeScript,
141
- which is read-only as well.
251
+ **When the declaring class cannot be read** an unsaved buffer, a template with no twin
252
+ `.ts` its signals are unknown, and the rule falls back to treating any argument-less
253
+ accessor on the component as a signal. It may then report a plain method. That direction is
254
+ deliberate: a false positive costs one `eslint-disable-next-line`, a missed mutation is a
255
+ silent bug.
142
256
 
143
257
  ### `no-uninvoked-signal-in-template`
144
258