eslint-plugin-react-suspense-check 1.0.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/README.ko.md ADDED
@@ -0,0 +1,124 @@
1
+ ### πŸ‡°πŸ‡· ν•œκ΅­μ–΄
2
+
3
+ ν•œκ΅­ κ°œλ°œμžλ“€μ„ μœ„ν•œ μ„€λͺ…μ„œμž…λ‹ˆλ‹€.
4
+
5
+ # eslint-plugin-react-suspense-check πŸ‡°πŸ‡·
6
+
7
+ > **React Suspense** λŸ°νƒ€μž„ μ—λŸ¬λ₯Ό 넀이밍 κ·œμΉ™(Naming Convention)으둜 μ˜ˆλ°©ν•˜μ„Έμš”.
8
+
9
+ [πŸ‡ΊπŸ‡Έ English Docs](./README.md)
10
+
11
+ ## 🧐 μ™œ ν•„μš”ν•œκ°€μš”?
12
+
13
+ React SuspenseλŠ” κ°•λ ₯ν•˜μ§€λ§Œ, 데이터 λ‘œλ”© 쀑인 μ»΄ν¬λ„ŒνŠΈλ₯Ό μƒμœ„ `<Suspense>`둜 감싸지 μ•ŠμœΌλ©΄ **μ•± 전체가 λ©ˆμΆ”κ±°λ‚˜ ν•˜μ–€ ν™”λ©΄**이 될 수 μžˆμŠ΅λ‹ˆλ‹€.
14
+
15
+ ESLintλŠ” 파일 κ±΄λ„ˆνŽΈμ˜ μƒμœ„ νŠΈλ¦¬μ— `<Suspense>`κ°€ μžˆλŠ”μ§€ 확인할 수 μ—†μŠ΅λ‹ˆλ‹€. κ·Έλž˜μ„œ 이 ν”ŒλŸ¬κ·ΈμΈμ€ **이름 μ§“κΈ° κ·œμΉ™**을 κ°•μ œν•˜μ—¬ κ°œλ°œμžκ°€ μ‹€μˆ˜λ₯Ό μΈμ§€ν•˜λ„λ‘ λ•μŠ΅λ‹ˆλ‹€.
16
+
17
+ 1. **ν›…(Hook)**: λ‚΄λΆ€μ—μ„œ Suspenseλ₯Ό 유발(Promise throw)ν•˜λŠ” 훅은 λ°˜λ“œμ‹œ **`useSuspense...`** 둜 μ‹œμž‘ν•΄μ•Ό ν•©λ‹ˆλ‹€.
18
+ 2. **μ»΄ν¬λ„ŒνŠΈ**: μœ„ 훅을 μ‚¬μš©ν•˜λŠ” μ»΄ν¬λ„ŒνŠΈλŠ” λ°˜λ“œμ‹œ **`Suspense...`** 둜 μ‹œμž‘ν•΄μ•Ό ν•©λ‹ˆλ‹€.
19
+
20
+ 즉, μ»΄ν¬λ„ŒνŠΈ μ΄λ¦„λ§Œ 봐도 _"μ•„, 이건 μ“Έ λ•Œ Suspense둜 κ°μ‹Έμ€˜μ•Ό ν•˜λŠ”κ΅¬λ‚˜!"_ 라고 μ•Œ 수 있게 λ§Œλ“œλŠ” κ²ƒμž…λ‹ˆλ‹€.
21
+
22
+ ## πŸ“¦ μ„€μΉ˜
23
+
24
+ ```bash
25
+ # npm
26
+ npm install --save-dev eslint-plugin-react-suspense-check
27
+ ```
28
+
29
+ ```bash
30
+ # pnpm
31
+ pnpm add -D eslint-plugin-react-suspense-check
32
+ ```
33
+
34
+ ```bash
35
+ # yarn
36
+ yarn add -D eslint-plugin-react-suspense-check
37
+ ```
38
+
39
+ ### βš™οΈ μ„€μ • 방법
40
+
41
+ eslint.config.mjs (λ˜λŠ” .js) νŒŒμΌμ— μ•„λž˜ λ‚΄μš©μ„ μΆ”κ°€ν•˜μ„Έμš”.
42
+
43
+ ```JavaScript
44
+
45
+ import suspensePlugin from "eslint-plugin-react-suspense-check";
46
+
47
+ export default defineConfig([
48
+ // ... λ‹€λ₯Έ μ„€μ •λ“€
49
+ ...suspensePlugin.configs.recommended,// μΆ”μ²œ μ„€μ • 적용
50
+
51
+ //... λ‹€λ₯Έ μ„€μ •λ“€
52
+ ]);
53
+ ```
54
+
55
+ or
56
+
57
+ ```JavaScript
58
+
59
+ import suspensePlugin from "eslint-plugin-react-suspense-check";
60
+
61
+ export default defineConfig([
62
+ // ... λ‹€λ₯Έ μ„€μ •λ“€
63
+
64
+ extends: [
65
+ suspensePlugin.configs.recommended, // μΆ”μ²œ μ„€μ • 적용
66
+ ],
67
+ //... λ‹€λ₯Έ μ„€μ •λ“€
68
+ ]);
69
+ ```
70
+
71
+ ### πŸ“ κ·œμΉ™ μ„€λͺ…
72
+
73
+ `Suspense` λ₯Ό μœ λ°œν•˜λŠ” ν›…κ³Ό μ»΄ν¬λ„ŒνŠΈμ˜ 이름을 κ²€μ‚¬ν•©λ‹ˆλ‹€.
74
+
75
+ ❌ 잘λͺ»λœ μ˜ˆμ‹œ
76
+
77
+ ```ts
78
+ // 1. ν›… 이름 μœ„λ°˜
79
+ // Suspenseλ₯Ό μœ λ°œν•˜λŠ”λ° 일반 ν›…μ²˜λŸΌ 이름을 μ§€μŒ
80
+ function useUserData() {
81
+ throw promise;
82
+ }
83
+
84
+ // 2. μ»΄ν¬λ„ŒνŠΈ 이름 μœ„λ°˜
85
+ // Suspense 훅을 μ‚¬μš©ν•˜λŠ”λ° 일반 μ»΄ν¬λ„ŒνŠΈμ²˜λŸΌ 이름을 μ§€μŒ
86
+ function UserProfile() {
87
+ const data = useSuspenseUser(); // <--- ⚠️ κ²½κ³ : μ»΄ν¬λ„ŒνŠΈ 이름을 'SuspenseUserProfile'둜 λ³€κ²½ν•˜μ„Έμš”.
88
+ return <div>{data.name}</div>;
89
+ }
90
+ ```
91
+
92
+ βœ… μ˜¬λ°”λ₯Έ μ˜ˆμ‹œ
93
+
94
+ ```ts
95
+ // 1. ν›… 이름이 'useSuspense'둜 μ‹œμž‘ν•¨
96
+ function useSuspenseUserData() {
97
+ throw promise;
98
+ }
99
+
100
+ // 2. μ»΄ν¬λ„ŒνŠΈ 이름이 'Suspense'둜 μ‹œμž‘ν•¨
101
+ // ν˜ΈμΆœν•˜λŠ” μ‚¬λžŒμ΄ <Suspense>κ°€ ν•„μš”ν•˜λ‹€λŠ” 것을 μ΄λ¦„λ§Œ 보고 μ•Œ 수 있음.
102
+ function SuspenseUserProfile() {
103
+ const data = useSuspenseUserData();
104
+ return <div>{data.name}</div>;
105
+ }
106
+ ```
107
+
108
+ ### 🌍 μ–Έμ–΄ μ„€μ • (ν•œκΈ€ 지원)
109
+
110
+ μ—λŸ¬ λ©”μ‹œμ§€λ₯Ό ν•œκ΅­μ–΄λ‘œ 보고 μ‹Άλ‹€λ©΄ μ˜΅μ…˜μ„ μ„€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€. (기본값은 μ˜μ–΄)
111
+
112
+ ν•œκ΅­μ–΄ μ„€μ • μ˜ˆμ‹œ:
113
+
114
+ ```ts
115
+ // eslint.config.mjs
116
+ export default [
117
+ {
118
+ //...
119
+ rules: {
120
+ 'react-suspense-check/detect-suspense-hook': ['warn', { language: 'kr' }],
121
+ },
122
+ },
123
+ ];
124
+ ```
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ ### 🧐 Why is this necessary?
2
+
3
+ React Suspense is powerful, but failing to wrap a component that is loading data in a parent `<Suspense>` boundary can cause the entire app to freeze or result in a white screen.
4
+
5
+ ESLint cannot verify if a `<Suspense>` boundary exists in the parent tree across different files. Therefore, this plugin **enforces naming conventions** to help developers recognize potential mistakes.
6
+
7
+ **Hook**: Hooks that trigger Suspense (throw a Promise) internally must start with useSuspense....
8
+
9
+ **Component**: Components using the above hooks must start with Suspense....
10
+
11
+ In short, it ensures that just by looking at the component name, you realize, "Ah, I need to wrap this in Suspense when using it!"
12
+
13
+ ### πŸ“¦ Installation
14
+
15
+ ```Bash
16
+ # npm
17
+ npm install --save-dev eslint-plugin-react-suspense-check
18
+ ```
19
+
20
+ ```Bash
21
+ # pnpm
22
+ pnpm add -D eslint-plugin-react-suspense-check
23
+ ```
24
+
25
+ ```Bash
26
+ # yarn
27
+ yarn add -D eslint-plugin-react-suspense-check
28
+ ```
29
+
30
+ ### βš™οΈ Configuration
31
+
32
+ Add the following to your eslint.config.mjs (or .js) file.
33
+
34
+ ```js
35
+ import suspensePlugin from 'eslint-plugin-react-suspense-check';
36
+
37
+ export default defineConfig([
38
+ // ... other configs
39
+ ...suspensePlugin.configs.recommended, // Apply recommended config
40
+
41
+ //... other configs
42
+ ]);
43
+ ```
44
+
45
+ or
46
+
47
+ ```js
48
+ import suspensePlugin from "eslint-plugin-react-suspense-check";
49
+
50
+ export default defineConfig([
51
+ // ... other configs
52
+
53
+ extends: [
54
+ suspensePlugin.configs.recommended, // Apply recommended config
55
+ ],
56
+ //... other configs
57
+ ]);
58
+ ```
59
+
60
+ ### πŸ“ Rules Description
61
+
62
+ Checks the names of hooks and components that trigger `Suspense`.
63
+
64
+ ❌ Incorrect Examples
65
+
66
+ ```ts
67
+ // 1. Hook naming violation
68
+ // Triggers Suspense but named like a normal hook
69
+ function useUserData() {
70
+ throw promise;
71
+ }
72
+
73
+ // 2. Component naming violation
74
+ // Uses a Suspense hook but named like a normal component
75
+ function UserProfile() {
76
+ const data = useSuspenseUser(); // <--- ⚠️ Warning: Change component name to 'SuspenseUserProfile'.
77
+ return <div>{data.name}</div>;
78
+ }
79
+ ```
80
+
81
+ βœ… Correct Examples
82
+
83
+ ```ts
84
+ // 1. Hook name starts with 'useSuspense'
85
+ function useSuspenseUserData() {
86
+ throw promise;
87
+ }
88
+
89
+ // 2. Component name starts with 'Suspense'
90
+ // The caller can tell just by the name that <Suspense> is required.
91
+ function SuspenseUserProfile() {
92
+ const data = useSuspenseUserData();
93
+ return <div>{data.name}</div>;
94
+ }
95
+ ```
package/index.js ADDED
@@ -0,0 +1,33 @@
1
+ const detectSuspenseHook = require('./rules/detect-suspense-hook');
2
+
3
+ const plugin = {
4
+ meta: {
5
+ name: 'eslint-plugin-react-suspense-check',
6
+ version: '1.0.0',
7
+ },
8
+ rules: {
9
+ 'detect-suspense-hook': detectSuspenseHook,
10
+ },
11
+ configs: {}, // μ•„λž˜μ—μ„œ 채움
12
+ };
13
+
14
+ plugin.configs.recommended = {
15
+ plugins: {
16
+ 'react-suspense-check': plugin,
17
+ },
18
+ rules: {
19
+ 'react-suspense-check/detect-suspense-hook': 'warn',
20
+ },
21
+ // β‘’ (선택사항) μ μš©ν•  파일 ν™•μž₯μžλ„ 미리 μ§€μ •ν•΄μ£Όλ©΄ μ‚¬μš©μžκ°€ νŽΈν•¨
22
+ // 이걸 μ•ˆ λ„£μœΌλ©΄ λͺ¨λ“  파일(.css, .json λ“±)을 λ‹€ μ°”λŸ¬λ΄μ„œ λΉ„νš¨μœ¨μ μΌ 수 있음
23
+ files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
24
+ languageOptions: {
25
+ parserOptions: {
26
+ ecmaFeatures: {
27
+ jsx: true,
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ module.exports = plugin;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "eslint-plugin-react-suspense-check",
3
+ "version": "1.0.0",
4
+ "description": "ESLint plugin to enforce naming conventions for React Suspense safety",
5
+ "main": "index.js",
6
+ "engines": {
7
+ "node": ">=16.0.0"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "eslint",
14
+ "eslintplugin",
15
+ "eslint-plugin",
16
+ "react",
17
+ "suspense",
18
+ "naming-convention",
19
+ "hook",
20
+ "lint",
21
+ "analysis"
22
+ ],
23
+ "author": "Your Name <your.email@example.com> (https://your-website.com)",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/rhehfl/eslint-plugin-react-suspense-check.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/rhehfl/eslint-plugin-react-suspense-check/issues"
31
+ },
32
+ "homepage": "https://github.com/rhehfl/eslint-plugin-react-suspense-check#readme",
33
+ "peerDependencies": {
34
+ "eslint": ">=8.0.0"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "rules",
39
+ "package.json",
40
+ "README.md",
41
+ "LICENSE"
42
+ ]
43
+ }
@@ -0,0 +1,119 @@
1
+ module.exports = {
2
+ meta: {
3
+ type: 'problem',
4
+ docs: {
5
+ description:
6
+ 'Enforce naming conventions (Chain of Suspense) for hooks triggering Suspense',
7
+ category: 'Best Practices',
8
+ recommended: true,
9
+ },
10
+ messages: {
11
+ // [Case 1] ν›… 이름 λ³€κ²½ ν•„μš”
12
+ hookRenamingRequired_en:
13
+ "πŸ›‘ This hook triggers Suspense internally. Rename it to start with 'useSuspense' to signal its behavior.\n(Suggested: {{ suggestedName }})",
14
+ hookRenamingRequired_kr:
15
+ "πŸ›‘ λ‚΄λΆ€μ—μ„œ Suspenseλ₯Ό μœ λ°œν•˜λŠ” ν›…μž…λ‹ˆλ‹€. 이름을 'useSuspense'둜 μ‹œμž‘ν•˜κ²Œ λ³€κ²½ν•˜μ—¬ λ™μž‘μ„ λͺ…μ‹œν•˜μ„Έμš”.\n(μΆ”μ²œ 이름: {{ suggestedName }})",
16
+
17
+ // [Case 2] μ»΄ν¬λ„ŒνŠΈ 이름 λ³€κ²½ ν•„μš”
18
+ componentRenamingRequired_en:
19
+ "πŸ›‘ This component uses a Suspense-triggering hook. Rename it to 'Suspense{{name}}' so callers know to wrap it in a <Suspense> boundary.",
20
+ componentRenamingRequired_kr:
21
+ "πŸ›‘ λ‚΄λΆ€μ—μ„œ Suspense 훅을 μ‚¬μš©ν•˜λŠ” μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€. μƒμœ„μ—μ„œ <Suspense> μ²˜λ¦¬κ°€ ν•„μš”ν•¨μ„ μ•Œλ¦΄ 수 μžˆλ„λ‘ 이름을 'Suspense{{name}}' ν˜•μ‹μœΌλ‘œ λ³€κ²½ν•˜μ„Έμš”.",
22
+ },
23
+ schema: [
24
+ {
25
+ type: 'object',
26
+ properties: {
27
+ language: {
28
+ type: 'string',
29
+ enum: ['en', 'kr'],
30
+ },
31
+ },
32
+ additionalProperties: false,
33
+ },
34
+ ],
35
+ },
36
+
37
+ create(context) {
38
+ const configuration = context.options[0] || {};
39
+ const lang = configuration.language || 'en';
40
+ return {
41
+ CallExpression(node) {
42
+ let hookName = null;
43
+ if (node.callee.type === 'Identifier') {
44
+ hookName = node.callee.name;
45
+ } else if (node.callee.type === 'MemberExpression') {
46
+ hookName = node.callee.property.name;
47
+ }
48
+ const isSuspenseTrigger =
49
+ hookName === 'use' ||
50
+ hookName === 'lazy' ||
51
+ (typeof hookName === 'string' && /^useSuspense/.test(hookName));
52
+
53
+ if (!isSuspenseTrigger) return;
54
+
55
+ let parent = node.parent;
56
+ while (parent) {
57
+ if (
58
+ parent.type === 'FunctionDeclaration' ||
59
+ parent.type === 'ArrowFunctionExpression' ||
60
+ parent.type === 'FunctionExpression'
61
+ ) {
62
+ break;
63
+ }
64
+ parent = parent.parent;
65
+ }
66
+
67
+ if (!parent) return;
68
+
69
+ let parentFunctionName = null;
70
+ let parentIdNode = null;
71
+
72
+ if (parent.type === 'FunctionDeclaration' && parent.id) {
73
+ parentFunctionName = parent.id.name;
74
+ parentIdNode = parent.id;
75
+ } else if (
76
+ parent.type === 'ArrowFunctionExpression' ||
77
+ parent.type === 'FunctionExpression'
78
+ ) {
79
+ if (parent.parent.type === 'VariableDeclarator' && parent.parent.id) {
80
+ parentFunctionName = parent.parent.id.name;
81
+ parentIdNode = parent.parent.id;
82
+ }
83
+ }
84
+
85
+ if (!parentFunctionName) return;
86
+
87
+ const isParentHook = /^use/.test(parentFunctionName);
88
+
89
+ if (isParentHook) {
90
+ if (/^useSuspense/.test(parentFunctionName)) return;
91
+
92
+ const suggestedName = parentFunctionName.replace(
93
+ /^use/,
94
+ 'useSuspense'
95
+ );
96
+ context.report({
97
+ node: parentIdNode,
98
+ messageId: `hookRenamingRequired_${lang}`,
99
+ data: {
100
+ suggestedName: suggestedName,
101
+ },
102
+ });
103
+ } else {
104
+ const isSuspenseComponent = /^Suspense/.test(parentFunctionName);
105
+
106
+ if (!isSuspenseComponent) {
107
+ context.report({
108
+ node: parentIdNode,
109
+ messageId: `componentRenamingRequired_${lang}`,
110
+ data: {
111
+ name: parentFunctionName,
112
+ },
113
+ });
114
+ }
115
+ }
116
+ },
117
+ };
118
+ },
119
+ };