@thomaflette/eslint-plugin-solid-2 0.1.0 → 0.2.1
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 +8 -3
- package/dist/index.cjs +810 -468
- package/dist/index.d.cts +79 -63
- package/dist/index.d.mts +79 -63
- package/dist/index.mjs +810 -468
- package/docs/README.md +24 -0
- package/docs/adr/0001-only-false-positive-free-rules.md +65 -0
- package/docs/adr/0002-sound-component-detection.md +66 -0
- package/docs/adr/0003-require-solid-binding.md +50 -0
- package/docs/adr/0004-correctness-guard-not-migration-tool.md +48 -0
- package/docs/adr/0005-recommended-type-checked-config.md +27 -0
- package/docs/adr/0006-merge-leaf-owner-rules.md +42 -0
- package/docs/adr/0007-typescript-audience.md +35 -0
- package/docs/components-return-once.md +89 -0
- package/docs/dev-diagnostic-coverage.md +29 -0
- package/docs/jsx-no-duplicate-props.md +34 -0
- package/docs/no-destructure.md +92 -0
- package/docs/no-leaf-owner-operations.md +90 -0
- package/docs/no-owned-scope-writes.md +117 -0
- package/docs/no-reactive-read-after-await.md +87 -0
- package/docs/no-stale-props-alias.md +157 -0
- package/docs/no-untracked-read-in-effect-apply.md +97 -0
- package/docs/prefer-for.md +81 -0
- package/docs/prefer-show.md +59 -0
- package/docs/self-closing-comp.md +42 -0
- package/package.json +7 -5
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# `solid/no-leaf-owner-operations`
|
|
2
|
+
|
|
3
|
+
Disallow `onCleanup`, child computation/owner creation, and `flush()` directly inside
|
|
4
|
+
`createTrackedEffect(...)` and owner-backed `onSettled(...)` callbacks.
|
|
5
|
+
|
|
6
|
+
A leaf owner cannot own or schedule. Three things are therefore forbidden in its callback body, all facets of the same runtime constraint:
|
|
7
|
+
|
|
8
|
+
- **`onCleanup`** — return a cleanup function instead.
|
|
9
|
+
- **Creating child computations/owners** (`createMemo`, effects, projections, roots, list mappings,
|
|
10
|
+
boundaries, and the function forms of signal/store factories) — move them to the component body
|
|
11
|
+
or another owner. Plain value-form signals and stores do not create a child owner and are allowed
|
|
12
|
+
by the beta.17 runtime.
|
|
13
|
+
- **`flush()`** — schedule work outside instead.
|
|
14
|
+
|
|
15
|
+
## Bad
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
onSettled(() => {
|
|
19
|
+
onCleanup(() => console.log("cleanup"));
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
createTrackedEffect(() => {
|
|
25
|
+
const memo = createMemo(() => count() * 2);
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
onSettled(() => {
|
|
31
|
+
flush();
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Good
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
function Ticker() {
|
|
39
|
+
onSettled(() => {
|
|
40
|
+
const id = setInterval(tick, 1000);
|
|
41
|
+
return () => clearInterval(id);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return <output>{time()}</output>;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const memo = createMemo(() => count() * 2);
|
|
50
|
+
createTrackedEffect(() => {
|
|
51
|
+
console.log(memo());
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
onSettled(() => {
|
|
57
|
+
queueMicrotask(() => {
|
|
58
|
+
setOpen(true);
|
|
59
|
+
flush(); // Commit the update before reading the resulting DOM.
|
|
60
|
+
dialog.focus();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
|
|
67
|
+
- Only **direct** calls inside the forbidden callback body are checked; nested helper function definitions are allowed.
|
|
68
|
+
- `flush()` is normally unnecessary because Solid flushes updates automatically. Use it only at an
|
|
69
|
+
imperative boundary that must observe the updated DOM synchronously; a microtask scheduled from a
|
|
70
|
+
leaf-owner callback is outside that callback's non-reentrant flush cycle.
|
|
71
|
+
- Calls are matched by binding (a direct, aliased, or namespace `solid-js` import, or an unresolved
|
|
72
|
+
global) — a same-named function from another package is not flagged. Type-aware mode also follows
|
|
73
|
+
re-export chains.
|
|
74
|
+
- `createContext` is a context factory and is allowed. `createRoot` creates a child owner and is
|
|
75
|
+
rejected by beta.17 beneath a leaf owner.
|
|
76
|
+
- `onSettled` is a leaf owner only when called under an owner. Calls from event handlers or another
|
|
77
|
+
leaf owner are scheduled unowned, so their callbacks are not classified as leaf-owner scopes.
|
|
78
|
+
- This rule replaces the former `no-cleanup-in-forbidden-scope`, `no-flush-in-forbidden-scope`, and `no-primitives-in-forbidden-scope` rules (see [ADR-0006](./adr/0006-merge-leaf-owner-rules.md)).
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
### `typescriptEnabled` (default `false`)
|
|
83
|
+
|
|
84
|
+
Set `typescriptEnabled: true` to additionally recognize Solid APIs that arrive through re-export
|
|
85
|
+
chains. Direct, aliased, and namespace `solid-js` imports are handled without type information.
|
|
86
|
+
This option requires ESLint type information and is enabled by `recommendedTypeChecked`.
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{ "rules": { "solid/no-leaf-owner-operations": ["error", { "typescriptEnabled": true }] } }
|
|
90
|
+
```
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# `solid/no-owned-scope-writes`
|
|
2
|
+
|
|
3
|
+
Disallow writing to signals/stores or calling actions inside component bodies and reactive compute
|
|
4
|
+
scopes.
|
|
5
|
+
|
|
6
|
+
This matches Solid 2's `REACTIVE_WRITE_IN_OWNED_SCOPE` behavior. In these places, derive values
|
|
7
|
+
instead of writing state back into the graph. It also matches the runtime's
|
|
8
|
+
`ACTION_CALLED_IN_OWNED_SCOPE` error: actions belong at imperative boundaries such as event handlers.
|
|
9
|
+
|
|
10
|
+
## Bad
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
createMemo(() => setCount(count() + 1));
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
createMemo(() => refresh());
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
createEffect(
|
|
22
|
+
() => {
|
|
23
|
+
setCount(count() + 1);
|
|
24
|
+
return count();
|
|
25
|
+
},
|
|
26
|
+
(value) => console.log(value),
|
|
27
|
+
);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
function Component() {
|
|
32
|
+
const [count, setCount] = createSignal(0);
|
|
33
|
+
setCount(1);
|
|
34
|
+
return <div>{count()}</div>;
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const save = action(function* () {
|
|
40
|
+
yield api.save();
|
|
41
|
+
});
|
|
42
|
+
createMemo(() => save());
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
function Component() {
|
|
47
|
+
const [state, setState] = createStore({ count: 0 });
|
|
48
|
+
setState((s) => {
|
|
49
|
+
s.count += 1;
|
|
50
|
+
});
|
|
51
|
+
return <div>{state.count}</div>;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Good
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const doubled = createMemo(() => count() * 2);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
createEffect(
|
|
63
|
+
() => count(),
|
|
64
|
+
(value) => {
|
|
65
|
+
setOther(value);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
function Component() {
|
|
72
|
+
const [count, setCount] = createSignal(0);
|
|
73
|
+
return <button onClick={() => setCount((c) => c + 1)}>Increment</button>;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
function Component() {
|
|
79
|
+
let button: HTMLButtonElement | undefined;
|
|
80
|
+
const [node, setNode] = createSignal<HTMLButtonElement | undefined>(undefined, {
|
|
81
|
+
ownedWrite: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
createRenderEffect(() => {
|
|
85
|
+
setNode(button);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return <button ref={button}>{node() ? "Ready" : "Loading"}</button>;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Notes
|
|
93
|
+
|
|
94
|
+
- Component bodies count as owned scopes.
|
|
95
|
+
- The compute phase of effects, memos, projections, and derived signal/store factories counts as
|
|
96
|
+
an owned scope. The apply phase (second argument) of `createEffect`/`createRenderEffect` does not.
|
|
97
|
+
- The 1.x single-callback form `createEffect(() => { ... })` is already deprecated in Solid 2 and is not flagged by this rule — the deprecation marker on the type is the relevant signal there.
|
|
98
|
+
- `ownedWrite: true` is respected for `createSignal`, including aliased imports.
|
|
99
|
+
- Actions created with `action(...)`, their `const` aliases, and immediately-invoked action
|
|
100
|
+
factories are detected.
|
|
101
|
+
- `refresh()` is a write-like graph operation and is rejected in the same owned scopes.
|
|
102
|
+
- Direct, aliased, and namespace `solid-js` imports are resolved by binding rather than by their
|
|
103
|
+
local spelling.
|
|
104
|
+
|
|
105
|
+
## Options
|
|
106
|
+
|
|
107
|
+
### `typescriptEnabled` (default `false`)
|
|
108
|
+
|
|
109
|
+
Component bodies are recognised by annotation (`Component`/…) or in-file `<C/>` usage by default.
|
|
110
|
+
Set `typescriptEnabled: true` to also detect components by their cross-file JSX usage and recognize
|
|
111
|
+
Solid factories, setters, actions, and `refresh` through re-export chains. Direct, aliased, and
|
|
112
|
+
namespace `solid-js` imports remain AST-only. The option is slower and requires ESLint type
|
|
113
|
+
information.
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{ "rules": { "solid/no-owned-scope-writes": ["error", { "typescriptEnabled": true }] } }
|
|
117
|
+
```
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# `solid/no-reactive-read-after-await`
|
|
2
|
+
|
|
3
|
+
Disallow reading a signal/memo accessor **after an `await`** inside an async reactive computation, where it is no longer tracked as a dependency.
|
|
4
|
+
|
|
5
|
+
Solid registers dependencies **synchronously**: when a computation runs, tracking is enabled, the compute function is called, and tracking is torn down the instant it returns. For an `async` compute function, that return happens at the **first `await`** — it hands back a promise and the tracking context is gone. Any accessor read in the continuation after the await runs untracked: it still returns a value, so nothing breaks loudly, but it never records a dependency, so the computation **won't re-run when that signal changes**.
|
|
6
|
+
|
|
7
|
+
Solid 2.0 has no dedicated `createAsync` primitive — the async primitive is an `async` compute function passed to `createMemo`, `createEffect`, `createRenderEffect`, or `createProjection`. This rule checks the compute callback (the first argument) of each.
|
|
8
|
+
|
|
9
|
+
## Bad
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const [count, setCount] = createSignal(0);
|
|
13
|
+
|
|
14
|
+
const data = createMemo(async () => {
|
|
15
|
+
const res = await fetch(`/api?since=${lastSync}`);
|
|
16
|
+
return (await res.json()).filter((x) => x.id > count()); // ❌ count() after await — not tracked
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
createEffect(async () => {
|
|
22
|
+
await ready();
|
|
23
|
+
console.log(count()); // ❌ count() after await — the effect won't re-run when count changes
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Good
|
|
28
|
+
|
|
29
|
+
Read what you need **before** the first `await`, then use the captured value:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const [count, setCount] = createSignal(0);
|
|
33
|
+
|
|
34
|
+
const data = createMemo(async () => {
|
|
35
|
+
const min = count(); // ✅ tracked
|
|
36
|
+
const res = await fetch(`/api?since=${lastSync}`);
|
|
37
|
+
return (await res.json()).filter((x) => x.id > min);
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If reading after the await is genuinely intentional (you do **not** want it to be a dependency), make that explicit with `untrack()`:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
createMemo(async () => {
|
|
45
|
+
await ready();
|
|
46
|
+
return untrack(() => count()); // ✅ explicitly untracked
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Why not a runtime warning?
|
|
51
|
+
|
|
52
|
+
In the browser there is no reliable way to detect this at runtime: once an async function yields at `await`, native `await` resumes through the engine's internal machinery (not `Promise.prototype.then`), and there is no `AsyncContext` yet — so a post-await read is indistinguishable from any other untracked read (an event handler, `untrack`). Static analysis sees the source directly, which is why this is a lint rule.
|
|
53
|
+
|
|
54
|
+
## Type-aware mode (additive)
|
|
55
|
+
|
|
56
|
+
By default the rule is **AST-only**: it tracks accessors that come from a recognizable factory call
|
|
57
|
+
in the same file (`createSignal`/`createMemo`/`createOptimistic`, plus `const` aliases). Async compute
|
|
58
|
+
callbacks are recognized for effects, memos, projections, and derived signal/store factories,
|
|
59
|
+
including direct, aliased, and namespace `solid-js` imports. With type information enabled (the
|
|
60
|
+
`recommendedTypeChecked` config, or `{ typescriptEnabled: true }`), it additionally recognizes
|
|
61
|
+
accessors **by type** — a callee whose type is Solid's `Accessor`, and factory calls resolved by
|
|
62
|
+
symbol. This catches cases the AST path can't see:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// member accessor — only caught with type info
|
|
66
|
+
function Row(props: { value: Accessor<number> }) {
|
|
67
|
+
return createMemo(async () => {
|
|
68
|
+
await fetch("/x");
|
|
69
|
+
return props.value(); // ❌ flagged under recommendedTypeChecked
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This is **purely additive** (ADR-0005): it never changes a verdict on code the AST path already handles, and it never reports more loosely — the type must be solid's `Accessor` (originating from `solid-js`/`@solidjs/signals`), so a plain `() => T` or a same-named non-solid `Accessor` is left alone. Both modes have **zero false positives**; type info only reduces false negatives.
|
|
75
|
+
|
|
76
|
+
## Limitations
|
|
77
|
+
|
|
78
|
+
The "after an await" check is intentionally **sound over complete** — it never reports correct code, at the cost of missing some genuinely-buggy shapes (false negatives):
|
|
79
|
+
|
|
80
|
+
- An `await` reached only on one branch of an `if`/`switch`, or only on later iterations of a loop, is not treated as guaranteed, so a read after it is not flagged.
|
|
81
|
+
- Reads inside a nested helper closure are not analyzed — the rule can't know when the helper is invoked.
|
|
82
|
+
- Only signal/memo **accessors** are tracked. A store proxy read after an await is not covered (`createProjection` returns a store, so its _result_ isn't checked — but its async compute body is).
|
|
83
|
+
|
|
84
|
+
## Notes
|
|
85
|
+
|
|
86
|
+
- Accessors aliased through a plain `const` (`const c = count`) are still caught; `untrack(() => count())` is allowed.
|
|
87
|
+
- The async **apply** callback of an effect (the second argument) is not checked here — its untracked reads are reported by [`no-untracked-read-in-effect-apply`](./no-untracked-read-in-effect-apply.md).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# `solid/no-stale-props-alias`
|
|
2
|
+
|
|
3
|
+
Disallow provable untracked reactive reads in component bodies and Solid control-flow function
|
|
4
|
+
children. This includes stale props aliases, direct props reads, local signal/memo accessor calls,
|
|
5
|
+
store reads, and accessor parameters supplied by control flow.
|
|
6
|
+
|
|
7
|
+
Solid 2 tracks prop reads when the property is read in JSX or another tracked scope. A top-level
|
|
8
|
+
alias reads once during component setup, outside tracking, so the alias can become stale.
|
|
9
|
+
|
|
10
|
+
The same execution rule applies to direct reads such as `console.log(props.name)` and to the
|
|
11
|
+
structure-building bodies of `For`, `Show`, and `Match` function children. JSX expressions,
|
|
12
|
+
reactive computations, nested event/helper closures, and explicit `untrack` calls are excluded.
|
|
13
|
+
|
|
14
|
+
## Invalid
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
const Card: Component = (props) => {
|
|
18
|
+
const name = props.name;
|
|
19
|
+
return <h1>{name}</h1>;
|
|
20
|
+
};
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
const Counter: Component = () => {
|
|
25
|
+
const [count] = createSignal(0);
|
|
26
|
+
console.log(count()); // direct component-body accessor read
|
|
27
|
+
return <span>{count()}</span>;
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<Show when={user()}>
|
|
33
|
+
{(user) => {
|
|
34
|
+
const name = user().name; // function-child accessor read outside JSX tracking
|
|
35
|
+
return <span>{name}</span>;
|
|
36
|
+
}}
|
|
37
|
+
</Show>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
const Card: Component = (props) => {
|
|
42
|
+
const userName = props.user.name;
|
|
43
|
+
return <h1>{userName}</h1>;
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
const Card: Component = (props) => {
|
|
49
|
+
const name = props.name;
|
|
50
|
+
validate(name);
|
|
51
|
+
return <h1>{props.name}</h1>;
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
const Card: Component = (props) => {
|
|
57
|
+
const name = props.name ?? "Anonymous";
|
|
58
|
+
return <h1>{name}</h1>;
|
|
59
|
+
};
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
const Card: Component = (props) => {
|
|
64
|
+
const label = formatName(props.name);
|
|
65
|
+
return <h1>{label}</h1>;
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
const Card: Component = (props) => {
|
|
71
|
+
const alias = props;
|
|
72
|
+
const name = alias.name; // the read through the alias is reported, not the alias itself
|
|
73
|
+
return <h1>{name}</h1>;
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
const Card: Component = (props) => {
|
|
79
|
+
const copy = { ...props }; // spreading reads every property eagerly
|
|
80
|
+
return <h1>{copy.name}</h1>;
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
const Card: Component = (props) => {
|
|
86
|
+
let name;
|
|
87
|
+
name = props.name;
|
|
88
|
+
return <h1>{name}</h1>;
|
|
89
|
+
};
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Valid
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
const Card: Component = (props) => {
|
|
96
|
+
return <h1>{props.name}</h1>;
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
const Card: Component = (props) => {
|
|
102
|
+
const name = untrack(() => props.name);
|
|
103
|
+
return <h1>{name}</h1>;
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
// The canonical defaults / rest patterns: merge and omit return reactive proxies, so passing the
|
|
109
|
+
// props object to them is a passthrough, not a read. Their results are tracked as props-like, so
|
|
110
|
+
// a later top-level read from them is still reported.
|
|
111
|
+
const Card: Component = (_props) => {
|
|
112
|
+
const props = merge({ size: "md" }, _props);
|
|
113
|
+
const rest = omit(props, "class");
|
|
114
|
+
return <div {...rest}>{props.size}</div>;
|
|
115
|
+
};
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
// A whole-object alias performs no read; property reads through it in JSX stay reactive.
|
|
120
|
+
const Card: Component = (props) => {
|
|
121
|
+
const alias = props;
|
|
122
|
+
return <h1>{alias.name}</h1>;
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Options
|
|
127
|
+
|
|
128
|
+
### `typescriptEnabled` (default `false`)
|
|
129
|
+
|
|
130
|
+
Component bodies are recognised by annotation (`Component`/…) or in-file `<C/>` usage by default.
|
|
131
|
+
Set `typescriptEnabled: true` to also detect components by their cross-file JSX usage via the
|
|
132
|
+
TypeScript type-checker, recognize nominal Solid accessors, and follow re-exported Solid
|
|
133
|
+
control-flow components (slower; requires ESLint type information).
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{ "rules": { "solid/no-stale-props-alias": ["warn", { "typescriptEnabled": true }] } }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Notes
|
|
140
|
+
|
|
141
|
+
- A props report requires an _eager read_: a member access rooted at the props object
|
|
142
|
+
(`props.name`, `props[key]`, reads through a stable alias) or a spread of it (`{ ...props }`). A
|
|
143
|
+
bare `props` reference is never a read — passing the object to `merge`/`omit` or another helper
|
|
144
|
+
keeps reactivity intact. A helper that reads eagerly inside (`createForm(props)`) is a tolerated
|
|
145
|
+
false negative because its behavior is undecidable from the call site.
|
|
146
|
+
- Direct reads in nested functions and explicit `untrack(...)` calls are not reported.
|
|
147
|
+
- Reassignable props aliases are not followed.
|
|
148
|
+
- Destructured props are handled by `solid/no-destructure`.
|
|
149
|
+
- Function children of binding-proven Solid `For`, `Repeat`, `Show`, and `Match` elements are also
|
|
150
|
+
checked, including `children={fn}`, immutable function aliases, namespace imports, and nested
|
|
151
|
+
Solid control flow. JSX reads and explicit `untrack(...)` reads inside those callbacks remain
|
|
152
|
+
valid because they execute in tracked contexts.
|
|
153
|
+
- Callback parameters follow Solid 2's mode-specific semantics: default `For` indexes,
|
|
154
|
+
`keyed={false}` items, custom-key `For` items/indexes, and non-keyed `Show`/`Match` values are
|
|
155
|
+
accessors. Default `For` items, `Repeat` indexes, and keyed `Show`/`Match` values are raw.
|
|
156
|
+
- Type-aware mode additionally recognizes canonically typed Solid control-flow components that
|
|
157
|
+
arrive through re-export chains.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# `solid/no-untracked-read-in-effect-apply`
|
|
2
|
+
|
|
3
|
+
Disallow reading reactive state in a `createEffect` / `createRenderEffect` **apply** callback, which runs untracked.
|
|
4
|
+
|
|
5
|
+
An effect has two phases: **compute** (tracked — reads here record dependencies) and **apply** (untracked — the side effect). Reading reactive state in the apply phase triggers `STRICT_READ_UNTRACKED` in dev and won't re-run the effect. There are two ways to trip this, both reported by this rule:
|
|
6
|
+
|
|
7
|
+
1. **Calling a signal/memo accessor** directly in the apply callback.
|
|
8
|
+
2. **Reading a store proxy** that was passed through the compute return into the apply callback.
|
|
9
|
+
|
|
10
|
+
The fix for both: read what you need in the **compute** phase and use the passed value (or `untrack()` for signals, `deep()` for whole-store snapshots).
|
|
11
|
+
|
|
12
|
+
## Bad
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const [count, setCount] = createSignal(0);
|
|
16
|
+
createEffect(
|
|
17
|
+
() => count(),
|
|
18
|
+
(value) => {
|
|
19
|
+
console.log(count());
|
|
20
|
+
}, // ❌ accessor called in apply
|
|
21
|
+
);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const [store] = createStore({ user: { name: "A" } });
|
|
26
|
+
createEffect(
|
|
27
|
+
() => store.user,
|
|
28
|
+
(user) => sendAnalytics(user.name), // ❌ store proxy read in apply
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Good
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
createEffect(
|
|
36
|
+
() => count(),
|
|
37
|
+
(value) => {
|
|
38
|
+
console.log(value);
|
|
39
|
+
}, // ✅ use the passed value
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
createEffect(
|
|
45
|
+
() => ({ name: store.user.name }), // ✅ extract in compute
|
|
46
|
+
(value) => sendAnalytics(value.name),
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
createEffect(
|
|
52
|
+
() => deep(store), // ✅ deep() returns a plain snapshot safe to read in apply
|
|
53
|
+
(snapshot) => saveToLocalStorage(JSON.stringify(snapshot)),
|
|
54
|
+
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
createEffect(
|
|
59
|
+
() => count(),
|
|
60
|
+
(value) => {
|
|
61
|
+
// ✅ the handler runs later, on click — untracked reads are sanctioned there,
|
|
62
|
+
// and the runtime's STRICT_READ_UNTRACKED does not fire for it either
|
|
63
|
+
el.addEventListener("click", () => setOpen(!open()));
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
### `typescriptEnabled` (default `false`)
|
|
71
|
+
|
|
72
|
+
The AST-only path recognises accessors created in the same file. Set `typescriptEnabled: true` to
|
|
73
|
+
also detect imported, parameter, and member accessors whose nominal type originates from Solid.
|
|
74
|
+
Structurally similar plain functions and readonly objects are not treated as accessors or stores.
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"rules": {
|
|
79
|
+
"solid/no-untracked-read-in-effect-apply": ["warn", { "typescriptEnabled": true }]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Notes
|
|
85
|
+
|
|
86
|
+
- Only reads that happen **directly** in the apply callback are reported — the read's nearest
|
|
87
|
+
enclosing function must be the apply callback itself. A read inside a closure created during
|
|
88
|
+
apply (an event handler, a `setInterval` callback) executes later, outside the apply phase, where
|
|
89
|
+
untracked reads are legitimate; flagging those was a false positive on sanctioned code. A closure
|
|
90
|
+
that is _invoked synchronously during apply_ (`arr.forEach(() => count())`) is a tolerated false
|
|
91
|
+
negative — the runtime diagnostic still covers it on executed paths.
|
|
92
|
+
- The `EffectBundle` form (`{ effect(value) { ... } }`) is checked the same way as a bare apply callback.
|
|
93
|
+
- Accessors aliased through a plain `const` (`const c = count`) are still caught; `untrack(() => count())` is allowed.
|
|
94
|
+
- Store proxy reports require proof: a bare store or a member path known from a literal initializer
|
|
95
|
+
to be an object/array. Computed paths and non-literal initializer shapes are left alone because
|
|
96
|
+
they may return primitives.
|
|
97
|
+
- This rule replaces the former `no-signal-in-effect-apply` and `no-store-proxy-in-effect-apply` rules (see [ADR-0006](./adr/0006-merge-leaf-owner-rules.md)).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# `solid/prefer-for`
|
|
2
|
+
|
|
3
|
+
Prefer Solid's `<For />` component over `Array#map(...)` when rendering JSX lists.
|
|
4
|
+
|
|
5
|
+
`<For />` matches Solid's list rendering model better than embedding `map(...)` inside JSX. In Solid 2's default `<For />`, the item callback parameter is a raw value (unlike `index`, which is an `Accessor<number>`), so the autofix leaves item reads untouched and only calls the index parameter.
|
|
6
|
+
|
|
7
|
+
## Bad
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
<ul>
|
|
11
|
+
{items.map((item) => (
|
|
12
|
+
<li>{item.name}</li>
|
|
13
|
+
))}
|
|
14
|
+
</ul>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<For each={groups}>{(group) => group.items.map((item) => <Row item={item} />)}</For>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
<ol>
|
|
23
|
+
{props.data.map(({ text }) => (
|
|
24
|
+
<li>{text}</li>
|
|
25
|
+
))}
|
|
26
|
+
</ol>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Good
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<ul>
|
|
33
|
+
<For each={items}>{(item) => <li>{item.name}</li>}</For>
|
|
34
|
+
</ul>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
<ol>
|
|
39
|
+
{props.data.map((item, i) => (
|
|
40
|
+
<li>
|
|
41
|
+
{i}: {item.name}
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ol>
|
|
45
|
+
// autofixable to (index is an accessor, so it is called):
|
|
46
|
+
<ol>
|
|
47
|
+
<For each={props.data}>
|
|
48
|
+
{(item, i) => (
|
|
49
|
+
<li>
|
|
50
|
+
{i()}: {item.name}
|
|
51
|
+
</li>
|
|
52
|
+
)}
|
|
53
|
+
</For>
|
|
54
|
+
</ol>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<ol>{props.data.map((value) => value.text)}</ol>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- The rule requires TypeScript to prove the receiver is an array. Syntax alone cannot distinguish
|
|
64
|
+
Array#map from an observable or another collection's `.map` method without false positives.
|
|
65
|
+
- Autofix only applies to arrow callbacks with at most the item and index parameters. Array's third
|
|
66
|
+
callback argument, normal functions (`arguments`/`this`), destructuring, and rest parameters are
|
|
67
|
+
reported without a fixer.
|
|
68
|
+
- The rule reports `map(...)` calls that produce JSX inside JSX, including when wrapped in a `{cond && …}` or `{cond ? … : …}` slot (those wrapped forms are reported but not autofixed).
|
|
69
|
+
- Destructured parameters, rest parameters, and other complex callback shapes are reported without a fixer.
|
|
70
|
+
|
|
71
|
+
## Options
|
|
72
|
+
|
|
73
|
+
### `typescriptEnabled` (default `false`)
|
|
74
|
+
|
|
75
|
+
Set `typescriptEnabled: true` to enable the rule. It reports only when the receiver is provably an
|
|
76
|
+
array and skips `Map`, `Set`, observables, `any`, and `unknown`. This is enabled by
|
|
77
|
+
`recommendedTypeChecked`; the AST-only `recommended` config leaves the rule off.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "rules": { "solid/prefer-for": ["warn", { "typescriptEnabled": true }] } }
|
|
81
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# `solid/prefer-show`
|
|
2
|
+
|
|
3
|
+
Prefer Solid's `<Show />` component over JSX `&&` and ternary conditionals when rendering content.
|
|
4
|
+
|
|
5
|
+
`<Show />` makes conditional rendering explicit and aligns with Solid's control-flow components. Ternary cases can often be autofixed. `&&` cases are only reported because JSX and `<Show when={...}>` differ for falsy non-boolean values.
|
|
6
|
+
|
|
7
|
+
## Bad
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
<div>{cond && <span>Content</span>}</div>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
<div>{cond ? <span>Content</span> : <span>Fallback</span>}</div>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<For each={items}>{(item) => (item.ready ? <Ready /> : <Pending />)}</For>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Good
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
<div>
|
|
25
|
+
<Show when={cond}>
|
|
26
|
+
<span>Content</span>
|
|
27
|
+
</Show>
|
|
28
|
+
</div>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<div>
|
|
33
|
+
<Show when={cond} fallback={<span>Fallback</span>}>
|
|
34
|
+
<span>Content</span>
|
|
35
|
+
</Show>
|
|
36
|
+
</div>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
<For each={items}>
|
|
41
|
+
{(item) => (
|
|
42
|
+
<Show when={item.ready} fallback={<Pending />}>
|
|
43
|
+
<Ready />
|
|
44
|
+
</Show>
|
|
45
|
+
)}
|
|
46
|
+
</For>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
<Show when={cond}>Content</Show>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Notes
|
|
54
|
+
|
|
55
|
+
- Only conditionals with a JSX branch are reported; plain value conditionals like `{a ? b : c}` or `{cond && value}` are left alone (rewriting them into `<Show>` would change behavior).
|
|
56
|
+
- Ternary expressions with a JSX branch are autofixable.
|
|
57
|
+
- Render callbacks are inspected whether written with a concise body (`(item) => cond ? <A/> : <B/>`) or a block body (`(item) => { return cond ? <A/> : <B/>; }`).
|
|
58
|
+
- `&&` expressions with JSX on the right are reported and offer the `<Show>` rewrite as an **editor suggestion**, never an autofix. `cond && <X/>` evaluates to the left operand when it is falsy, and Solid renders falsy-but-renderable values (`0`, `NaN`) as text — which `<Show>` would drop. A blanket `--fix` could therefore change behavior, so the rewrite is left for you to apply per occurrence after checking the condition's type.
|
|
59
|
+
- Example: `0 && <X />` renders `0` in JSX, but `<Show when={0}><X /></Show>` renders nothing. (Do **not** "promote" this suggestion to an autofix.)
|