kensington-eslint-plugin 0.1.2
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/README.md +347 -0
- package/index.js +60 -0
- package/package.json +28 -0
- package/rules/no-async-computed.js +47 -0
- package/rules/no-async-effect.js +49 -0
- package/rules/no-effect-in-computed.js +72 -0
- package/rules/no-effect-in-effect.js +74 -0
- package/rules/no-ignored-effect-return.js +60 -0
- package/rules/no-new-computed-in-computed.js +71 -0
- package/rules/no-new-computed-in-effect.js +71 -0
- package/rules/no-new-signal-in-computed.js +73 -0
- package/rules/no-new-signal-in-effect.js +74 -0
- package/rules/no-self-read-write.js +81 -0
- package/rules/no-set-in-computed.js +74 -0
- package/rules/no-set-on-computed.js +56 -0
- package/rules/no-signal-async-write.js +105 -0
- package/rules/no-unsafe-literal.js +28 -0
- package/rules/prefer-value-in-async.js +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# kensington-eslint-plugin
|
|
2
|
+
|
|
3
|
+
ESLint rules for [kensington](https://github.com/beezwax/kensington) signal correctness.
|
|
4
|
+
|
|
5
|
+
Catches common reactive programming mistakes — read/write loops, writes inside computed derivations, orphaned effects, and async subscription pitfalls — at lint time rather than at runtime.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --save-dev kensington-eslint-plugin
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires ESLint 9+ and Node 18+.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Add the recommended config to your `eslint.config.js`:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import kensington from 'kensington-eslint-plugin';
|
|
21
|
+
|
|
22
|
+
export default [
|
|
23
|
+
kensington.configs.recommended,
|
|
24
|
+
// ...your other configs
|
|
25
|
+
];
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or enable rules individually:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import kensington from 'kensington-eslint-plugin';
|
|
32
|
+
|
|
33
|
+
export default [
|
|
34
|
+
{
|
|
35
|
+
plugins: { kensington },
|
|
36
|
+
rules: {
|
|
37
|
+
'kensington/no-set-in-computed': 'error',
|
|
38
|
+
'kensington/no-self-read-write': 'error',
|
|
39
|
+
// ...
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Editor and tooling support
|
|
46
|
+
|
|
47
|
+
Because this is a standard ESLint plugin, it works anywhere ESLint runs — no extra configuration needed:
|
|
48
|
+
|
|
49
|
+
- **Editors** — VS Code, JetBrains IDEs (RubyMine, WebStorm, etc.), Neovim, and any editor with an ESLint language server show inline errors automatically once the plugin is configured.
|
|
50
|
+
- **CI** — run `eslint --max-warnings 0` in any pipeline to enforce rules on every push.
|
|
51
|
+
- **Pre-commit hooks** — works with `lint-staged` or any hook runner that invokes ESLint.
|
|
52
|
+
- **Programmatic use** — available via the ESLint Node.js API (`new ESLint()`) for custom tooling.
|
|
53
|
+
|
|
54
|
+
## Rules
|
|
55
|
+
|
|
56
|
+
| Rule | Description | Recommended |
|
|
57
|
+
|------|-------------|-------------|
|
|
58
|
+
| [`no-set-in-computed`](#no-set-in-computed) | Disallow `.set()` inside a `computed()` body | error |
|
|
59
|
+
| [`no-self-read-write`](#no-self-read-write) | Disallow reading and writing the same signal in the same reactive run | error |
|
|
60
|
+
| [`no-set-on-computed`](#no-set-on-computed) | Disallow `.set()` on a computed signal | error |
|
|
61
|
+
| [`no-new-signal-in-effect`](#no-new-signal-in-effect) | Disallow creating a new `signal()` inside an `effect()` body | error |
|
|
62
|
+
| [`no-effect-in-computed`](#no-effect-in-computed) | Disallow calling `effect()` inside a `computed()` body | error |
|
|
63
|
+
| [`no-signal-async-write`](#no-signal-async-write) | Disallow writing a signal in an async callback when it was read in the enclosing `effect()` | warn |
|
|
64
|
+
| [`no-ignored-effect-return`](#no-ignored-effect-return) | Require capturing the return value of `effect()` inside a function | warn |
|
|
65
|
+
| [`prefer-value-in-async`](#prefer-value-in-async) | Prefer `.value` over `.get()` inside async callbacks within an `effect()` | warn |
|
|
66
|
+
| [`no-new-computed-in-effect`](#no-new-computed-in-effect) | Disallow creating a new `computed()` inside an `effect()` body | error |
|
|
67
|
+
| [`no-new-signal-in-computed`](#no-new-signal-in-computed) | Disallow creating a new `signal()` inside a `computed()` body | error |
|
|
68
|
+
| [`no-unsafe-literal`](#no-unsafe-literal) | Disallow `.unsafeLiteral()` calls that bypass XSS protection | error |
|
|
69
|
+
| [`no-new-computed-in-computed`](#no-new-computed-in-computed) | Disallow creating a new `computed()` inside a `computed()` body | error |
|
|
70
|
+
| [`no-effect-in-effect`](#no-effect-in-effect) | Disallow creating a new `effect()` inside an `effect()` body | error |
|
|
71
|
+
| [`no-async-effect`](#no-async-effect) | Disallow async callbacks passed to `effect()` | error |
|
|
72
|
+
| [`no-async-computed`](#no-async-computed) | Disallow async callbacks passed to `computed()` | error |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
### `no-set-in-computed`
|
|
77
|
+
|
|
78
|
+
Computed functions must be pure derivations. Calling `.set()` inside one causes a write during a read pass.
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
// Bad
|
|
82
|
+
const doubled = computed(() => {
|
|
83
|
+
sideEffect.set(true); // error
|
|
84
|
+
return count.get() * 2;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Good — move the write into an effect
|
|
88
|
+
effect(() => {
|
|
89
|
+
sideEffect.set(doubled.get() > 10);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### `no-self-read-write`
|
|
96
|
+
|
|
97
|
+
Reading a signal with `.get()` subscribes to it. Writing it with `.set()` in the same run re-triggers the run, creating an infinite loop.
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
// Bad
|
|
101
|
+
effect(() => {
|
|
102
|
+
const val = count.get();
|
|
103
|
+
count.set(val + 1); // error — triggers the effect again
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Good — use .value to read without subscribing
|
|
107
|
+
effect(() => {
|
|
108
|
+
const val = count.value;
|
|
109
|
+
count.set(val + 1);
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### `no-set-on-computed`
|
|
116
|
+
|
|
117
|
+
Computed signals are read-only. Kensington throws at runtime if you call `.set()` on one; this catches it statically.
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
const doubled = computed(() => count.get() * 2);
|
|
121
|
+
doubled.set(10); // error — use signal() for writable state
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### `no-new-signal-in-effect`
|
|
127
|
+
|
|
128
|
+
Each effect run creates a fresh signal with no cleanup path. The signal should be declared outside the effect.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
// Bad
|
|
132
|
+
effect(() => {
|
|
133
|
+
const local = signal(0); // error — orphaned on every run
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Good
|
|
137
|
+
const local = signal(0);
|
|
138
|
+
effect(() => {
|
|
139
|
+
local.set(local.get() + 1);
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### `no-effect-in-computed`
|
|
146
|
+
|
|
147
|
+
Computed functions must be pure. An `effect()` call inside one runs on every re-evaluation and its handle is dropped, making cleanup impossible.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
// Bad
|
|
151
|
+
const doubled = computed(() => {
|
|
152
|
+
effect(() => console.log('hi')); // error
|
|
153
|
+
return count.get() * 2;
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### `no-signal-async-write`
|
|
160
|
+
|
|
161
|
+
If a signal is read via `.get()` in an effect and then written in an async callback, the write re-triggers the effect after each async resolution.
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
// Bad
|
|
165
|
+
effect(() => {
|
|
166
|
+
const val = count.get(); // subscribes
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
count.set(val + 1); // error — re-triggers the effect
|
|
169
|
+
}, 100);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Good — use .value to read without subscribing
|
|
173
|
+
effect(() => {
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
count.set(count.value + 1);
|
|
176
|
+
}, 100);
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
### `no-ignored-effect-return`
|
|
183
|
+
|
|
184
|
+
`effect()` returns `{ pause, resume, stop }`. Discarding the return value inside a function makes cleanup impossible, leaking the subscription across calls.
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
// Bad
|
|
188
|
+
function setup() {
|
|
189
|
+
effect(() => console.log(count.get())); // warn — can't stop it
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Good
|
|
193
|
+
function setup() {
|
|
194
|
+
const fx = effect(() => console.log(count.get()));
|
|
195
|
+
return () => fx.stop();
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Module-level effects are intentionally long-lived and are not flagged.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### `prefer-value-in-async`
|
|
204
|
+
|
|
205
|
+
Once an effect's synchronous body completes, async callbacks run outside its reactive context. `.get()` registers no subscription there — `.value` makes that explicit.
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
// Bad
|
|
209
|
+
effect(() => {
|
|
210
|
+
fetch('/api').then(() => {
|
|
211
|
+
console.log(count.get()); // warn — no subscription is registered
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Good
|
|
216
|
+
effect(() => {
|
|
217
|
+
fetch('/api').then(() => {
|
|
218
|
+
console.log(count.value);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### `no-new-computed-in-effect`
|
|
226
|
+
|
|
227
|
+
Creating `computed()` inside an `effect()` creates a new orphaned derived signal on every run. The previous one silently loses its subscriber with no cleanup.
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
// Bad
|
|
231
|
+
effect(() => {
|
|
232
|
+
const doubled = computed(() => count.get() * 2); // error — orphaned every run
|
|
233
|
+
console.log(doubled.get());
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Good
|
|
237
|
+
const doubled = computed(() => count.get() * 2);
|
|
238
|
+
effect(() => { console.log(doubled.get()); });
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
### `no-new-signal-in-computed`
|
|
244
|
+
|
|
245
|
+
Creating `signal()` inside `computed()` creates a new orphaned signal on every recompute.
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
// Bad
|
|
249
|
+
const c = computed(() => {
|
|
250
|
+
const temp = signal(0); // error — orphaned every recompute
|
|
251
|
+
return temp.get() + base.get();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Good
|
|
255
|
+
const temp = signal(0);
|
|
256
|
+
const c = computed(() => temp.get() + base.get());
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
### `no-unsafe-literal`
|
|
262
|
+
|
|
263
|
+
`.unsafeLiteral()` injects raw HTML with no script-tag validation. Use `.literal()` instead, which validates the string before injecting it.
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
// Bad
|
|
267
|
+
t.unsafeLiteral(userContent); // error — bypasses XSS protection
|
|
268
|
+
|
|
269
|
+
// Good
|
|
270
|
+
t.literal(userContent);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### `no-new-computed-in-computed`
|
|
276
|
+
|
|
277
|
+
Creating `computed()` inside a `computed()` body creates a new orphaned derived signal on every recompute.
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
// Bad
|
|
281
|
+
const outer = computed(() => {
|
|
282
|
+
const inner = computed(() => count.get() * 2); // error — orphaned every recompute
|
|
283
|
+
return inner.get() + 1;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Good
|
|
287
|
+
const inner = computed(() => count.get() * 2);
|
|
288
|
+
const outer = computed(() => inner.get() + 1);
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
### `no-effect-in-effect`
|
|
294
|
+
|
|
295
|
+
Creating `effect()` inside an `effect()` body means every re-run of the outer effect adds a new inner effect without stopping the previous one — subscriptions accumulate indefinitely. Capturing the return handle does not fix this; the previous handle would need to be explicitly stopped at the top of each run.
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
// Bad
|
|
299
|
+
effect(() => {
|
|
300
|
+
const items = list.get();
|
|
301
|
+
effect(() => console.log(items)); // error — previous inner effect never stopped
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Good — restructure as a single effect
|
|
305
|
+
effect(() => {
|
|
306
|
+
console.log(list.get());
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
### `no-async-effect`
|
|
313
|
+
|
|
314
|
+
The effect system runs callbacks synchronously and ignores the returned `Promise`. Any `.get()` calls after the first `await` run outside the reactive context and register no subscription. Errors thrown inside the async body are also silently swallowed.
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
// Bad
|
|
318
|
+
effect(async () => { // error
|
|
319
|
+
const data = await fetch(`/api/${id.get()}`).then(r => r.json());
|
|
320
|
+
title.set(data.title); // runs outside reactive context
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Good — keep reactive reads synchronous, push async work into .then()
|
|
324
|
+
effect(() => {
|
|
325
|
+
fetch(`/api/${id.get()}`).then(r => r.json()).then(data => title.set(data.title));
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
### `no-async-computed`
|
|
332
|
+
|
|
333
|
+
The reactive system runs `computed()` callbacks synchronously. An async callback returns a `Promise` immediately, so the computed value is always a `Promise` object rather than the intended derived value.
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
// Bad — computed value is a Promise, not the resolved data
|
|
337
|
+
const data = computed(async () => { // error
|
|
338
|
+
return await fetch('/api').then(r => r.json());
|
|
339
|
+
});
|
|
340
|
+
t.p(data); // renders "[object Promise]"
|
|
341
|
+
|
|
342
|
+
// Good — signal for the result, effect to populate it
|
|
343
|
+
const data = signal(null);
|
|
344
|
+
effect(() => {
|
|
345
|
+
fetch('/api').then(r => r.json()).then(v => data.set(v));
|
|
346
|
+
});
|
|
347
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import noSetInComputed from './rules/no-set-in-computed.js';
|
|
2
|
+
import noSelfReadWrite from './rules/no-self-read-write.js';
|
|
3
|
+
import noSignalAsyncWrite from './rules/no-signal-async-write.js';
|
|
4
|
+
import noSetOnComputed from './rules/no-set-on-computed.js';
|
|
5
|
+
import noNewSignalInEffect from './rules/no-new-signal-in-effect.js';
|
|
6
|
+
import noEffectInComputed from './rules/no-effect-in-computed.js';
|
|
7
|
+
import noIgnoredEffectReturn from './rules/no-ignored-effect-return.js';
|
|
8
|
+
import preferValueInAsync from './rules/prefer-value-in-async.js';
|
|
9
|
+
import noNewComputedInEffect from './rules/no-new-computed-in-effect.js';
|
|
10
|
+
import noNewSignalInComputed from './rules/no-new-signal-in-computed.js';
|
|
11
|
+
import noUnsafeLiteral from './rules/no-unsafe-literal.js';
|
|
12
|
+
import noNewComputedInComputed from './rules/no-new-computed-in-computed.js';
|
|
13
|
+
import noEffectInEffect from './rules/no-effect-in-effect.js';
|
|
14
|
+
import noAsyncEffect from './rules/no-async-effect.js';
|
|
15
|
+
import noAsyncComputed from './rules/no-async-computed.js';
|
|
16
|
+
|
|
17
|
+
const plugin = {
|
|
18
|
+
meta: { name: 'eslint-plugin-kensington' },
|
|
19
|
+
rules: {
|
|
20
|
+
'no-set-in-computed': noSetInComputed,
|
|
21
|
+
'no-self-read-write': noSelfReadWrite,
|
|
22
|
+
'no-signal-async-write': noSignalAsyncWrite,
|
|
23
|
+
'no-set-on-computed': noSetOnComputed,
|
|
24
|
+
'no-new-signal-in-effect': noNewSignalInEffect,
|
|
25
|
+
'no-effect-in-computed': noEffectInComputed,
|
|
26
|
+
'no-ignored-effect-return': noIgnoredEffectReturn,
|
|
27
|
+
'prefer-value-in-async': preferValueInAsync,
|
|
28
|
+
'no-new-computed-in-effect': noNewComputedInEffect,
|
|
29
|
+
'no-new-signal-in-computed': noNewSignalInComputed,
|
|
30
|
+
'no-unsafe-literal': noUnsafeLiteral,
|
|
31
|
+
'no-new-computed-in-computed': noNewComputedInComputed,
|
|
32
|
+
'no-effect-in-effect': noEffectInEffect,
|
|
33
|
+
'no-async-effect': noAsyncEffect,
|
|
34
|
+
'no-async-computed': noAsyncComputed,
|
|
35
|
+
},
|
|
36
|
+
configs: {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
plugin.configs.recommended = {
|
|
40
|
+
plugins: { kensington: plugin },
|
|
41
|
+
rules: {
|
|
42
|
+
'kensington/no-set-in-computed': 'error',
|
|
43
|
+
'kensington/no-self-read-write': 'error',
|
|
44
|
+
'kensington/no-signal-async-write': 'warn',
|
|
45
|
+
'kensington/no-set-on-computed': 'error',
|
|
46
|
+
'kensington/no-new-signal-in-effect': 'error',
|
|
47
|
+
'kensington/no-effect-in-computed': 'error',
|
|
48
|
+
'kensington/no-ignored-effect-return': 'warn',
|
|
49
|
+
'kensington/prefer-value-in-async': 'warn',
|
|
50
|
+
'kensington/no-new-computed-in-effect': 'error',
|
|
51
|
+
'kensington/no-new-signal-in-computed': 'error',
|
|
52
|
+
'kensington/no-unsafe-literal': 'error',
|
|
53
|
+
'kensington/no-new-computed-in-computed': 'error',
|
|
54
|
+
'kensington/no-effect-in-effect': 'error',
|
|
55
|
+
'kensington/no-async-effect': 'error',
|
|
56
|
+
'kensington/no-async-computed': 'error',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kensington-eslint-plugin",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "ESLint rules for kensington signal correctness",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test tests/no-set-in-computed.test.js tests/no-self-read-write.test.js tests/no-signal-async-write.test.js tests/no-set-on-computed.test.js tests/no-new-signal-in-effect.test.js tests/no-effect-in-computed.test.js tests/no-ignored-effect-return.test.js tests/prefer-value-in-async.test.js tests/no-new-computed-in-effect.test.js tests/no-new-signal-in-computed.test.js tests/no-unsafe-literal.test.js tests/no-new-computed-in-computed.test.js tests/no-effect-in-effect.test.js tests/no-async-effect.test.js tests/no-async-computed.test.js"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"eslint": ">=9"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"eslint": "^9.39.4"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/ryanlsimms/kensington-eslint-plugin"
|
|
22
|
+
},
|
|
23
|
+
"license": "ISC",
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"rules"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Reports computed() calls whose callback is declared async. The reactive system runs the
|
|
2
|
+
// callback synchronously — an async callback returns a Promise immediately, so the computed
|
|
3
|
+
// value is always a Promise object rather than the intended derived value.
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'disallow async callbacks passed to computed()',
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
noAsyncComputed:
|
|
12
|
+
'computed() callback is async. The reactive system runs callbacks synchronously, so the ' +
|
|
13
|
+
'computed value will be a Promise rather than the intended derived value. ' +
|
|
14
|
+
'Use a signal for the result and an effect() to populate it asynchronously instead.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
create(context) {
|
|
19
|
+
const computedNames = new Set();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
ImportDeclaration(node) {
|
|
23
|
+
if (node.source.value !== 'kensington') { return; }
|
|
24
|
+
for (const spec of node.specifiers) {
|
|
25
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
26
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
CallExpression(node) {
|
|
31
|
+
if (
|
|
32
|
+
node.callee.type !== 'Identifier' ||
|
|
33
|
+
!computedNames.has(node.callee.name) ||
|
|
34
|
+
node.arguments.length === 0
|
|
35
|
+
) { return; }
|
|
36
|
+
|
|
37
|
+
const cb = node.arguments[0];
|
|
38
|
+
if (
|
|
39
|
+
(cb.type === 'ArrowFunctionExpression' || cb.type === 'FunctionExpression') &&
|
|
40
|
+
cb.async
|
|
41
|
+
) {
|
|
42
|
+
context.report({ node, messageId: 'noAsyncComputed' });
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Reports effect() calls whose callback is declared async. The effect system runs the
|
|
2
|
+
// callback synchronously and ignores the returned Promise — any .get() calls after the
|
|
3
|
+
// first await run outside the reactive context and register no subscription. Errors thrown
|
|
4
|
+
// inside the async body are also silently swallowed.
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'disallow async callbacks passed to effect()',
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
noAsyncEffect:
|
|
13
|
+
'effect() callback is async. The effect system runs callbacks synchronously and ignores ' +
|
|
14
|
+
'the returned Promise. Any signal reads after the first await register no subscription, ' +
|
|
15
|
+
'and errors thrown inside the async body are silently swallowed. ' +
|
|
16
|
+
'Move async work into a .then() chain inside a synchronous callback instead.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
const effectNames = new Set();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
ImportDeclaration(node) {
|
|
25
|
+
if (node.source.value !== 'kensington') { return; }
|
|
26
|
+
for (const spec of node.specifiers) {
|
|
27
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
28
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
CallExpression(node) {
|
|
33
|
+
if (
|
|
34
|
+
node.callee.type !== 'Identifier' ||
|
|
35
|
+
!effectNames.has(node.callee.name) ||
|
|
36
|
+
node.arguments.length === 0
|
|
37
|
+
) { return; }
|
|
38
|
+
|
|
39
|
+
const cb = node.arguments[0];
|
|
40
|
+
if (
|
|
41
|
+
(cb.type === 'ArrowFunctionExpression' || cb.type === 'FunctionExpression') &&
|
|
42
|
+
cb.async
|
|
43
|
+
) {
|
|
44
|
+
context.report({ node, messageId: 'noAsyncEffect' });
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Reports effect() called inside a computed() callback. Computed functions must be
|
|
2
|
+
// pure derivations — side effects inside them run on every re-evaluation and the
|
|
3
|
+
// returned effect handle is dropped, making cleanup impossible.
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'disallow calling effect() inside a computed() body',
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
noEffectInComputed:
|
|
12
|
+
'effect() called inside a computed() body. Computed functions must be pure derivations. ' +
|
|
13
|
+
'Move the effect() call outside, or restructure using only signal reads.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
create(context) {
|
|
18
|
+
const effectNames = new Set();
|
|
19
|
+
const computedNames = new Set();
|
|
20
|
+
// Each entry is 'computed', 'effect', or 'other'.
|
|
21
|
+
const fnStack = [];
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
ImportDeclaration(node) {
|
|
25
|
+
if (node.source.value !== 'kensington') { return; }
|
|
26
|
+
for (const spec of node.specifiers) {
|
|
27
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
28
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
29
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
34
|
+
const { parent } = node;
|
|
35
|
+
if (
|
|
36
|
+
parent.type === 'CallExpression' &&
|
|
37
|
+
parent.arguments[0] === node &&
|
|
38
|
+
parent.callee.type === 'Identifier'
|
|
39
|
+
) {
|
|
40
|
+
if (computedNames.has(parent.callee.name)) {
|
|
41
|
+
fnStack.push('computed');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (effectNames.has(parent.callee.name)) {
|
|
45
|
+
fnStack.push('effect');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
fnStack.push('other');
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
53
|
+
fnStack.pop();
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
CallExpression(node) {
|
|
57
|
+
if (
|
|
58
|
+
node.callee.type !== 'Identifier' ||
|
|
59
|
+
!effectNames.has(node.callee.name)
|
|
60
|
+
) { return; }
|
|
61
|
+
|
|
62
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
63
|
+
if (fnStack[i] === 'computed') {
|
|
64
|
+
context.report({ node, messageId: 'noEffectInComputed' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (fnStack[i] === 'effect') { return; }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reports effect() called inside an effect() callback. Every re-run of the outer effect
|
|
2
|
+
// creates a new inner effect — the previous one is never stopped, so subscriptions accumulate.
|
|
3
|
+
// Capturing the return handle does not help; you would need to call .stop() on the previous
|
|
4
|
+
// handle at the top of each run, which the no-ignored-effect-return rule does not enforce.
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'disallow creating a new effect() inside an effect() body',
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
noEffectInEffect:
|
|
13
|
+
'effect() called inside an effect() body. Each outer re-run creates a new inner effect ' +
|
|
14
|
+
'without stopping the previous one, causing subscriptions to accumulate. ' +
|
|
15
|
+
'Declare the effect at the same level or restructure using a single effect.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
create(context) {
|
|
20
|
+
const effectNames = new Set();
|
|
21
|
+
const computedNames = new Set();
|
|
22
|
+
// Each entry is 'effect', 'computed', or 'other' — innermost frame is last.
|
|
23
|
+
const fnStack = [];
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
ImportDeclaration(node) {
|
|
27
|
+
if (node.source.value !== 'kensington') { return; }
|
|
28
|
+
for (const spec of node.specifiers) {
|
|
29
|
+
if (spec.type !== 'ImportSpecifier') { continue; }
|
|
30
|
+
if (spec.imported.name === 'effect') { effectNames.add(spec.local.name); }
|
|
31
|
+
if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
':matches(ArrowFunctionExpression, FunctionExpression)'(node) {
|
|
36
|
+
const { parent } = node;
|
|
37
|
+
if (
|
|
38
|
+
parent.type === 'CallExpression' &&
|
|
39
|
+
parent.arguments[0] === node &&
|
|
40
|
+
parent.callee.type === 'Identifier'
|
|
41
|
+
) {
|
|
42
|
+
if (effectNames.has(parent.callee.name)) {
|
|
43
|
+
fnStack.push('effect');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (computedNames.has(parent.callee.name)) {
|
|
47
|
+
fnStack.push('computed');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
fnStack.push('other');
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
':matches(ArrowFunctionExpression, FunctionExpression):exit'() {
|
|
55
|
+
fnStack.pop();
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
CallExpression(node) {
|
|
59
|
+
if (
|
|
60
|
+
node.callee.type !== 'Identifier' ||
|
|
61
|
+
!effectNames.has(node.callee.name)
|
|
62
|
+
) { return; }
|
|
63
|
+
|
|
64
|
+
for (let i = fnStack.length - 1; i >= 0; i--) {
|
|
65
|
+
if (fnStack[i] === 'effect') {
|
|
66
|
+
context.report({ node, messageId: 'noEffectInEffect' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (fnStack[i] === 'computed') { return; }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|