Skip to content

Commit 2b772f5

Browse files
committed
Merge branch 'feature/no-proxy-apis'
2 parents 197dbb5 + c20c14c commit 2b772f5

File tree

8 files changed

+313
-8
lines changed

8 files changed

+313
-8
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ const [editedValue, setEditedValue] = createSignal(props.value);
130130
|| | [solid/jsx-uses-vars](docs/jsx-uses-vars.md) | Prevent variables used in JSX from being marked as unused. |
131131
|| 🔧 | [solid/no-destructure](docs/no-destructure.md) | Disallow destructuring props. In Solid, props must be used with property accesses (`props.foo`) to preserve reactivity. This rule only tracks destructuring in the parameter list. |
132132
|| 🔧 | [solid/no-innerhtml](docs/no-innerhtml.md) | Disallow usage of the innerHTML attribute, which can often lead to security vulnerabilities. |
133+
|| | [solid/no-proxy-apis](docs/no-proxy-apis.md) | Disallow usage of APIs that use ES6 Proxies, only to target environments that don't support them. |
133134
|| 🔧 | [solid/no-react-specific-props](docs/no-react-specific-props.md) | Disallow usage of React-specific `className`/`htmlFor` props, which were deprecated in v1.4.0. |
134135
|| | [solid/no-unknown-namespaces](docs/no-unknown-namespaces.md) | Enforce using only Solid-specific namespaced attribute names (i.e. `'on:'` in `<div on:click={...} />`). |
135136
|| 🔧 | [solid/prefer-classlist](docs/prefer-classlist.md) | Enforce using the classlist prop over importing a classnames helper. The classlist prop accepts an object `{ [class: string]: boolean }` just like classnames. |

docs/no-proxy-apis.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<!-- AUTO-GENERATED-CONTENT:START (HEADER) -->
2+
# solid/no-proxy-apis
3+
Disallow usage of APIs that use ES6 Proxies, only to target environments that don't support them.
4+
This rule is **off** by default.
5+
6+
[View source](../src/rules/no-proxy-apis.ts) · [View tests](../test/rules/no-proxy-apis.test.ts)
7+
8+
<!-- AUTO-GENERATED-CONTENT:END -->
9+
10+
<!-- AUTO-GENERATED-CONTENT:START (OPTIONS) -->
11+
12+
<!-- AUTO-GENERATED-CONTENT:END -->
13+
14+
<!-- AUTO-GENERATED-CONTENT:START (CASES) -->
15+
## Tests
16+
17+
### Invalid Examples
18+
19+
These snippets cause lint errors, and some can be auto-fixed.
20+
21+
```js
22+
let el = <div className="greeting">Hello world!</div>;
23+
// after eslint --fix:
24+
let el = <div class="greeting">Hello world!</div>;
25+
26+
let el = <div className={"greeting"}>Hello world!</div>;
27+
// after eslint --fix:
28+
let el = <div class={"greeting"}>Hello world!</div>;
29+
30+
let el = <div className="greeting" />;
31+
// after eslint --fix:
32+
let el = <div class="greeting" />;
33+
34+
let el = (
35+
<div many other attributes className="greeting">
36+
Hello world!
37+
</div>
38+
);
39+
// after eslint --fix:
40+
let el = (
41+
<div many other attributes class="greeting">
42+
Hello world!
43+
</div>
44+
);
45+
46+
let el = <PascalComponent className="greeting">Hello world!</PascalComponent>;
47+
// after eslint --fix:
48+
let el = <PascalComponent class="greeting">Hello world!</PascalComponent>;
49+
50+
let el = <label htmlFor="id">Hello world!</label>;
51+
// after eslint --fix:
52+
let el = <label for="id">Hello world!</label>;
53+
54+
let el = <label htmlFor={"id"}>Hello world!</label>;
55+
// after eslint --fix:
56+
let el = <label for={"id"}>Hello world!</label>;
57+
58+
let el = (
59+
<label many other attributes htmlFor="id">
60+
Hello world!
61+
</label>
62+
);
63+
// after eslint --fix:
64+
let el = (
65+
<label many other attributes for="id">
66+
Hello world!
67+
</label>
68+
);
69+
70+
let el = <PascalComponent htmlFor="id">Hello world!</PascalComponent>;
71+
// after eslint --fix:
72+
let el = <PascalComponent for="id">Hello world!</PascalComponent>;
73+
74+
let el = <div key={item.id} />;
75+
// after eslint --fix:
76+
let el = <div />;
77+
78+
```
79+
80+
### Valid Examples
81+
82+
These snippets don't cause lint errors.
83+
84+
```js
85+
let el = <div>Hello world!</div>;
86+
87+
let el = <div class="greeting">Hello world!</div>;
88+
89+
let el = <div class={"greeting"}>Hello world!</div>;
90+
91+
let el = (
92+
<div many other attributes class="greeting">
93+
Hello world!
94+
</div>
95+
);
96+
97+
let el = <label for="id">Hello world!</label>;
98+
99+
let el = <label for="id">Hello world!</label>;
100+
101+
let el = <label for={"id"}>Hello world!</label>;
102+
103+
let el = (
104+
<label many other attributes for="id">
105+
Hello world!
106+
</label>
107+
);
108+
109+
let el = <PascalComponent class="greeting" for="id" />;
110+
111+
let el = <PascalComponent key={item.id} />;
112+
113+
```
114+
<!-- AUTO-GENERATED-CONTENT:END -->

