@qq99/eslint-plugin-solid 0.18.0-no-reactive-read-in-effect-callback.0
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/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/chunk-4UZNJ5MS.mjs +32 -0
- package/dist/chunk-4UZNJ5MS.mjs.map +1 -0
- package/dist/chunk-EBU4C5NV.mjs +77 -0
- package/dist/chunk-EBU4C5NV.mjs.map +1 -0
- package/dist/chunk-LE6SW2M3.mjs +6231 -0
- package/dist/chunk-LE6SW2M3.mjs.map +1 -0
- package/dist/chunk-MHDQJMLE.mjs +39 -0
- package/dist/chunk-MHDQJMLE.mjs.map +1 -0
- package/dist/configs/recommended.d.mts +109 -0
- package/dist/configs/recommended.d.ts +109 -0
- package/dist/configs/recommended.js +5877 -0
- package/dist/configs/recommended.js.map +1 -0
- package/dist/configs/recommended.mjs +5 -0
- package/dist/configs/recommended.mjs.map +1 -0
- package/dist/configs/typescript.d.mts +103 -0
- package/dist/configs/typescript.d.ts +103 -0
- package/dist/configs/typescript.js +6246 -0
- package/dist/configs/typescript.js.map +1 -0
- package/dist/configs/typescript.mjs +6 -0
- package/dist/configs/typescript.mjs.map +1 -0
- package/dist/configs/v2-strict.d.mts +130 -0
- package/dist/configs/v2-strict.d.ts +130 -0
- package/dist/configs/v2-strict.js +6317 -0
- package/dist/configs/v2-strict.js.map +1 -0
- package/dist/configs/v2-strict.mjs +7 -0
- package/dist/configs/v2-strict.mjs.map +1 -0
- package/dist/configs/v2.d.mts +127 -0
- package/dist/configs/v2.d.ts +127 -0
- package/dist/configs/v2.js +6289 -0
- package/dist/configs/v2.js.map +1 -0
- package/dist/configs/v2.mjs +6 -0
- package/dist/configs/v2.mjs.map +1 -0
- package/dist/index.d.mts +740 -0
- package/dist/index.d.ts +740 -0
- package/dist/index.js +6365 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +42 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +109 -0
- package/src/compat.ts +53 -0
- package/src/configs/recommended.ts +48 -0
- package/src/configs/typescript.ts +21 -0
- package/src/configs/v2-strict.ts +28 -0
- package/src/configs/v2.ts +68 -0
- package/src/deps.d.ts +1 -0
- package/src/index.ts +29 -0
- package/src/plugin.ts +92 -0
- package/src/rules/components-return-once.ts +209 -0
- package/src/rules/event-handlers.ts +397 -0
- package/src/rules/imports.ts +338 -0
- package/src/rules/jsx-no-duplicate-props.ts +110 -0
- package/src/rules/jsx-no-script-url.ts +61 -0
- package/src/rules/jsx-no-undef.ts +230 -0
- package/src/rules/jsx-uses-vars.ts +66 -0
- package/src/rules/no-accessor-as-prop.ts +147 -0
- package/src/rules/no-array-handlers.ts +57 -0
- package/src/rules/no-boolean-enumerated-attribute.ts +209 -0
- package/src/rules/no-browser-globals-in-server-function.ts +108 -0
- package/src/rules/no-destructure.ts +245 -0
- package/src/rules/no-innerhtml.ts +143 -0
- package/src/rules/no-invalid-server-capture.ts +104 -0
- package/src/rules/no-module-scope-reactive-primitive.ts +69 -0
- package/src/rules/no-proxy-apis.ts +96 -0
- package/src/rules/no-react-deps.ts +68 -0
- package/src/rules/no-react-specific-props.ts +60 -0
- package/src/rules/no-reactive-read-in-effect-callback.ts +570 -0
- package/src/rules/no-restated-default-options.ts +96 -0
- package/src/rules/no-single-arg-create-effect.ts +56 -0
- package/src/rules/no-store-mutation-outside-setter.ts +137 -0
- package/src/rules/no-unknown-namespaces.ts +140 -0
- package/src/rules/no-unused-signal.ts +119 -0
- package/src/rules/no-write-in-pure-computation.ts +231 -0
- package/src/rules/prefer-classlist.ts +93 -0
- package/src/rules/prefer-for.ts +93 -0
- package/src/rules/prefer-onSettled-for-side-effects.ts +101 -0
- package/src/rules/prefer-show.ts +102 -0
- package/src/rules/prefer-structured-class.ts +117 -0
- package/src/rules/reactivity.ts +1730 -0
- package/src/rules/removed-api.ts +212 -0
- package/src/rules/require-async-server-function.ts +122 -0
- package/src/rules/self-closing-comp.ts +150 -0
- package/src/rules/style-prop.ts +140 -0
- package/src/rules/valid-use-server.ts +192 -0
- package/src/rules/validate-jsx-nesting.ts +1 -0
- package/src/utils/reactive-imports.ts +382 -0
- package/src/utils/reactive-types.ts +272 -0
- package/src/utils/reactive-values.ts +162 -0
- package/src/utils.ts +442 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
10
|
+
import { getSolidSourceRegex, trackImports } from "../utils";
|
|
11
|
+
|
|
12
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
13
|
+
|
|
14
|
+
type MessageIds = "singleArgEffect" | "singleArgRenderEffect";
|
|
15
|
+
type Options = [];
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Solid 2.0 splits effects into a tracked compute function and an untracked
|
|
19
|
+
* effect function: `createEffect(compute, effect)`. The single-argument 1.x
|
|
20
|
+
* form types as `never` via a deprecated overload — which produces no compile
|
|
21
|
+
* error on a bare statement call — and only throws at runtime in dev mode.
|
|
22
|
+
* This rule is the build-time hard stop.
|
|
23
|
+
*/
|
|
24
|
+
export default createRule<Options, MessageIds>({
|
|
25
|
+
meta: {
|
|
26
|
+
type: "problem",
|
|
27
|
+
docs: {
|
|
28
|
+
description:
|
|
29
|
+
"Require the two-argument `createEffect(compute, effect)` form used by Solid 2.0.",
|
|
30
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/no-single-arg-create-effect.md",
|
|
31
|
+
},
|
|
32
|
+
schema: [],
|
|
33
|
+
messages: {
|
|
34
|
+
singleArgEffect:
|
|
35
|
+
"In Solid 2.0, createEffect takes separate compute and effect functions: `createEffect(() => signal(), (value) => doWork(value))`. For a derived value use `createMemo`; for a one-shot side effect, just call the function directly.",
|
|
36
|
+
singleArgRenderEffect:
|
|
37
|
+
"In Solid 2.0, createRenderEffect takes separate compute and effect functions: `createRenderEffect(() => signal(), (value) => doWork(value))`.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
defaultOptions: [],
|
|
41
|
+
create(context) {
|
|
42
|
+
const { matchImport, handleImportDeclaration } = trackImports(getSolidSourceRegex(context));
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ImportDeclaration: handleImportDeclaration,
|
|
46
|
+
CallExpression(node) {
|
|
47
|
+
if (node.callee.type !== "Identifier" || node.arguments.length !== 1) return;
|
|
48
|
+
if (matchImport("createEffect", node.callee.name)) {
|
|
49
|
+
context.report({ node, messageId: "singleArgEffect" });
|
|
50
|
+
} else if (matchImport("createRenderEffect", node.callee.name)) {
|
|
51
|
+
context.report({ node, messageId: "singleArgRenderEffect" });
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { TSESTree as T, ESLintUtils } from "@typescript-eslint/utils";
|
|
10
|
+
import { findVariable } from "../compat";
|
|
11
|
+
import { getSolidSourceRegex, trackImports } from "../utils";
|
|
12
|
+
|
|
13
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
14
|
+
|
|
15
|
+
// Array methods that mutate their receiver in place.
|
|
16
|
+
const MUTATING_METHODS = new Set([
|
|
17
|
+
"push",
|
|
18
|
+
"pop",
|
|
19
|
+
"shift",
|
|
20
|
+
"unshift",
|
|
21
|
+
"splice",
|
|
22
|
+
"sort",
|
|
23
|
+
"reverse",
|
|
24
|
+
"fill",
|
|
25
|
+
"copyWithin",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
type MessageIds = "mutateStore" | "mutateProjection";
|
|
29
|
+
type Options = [];
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* Store setters receive a mutable draft in Solid 2.0 (`produce` semantics by
|
|
33
|
+
* default), which makes mutating the read proxy directly — `store.count++`,
|
|
34
|
+
* `store.items.push(x)` — look plausible. It isn't: the proxy's write traps
|
|
35
|
+
* silently ignore writes outside a draft scope, with no error or dev warning
|
|
36
|
+
* of any kind — the mutation just vanishes. That silence is exactly why this
|
|
37
|
+
* rule matters: lint is currently the only guardrail. Draft mutations inside
|
|
38
|
+
* the setter are written through the draft parameter, a different variable,
|
|
39
|
+
* so they never resolve to the store and are naturally exempt.
|
|
40
|
+
*/
|
|
41
|
+
export default createRule<Options, MessageIds>({
|
|
42
|
+
meta: {
|
|
43
|
+
type: "problem",
|
|
44
|
+
docs: {
|
|
45
|
+
description:
|
|
46
|
+
"Disallow mutating a store's read proxy; store state changes only through the setter's mutable draft.",
|
|
47
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/no-store-mutation-outside-setter.md",
|
|
48
|
+
},
|
|
49
|
+
schema: [],
|
|
50
|
+
messages: {
|
|
51
|
+
mutateStore:
|
|
52
|
+
"'{{name}}' is a store's read proxy, which is read-only; this mutation is silently ignored — no error, no update, the write just vanishes. Mutate the draft inside the setter instead: `{{setter}}(draft => { ... })`.",
|
|
53
|
+
mutateProjection:
|
|
54
|
+
"'{{name}}' is a projection, a read-only derived store. Derive this value inside the projection function instead of mutating the result.",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultOptions: [],
|
|
58
|
+
create(context) {
|
|
59
|
+
const { matchImport, handleImportDeclaration } = trackImports(getSolidSourceRegex(context));
|
|
60
|
+
|
|
61
|
+
type Target = { kind: "store"; setter: string | null } | { kind: "projection" };
|
|
62
|
+
|
|
63
|
+
/** Resolves an identifier to the store or projection it names, if any. */
|
|
64
|
+
const resolveTarget = (id: T.Identifier): Target | null => {
|
|
65
|
+
const def = findVariable(context, id)?.defs[0];
|
|
66
|
+
if (!def || def.type !== "Variable" || !def.node.init) return null;
|
|
67
|
+
const { id: declId, init } = def.node;
|
|
68
|
+
if (init.type !== "CallExpression" || init.callee.type !== "Identifier") return null;
|
|
69
|
+
|
|
70
|
+
// const [store, setStore] = createStore(...)
|
|
71
|
+
if (
|
|
72
|
+
declId.type === "ArrayPattern" &&
|
|
73
|
+
declId.elements[0]?.type === "Identifier" &&
|
|
74
|
+
declId.elements[0].name === id.name &&
|
|
75
|
+
matchImport("createStore", init.callee.name)
|
|
76
|
+
) {
|
|
77
|
+
const setter = declId.elements[1]?.type === "Identifier" ? declId.elements[1].name : null;
|
|
78
|
+
return { kind: "store", setter };
|
|
79
|
+
}
|
|
80
|
+
// const projected = createProjection(...)
|
|
81
|
+
if (declId.type === "Identifier" && matchImport("createProjection", init.callee.name)) {
|
|
82
|
+
return { kind: "projection" };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Walks a member chain (`store.a.b`) down to its base identifier. */
|
|
88
|
+
const baseIdentifier = (node: T.Node): T.Identifier | null => {
|
|
89
|
+
let current: T.Node = node;
|
|
90
|
+
while (current.type === "MemberExpression") current = current.object;
|
|
91
|
+
return current.type === "Identifier" ? current : null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const checkMutation = (member: T.MemberExpression, reportNode: T.Node) => {
|
|
95
|
+
const base = baseIdentifier(member);
|
|
96
|
+
const target = base && resolveTarget(base);
|
|
97
|
+
if (!target) return;
|
|
98
|
+
if (target.kind === "projection") {
|
|
99
|
+
context.report({
|
|
100
|
+
node: reportNode,
|
|
101
|
+
messageId: "mutateProjection",
|
|
102
|
+
data: { name: base.name },
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
context.report({
|
|
106
|
+
node: reportNode,
|
|
107
|
+
messageId: "mutateStore",
|
|
108
|
+
data: { name: base.name, setter: target.setter ?? "setStore" },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
ImportDeclaration: handleImportDeclaration,
|
|
115
|
+
AssignmentExpression(node) {
|
|
116
|
+
if (node.left.type === "MemberExpression") checkMutation(node.left, node);
|
|
117
|
+
},
|
|
118
|
+
UpdateExpression(node) {
|
|
119
|
+
if (node.argument.type === "MemberExpression") checkMutation(node.argument, node);
|
|
120
|
+
},
|
|
121
|
+
UnaryExpression(node) {
|
|
122
|
+
if (node.operator === "delete" && node.argument.type === "MemberExpression") {
|
|
123
|
+
checkMutation(node.argument, node);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
CallExpression(node) {
|
|
127
|
+
if (
|
|
128
|
+
node.callee.type === "MemberExpression" &&
|
|
129
|
+
node.callee.property.type === "Identifier" &&
|
|
130
|
+
MUTATING_METHODS.has(node.callee.property.name)
|
|
131
|
+
) {
|
|
132
|
+
checkMutation(node.callee, node);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { ESLintUtils, TSESTree as T } from "@typescript-eslint/utils";
|
|
10
|
+
import { isDOMElementName, isSolidV2 } from "../utils";
|
|
11
|
+
|
|
12
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
13
|
+
|
|
14
|
+
const knownNamespaces = ["on", "oncapture", "use", "prop", "attr", "bool"];
|
|
15
|
+
const styleNamespaces = ["style", "class"];
|
|
16
|
+
const otherNamespaces = ["xmlns", "xlink"];
|
|
17
|
+
|
|
18
|
+
// In Solid 2.0, namespaces are no longer reserved: any colon-name passes
|
|
19
|
+
// through as a literal attribute, and `prop:` is the only special prefix left.
|
|
20
|
+
// The formerly-public 1.x prefixes now silently render literal attributes, so
|
|
21
|
+
// in v2 mode they're flagged as near-certain migration bugs with per-prefix
|
|
22
|
+
// guidance.
|
|
23
|
+
const v2MigrationGuidance: Record<string, string> = {
|
|
24
|
+
on: "For an event handler, use the camelCase form instead: `onClick`",
|
|
25
|
+
oncapture:
|
|
26
|
+
"There is no capture-phase prefix in Solid 2.0; add a capture listener with `addEventListener` in a `ref` if needed",
|
|
27
|
+
use: "Directives were removed in Solid 2.0; call the function in `ref` instead: `ref={(el) => myDirective(el)}`",
|
|
28
|
+
attr: "Use the plain attribute name; attributes are the default in Solid 2.0",
|
|
29
|
+
bool: "Use the plain attribute with a boolean or conditional value",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type MessageIds = "unknown" | "style" | "component" | "component-suggest" | "v2Namespace";
|
|
33
|
+
type Options = [{ allowedNamespaces: Array<string> }?];
|
|
34
|
+
|
|
35
|
+
export default createRule<Options, MessageIds>({
|
|
36
|
+
meta: {
|
|
37
|
+
type: "problem",
|
|
38
|
+
docs: {
|
|
39
|
+
description:
|
|
40
|
+
"Enforce using only Solid-specific namespaced attribute names (i.e. `'on:'` in `<div on:click={...} />`).",
|
|
41
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/no-unknown-namespaces.md",
|
|
42
|
+
},
|
|
43
|
+
hasSuggestions: true,
|
|
44
|
+
schema: [
|
|
45
|
+
{
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
allowedNamespaces: {
|
|
49
|
+
description: "an array of additional namespace names to allow",
|
|
50
|
+
type: "array",
|
|
51
|
+
items: {
|
|
52
|
+
type: "string",
|
|
53
|
+
},
|
|
54
|
+
default: [],
|
|
55
|
+
minItems: 1,
|
|
56
|
+
uniqueItems: true,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
messages: {
|
|
63
|
+
unknown: `'{{namespace}}:' is not one of Solid's special prefixes for JSX attributes (${knownNamespaces
|
|
64
|
+
.map((n) => `'${n}:'`)
|
|
65
|
+
.join(", ")}).`,
|
|
66
|
+
style:
|
|
67
|
+
"Using the '{{namespace}}:' special prefix is potentially confusing, prefer the '{{namespace}}' prop instead.",
|
|
68
|
+
component: "Namespaced props have no effect on components.",
|
|
69
|
+
"component-suggest": "Replace {{namespace}}:{{name}} with {{name}}.",
|
|
70
|
+
v2Namespace:
|
|
71
|
+
"'{{namespace}}:' is not a special prefix in Solid 2.0; this renders a literal '{{namespace}}:{{name}}' attribute. {{guidance}}.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
defaultOptions: [],
|
|
75
|
+
create(context) {
|
|
76
|
+
const explicitlyAllowedNamespaces = context.options?.[0]?.allowedNamespaces;
|
|
77
|
+
const v2 = isSolidV2(context);
|
|
78
|
+
return {
|
|
79
|
+
"JSXAttribute > JSXNamespacedName": (node: T.JSXNamespacedName) => {
|
|
80
|
+
const openingElement = node.parent!.parent as T.JSXOpeningElement;
|
|
81
|
+
if (
|
|
82
|
+
openingElement.name.type === "JSXIdentifier" &&
|
|
83
|
+
!isDOMElementName(openingElement.name.name)
|
|
84
|
+
) {
|
|
85
|
+
// no namespaces on Solid component elements
|
|
86
|
+
context.report({
|
|
87
|
+
node,
|
|
88
|
+
messageId: "component",
|
|
89
|
+
suggest: [
|
|
90
|
+
{
|
|
91
|
+
messageId: "component-suggest",
|
|
92
|
+
data: { namespace: node.namespace.name, name: node.name.name },
|
|
93
|
+
fix: (fixer) => fixer.replaceText(node, node.name.name),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const namespace = node.namespace?.name;
|
|
101
|
+
|
|
102
|
+
if (v2) {
|
|
103
|
+
// In 2.0 any colon-name is a legal literal attribute; only flag the
|
|
104
|
+
// formerly-special 1.x prefixes, which are near-certain migration bugs.
|
|
105
|
+
const guidance = v2MigrationGuidance[namespace];
|
|
106
|
+
if (guidance && !explicitlyAllowedNamespaces?.includes(namespace)) {
|
|
107
|
+
context.report({
|
|
108
|
+
node,
|
|
109
|
+
messageId: "v2Namespace",
|
|
110
|
+
data: { namespace, name: node.name.name, guidance },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
!(
|
|
118
|
+
knownNamespaces.includes(namespace) ||
|
|
119
|
+
otherNamespaces.includes(namespace) ||
|
|
120
|
+
explicitlyAllowedNamespaces?.includes(namespace)
|
|
121
|
+
)
|
|
122
|
+
) {
|
|
123
|
+
if (styleNamespaces.includes(namespace)) {
|
|
124
|
+
context.report({
|
|
125
|
+
node,
|
|
126
|
+
messageId: "style",
|
|
127
|
+
data: { namespace },
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
context.report({
|
|
131
|
+
node,
|
|
132
|
+
messageId: "unknown",
|
|
133
|
+
data: { namespace },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { TSESTree as T, ESLintUtils } from "@typescript-eslint/utils";
|
|
4
|
+
import { findVariable, getSourceCode } from "../compat";
|
|
5
|
+
import { findParent, getSolidSourceRegex, trackImports } from "../utils";
|
|
6
|
+
|
|
7
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
8
|
+
|
|
9
|
+
type MessageIds = "neverWritten" | "neverRead" | "replaceWithAccessor";
|
|
10
|
+
type Options = [];
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* A destructured signal tuple is the only handle on the signal, so scope
|
|
14
|
+
* analysis is conclusive: if the setter is never referenced the value can
|
|
15
|
+
* never change, and if the accessor is never referenced the state can never
|
|
16
|
+
* be observed. Unused-variable rules miss both halves because the *other*
|
|
17
|
+
* half keeps the declaration "used". Exported declarations are skipped —
|
|
18
|
+
* references in other modules are invisible here.
|
|
19
|
+
*/
|
|
20
|
+
export default createRule<Options, MessageIds>({
|
|
21
|
+
meta: {
|
|
22
|
+
type: "suggestion",
|
|
23
|
+
docs: {
|
|
24
|
+
description:
|
|
25
|
+
"Disallow signals that are never written (use a plain value) or never read (dead state).",
|
|
26
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/no-unused-signal.md",
|
|
27
|
+
},
|
|
28
|
+
hasSuggestions: true,
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: {
|
|
31
|
+
neverWritten:
|
|
32
|
+
"This {{kind}} is never written, so its value can never change. Use a plain value, or `createMemo` if it should derive from reactive state.",
|
|
33
|
+
neverRead:
|
|
34
|
+
"This {{kind}} is never read, so setting it has no effect. Remove the {{kind}}, or read the value where the state should be used.",
|
|
35
|
+
replaceWithAccessor: "Replace with a constant accessor.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
defaultOptions: [],
|
|
39
|
+
create(context) {
|
|
40
|
+
const { matchImport, handleImportDeclaration } = trackImports(getSolidSourceRegex(context));
|
|
41
|
+
|
|
42
|
+
/** Does this pattern element's variable have any reference besides its initialization? */
|
|
43
|
+
const isUsed = (element: T.Identifier): boolean => {
|
|
44
|
+
const variable = findVariable(context, element);
|
|
45
|
+
// Unresolvable variables can't be proven unused; assume they're fine.
|
|
46
|
+
if (!variable) return true;
|
|
47
|
+
return variable.references.some((ref) => !ref.init);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
ImportDeclaration: handleImportDeclaration,
|
|
52
|
+
VariableDeclarator(node) {
|
|
53
|
+
if (
|
|
54
|
+
node.init?.type !== "CallExpression" ||
|
|
55
|
+
node.init.callee.type !== "Identifier" ||
|
|
56
|
+
node.id.type !== "ArrayPattern" ||
|
|
57
|
+
node.id.elements.length > 2
|
|
58
|
+
) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const primitive = matchImport(
|
|
62
|
+
["createSignal", "createStore", "createOptimistic"],
|
|
63
|
+
node.init.callee.name
|
|
64
|
+
);
|
|
65
|
+
if (!primitive) return;
|
|
66
|
+
const kind =
|
|
67
|
+
primitive === "createSignal"
|
|
68
|
+
? "signal"
|
|
69
|
+
: primitive === "createStore"
|
|
70
|
+
? "store"
|
|
71
|
+
: "optimistic value";
|
|
72
|
+
// Only plain `[accessor, setter]` shapes (with possible holes) are conclusive.
|
|
73
|
+
const [accessor = null, setter = null] = node.id.elements;
|
|
74
|
+
if (
|
|
75
|
+
(accessor && accessor.type !== "Identifier") ||
|
|
76
|
+
(setter && setter.type !== "Identifier")
|
|
77
|
+
) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Exported signals can be read or written by other modules.
|
|
81
|
+
if (findParent(node, (n) => n.type === "ExportNamedDeclaration")) return;
|
|
82
|
+
|
|
83
|
+
if (!accessor || !isUsed(accessor)) {
|
|
84
|
+
context.report({ node: accessor ?? node.id, messageId: "neverRead", data: { kind } });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!setter || !isUsed(setter)) {
|
|
88
|
+
const init = node.init;
|
|
89
|
+
const initialValue = init.arguments[0];
|
|
90
|
+
// Only suggest the rewrite for signals whose initial value is a
|
|
91
|
+
// simple literal; richer expressions may want createMemo or
|
|
92
|
+
// evaluate-once semantics, and store/optimistic results aren't
|
|
93
|
+
// accessor-shaped.
|
|
94
|
+
const suggest =
|
|
95
|
+
primitive === "createSignal" && (!initialValue || initialValue.type === "Literal")
|
|
96
|
+
? [
|
|
97
|
+
{
|
|
98
|
+
messageId: "replaceWithAccessor" as const,
|
|
99
|
+
fix: (fixer: TSESLint.RuleFixer) =>
|
|
100
|
+
fixer.replaceText(
|
|
101
|
+
node,
|
|
102
|
+
`${accessor.name} = () => ${
|
|
103
|
+
initialValue ? getSourceCode(context).getText(initialValue) : "undefined"
|
|
104
|
+
}`
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
: undefined;
|
|
109
|
+
context.report({
|
|
110
|
+
node: setter ?? node.id,
|
|
111
|
+
messageId: "neverWritten",
|
|
112
|
+
data: { kind },
|
|
113
|
+
suggest,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { TSESTree as T, ESLintUtils } from "@typescript-eslint/utils";
|
|
10
|
+
import { findVariable } from "../compat";
|
|
11
|
+
import {
|
|
12
|
+
FunctionNode,
|
|
13
|
+
findParent,
|
|
14
|
+
getFunctionName,
|
|
15
|
+
isFunctionNode,
|
|
16
|
+
getSolidSourceRegex,
|
|
17
|
+
trackImports,
|
|
18
|
+
} from "../utils";
|
|
19
|
+
|
|
20
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
21
|
+
|
|
22
|
+
type MessageIds = "writeInMemo" | "writeInCompute" | "writeInComponent";
|
|
23
|
+
type Options = [];
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* `createMemo` callbacks, the compute half of `createEffect(compute,
|
|
27
|
+
* effect)`, and component bodies are pure owned scopes in Solid 2.0:
|
|
28
|
+
* writing other reactive state from inside them creates update cycles and
|
|
29
|
+
* throws in dev mode (REACTIVE_WRITE_IN_OWNED_SCOPE). Only setter calls
|
|
30
|
+
* whose nearest enclosing function IS the pure scope are flagged — a setter
|
|
31
|
+
* inside a nested function (an event handler, an `onSettled` or
|
|
32
|
+
* `createTrackedEffect` callback, the effect half) runs later, in an
|
|
33
|
+
* imperative scope, and is fine.
|
|
34
|
+
*/
|
|
35
|
+
export default createRule<Options, MessageIds>({
|
|
36
|
+
meta: {
|
|
37
|
+
type: "problem",
|
|
38
|
+
docs: {
|
|
39
|
+
description:
|
|
40
|
+
"Disallow writing signals or stores inside `createMemo`, the compute half of `createEffect`, or a component body, which are pure owned scopes.",
|
|
41
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/no-write-in-pure-computation.md",
|
|
42
|
+
},
|
|
43
|
+
schema: [],
|
|
44
|
+
messages: {
|
|
45
|
+
writeInMemo:
|
|
46
|
+
"Calling the setter '{{name}}' inside createMemo makes the computation impure and creates an update cycle. Derive the value instead of storing it, or move the write to an effect.",
|
|
47
|
+
writeInCompute:
|
|
48
|
+
"Calling the setter '{{name}}' in the compute half of {{api}} — the compute function is tracked and must be pure. Move the write into the effect half (the second argument).",
|
|
49
|
+
writeInComponent:
|
|
50
|
+
"Calling the setter '{{name}}' during component setup — the body runs once as a pure owned scope, and Solid 2.0 throws on setup writes in dev. Initialize the state with the right value instead, or move the write into an effect half or event handler.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
defaultOptions: [],
|
|
54
|
+
create(context) {
|
|
55
|
+
const { matchImport, handleImportDeclaration } = trackImports(getSolidSourceRegex(context));
|
|
56
|
+
|
|
57
|
+
/** Pure computation functions seen so far, mapped to how to report writes in them. */
|
|
58
|
+
const computations = new Map<T.Node, { messageId: MessageIds; api: string }>();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Does this signal opt into owned-scope writes? Core skips its dev guard
|
|
62
|
+
* for signals created with `ownedWrite: true`, and its error message
|
|
63
|
+
* steers users to that option — so the lint must honor it too. The check
|
|
64
|
+
* only says "no" when it can *prove* the opt-in is absent: a missing
|
|
65
|
+
* options argument, or an object literal (followed through one level of
|
|
66
|
+
* const indirection) that is fully readable and lacks `ownedWrite`.
|
|
67
|
+
* Everything else — spreads, casts, unresolvable or reassigned
|
|
68
|
+
* variables — is assumed to opt in, so the rule can only under-report,
|
|
69
|
+
* never contradict core.
|
|
70
|
+
*/
|
|
71
|
+
const allowsOwnedWrite = (init: T.CallExpression): boolean => {
|
|
72
|
+
let options: T.Node | undefined = init.arguments[1];
|
|
73
|
+
if (!options) return false;
|
|
74
|
+
if (options.type === "Identifier") {
|
|
75
|
+
const variable = findVariable(context, options);
|
|
76
|
+
const def = variable?.defs[0];
|
|
77
|
+
if (
|
|
78
|
+
variable?.defs.length !== 1 ||
|
|
79
|
+
def?.type !== "Variable" ||
|
|
80
|
+
def.node.parent?.kind !== "const" ||
|
|
81
|
+
!def.node.init ||
|
|
82
|
+
// A const holding an object can still be given properties later
|
|
83
|
+
// (`opts.ownedWrite = true`); any reference that mutates the object
|
|
84
|
+
// or escapes it (passed elsewhere, aliased) makes the literal read
|
|
85
|
+
// inconclusive. Only references that ARE the options argument of a
|
|
86
|
+
// signal creation keep it conclusive.
|
|
87
|
+
variable.references.some(
|
|
88
|
+
(ref) =>
|
|
89
|
+
ref.identifier !== def.node.id &&
|
|
90
|
+
!(
|
|
91
|
+
ref.identifier.parent?.type === "CallExpression" &&
|
|
92
|
+
ref.identifier.parent.arguments[1] === ref.identifier
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
options = def.node.init;
|
|
99
|
+
}
|
|
100
|
+
if (options.type !== "ObjectExpression") return true;
|
|
101
|
+
return options.properties.some(
|
|
102
|
+
(property) =>
|
|
103
|
+
property.type === "SpreadElement" ||
|
|
104
|
+
((property.key.type === "Identifier"
|
|
105
|
+
? property.key.name === "ownedWrite"
|
|
106
|
+
: property.key.type === "Literal" && property.key.value === "ownedWrite") &&
|
|
107
|
+
!(property.value.type === "Literal" && property.value.value === false))
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Is `id` the setter half of a signal/store/optimistic tuple? */
|
|
112
|
+
const isSetter = (id: T.Identifier): boolean => {
|
|
113
|
+
const def = findVariable(context, id)?.defs[0];
|
|
114
|
+
if (!def || def.type !== "Variable" || !def.node.init) return false;
|
|
115
|
+
const { id: declId, init } = def.node;
|
|
116
|
+
if (
|
|
117
|
+
declId.type !== "ArrayPattern" ||
|
|
118
|
+
declId.elements[1]?.type !== "Identifier" ||
|
|
119
|
+
declId.elements[1].name !== id.name ||
|
|
120
|
+
init.type !== "CallExpression" ||
|
|
121
|
+
init.callee.type !== "Identifier"
|
|
122
|
+
) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const primitive = matchImport(
|
|
126
|
+
["createSignal", "createStore", "createOptimistic"],
|
|
127
|
+
init.callee.name
|
|
128
|
+
);
|
|
129
|
+
if (!primitive) return false;
|
|
130
|
+
// `ownedWrite` is a SignalOptions flag; store setters have no such
|
|
131
|
+
// exemption in core, so only signal-shaped primitives honor it.
|
|
132
|
+
if (primitive !== "createStore" && allowsOwnedWrite(init)) return false;
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Setter calls whose nearest enclosing function might be a component.
|
|
138
|
+
* Whether it IS one (contains JSX, component-shaped) is only known at the
|
|
139
|
+
* function's :exit, so reports are deferred until then.
|
|
140
|
+
*/
|
|
141
|
+
const pendingComponentWrites = new Map<FunctionNode, T.CallExpression[]>();
|
|
142
|
+
const functionStack: Array<{ hasJSX: boolean }> = [];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Matches the component detection in no-destructure/components-return-once:
|
|
146
|
+
* containing JSX isn't enough on its own, because data callbacks
|
|
147
|
+
* (`items.map((item) => <li />)`) and lowercase-named helpers also return
|
|
148
|
+
* JSX. Component-shaped means: not lowercase-named, not a "render prop",
|
|
149
|
+
* and not a plain call argument (unless the callee is PascalCase, like a
|
|
150
|
+
* HOC).
|
|
151
|
+
*/
|
|
152
|
+
const isComponent = (node: FunctionNode) =>
|
|
153
|
+
!getFunctionName(node)?.match(/^[a-z]/) &&
|
|
154
|
+
node.parent?.type !== "JSXExpressionContainer" &&
|
|
155
|
+
!(
|
|
156
|
+
node.parent?.type === "CallExpression" &&
|
|
157
|
+
node.parent.arguments.includes(node as T.CallExpressionArgument) &&
|
|
158
|
+
!(node.parent.callee as T.Identifier).name?.match(/^[A-Z]/)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
ImportDeclaration: handleImportDeclaration,
|
|
163
|
+
":function"() {
|
|
164
|
+
functionStack.push({ hasJSX: false });
|
|
165
|
+
},
|
|
166
|
+
JSXElement() {
|
|
167
|
+
if (functionStack.length) functionStack[functionStack.length - 1].hasJSX = true;
|
|
168
|
+
},
|
|
169
|
+
JSXFragment() {
|
|
170
|
+
if (functionStack.length) functionStack[functionStack.length - 1].hasJSX = true;
|
|
171
|
+
},
|
|
172
|
+
":function:exit"(node: T.Node) {
|
|
173
|
+
const { hasJSX } = functionStack.pop() ?? { hasJSX: false };
|
|
174
|
+
if (!isFunctionNode(node)) return;
|
|
175
|
+
const pending = pendingComponentWrites.get(node);
|
|
176
|
+
if (!pending) return;
|
|
177
|
+
pendingComponentWrites.delete(node);
|
|
178
|
+
// A function that is itself a registered computation is reported
|
|
179
|
+
// through the computation path, never as a component.
|
|
180
|
+
if (!hasJSX || computations.has(node) || !isComponent(node)) return;
|
|
181
|
+
for (const call of pending) {
|
|
182
|
+
context.report({
|
|
183
|
+
node: call,
|
|
184
|
+
messageId: "writeInComponent",
|
|
185
|
+
data: { name: (call.callee as T.Identifier).name },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
CallExpression(node) {
|
|
190
|
+
if (node.callee.type !== "Identifier") return;
|
|
191
|
+
|
|
192
|
+
// Register pure computation callbacks. Ancestors are visited before
|
|
193
|
+
// descendants, so these are known before their setter calls are seen.
|
|
194
|
+
if (matchImport("createMemo", node.callee.name)) {
|
|
195
|
+
const fn = node.arguments[0];
|
|
196
|
+
if (isFunctionNode(fn)) {
|
|
197
|
+
computations.set(fn, { messageId: "writeInMemo", api: "createMemo" });
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const effectName = matchImport(["createEffect", "createRenderEffect"], node.callee.name);
|
|
202
|
+
if (effectName && node.arguments.length >= 2) {
|
|
203
|
+
const fn = node.arguments[0];
|
|
204
|
+
if (isFunctionNode(fn)) {
|
|
205
|
+
computations.set(fn, { messageId: "writeInCompute", api: effectName });
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const enclosing = findParent(node, isFunctionNode);
|
|
211
|
+
if (!enclosing || !isFunctionNode(enclosing)) return;
|
|
212
|
+
const computation = computations.get(enclosing);
|
|
213
|
+
if (!computation && !isSetter(node.callee)) return;
|
|
214
|
+
if (computation) {
|
|
215
|
+
if (isSetter(node.callee)) {
|
|
216
|
+
context.report({
|
|
217
|
+
node,
|
|
218
|
+
messageId: computation.messageId,
|
|
219
|
+
data: { name: node.callee.name, api: computation.api },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Possibly a setup-scope write; decided at the function's :exit.
|
|
225
|
+
const pending = pendingComponentWrites.get(enclosing);
|
|
226
|
+
if (pending) pending.push(node);
|
|
227
|
+
else pendingComponentWrites.set(enclosing, [node]);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
});
|