Skip to content

Commit a2bad41

Browse files
committed
feat(plugins/naming-convention): add 'context-name' rule to enforce naming conventions for contexts
1 parent 2ab9b77 commit a2bad41

File tree

11 files changed

+281
-8
lines changed

11 files changed

+281
-8
lines changed

apps/website/content/docs/rules/meta.json

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"hooks-extra-prefer-use-state-lazy-initialization",
8383
"---Naming Convention Rules---",
8484
"naming-convention-component-name",
85+
"naming-convention-context-name",
8586
"naming-convention-filename",
8687
"naming-convention-filename-extension",
8788
"naming-convention-use-state",

apps/website/content/docs/rules/overview.md

+1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ full: true
114114
| Rule || Features | Description |
115115
| :------------------------------------------------------------- | :- | :------- | :------------------------------------------------------------------------------- |
116116
| [`component-name`](./naming-convention-component-name) | 0️⃣ | `🔍` `⚙️` | Enforces naming conventions for components. |
117+
| [`context-name`](./naming-convention-context-name) | 0️⃣ | `🔍` `⚙️` | Enforces naming conventions for context providers. |
117118
| [`filename`](./naming-convention-filename) | 0️⃣ | `🔍` `⚙️` | Enforces naming convention for JSX files. |
118119
| [`filename-extension`](./naming-convention-filename-extension) | 0️⃣ | `🔍` `⚙️` | Enforces consistent use of the JSX file extension. |
119120
| [`use-state`](./naming-convention-use-state) | 0️⃣ | `🔍` | Enforces destructuring and symmetric naming of `useState` hook value and setter. |

packages/plugins/eslint-plugin-react-naming-convention/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@eslint-react/core": "workspace:*",
5454
"@eslint-react/eff": "workspace:*",
5555
"@eslint-react/jsx": "workspace:*",
56+
"@eslint-react/var": "workspace:*",
5657
"@eslint-react/shared": "workspace:*",
5758
"@typescript-eslint/scope-manager": "^8.25.0",
5859
"@typescript-eslint/type-utils": "^8.25.0",