docs/prefer-classlist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- AUTO-GENERATED-CONTENT:START (HEADER) -->
22
# solid/prefer-classlist
33
Enforce using the classlist prop over importing a classnames helper. The classlist prop accepts an object `{ [class: string]: boolean }` just like classnames.
4-
This rule is **off** by default.
4+
This rule is **deprecated** and **off** by default.
55

66
[View source](../src/rules/prefer-classlist.ts) · [View tests](../test/rules/prefer-classlist.test.ts)
77

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import jsxNoUndef from "./rules/jsx-no-undef";
77
import jsxUsesVars from "./rules/jsx-uses-vars";
88
import noDestructure from "./rules/no-destructure";
99
import noInnerHTML from "./rules/no-innerhtml";
10+
import noProxyApis from "./rules/no-proxy-apis";
1011
import noReactSpecificProps from "./rules/no-react-specific-props";
1112
import noUnknownNamespaces from "./rules/no-unknown-namespaces";
1213
import preferClasslist from "./rules/prefer-classlist";
@@ -27,6 +28,7 @@ const allRules = {
2728
"jsx-uses-vars": jsxUsesVars,
2829
"no-destructure": noDestructure,
2930
"no-innerhtml": noInnerHTML,
31+
"no-proxy-apis": noProxyApis,
3032
"no-react-specific-props": noReactSpecificProps,
3133
"no-unknown-namespaces": noUnknownNamespaces,
3234
"prefer-classlist": preferClasslist,
@@ -78,6 +80,8 @@ const plugin = {
7880
"solid/self-closing-comp": 1,
7981
// handled by Solid compiler, opt-in style suggestion
8082
"solid/prefer-show": 0,
83+
// only necessary for resource-constrained environments
84+
"solid/no-proxy-apis": 0,
8185
// deprecated
8286
"solid/prefer-classlist": 0,
8387
},
@@ -111,6 +115,8 @@ const plugin = {
111115
"solid/no-unknown-namespaces": 0,
112116
// handled by Solid compiler, opt-in style suggestion
113117
"solid/prefer-show": 0,
118+
// only necessary for resource-constrained environments
119+
"solid/no-proxy-apis": 0,
114120
// deprecated
115121
"solid/prefer-classlist": 0,
116122
},

src/rules/no-proxy-apis.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { TSESTree as T, TSESLint } from "@typescript-eslint/utils";
2+
import { isFunctionNode, trackImports, isPropsByName, trace } from "../utils";
3+
4+
const rule: TSESLint.RuleModule<
5+
"noStore" | "spreadCall" | "spreadMember" | "proxyLiteral" | "mergeProps",
6+
[]
7+
> = {
8+
meta: {
9+
type: "problem",
10+
docs: {
11+
recommended: false,
12+
description:
13+
"Disallow usage of APIs that use ES6 Proxies, only to target environments that don't support them.",
14+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/docs/no-proxy-apis.md",
15+
},
16+
schema: [],
17+
messages: {
18+
noStore: "Solid Store APIs use Proxies, which are incompatible with your target environment.",
19+
spreadCall:
20+
"Using a function call in JSX spread makes Solid use Proxies, which are incompatible with your target environment.",
21+
spreadMember:
22+
"Using a property access in JSX spread makes Solid use Proxies, which are incompatible with your target environment.",
23+
proxyLiteral: "Proxies are incompatible with your target environment.",
24+
mergeProps:
25+
"If you pass a function to `mergeProps`, it will create a Proxy, which are incompatible with your target environment.",
26+
},
27+
},
28+
create(context) {
29+
const { matchImport, handleImportDeclaration } = trackImports();
30+
31+
return {
32+
ImportDeclaration(node) {
33+
handleImportDeclaration(node); // track import aliases
34+
35+
const source = node.source.value;
36+
if (source === "solid-js/store") {
37+
context.report({
38+
node,
39+
messageId: "noStore",
40+
});
41+
}
42+
},
43+
"JSXSpreadAttribute MemberExpression"(node: T.MemberExpression) {
44+
context.report({ node, messageId: "spreadMember" });
45+
},
46+
"JSXSpreadAttribute CallExpression"(node: T.CallExpression) {
47+
context.report({ node, messageId: "spreadCall" });
48+
},
49+
CallExpression(node) {
50+
if (node.callee.type === "Identifier") {
51+
if (matchImport("mergeProps", node.callee.name)) {
52+
node.arguments
53+
.filter((arg) => {
54+
if (arg.type === "SpreadElement") return true;
55+
const traced = trace(arg, context.getScope());
56+
return (
57+
(traced.type === "Identifier" && !isPropsByName(traced.name)) ||
58+
isFunctionNode(traced)
59+
);
60+
})
61+
.forEach((badArg) => {
62+
context.report({
63+
node: badArg,
64+
messageId: "mergeProps",
65+
});
66+
});
67+
}
68+
} else if (node.callee.type === "MemberExpression") {
69+
if (
70+
node.callee.object.type === "Identifier" &&
71+
node.callee.object.name === "Proxy" &&
72+
node.callee.property.type === "Identifier" &&
73+
node.callee.property.name === "revocable"
74+
) {
75+
context.report({
76+
node,
77+
messageId: "proxyLiteral",
78+
});
79+
}
80+
}
81+
},
82+
NewExpression(node) {
83+
if (node.callee.type === "Identifier" && node.callee.name === "Proxy") {
84+
context.report({ node, messageId: "proxyLiteral" });
85+
}
86+
},
87+
};
88+
},
89+
};
90+
91+
export default rule;

src/utils.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { TSESTree as T, TSESLint } from "@typescript-eslint/utils";
1+
import { TSESTree as T, TSESLint, ASTUtils } from "@typescript-eslint/utils";
2+
const { findVariable } = ASTUtils;
23

34
const domElementRegex = /^[a-z]/;
45
export const isDOMElementName = (name: string): boolean => domElementRegex.test(name);
@@ -42,6 +43,32 @@ export function findParent(node: T.Node, predicate: (node: T.Node) => boolean):
4243
return node.parent ? find(node.parent, predicate) : null;
4344
}
4445

