@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
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,98 +1,257 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import * as templateParser from '@angular-eslint/template-parser';
|
|
2
|
-
import { describe, it } from 'vitest';
|
|
3
5
|
import { RuleTester } from 'eslint';
|
|
6
|
+
import { beforeAll, describe, it } from 'vitest';
|
|
4
7
|
import rule from '../no-direct-signal-mutation.mjs';
|
|
5
8
|
|
|
6
9
|
describe('no-direct-signal-mutation', () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// The rule only reports the signals a class really declares, resolved through its
|
|
11
|
+
// inheritance chain, so the fixtures have to exist on disk. They are laid out like a
|
|
12
|
+
// real project — the base class package under `node_modules` — so that module
|
|
13
|
+
// resolution is exercised.
|
|
14
|
+
let componentPath;
|
|
15
|
+
let templatePath;
|
|
16
|
+
let inlineTemplatePath;
|
|
17
|
+
let unknownTemplatePath;
|
|
18
|
+
|
|
19
|
+
const write = (path, content) => {
|
|
20
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
21
|
+
writeFileSync(path, content, 'utf8');
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), 'no-direct-signal-mutation-'));
|
|
26
|
+
|
|
27
|
+
// A flattened declaration bundle, as published by ng-packagr: several classes in a
|
|
28
|
+
// single file, signal types qualified by the namespace they were imported under.
|
|
29
|
+
write(
|
|
30
|
+
join(root, 'node_modules', '@fake', 'kit', 'package.json'),
|
|
31
|
+
JSON.stringify({ name: '@fake/kit', types: 'index.d.ts' }),
|
|
32
|
+
);
|
|
33
|
+
write(
|
|
34
|
+
join(root, 'node_modules', '@fake', 'kit', 'index.d.ts'),
|
|
35
|
+
`import * as _angular_core from '@angular/core';
|
|
36
|
+
declare class KitInputComponent {
|
|
37
|
+
readonly inherited: _angular_core.ModelSignal<{ users: string[] }>;
|
|
38
|
+
readonly inheritedLabel: _angular_core.InputSignal<{ users: string[] }>;
|
|
39
|
+
readonly inheritedId: _angular_core.Signal<{ users: string[] }>;
|
|
40
|
+
}
|
|
41
|
+
declare class KitFieldComponent extends KitInputComponent {}
|
|
42
|
+
export { KitFieldComponent, KitInputComponent };`,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
componentPath = join(root, 'src', 'sample.component.ts');
|
|
46
|
+
write(
|
|
47
|
+
componentPath,
|
|
48
|
+
`import { Component, computed, contentChildren, input, model, signal, viewChild } from '@angular/core';
|
|
49
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
50
|
+
import { KitFieldComponent } from '@fake/kit';
|
|
51
|
+
|
|
52
|
+
@Component({ selector: 'app-sample', templateUrl: './sample.component.html' })
|
|
53
|
+
export class SampleComponent extends KitFieldComponent {
|
|
54
|
+
readonly partenaire = signal({ name: '', users: [] });
|
|
55
|
+
readonly items = signal<string[]>([]);
|
|
56
|
+
readonly byId = signal(new Map<string, string>());
|
|
57
|
+
readonly tags = signal(new Set<string>());
|
|
58
|
+
readonly comment = model<string>('');
|
|
59
|
+
|
|
60
|
+
readonly double = computed(() => ({ users: [] }));
|
|
61
|
+
readonly formArray = computed(() => new FormArray([]));
|
|
62
|
+
readonly title = input.required<{ users: string[] }>();
|
|
63
|
+
readonly rows = viewChild('rows');
|
|
64
|
+
readonly slots = contentChildren('slot');
|
|
65
|
+
readonly fromStream = toSignal(this.source$);
|
|
66
|
+
|
|
67
|
+
getConfig() { return { users: [] }; }
|
|
68
|
+
}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
templatePath = join(root, 'src', 'sample.component.html');
|
|
72
|
+
// Virtual block name produced by `angular.processInlineTemplates`.
|
|
73
|
+
inlineTemplatePath = join(
|
|
74
|
+
root,
|
|
75
|
+
'src',
|
|
76
|
+
'sample.component.ts',
|
|
77
|
+
'1_inline-template-sample.component.ts-1.component.html',
|
|
78
|
+
);
|
|
79
|
+
unknownTemplatePath = join(root, 'src', 'orphan.component.html');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const tsRuleTester = new RuleTester({
|
|
83
|
+
// 2021 at least: the rule covers `??=`, and the Angular parser accepts it too.
|
|
84
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
12
85
|
});
|
|
13
86
|
|
|
87
|
+
const ts = (code) => ({ code, filename: componentPath });
|
|
88
|
+
const invalidTs = (code, messageId) => ({ ...ts(code), errors: [{ messageId }] });
|
|
89
|
+
|
|
14
90
|
it('should validate signal mutation rule', () => {
|
|
15
|
-
|
|
91
|
+
tsRuleTester.run('no-direct-signal-mutation', rule, {
|
|
16
92
|
valid: [
|
|
17
|
-
//
|
|
18
|
-
'
|
|
19
|
-
'this.
|
|
20
|
-
'
|
|
21
|
-
|
|
22
|
-
// Using .set()
|
|
23
|
-
'model.set({ property: value })',
|
|
24
|
-
'this.model.set({ property: value })',
|
|
25
|
-
'getSignal().set({ property: value })',
|
|
93
|
+
// Going through the signal API
|
|
94
|
+
ts('this.partenaire.update((current) => ({ ...current, name: value }))'),
|
|
95
|
+
ts('this.partenaire.set({ name: value })'),
|
|
96
|
+
ts('this.comment.set(value)'),
|
|
26
97
|
|
|
27
98
|
// Regular property assignments
|
|
28
|
-
'this.property = value',
|
|
29
|
-
'obj.property = value',
|
|
30
|
-
'this.obj.property = value',
|
|
31
|
-
|
|
32
|
-
// Reading from
|
|
33
|
-
'
|
|
34
|
-
'this.
|
|
35
|
-
'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'this.
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
'
|
|
99
|
+
ts('this.property = value'),
|
|
100
|
+
ts('obj.property = value'),
|
|
101
|
+
ts('this.obj.property = value'),
|
|
102
|
+
|
|
103
|
+
// Reading from a signal, without mutating it
|
|
104
|
+
ts('this.partenaire().name'),
|
|
105
|
+
ts('this.items().toSorted()'),
|
|
106
|
+
ts('this.items().filter((x) => x.active)'),
|
|
107
|
+
ts('this.items().concat(other)'),
|
|
108
|
+
ts('[...this.items()].push(item)'),
|
|
109
|
+
|
|
110
|
+
// A plain method is not a signal, whatever it returns. This is what the
|
|
111
|
+
// inheritance-aware resolution buys: no guessing from the call shape.
|
|
112
|
+
ts('this.getConfig().users.push(user)'),
|
|
113
|
+
ts('this.getConfig().name = value'),
|
|
114
|
+
ts('this.notDeclaredAnywhere().name = value'),
|
|
115
|
+
|
|
116
|
+
// A view query is the one signal whose value is there to be mutated: it hands out
|
|
117
|
+
// a DOM element or a component instance.
|
|
118
|
+
ts('this.rows().nativeElement.hidden = true'),
|
|
119
|
+
ts('this.rows().items.push(item)'),
|
|
120
|
+
ts('const query = viewChild("rows"); query().items.push(item)'),
|
|
121
|
+
ts('const query = viewChild.required("rows"); query().items.push(item)'),
|
|
122
|
+
ts('const query = contentChildren("row"); query()[0].items.push(item)'),
|
|
123
|
+
|
|
124
|
+
// A published declaration spells a computed, a toSignal and a view query
|
|
125
|
+
// alike, `Signal<T>`. It has to be tolerated: this library publishes 54 view
|
|
126
|
+
// queries, on base classes every consumer extends.
|
|
127
|
+
ts('this.inheritedId().users.push(user)'),
|
|
128
|
+
ts('this.inheritedId().name = value'),
|
|
129
|
+
|
|
130
|
+
// A call taking arguments, or reached through a foreign receiver, is not a
|
|
131
|
+
// signal read.
|
|
132
|
+
ts('pendingFilesByFormKey.get(formKey).files = []'),
|
|
133
|
+
ts('pendingFilesByFormKey.get(formKey).files.push(file)'),
|
|
134
|
+
ts('component.partenaire().name = value'),
|
|
135
|
+
ts('component.partenaire().users.push(user)'),
|
|
136
|
+
|
|
137
|
+
// A local variable that holds no signal
|
|
138
|
+
ts('const plain = { users: [] }; plain.users.push(user)'),
|
|
139
|
+
ts('const viewQuery = viewChild("rows"); viewQuery().items.push(item)'),
|
|
140
|
+
|
|
141
|
+
// A chain reaching the mutation through a call designates a value of its own:
|
|
142
|
+
// `filter` and `slice` return a new array, so sorting THAT is nobody's business.
|
|
143
|
+
ts('this.items().filter((item) => item.active).sort()'),
|
|
144
|
+
ts('this.items().slice().sort()'),
|
|
145
|
+
ts('this.items().map((item) => item).push(item)'),
|
|
146
|
+
ts('this.items().concat(other).reverse()'),
|
|
147
|
+
ts('this.items().filter((item) => item.active)[0].name = value'),
|
|
148
|
+
|
|
149
|
+
// A name bound to a copy, not to the value itself
|
|
150
|
+
ts('const copy = [...this.items()]; copy.push(item)'),
|
|
151
|
+
ts('const copy = this.getConfig().users; copy.push(item)'),
|
|
152
|
+
// Reassigned: the name may hold something else by the time it is mutated
|
|
153
|
+
ts('let users = this.partenaire().users; users = other; users.push(item)'),
|
|
154
|
+
// A self reference must not send the resolution in circles
|
|
155
|
+
ts('const users = users; users.push(item)'),
|
|
156
|
+
|
|
157
|
+
// Following the callback parameter must not report on its own: the spread builds
|
|
158
|
+
// a new object instead of writing into `item`. Compare with the invalid twin,
|
|
159
|
+
// `map((item) => { item.done = true })`.
|
|
160
|
+
ts('this.items().map((item) => ({ ...item, done: true }))'),
|
|
161
|
+
ts('this.items().map((item) => item.done)'),
|
|
162
|
+
ts('this.items().reduce((acc, item) => acc.concat(item), [])'),
|
|
163
|
+
// Iterating something that is not a signal
|
|
164
|
+
ts('this.getConfig().users.forEach((user) => { user.done = true; })'),
|
|
165
|
+
ts('other.users.forEach((user) => { user.done = true; })'),
|
|
166
|
+
// `reduce` hands the accumulator first: mutating it is not mutating the signal
|
|
167
|
+
ts('this.items().reduce((acc, item) => { acc.push(item); return acc; }, [])'),
|
|
168
|
+
|
|
169
|
+
// `delete` and `Object.assign` on something that is not a signal
|
|
170
|
+
ts('delete this.plainObject.property'),
|
|
171
|
+
ts('delete this.getConfig().users[0]'),
|
|
172
|
+
ts('Object.assign({}, this.partenaire())'),
|
|
173
|
+
ts('Object.assign(target, this.partenaire())'),
|
|
174
|
+
|
|
175
|
+
// A computed member whose method does not mutate
|
|
176
|
+
ts('this.items()["toSorted"]()'),
|
|
57
177
|
],
|
|
58
178
|
|
|
59
179
|
invalid: [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
180
|
+
invalidTs('this.partenaire().name = value', 'directMutation'),
|
|
181
|
+
invalidTs('this.partenaire().deeply.nested.name = value', 'directMutation'),
|
|
182
|
+
invalidTs('this.partenaire().users[0] = value', 'directMutation'),
|
|
183
|
+
invalidTs('this.partenaire().users.length = 0', 'directMutation'),
|
|
184
|
+
invalidTs('this.partenaire().name += value', 'directMutation'),
|
|
185
|
+
invalidTs('this.partenaire().name ??= value', 'directMutation'),
|
|
186
|
+
|
|
187
|
+
// An in-place mutator changes the value without notifying the signal, exactly
|
|
188
|
+
// like an assignment does.
|
|
189
|
+
invalidTs('this.partenaire().users.splice(index, 1)', 'inPlaceMutation'),
|
|
190
|
+
invalidTs('this.items().push(item)', 'inPlaceMutation'),
|
|
191
|
+
invalidTs('this.items().sort((a, b) => a - b)', 'inPlaceMutation'),
|
|
192
|
+
invalidTs('this.partenaire().deeply.nested.users.reverse()', 'inPlaceMutation'),
|
|
193
|
+
// Map and Set mutators: safe to report now that only real signals are inspected.
|
|
194
|
+
invalidTs('this.byId().set(key, value)', 'inPlaceMutation'),
|
|
195
|
+
invalidTs('this.byId().delete(key)', 'inPlaceMutation'),
|
|
196
|
+
invalidTs('this.tags().add(tag)', 'inPlaceMutation'),
|
|
197
|
+
invalidTs('this.tags().clear()', 'inPlaceMutation'),
|
|
198
|
+
|
|
199
|
+
// A writable signal inherited from a published declaration bundle
|
|
200
|
+
invalidTs('this.inherited().users.push(user)', 'inPlaceMutation'),
|
|
201
|
+
invalidTs('this.inherited().name = value', 'directMutation'),
|
|
202
|
+
|
|
203
|
+
// A signal held by a plain variable of the file
|
|
204
|
+
invalidTs('const local = signal({ users: [] }); local().name = value', 'directMutation'),
|
|
205
|
+
invalidTs('const local = signal({ users: [] }); local().users.push(u)', 'inPlaceMutation'),
|
|
206
|
+
invalidTs('const local = model({ users: [] }); local().users.splice(i, 1)', 'inPlaceMutation'),
|
|
207
|
+
|
|
208
|
+
// `delete` drops an entry of the value, notifying nothing
|
|
209
|
+
invalidTs('delete this.partenaire().users[0]', 'inPlaceMutation'),
|
|
210
|
+
invalidTs('delete this.partenaire().name', 'inPlaceMutation'),
|
|
211
|
+
// `Object.assign` writes into its first argument
|
|
212
|
+
invalidTs('Object.assign(this.partenaire(), { name: value })', 'inPlaceMutation'),
|
|
213
|
+
invalidTs('Object.assign(this.partenaire().users, other)', 'inPlaceMutation'),
|
|
214
|
+
// A computed member is the same call written differently
|
|
215
|
+
invalidTs('this.partenaire().users["push"](user)', 'inPlaceMutation'),
|
|
216
|
+
invalidTs('this.partenaire().users["splice"](index, 1)', 'inPlaceMutation'),
|
|
217
|
+
|
|
218
|
+
// A name bound once to the value, or to a part of it
|
|
219
|
+
invalidTs('const users = this.partenaire().users; users.push(user)', 'inPlaceMutation'),
|
|
220
|
+
invalidTs('const users = this.partenaire().users; users[0].name = value', 'directMutation'),
|
|
221
|
+
invalidTs('const users = this.partenaire().users; delete users[0]', 'inPlaceMutation'),
|
|
222
|
+
invalidTs('const value = this.partenaire(); value.name = other', 'directMutation'),
|
|
223
|
+
invalidTs('const deep = this.partenaire().a.b.c; deep.push(item)', 'inPlaceMutation'),
|
|
224
|
+
|
|
225
|
+
// The element parameter of an iteration callback is a part of the value
|
|
226
|
+
invalidTs('this.items().forEach((item) => { item.done = true; })', 'directMutation'),
|
|
227
|
+
invalidTs('this.items().map((item) => { item.done = true; })', 'directMutation'),
|
|
228
|
+
invalidTs('this.items().forEach(function (item) { item.done = true; })', 'directMutation'),
|
|
229
|
+
invalidTs('this.items().filter((item) => item.tags.push(tag))', 'inPlaceMutation'),
|
|
230
|
+
invalidTs('this.partenaire().users.forEach((u) => u.tags.splice(0, 1))', 'inPlaceMutation'),
|
|
231
|
+
invalidTs('this.items().map((item) => Object.assign(item, { done: true }))', 'inPlaceMutation'),
|
|
232
|
+
invalidTs('this.items().map((item) => delete item.done)', 'inPlaceMutation'),
|
|
233
|
+
// A mutation hidden inside an otherwise honest spread
|
|
234
|
+
invalidTs('this.items().map((item) => ({ ...item, tags: item.tags.push(tag) }))', 'inPlaceMutation'),
|
|
235
|
+
|
|
236
|
+
// No signal's value is to be mutated, whatever its kind. Those without a `.set()`
|
|
237
|
+
// share one message, which names the kind and the fix that applies.
|
|
238
|
+
invalidTs('this.double().users.push(user)', 'readOnlyMutation'),
|
|
239
|
+
invalidTs('this.double().name = value', 'readOnlyMutation'),
|
|
240
|
+
invalidTs('delete this.double().users[0]', 'readOnlyMutation'),
|
|
241
|
+
invalidTs('const users = this.double().users; users.push(user)', 'readOnlyMutation'),
|
|
242
|
+
invalidTs('this.double().users.forEach((u) => { u.done = true; })', 'readOnlyMutation'),
|
|
243
|
+
invalidTs('const derived = computed(() => ({ users: [] })); derived().users.push(u)', 'readOnlyMutation'),
|
|
244
|
+
// An input's value belongs to the parent: mutating it corrupts the parent's state
|
|
245
|
+
// and notifies nothing either.
|
|
246
|
+
invalidTs('this.title().users.push(user)', 'readOnlyMutation'),
|
|
247
|
+
invalidTs('this.title().name = value', 'readOnlyMutation'),
|
|
248
|
+
invalidTs('this.fromStream().users.push(user)', 'readOnlyMutation'),
|
|
249
|
+
// Inherited from a published declaration bundle: `InputSignal` names its factory,
|
|
250
|
+
// while a bare `Signal<T>` only says read-only.
|
|
251
|
+
invalidTs('this.inheritedLabel().users.push(user)', 'readOnlyMutation'),
|
|
252
|
+
// The accepted false positive: a computed may legitimately hand out a live
|
|
253
|
+
// mutable object, and no type information is available to tell.
|
|
254
|
+
invalidTs('this.formArray().push(control)', 'readOnlyMutation'),
|
|
96
255
|
],
|
|
97
256
|
});
|
|
98
257
|
});
|
|
@@ -101,78 +260,156 @@ describe('no-direct-signal-mutation', () => {
|
|
|
101
260
|
languageOptions: { parser: templateParser },
|
|
102
261
|
});
|
|
103
262
|
|
|
263
|
+
const template = (code) => ({ code, filename: templatePath });
|
|
264
|
+
const invalidTemplate = (code, messageId) => ({
|
|
265
|
+
...template(code),
|
|
266
|
+
errors: [{ messageId }],
|
|
267
|
+
});
|
|
268
|
+
|
|
104
269
|
it('should validate signal mutation rule in templates', () => {
|
|
105
270
|
templateRuleTester.run('no-direct-signal-mutation', rule, {
|
|
106
271
|
valid: [
|
|
107
272
|
// Reads only
|
|
108
|
-
'{{
|
|
109
|
-
'<div [value]="
|
|
110
|
-
'<div [value]="
|
|
111
|
-
'<div [value]="
|
|
273
|
+
template('{{ partenaire().name }}'),
|
|
274
|
+
template('<div [value]="partenaire().deeply.nested.name"></div>'),
|
|
275
|
+
template('<div [value]="partenaire().users[0]"></div>'),
|
|
276
|
+
template('<div [value]="items().filter((x) => x.active)"></div>'),
|
|
277
|
+
template('<button (click)="items().toSorted()"></button>'),
|
|
112
278
|
|
|
113
279
|
// Going through the signal API
|
|
114
|
-
'<button (click)="
|
|
115
|
-
'<button (click)="
|
|
280
|
+
template('<button (click)="partenaire.set({ name: value })"></button>'),
|
|
281
|
+
template('<button (click)="comment.set(value)"></button>'),
|
|
282
|
+
template('<button (click)="items.update((v) => [...v, item])"></button>'),
|
|
283
|
+
|
|
284
|
+
// Assignments that do not target a signal
|
|
285
|
+
template('<button (click)="property = value"></button>'),
|
|
286
|
+
template('<button (click)="obj.property = value"></button>'),
|
|
287
|
+
template('<button (click)="getConfig().name = value"></button>'),
|
|
288
|
+
template('<button (click)="getConfig().users.push(user)"></button>'),
|
|
116
289
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
'<button (click)="
|
|
120
|
-
'<button (click)="
|
|
290
|
+
// A view query reached from a template used to be reported with a `.update()`
|
|
291
|
+
// that does not exist on it.
|
|
292
|
+
template('<button (click)="rows().nativeElement.hidden = true"></button>'),
|
|
293
|
+
template('<button (click)="rows().items.push(item)"></button>'),
|
|
294
|
+
template('@for (slot of slots(); track slot) { <button (click)="slot.done = 1"></button> }'),
|
|
121
295
|
|
|
122
296
|
// Two-way binding on the signal itself: Angular calls `.set()`
|
|
123
|
-
'<input [(ngModel)]="
|
|
124
|
-
'<app-child [(value)]="comment"></app-child>',
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
'<button (click)="pendingFilesByFormKey.get(formKey).files
|
|
129
|
-
'<button (click)="
|
|
130
|
-
'<button (click)="component.
|
|
131
|
-
|
|
297
|
+
template('<input [(ngModel)]="comment" />'),
|
|
298
|
+
template('<app-child [(value)]="comment"></app-child>'),
|
|
299
|
+
|
|
300
|
+
// A call taking arguments, or reached through a foreign receiver
|
|
301
|
+
template('<button (click)="pendingFilesByFormKey.get(formKey).files = []"></button>'),
|
|
302
|
+
template('<button (click)="pendingFilesByFormKey.get(formKey).files.push(f)"></button>'),
|
|
303
|
+
template('<button (click)="component.inputElement().nativeElement.value = 1"></button>'),
|
|
304
|
+
template('<button (click)="component.partenaire().users.push(user)"></button>'),
|
|
305
|
+
|
|
132
306
|
// An output named `…Change` that carries no assignment
|
|
133
|
-
'<app-child (valueChange)="
|
|
307
|
+
template('<app-child (valueChange)="comment.set($event)"></app-child>'),
|
|
308
|
+
|
|
309
|
+
// A `@for` item is only a signal's value when the loop iterates a signal
|
|
310
|
+
template('@for (u of getConfig().users; track u) { <button (click)="u.done = 1"></button> }'),
|
|
311
|
+
template('@for (u of rows().items; track u) { <button (click)="u.done = 1"></button> }'),
|
|
312
|
+
template('@for (u of other.users; track u) { <button (click)="u.done = 1"></button> }'),
|
|
313
|
+
// Reading the item mutates nothing
|
|
314
|
+
template('@for (u of items(); track u) { {{ u.name }} }'),
|
|
315
|
+
template('@let copy = getConfig().users; <button (click)="copy.push(item)"></button>'),
|
|
316
|
+
|
|
317
|
+
// A computed member whose method does not mutate
|
|
318
|
+
template(`<button (click)="items()['toSorted']()"></button>`),
|
|
319
|
+
template('<button (click)="items().filter(active).sort()"></button>'),
|
|
320
|
+
template('<button (click)="items().slice().sort()"></button>'),
|
|
134
321
|
],
|
|
135
322
|
|
|
136
323
|
invalid: [
|
|
324
|
+
invalidTemplate('<button (click)="partenaire().name = value"></button>', 'directMutation'),
|
|
325
|
+
invalidTemplate('<button (click)="this.partenaire().name = value"></button>', 'directMutation'),
|
|
326
|
+
invalidTemplate('<button (click)="partenaire().deeply.nested.name = value"></button>', 'directMutation'),
|
|
327
|
+
invalidTemplate('<button (click)="partenaire().users[0] = value"></button>', 'directMutation'),
|
|
328
|
+
// Every compound assignment accepted by the Angular parser
|
|
329
|
+
invalidTemplate('<button (click)="partenaire().name += value"></button>', 'directMutation'),
|
|
330
|
+
invalidTemplate('<button (click)="partenaire().name ??= value"></button>', 'directMutation'),
|
|
331
|
+
|
|
332
|
+
// The mutation hidden in a two-way binding, reported once and not twice
|
|
333
|
+
invalidTemplate('<input [(ngModel)]="partenaire().name" />', 'directMutation'),
|
|
334
|
+
invalidTemplate('<app-child [(value)]="partenaire().deeply.name"></app-child>', 'directMutation'),
|
|
335
|
+
// An explicit `…Change` handler carrying the assignment: the Binary reports it,
|
|
336
|
+
// the BoundEvent must not report it a second time.
|
|
337
|
+
invalidTemplate('<app-child (valueChange)="partenaire().name = $event"></app-child>', 'directMutation'),
|
|
338
|
+
|
|
339
|
+
// In-place mutators
|
|
340
|
+
invalidTemplate('<button (click)="partenaire().users.splice(index, 1)"></button>', 'inPlaceMutation'),
|
|
341
|
+
invalidTemplate('<button (click)="items().push(item)"></button>', 'inPlaceMutation'),
|
|
342
|
+
invalidTemplate('<button (click)="partenaire()?.users?.sort()"></button>', 'inPlaceMutation'),
|
|
343
|
+
invalidTemplate('<button (click)="byId().set(key, value)"></button>', 'inPlaceMutation'),
|
|
344
|
+
invalidTemplate('<button (click)="tags().add(tag)"></button>', 'inPlaceMutation'),
|
|
345
|
+
invalidTemplate('<button (click)="inherited().users.push(user)"></button>', 'inPlaceMutation'),
|
|
346
|
+
invalidTemplate(`<button (click)="items()['push'](item)"></button>`, 'inPlaceMutation'),
|
|
347
|
+
|
|
348
|
+
// Mutating a `@for` item mutates the array the signal holds. The item is bound
|
|
349
|
+
// only inside its own block, which the ancestor walk enforces.
|
|
350
|
+
invalidTemplate('@for (u of items(); track u) { <button (click)="u.done = 1"></button> }', 'directMutation'),
|
|
351
|
+
invalidTemplate('@for (u of partenaire().users; track u) { <button (click)="u.tags.push(t)"></button> }', 'inPlaceMutation'),
|
|
352
|
+
invalidTemplate('@for (a of items(); track a) { @for (b of a.items; track b) { <button (click)="b.done = 1"></button> } }', 'directMutation'),
|
|
353
|
+
|
|
354
|
+
// Same through a `@let`
|
|
355
|
+
invalidTemplate('@let users = partenaire().users; <button (click)="users.push(user)"></button>', 'inPlaceMutation'),
|
|
356
|
+
invalidTemplate('@let value = partenaire(); <button (click)="value.name = other"></button>', 'directMutation'),
|
|
357
|
+
|
|
358
|
+
// Every other kind of signal, each with the advice that applies to it
|
|
359
|
+
invalidTemplate('<button (click)="double().users.push(user)"></button>', 'readOnlyMutation'),
|
|
360
|
+
invalidTemplate('<button (click)="formArray().push(control)"></button>', 'readOnlyMutation'),
|
|
361
|
+
invalidTemplate('<button (click)="title().users.push(user)"></button>', 'readOnlyMutation'),
|
|
362
|
+
invalidTemplate('<button (click)="fromStream().users.push(user)"></button>', 'readOnlyMutation'),
|
|
363
|
+
invalidTemplate('@for (u of double().users; track u) { <button (click)="u.done = 1"></button> }', 'readOnlyMutation'),
|
|
364
|
+
invalidTemplate('@for (u of title().users; track u) { <button (click)="u.done = 1"></button> }', 'readOnlyMutation'),
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should cover inline templates', () => {
|
|
370
|
+
templateRuleTester.run('no-direct-signal-mutation', rule, {
|
|
371
|
+
valid: [
|
|
372
|
+
{ code: '{{ partenaire().name }}', filename: inlineTemplatePath },
|
|
137
373
|
{
|
|
138
|
-
code: '<button (click)="
|
|
139
|
-
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
code: '<button (click)="this.model().property = value"></button>',
|
|
143
|
-
errors: [{ messageId: 'directMutation' }],
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
code: '<button (click)="model().deeply.nested.property = value"></button>',
|
|
147
|
-
errors: [{ messageId: 'directMutation' }],
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
code: '<button (click)="model().array[0] = value"></button>',
|
|
151
|
-
errors: [{ messageId: 'directMutation' }],
|
|
374
|
+
code: '<button (click)="getConfig().users.push(user)"></button>',
|
|
375
|
+
filename: inlineTemplatePath,
|
|
152
376
|
},
|
|
153
|
-
|
|
377
|
+
],
|
|
378
|
+
invalid: [
|
|
154
379
|
{
|
|
155
|
-
code: '<button (click)="
|
|
156
|
-
|
|
380
|
+
code: '<button (click)="partenaire().users.splice(index, 1)"></button>',
|
|
381
|
+
filename: inlineTemplatePath,
|
|
382
|
+
errors: [{ messageId: 'inPlaceMutation' }],
|
|
157
383
|
},
|
|
384
|
+
],
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Without the declaring class, the signals are unknown. Reporting on the call shape
|
|
389
|
+
// alone can be wrong, but a false positive costs a disable comment while a missed
|
|
390
|
+
// mutation is silent.
|
|
391
|
+
it('should fall back to the call shape when the component cannot be read', () => {
|
|
392
|
+
templateRuleTester.run('no-direct-signal-mutation', rule, {
|
|
393
|
+
valid: [
|
|
158
394
|
{
|
|
159
|
-
code: '<button (click)="
|
|
160
|
-
|
|
395
|
+
code: '<button (click)="component.whatever().name = value"></button>',
|
|
396
|
+
filename: unknownTemplatePath,
|
|
161
397
|
},
|
|
162
|
-
// The mutation hidden in a two-way binding, reported once and not twice
|
|
163
398
|
{
|
|
164
|
-
code: '<
|
|
165
|
-
|
|
399
|
+
code: '<button (click)="map.get(key).name = value"></button>',
|
|
400
|
+
filename: unknownTemplatePath,
|
|
166
401
|
},
|
|
402
|
+
],
|
|
403
|
+
invalid: [
|
|
167
404
|
{
|
|
168
|
-
code: '<
|
|
405
|
+
code: '<button (click)="whatever().name = value"></button>',
|
|
406
|
+
filename: unknownTemplatePath,
|
|
169
407
|
errors: [{ messageId: 'directMutation' }],
|
|
170
408
|
},
|
|
171
|
-
// An explicit `…Change` handler carrying the assignment: the Binary reports it,
|
|
172
|
-
// the BoundEvent must not report it a second time.
|
|
173
409
|
{
|
|
174
|
-
code: '<
|
|
175
|
-
|
|
410
|
+
code: '<button (click)="whatever().users.push(user)"></button>',
|
|
411
|
+
filename: unknownTemplatePath,
|
|
412
|
+
errors: [{ messageId: 'inPlaceMutation' }],
|
|
176
413
|
},
|
|
177
414
|
],
|
|
178
415
|
});
|