packages/plugins/eslint-plugin-react-naming-convention/src/plugin.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { name, version } from "../package.json";
22
import componentName from "./rules/component-name";
3+
import contextName from "./rules/context-name";
34
import filename from "./rules/filename";
45
import filenameExtension from "./rules/filename-extension";
56
import useState from "./rules/use-state";
@@ -11,6 +12,7 @@ export const plugin = {
1112
},
1213
rules: {
1314
"component-name": componentName,
15+
"context-name": contextName,
1416
filename,
1517
"filename-extension": filenameExtension,
1618
"use-state": useState,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: context-name
3+
---
4+
5+
**Full Name in `eslint-plugin-react-naming-convention`**
6+
7+
```plain copy
8+
react-naming-convention/context-name
9+
```
10+
11+
**Full Name in `@eslint-react/eslint-plugin`**
12+
13+
```plain copy
14+
@eslint-react/naming-convention/context-name
15+
```
16+
17+
**Features**
18+
19+
`🔍`
20+
21+
## What it does
22+
23+
Enforces naming conventions for context providers.
24+
25+
## Examples
26+
27+
### Failing
28+
29+
```tsx
30+
const Theme = createContext({});
31+
```
32+
33+
### Passing
34+
35+
```tsx
36+
const ThemeContext = createContext({});
37+
```
38+
39+
## Implementation
40+
41+
- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.ts)
42+
- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.spec.ts)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { allFunctions, ruleTester } from "../../../../../test";
2+
import rule, { RULE_NAME } from "./context-name";
3+
4+
ruleTester.run(RULE_NAME, rule, {
5+
invalid: [
6+
{
7+
code: `
8+
import { createContext } from "react";
9+
const Foo = createContext({});
10+
`,
11+
errors: [{ messageId: "contextName" }],
12+
},
13+
{
14+
code: `
15+
import { createContext } from "react";
16+
const Ctx = createContext({});
17+
`,
18+
errors: [{ messageId: "contextName" }],
19+
},
20+
],
21+
valid: [
22+
...allFunctions,
23+
/* tsx */ `
24+
import { createContext } from "react";
25+
const MyContext = createContext({});
26+
`,
27+
],
28+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as AST from "@eslint-react/ast";
2+
import { isCreateContextCall } from "@eslint-react/core";
3+
import type { RuleFeature } from "@eslint-react/shared";
4+
import * as VAR from "@eslint-react/var";
5+
import type { CamelCase } from "string-ts";
6+
7+
import { createRule } from "../utils";
8+
9+
export const RULE_NAME = "context-name";
10+
11+
export const RULE_FEATURES = [
12+
"CHK",
13+
] as const satisfies RuleFeature[];
14+
15+
export type MessageID = CamelCase<typeof RULE_NAME>;
16+
17+
export default createRule<[], MessageID>({
18+
meta: {
19+
type: "problem",
20+
docs: {
21+
description: "enforce context name to end with `Context`.",
22+
},
23+
messages: {
24+
contextName: "Context name must end with `Context`.",
25+
},
26+
schema: [],
27+
},
28+
name: RULE_NAME,
29+
create(context) {
30+
if (!context.sourceCode.text.includes("createContext")) return {};
31+
return {
32+
CallExpression(node) {
33+
if (!isCreateContextCall(context, node)) return;
34+
const id = VAR.getVariableId(node);
35+
if (id == null) return;
36+
const name = context.sourceCode.getText(AST.getEcmaExpression(id));
37+
if (name.endsWith("Context")) return;
38+
context.report({
39+
messageId: "contextName",
40+
node: id,
41+
});
42+
},
43+
};
44+
},
45+
defaultOptions: [],
46+
});

packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.md

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ Prevents non-stable values (i.e. object literals) from being used as a value for
3131

3232
React will re-render all consumers of a context whenever the context value changes, and if the value is not stable, this can lead to unnecessary re-renders.
3333

34+
In React 19 and later, the [`Context` component can be used via `<Context>` instead of `<Context.Provider>`](https://react.dev/blog/2024/12/05/react-19#context-as-a-provider), so it is recommended to use the [`context-name`](./naming-convention-context-name) rule to avoid false negatives.
35+
3436
## Examples
3537

3638
### Failing

packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.spec.ts

+138
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,92 @@ ruleTester.run(RULE_NAME, rule, {
8888
},
8989
],
9090
},
91+
{
92+
code: /* tsx */ `
93+
function App() {
94+
const foo = {}
95+
return <Context value={foo}></Context>;
96+
}
97+
`,
98+
errors: [{
99+
messageId: "unstableContextValue",
100+
data: {
101+
type: "object expression",
102+
suggestion: "Consider wrapping it in a useMemo hook.",
103+
},
104+
}],
105+
settings: {
106+
"react-x": {
107+
version: "19.0.0",
108+
},
109+
},
110+
},
111+
{
112+
code: /* tsx */ `
113+
function App() {
114+
const foo = []
115+
return <CONTEXT value={foo}></CONTEXT>
116+
}
117+
`,
118+
errors: [
119+
{
120+
messageId: "unstableContextValue",
121+
data: {
122+
type: "array expression",
123+
suggestion: "Consider wrapping it in a useMemo hook.",
124+
},
125+
},
126+
],
127+
settings: {
128+
"react-x": {
129+
version: "19.0.0",
130+
},
131+
},
132+
},
133+
{
134+
code: /* tsx */ `
135+
function App() {
136+
const foo = []
137+
return <ThemeContext value={foo}></ThemeContext>
138+
}
139+
`,
140+
errors: [
141+
{
142+
messageId: "unstableContextValue",
143+
data: {
144+
type: "array expression",
145+
suggestion: "Consider wrapping it in a useMemo hook.",
146+
},
147+
},
148+
],
149+
settings: {
150+
"react-x": {
151+
version: "19.0.0",
152+
},
153+
},
154+
},
155+
{
156+
code: /* tsx */ `
157+
function App() {
158+
const foo = []
159+
return <THEME_CONTEXT value={foo}></THEME_CONTEXT>
160+
}
161+
`,
162+
errors: [
163+
{
164+
messageId: "unstableContextValue",
165+
data: {
166+
type: "array expression",
167+
suggestion: "Consider wrapping it in a useMemo hook.",
168+
},
169+
},
170+
],
171+
settings: {
172+
"react-x": {
173+
version: "19.0.0",
174+
},
175+
},
176+
},
91177
],
92178
valid: [
93179
...allValid,
@@ -127,5 +213,57 @@ ruleTester.run(RULE_NAME, rule, {
127213
return <Context.Provider value={foo}></Context.Provider>;
128214
}
129215
`,
216+
{
217+
code: /* tsx */ `
218+
function App() {
219+
const foo = {}
220+
return <Context value={foo}></Context>;
221+
}
222+
`,
223+
settings: {
224+
"react-x": {
225+
version: "18.0.0",
226+
},
227+
},
228+
},
229+
{
230+
code: /* tsx */ `
231+
function App() {
232+
const foo = []
233+
return <CONTEXT value={foo}></CONTEXT>
234+
}
235+
`,
236+
settings: {
237+
"react-x": {
238+
version: "18.0.0",
239+
},
240+
},
241+
},
242+
{
243+
code: /* tsx */ `
244+
function App() {
245+
const foo = []
246+
return <ThemeContext value={foo}></ThemeContext>
247+
}
248+
`,
249+
settings: {
250+
"react-x": {
251+
version: "18.0.0",
252+
},
253+
},
254+
},
255+
{
256+
code: /* tsx */ `
257+
function App() {
258+
const foo = []
259+
return <THEME_CONTEXT value={foo}></THEME_CONTEXT>
260+
}
261+
`,
262+
settings: {
263+
"react-x": {
264+
version: "18.0.0",
265+
},
266+
},
267+
},
130268
],
131269
});

packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts

+17-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as AST from "@eslint-react/ast";
22
import { isReactHookCall, useComponentCollector } from "@eslint-react/core";
33
import { getOrUpdate } from "@eslint-react/eff";
4-
import type { RuleFeature } from "@eslint-react/shared";
4+
import * as JSX from "@eslint-react/jsx";
5+
import { getSettingsFromContext, type RuleFeature } from "@eslint-react/shared";
56
import * as VAR from "@eslint-react/var";
67
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
8+
import { compare } from "compare-versions";
79

810
import { createRule } from "../utils";
911

@@ -30,19 +32,18 @@ export default createRule<[], MessageID>({
3032
},
3133
name: RULE_NAME,
3234
create(context) {
35+
const { version } = getSettingsFromContext(context);
36+
const isReact18OrBelow = compare(version, "19.0.0", "<");
3337
const { ctx, listeners } = useComponentCollector(context);
3438
const constructions = new Map<AST.TSESTreeFunction, VAR.ValueConstruction[]>();
3539

3640
return {
3741
...listeners,
3842
JSXOpeningElement(node) {
39-
const openingElementName = node.name;
40-
if (openingElementName.type !== T.JSXMemberExpression) {
41-
return;
42-
}
43-
if (openingElementName.property.name !== "Provider") {
44-
return;
45-
}
43+
const fullName = JSX.getElementName(node.parent);
44+
const selfName = fullName.split(".").at(-1);
45+
if (selfName == null) return;
46+
if (!isContextName(selfName, isReact18OrBelow)) return;
4647
const functionEntry = ctx.getCurrentEntry();
4748
if (functionEntry == null) return;
4849
const attribute = node
@@ -86,3 +87,11 @@ export default createRule<[], MessageID>({
8687
},
8788
defaultOptions: [],
8889
});
90+
91+
function isContextName(name: string, isReact18OrBelow: boolean): boolean {
92+
if (name === "Provider") return true;
93+
if (!isReact18OrBelow) {
94+
return name.endsWith("Context") || name.endsWith("CONTEXT");
95+
}
96+
return false;
97+
}

pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)