46+
// Try to resolve a variable to its definition
47+
export function trace(node: T.Node, initialScope: TSESLint.Scope.Scope): T.Node {
48+
if (node.type === "Identifier") {
49+
const variable = findVariable(initialScope, node);
50+
if (!variable) return node;
51+
52+
const def = variable.defs[0];
53+
switch (def.type) {
54+
case "FunctionName":
55+
case "ClassName":
56+
case "ImportBinding":
57+
return def.node;
58+
case "Variable":
59+
if (
60+
((def.node.parent as T.VariableDeclaration).kind === "const" ||
61+
variable.references.every((ref) => ref.init || ref.isReadOnly())) &&
62+
def.node.id.type === "Identifier" &&
63+
def.node.init
64+
) {
65+
return trace(def.node.init, initialScope);
66+
}
67+
}
68+
}
69+
return node;
70+
}
71+
4572
export type FunctionNode = T.FunctionExpression | T.ArrowFunctionExpression | T.FunctionDeclaration;
4673
const FUNCTION_TYPES = ["FunctionExpression", "ArrowFunctionExpression", "FunctionDeclaration"];
4774
export const isFunctionNode = (node: T.Node | null | undefined): node is FunctionNode =>
@@ -95,12 +122,9 @@ export const trackImports = (fromModule = /^solid-js(?:\/?|\b)/) => {
95122
}
96123
}
97124
};
98-
const matchImport = (imports: string | Array<string>, str: string) => {
125+
const matchImport = (imports: string | Array<string>, str: string): string | undefined => {
99126
const importArr = Array.isArray(imports) ? imports : [imports];
100-
return importArr
101-
.map((i) => importMap.get(i))
102-
.filter(Boolean)
103-
.includes(str);
127+
return importArr.find((i) => importMap.get(i) === str);
104128
};
105129
return { matchImport, handleImportDeclaration };
106130
};

