Skip to content

Commit bf8ed35

Browse files
committed
feat: Option to track JSX components as references
fixes #645
1 parent 0970b56 commit bf8ed35

File tree

7 files changed

+225
-3
lines changed

7 files changed

+225
-3
lines changed

packages/eslint-scope/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ In order to analyze scope, you'll need to have an [ESTree](https://github.com/es
3737
* `sourceType` (default: `"script"`) - The type of JavaScript file to evaluate. Change to `"module"` for ECMAScript module code.
3838
* `childVisitorKeys` (default: `null`) - An object with visitor key information (like [`eslint-visitor-keys`](https://github.com/eslint/js/tree/main/packages/eslint-visitor-keys)). Without this, `eslint-scope` finds child nodes to visit algorithmically. Providing this option is a performance enhancement.
3939
* `fallback` (default: `"iteration"`) - The strategy to use when `childVisitorKeys` is not specified. May be a function.
40+
* `jsx` (default: `false`) - Enables the tracking of JSX components as variable references.
4041

4142
Example:
4243

packages/eslint-scope/lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ function updateDeeply(target, override) {
121121
* (if ecmaVersion >= 5).
122122
* @param {string} [providedOptions.sourceType='script'] the source type of the script. one of 'script', 'module', and 'commonjs'
123123
* @param {number} [providedOptions.ecmaVersion=5] which ECMAScript version is considered
124+
* @param {boolean} [providedOptions.jsx=false] support JSX references
124125
* @param {Object} [providedOptions.childVisitorKeys=null] Additional known visitor keys. See [esrecurse](https://github.com/estools/esrecurse)'s the `childVisitorKeys` option.
125126
* @param {string} [providedOptions.fallback='iteration'] A kind of the fallback in order to encounter with unknown node. See [esrecurse](https://github.com/estools/esrecurse)'s the `fallback` option.
126127
* @returns {ScopeManager} ScopeManager

packages/eslint-scope/lib/referencer.js

+45
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,51 @@ class Referencer extends esrecurse.Visitor {
649649

650650
// do nothing.
651651
}
652+
653+
JSXIdentifier(node) {
654+
if (this.scopeManager.__isJSXEnabled()) {
655+
this.currentScope().__referencing(node);
656+
}
657+
}
658+
659+
JSXMemberExpression(node) {
660+
if (this.scopeManager.__isJSXEnabled()) {
661+
this.visit(node.object);
662+
}
663+
}
664+
665+
JSXElement(node) {
666+
if (this.scopeManager.__isJSXEnabled()) {
667+
this.visit(node.openingElement);
668+
node.children.forEach(this.visit, this);
669+
}
670+
}
671+
672+
JSXOpeningElement(node) {
673+
if (this.scopeManager.__isJSXEnabled()) {
674+
const name = node.name.name;
675+
676+
if (name[0].toUpperCase() === name[0]) {
677+
this.currentScope().__referencing(node.name);
678+
}
679+
680+
node.attributes.forEach(this.visit, this);
681+
}
682+
}
683+
684+
JSXAttribute(node) {
685+
if (this.scopeManager.__isJSXEnabled()) {
686+
if (node.value) {
687+
this.visit(node.value);
688+
}
689+
}
690+
}
691+
692+
JSXExpressionContainer(node) {
693+
if (this.scopeManager.__isJSXEnabled()) {
694+
this.visit(node.expression);
695+
}
696+
}
652697
}
653698

654699
export default Referencer;

packages/eslint-scope/lib/scope-manager.js

+4
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class ScopeManager {
5959
return this.__options.ignoreEval;
6060
}
6161

62+
__isJSXEnabled() {
63+
return this.__options.jsx === true;
64+
}
65+
6266
isGlobalReturn() {
6367
return this.__options.nodejsScope || this.__options.sourceType === "commonjs";
6468
}

packages/eslint-scope/lib/scope.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ class Scope {
421421
__referencing(node, assign, writeExpr, maybeImplicitGlobal, partial, init) {
422422

423423
// because Array element may be null
424-
if (!node || node.type !== Syntax.Identifier) {
424+
if (!node || (node.type !== Syntax.Identifier && node.type !== "JSXIdentifier")) {
425425
return;
426426
}
427427

packages/eslint-scope/tests/jsx.js

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* @fileoverview Tests for JSX reference tracking.
3+
* @author Nicholas C. Zakas
4+
*/
5+
6+
import { expect } from "chai";
7+
import espree from "./util/espree.js";
8+
import { analyze } from "../lib/index.js";
9+
10+
describe("References:", () => {
11+
12+
describe("JSX References:", () => {
13+
it("should treat JSX identifiers as references", () => {
14+
const ast = espree(`
15+
const MyComponent = () => <div/>;
16+
const element = <MyComponent />;
17+
`, "script", true);
18+
19+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
20+
const scope = scopeManager.scopes[0];
21+
22+
expect(scope.variables).to.have.length(2); // MyComponent, element
23+
expect(scope.references).to.have.length(3); // MyComponent def, element def, MyComponent use
24+
25+
const myComponentRef = scope.references[2];
26+
27+
expect(myComponentRef.identifier.name).to.equal("MyComponent");
28+
expect(myComponentRef.isRead()).to.be.true;
29+
expect(myComponentRef.resolved).to.equal(scope.variables[0]);
30+
});
31+
32+
it("should handle JSX attributes as references", () => {
33+
const ast = espree(`
34+
const value = "test";
35+
const MyComponent = () => <div attr={value}/>;
36+
`, "script", true);
37+
38+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
39+
const scope = scopeManager.scopes[0];
40+
41+
expect(scope.variables).to.have.length(2); // value, MyComponent
42+
expect(scope.references).to.have.length(2); // value def, MyComponent def
43+
expect(scope.variables[0].references).to.have.length(2); // value def, value use
44+
expect(scope.through).to.have.length(0); // attr should not be a reference
45+
46+
const valueRef = scope.references[0];
47+
48+
expect(valueRef.identifier.name).to.equal("value");
49+
expect(valueRef.isWrite()).to.be.true;
50+
expect(valueRef.resolved).to.equal(scope.variables[0]);
51+
});
52+
53+
it("should handle nested JSX component references", () => {
54+
const ast = espree(`
55+
const Child = () => <div/>;
56+
const Parent = () => (
57+
<div>
58+
<Child/>
59+
<Child/>
60+
</div>
61+
);
62+
`, "script", true);
63+
64+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
65+
const scope = scopeManager.scopes[0];
66+
67+
expect(scope.variables).to.have.length(2); // Child, Parent
68+
expect(scope.references).to.have.length(2); // Child, Parent
69+
expect(scope.variables[0].references).to.have.length(3); // Child def, Child use x2
70+
71+
const childRefs = scope.references.filter(ref => ref.identifier.name === "Child");
72+
73+
expect(childRefs).to.have.length(1); // 1 def + 2 uses
74+
childRefs.slice(1).forEach(ref => {
75+
expect(ref.isRead()).to.be.true;
76+
expect(ref.resolved).to.equal(scope.variables[0]);
77+
});
78+
});
79+
80+
it("should handle JSX fragment references", () => {
81+
const ast = espree(`
82+
const MyComponent = () => (
83+
<>
84+
<div/>
85+
<div/>
86+
</>
87+
);
88+
`, "script", true);
89+
90+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
91+
const scope = scopeManager.scopes[0];
92+
93+
expect(scope.variables).to.have.length(1); // MyComponent
94+
expect(scope.references).to.have.length(1); // MyComponent
95+
});
96+
97+
it("should handle JSX fragments with component children", () => {
98+
const ast = espree(`
99+
const Child = () => <div/>;
100+
const Parent = () => (
101+
<>
102+
<Child/>
103+
<Child/>
104+
</>
105+
);
106+
`, "script", true);
107+
108+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
109+
const scope = scopeManager.scopes[0];
110+
111+
expect(scope.variables).to.have.length(2); // Child, Parent
112+
expect(scope.references).to.have.length(2); // Child, Parent
113+
expect(scope.variables[0].references).to.have.length(3); // Child def, Child use x2
114+
115+
const childRefs = scope.references.filter(ref => ref.identifier.name === "Child");
116+
117+
expect(childRefs).to.have.length(1); // 1 def + 2 uses
118+
childRefs.slice(1).forEach(ref => {
119+
expect(ref.isRead()).to.be.true;
120+
expect(ref.resolved).to.equal(scope.variables[0]);
121+
});
122+
});
123+
124+
it("should handle JSX spread attributes", () => {
125+
const ast = espree(`
126+
const props = { attr: "value" };
127+
const MyComponent = () => <div {...props}/>;
128+
`, "script", true);
129+
130+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
131+
const scope = scopeManager.scopes[0];
132+
133+
expect(scope.variables).to.have.length(2); // props, MyComponent
134+
expect(scope.references).to.have.length(2); // props def, MyComponent def
135+
expect(scope.variables[0].references).to.have.length(2); // props def, props use
136+
137+
const propsRef = scope.references[0];
138+
139+
expect(propsRef.identifier.name).to.equal("props");
140+
expect(propsRef.isWrite()).to.be.true;
141+
expect(propsRef.resolved).to.equal(scope.variables[0]);
142+
});
143+
144+
it("should handle JSX spread attributes with destructuring", () => {
145+
const ast = espree(`
146+
const props = { attr: "value" };
147+
const MyComponent = ({ attr }) => <div {...props}/>;
148+
`, "script", true);
149+
150+
const scopeManager = analyze(ast, { ecmaVersion: 6, jsx: true });
151+
const scope = scopeManager.scopes[0];
152+
153+
expect(scope.variables).to.have.length(2); // props, MyComponent
154+
expect(scope.references).to.have.length(2); // props def, MyComponent def
155+
expect(scope.variables[0].references).to.have.length(2); // props def, props use
156+
157+
const propsRef = scope.references[0];
158+
159+
expect(propsRef.identifier.name).to.equal("props");
160+
expect(propsRef.isWrite()).to.be.true;
161+
expect(propsRef.resolved).to.equal(scope.variables[0]);
162+
});
163+
164+
});
165+
166+
167+
});

packages/eslint-scope/tests/util/espree.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,17 @@ import * as espree from "espree";
2828
* Parse into Espree AST.
2929
* @param {string} code The code
3030
* @param {"module"|"script"} [sourceType="module"] The source type
31+
* @param {boolean} [jsx=false] The flag to enable JSX parsing
3132
* @returns {Object} The parsed Espree AST
3233
*/
33-
export default function(code, sourceType = "module") {
34+
export default function(code, sourceType = "module", jsx = false) {
3435
return espree.parse(code, {
3536
range: true,
3637
ecmaVersion: 7,
37-
sourceType
38+
sourceType,
39+
ecmaFeatures: {
40+
jsx
41+
}
3842
});
3943
}
4044

0 commit comments

Comments
 (0)