test/rules/no-proxy-apis.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { AST_NODE_TYPES as T } from "@typescript-eslint/utils";
2+
import { run } from "../ruleTester";
3+
import rule from "../../src/rules/no-proxy-apis";
4+
5+
// Don't bother checking for imports for every test
6+
jest.mock("../../src/utils", () => {
7+
return {
8+
...jest.requireActual("../../src/utils"),
9+
trackImports: () => {
10+
// eslint-disable-next-line @typescript-eslint/no-empty-function
11+
const handleImportDeclaration = () => {};
12+
const matchImport = (imports: string | Array<string>, str: string) => {
13+
const importArr = Array.isArray(imports) ? imports : [imports];
14+
return importArr.find((i) => i === str);
15+
};
16+
return { matchImport, handleImportDeclaration };
17+
},
18+
};
19+
});
20+
21+
export const cases = run("no-proxy-apis", rule, {
22+
valid: [
23+
`let merged = mergeProps({}, props);`,
24+
`const obj = {}; let merged = mergeProps(obj, props);`,
25+
`let obj = {}; let merged = mergeProps(obj, props);`,
26+
`let merged = mergeProps({ get asdf() { signal() } }, props);`,
27+
`let el = <div {...{ asdf: 'asdf' }} />`,
28+
`let el = <div {...asdf} />`,
29+
`let obj = { Proxy: 1 }`,
30+
],
31+
invalid: [
32+
{
33+
code: `let proxy = new Proxy(asdf, {});`,
34+
errors: [{ messageId: "proxyLiteral" }],
35+
},
36+
{
37+
code: `let proxy = Proxy.revocable(asdf, {});`,
38+
errors: [{ messageId: "proxyLiteral" }],
39+
},
40+
{
41+
code: `import {} from 'solid-js/store';`,
42+
errors: [{ messageId: "noStore", type: T.ImportDeclaration }],
43+
},
44+
{
45+
code: `let el = <div {...maybeSignal()} />`,
46+
errors: [{ messageId: "spreadCall" }],
47+
},
48+
{
49+
code: `let el = <div {...{ ...maybeSignal() }} />`,
50+
errors: [{ messageId: "spreadCall" }],
51+
},
52+
{
53+
code: `let el = <div {...maybeProps.foo} />`,
54+
errors: [{ messageId: "spreadMember" }],
55+
},
56+
{
57+
code: `let el = <div {...{ ...maybeProps.foo }} />`,
58+
errors: [{ messageId: "spreadMember" }],
59+
},
60+
{
61+
code: `let merged = mergeProps(maybeSignal)`,
62+
errors: [{ messageId: "mergeProps" }],
63+
},
64+
{
65+
code: `let func = () => ({}); let merged = mergeProps(func, props)`,
66+
errors: [{ messageId: "mergeProps" }],
67+
},
68+
],
69+
});

test/rules/reactivity.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jest.mock("../../src/utils", () => {
1111
const handleImportDeclaration = () => {};
1212
const matchImport = (imports: string | Array<string>, str: string) => {
1313
const importArr = Array.isArray(imports) ? imports : [imports];
14-
return importArr.includes(str);
14+
return importArr.find((i) => i === str);
1515
};
1616
return { matchImport, handleImportDeclaration };
1717
},

0 commit comments

Comments
 (0)