eslint-plugin-smarthr 6.19.0 → 6.20.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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [6.20.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.19.0...eslint-plugin-smarthr-v6.20.0) (2026-06-16)
6
+
7
+
8
+ ### Features
9
+
10
+ * **best-practice-for-no-unnecessary-variable:** export { xxx as yyy } パターンで適切なメッセージを表示 ([#1375](https://github.com/kufu/tamatebako/issues/1375)) ([52d90fa](https://github.com/kufu/tamatebako/commit/52d90fa6eb682c956d71a12269d2cc2c4173d3dd))
11
+ * 依存配列に不安定な参照を含めることを禁止するルールを追加 ([#1376](https://github.com/kufu/tamatebako/issues/1376)) ([af7c18e](https://github.com/kufu/tamatebako/commit/af7c18e74ce38391078a0bd14bf815529e852f8a))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * best-practice-for-rest-parametersでin演算子も内部属性アクセスとして検知 ([#1372](https://github.com/kufu/tamatebako/issues/1372)) ([086b414](https://github.com/kufu/tamatebako/commit/086b414ef731ebd27aee935f2d31aa5d303dd9fd))
17
+ * lazy-variable/no-unnecessary-variableでjsxタグ名を正しく扱う ([#1382](https://github.com/kufu/tamatebako/issues/1382)) ([391fe7e](https://github.com/kufu/tamatebako/commit/391fe7ea655e030ded3d2a3d45ae511c2da75dd1))
18
+
5
19
  ## [6.19.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.18.0...eslint-plugin-smarthr-v6.19.0) (2026-06-10)
6
20
 
7
21
 
package/README.md CHANGED
@@ -40,6 +40,7 @@
40
40
  - [best-practice-for-tailwind-variants](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-tailwind-variants)
41
41
  - [best-practice-for-text-component](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component)
42
42
  - [best-practice-for-unnesessary-early-return](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-unnesessary-early-return)
43
+ - [best-practice-for-unstable-dependencies](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-unstable-dependencies)
43
44
  - [component-name](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/component-name)
44
45
  - [design-system-guideline-bulk-action-row-button](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/design-system-guideline-bulk-action-row-button)
45
46
  - [design-system-guideline-prohibit-dialog-button-icon](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/design-system-guideline-prohibit-dialog-button-icon)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-smarthr",
3
- "version": "6.19.0",
3
+ "version": "6.20.0",
4
4
  "author": "SmartHR",
5
5
  "license": "MIT",
6
6
  "description": "A sharable ESLint plugin for SmartHR",
@@ -84,7 +84,8 @@ function getVariableUsages(sourceCode, varName, declarationNode) {
84
84
  if (!node || typeof node !== 'object') return
85
85
 
86
86
  switch (node.type) {
87
- case 'Identifier': {
87
+ case 'Identifier':
88
+ case 'JSXIdentifier': {
88
89
  // 変数名が一致し、変数参照である場合のみ収集(宣言自体は除外)
89
90
  if (node.name === varName && node !== declarationNode.id && isVariableReference(node)) {
90
91
  usages.push(node)
@@ -50,9 +50,7 @@ function shouldSkipVariableExceptComplexity(node) {
50
50
  // UPPER_SNAKE_CASE形式の定数は除外(慣習的な定数命名)
51
51
  UPPER_SNAKE_CASE_PATTERN.test(node.id.name) ||
52
52
  // 関数式、TaggedTemplateExpressionは除外
53
- EXCLUDED_INIT_TYPES.includes(node.init.type) ||
54
- // React Hooks(useXxxで始まる関数)で初期化される変数は対象外
55
- (node.init.type === 'CallExpression' && node.init.callee.type === 'Identifier' && node.init.callee.name.startsWith('use'))
53
+ EXCLUDED_INIT_TYPES.includes(node.init.type)
56
54
  ) {
57
55
  return true
58
56
  }
@@ -69,6 +67,16 @@ function shouldSkipVariableExceptComplexity(node) {
69
67
  return false
70
68
  }
71
69
 
70
+ /**
71
+ * React Hooksで初期化されているかを判定
72
+ */
73
+ function isReactHookCall(node) {
74
+ return node.init &&
75
+ node.init.type === 'CallExpression' &&
76
+ node.init.callee.type === 'Identifier' &&
77
+ node.init.callee.name.startsWith('use')
78
+ }
79
+
72
80
  /**
73
81
  * 型注釈のテキストを取得
74
82
  * @param {object} sourceCode - SourceCode
@@ -165,7 +173,7 @@ function getVariableUsagesInScope(sourceCode, varName, declarationNode) {
165
173
  * 変数の使用箇所として収集すべきIdentifierかチェック
166
174
  */
167
175
  function isTargetIdentifier(node) {
168
- return node.type === 'Identifier' &&
176
+ return (node.type === 'Identifier' || node.type === 'JSXIdentifier') &&
169
177
  node.name === varName &&
170
178
  node !== declarationNode.id &&
171
179
  !isInsideInit(node)
@@ -195,7 +203,11 @@ function getVariableUsagesInScope(sourceCode, varName, declarationNode) {
195
203
 
196
204
  // 変数名が一致するIdentifierを収集(宣言自体と初期化式内は除外)
197
205
  if (isTargetIdentifier(node)) {
198
- usages.push(node)
206
+ // 同じノードオブジェクトを重複してカウントしないようにする
207
+ // (export { foo } の場合、localとexportedが同じノードを指すため)
208
+ if (!usages.includes(node)) {
209
+ usages.push(node)
210
+ }
199
211
  return
200
212
  }
201
213
 
@@ -371,6 +383,24 @@ function isAtStartOfExpressionStatement(usageNode) {
371
383
  return false
372
384
  }
373
385
 
386
+ /**
387
+ * 使用箇所がexport { xxx as yyy }のExportSpecifierの中にあるかチェック
388
+ */
389
+ function isInExportSpecifier(usageNode) {
390
+ let current = usageNode.parent
391
+ while (current) {
392
+ if (current.type === 'ExportSpecifier') {
393
+ return true
394
+ }
395
+ // ExportNamedDeclarationまで到達したら終了
396
+ if (current.type === 'ExportNamedDeclaration') {
397
+ return false
398
+ }
399
+ current = current.parent
400
+ }
401
+ return false
402
+ }
403
+
374
404
  /**
375
405
  * 複雑さをチェックしてインライン化可能かを判定
376
406
  */
@@ -413,6 +443,19 @@ function analyzeVariable(sourceCode, node, options = {}) {
413
443
  }
414
444
 
415
445
  const usage = usages[0]
446
+
447
+ // JSXIdentifierとして使用されている場合は対象外(JSXタグ名はインライン化できない)
448
+ if (usage.type === 'JSXIdentifier') {
449
+ return null
450
+ }
451
+
452
+ const isExportSpec = isInExportSpecifier(usage)
453
+
454
+ // React Hooks呼び出しは対象外(ただしexport { xxx as yyy }パターンは対象)
455
+ if (!isExportSpec && isReactHookCall(node)) {
456
+ return null
457
+ }
458
+
416
459
  const typeAnnotation = getTypeAnnotationText(sourceCode, node)
417
460
 
418
461
  // 複雑さチェック
@@ -425,6 +468,7 @@ function analyzeVariable(sourceCode, node, options = {}) {
425
468
  varName,
426
469
  usage,
427
470
  typeAnnotation,
471
+ isExportSpecifier: isExportSpec,
428
472
  }
429
473
  }
430
474
 
@@ -438,6 +482,7 @@ module.exports = {
438
482
  schema: SCHEMA,
439
483
  messages: {
440
484
  inlineVariable: '変数"{{name}}"は一度しか使用されていません。直接使用してください。',
485
+ exportDirectly: '変数"{{name}}"は一度しか使用されていません。export const {{exportedName}} = ... の形式で直接エクスポートしてください。',
441
486
  },
442
487
  },
443
488
  create(context) {
@@ -450,12 +495,35 @@ module.exports = {
450
495
  const analysis = analyzeVariable(sourceCode, node, options)
451
496
 
452
497
  if (analysis) {
453
- context.report({
454
- node: analysis.node,
455
- messageId: 'inlineVariable',
456
- data: { name: analysis.varName },
457
- fix: fix ? createInlineFixer(sourceCode, analysis.node, analysis.usage, analysis.typeAnnotation) : null,
458
- })
498
+ // export { xxx as yyy } パターンの場合は異なるメッセージを使用
499
+ if (analysis.isExportSpecifier) {
500
+ // ExportSpecifierからエクスポート名を取得
501
+ let exportedName = analysis.varName
502
+ let current = analysis.usage.parent
503
+ while (current && current.type !== 'ExportSpecifier') {
504
+ current = current.parent
505
+ }
506
+ if (current && current.type === 'ExportSpecifier' && current.exported) {
507
+ exportedName = current.exported.name
508
+ }
509
+
510
+ context.report({
511
+ node: analysis.node,
512
+ messageId: 'exportDirectly',
513
+ data: {
514
+ name: analysis.varName,
515
+ exportedName,
516
+ },
517
+ fix: fix ? createInlineFixer(sourceCode, analysis.node, analysis.usage, analysis.typeAnnotation) : null,
518
+ })
519
+ } else {
520
+ context.report({
521
+ node: analysis.node,
522
+ messageId: 'inlineVariable',
523
+ data: { name: analysis.varName },
524
+ fix: fix ? createInlineFixer(sourceCode, analysis.node, analysis.usage, analysis.typeAnnotation) : null,
525
+ })
526
+ }
459
527
  }
460
528
  },
461
529
  }
@@ -2,6 +2,15 @@ const SCHEMA = []
2
2
 
3
3
  const REST_REGEX = /(^r|R)est$/
4
4
  const MEMBER_EXPRESSION_REST_REGEX = /^(r|[a-zA-Z0-9_]+R)est\./
5
+
6
+ const UNFORMAT_NAME__REST_ELEMENT_SELECTOR = `RestElement:not([argument.name=${REST_REGEX}])`
7
+ const CONFUSING_NAME_VARIABLE_SELECTOR_1 = `:not(:matches(RestElement,JSXSpreadAttribute,JSXSpreadAttribute > TSAsExpression,SpreadElement,SpreadElement > TSAsExpression,MemberExpression,BinaryExpression,VariableDeclarator,ArrayExpression,CallExpression,ObjectPattern > Property,ObjectExpression > Property,ReturnStatement,ArrowFunctionExpression)) > Identifier[name=${REST_REGEX}]`
8
+ const CONFUSING_NAME_VARIABLE_SELECTOR_2 = `:matches(VariableDeclarator[id.name=${REST_REGEX}],ObjectPattern > Property[value.name=${REST_REGEX}],ObjectExpression > Property[key.name=${REST_REGEX}])`
9
+ const ARROW_FUNCTION_PARAMS_SELECTOR = `ArrowFunctionExpression > Identifier[name=${REST_REGEX}]`
10
+ const USE_REST_VIORATION_SELECTOR_MEMBER_EXPRESSION = `MemberExpression[object.name=${REST_REGEX}]`
11
+ const USE_REST_VIORATION_SELECTOR_IN_OPERATOR = `BinaryExpression[operator="in"][right.name=${REST_REGEX}]`
12
+ const USE_REST_VIORATION_OBJECT_PATTERN = `VariableDeclarator[id.type='ObjectPattern'][init.name=${REST_REGEX}]`
13
+
5
14
  const DETAIL_LINK = `
6
15
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-rest-parameters`
7
16
 
@@ -21,46 +30,47 @@ module.exports = {
21
30
  - 残余引数(rest parameters)と混同する可能性があるため別の名称に修正してください`,
22
31
  })
23
32
  }
33
+ const actionUseRestVioration = (node) => {
34
+ context.report({
35
+ node,
36
+ message: `残余引数内の属性を参照しないでください${DETAIL_LINK}`,
37
+ })
38
+ }
24
39
  const actionMemberExpressionName = (node) => {
25
40
  if (node.parent.type === 'MemberExpression') {
26
41
  return actionMemberExpressionName(node.parent)
27
42
  }
28
43
 
29
44
  if (MEMBER_EXPRESSION_REST_REGEX.test(context.sourceCode.getText(node))){
30
- context.report({
31
- node,
32
- message: `残余引数内の属性を参照しないでください${DETAIL_LINK}`,
33
- })
45
+ actionUseRestVioration(node)
34
46
  }
35
47
  }
36
48
 
37
49
  return {
38
- [`ObjectPattern[properties.length=1]>RestElement`]: (node) => {
50
+ 'ObjectPattern[properties.length=1]>RestElement': (node) => {
39
51
  context.report({
40
52
  node,
41
53
  message: `意味のない残余引数のため、単一の引数に変更してください${DETAIL_LINK}`,
42
54
  })
43
55
  },
44
- [`RestElement:not([argument.name=${REST_REGEX}])`]: (node) => {
56
+ [UNFORMAT_NAME__REST_ELEMENT_SELECTOR]: (node) => {
45
57
  context.report({
46
58
  node,
47
59
  message: `残余引数には ${REST_REGEX} とマッチする名称を指定してください${DETAIL_LINK}`,
48
60
  })
49
61
  },
50
- [`:not(:matches(RestElement,JSXSpreadAttribute,JSXSpreadAttribute > TSAsExpression,SpreadElement,SpreadElement > TSAsExpression,MemberExpression,VariableDeclarator,ArrayExpression,CallExpression,ObjectPattern > Property,ObjectExpression > Property,ReturnStatement,ArrowFunctionExpression)) > Identifier[name=${REST_REGEX}]`]: actionNotRest,
51
- [`:matches(VariableDeclarator[id.name=${REST_REGEX}],ObjectPattern > Property[value.name=${REST_REGEX}],ObjectExpression > Property[key.name=${REST_REGEX}])`]: actionNotRest,
52
- [`MemberExpression[object.name=${REST_REGEX}]`]: actionMemberExpressionName,
53
- [`ArrowFunctionExpression > Identifier[name=${REST_REGEX}]`]: (node) => {
62
+ [CONFUSING_NAME_VARIABLE_SELECTOR_1]: actionNotRest,
63
+ [CONFUSING_NAME_VARIABLE_SELECTOR_2]: actionNotRest,
64
+ [ARROW_FUNCTION_PARAMS_SELECTOR]: (node) => {
54
65
  if (node !== node.parent.body) {
55
66
  actionNotRest(node)
56
67
  }
57
68
  },
58
- [`VariableDeclarator[id.type='ObjectPattern'][init.name=${REST_REGEX}]`]: (node) => {
59
- context.report({
60
- node,
61
- message: `残余引数内の属性を参照しないでください${DETAIL_LINK}`,
62
- })
69
+ [USE_REST_VIORATION_SELECTOR_MEMBER_EXPRESSION]: actionMemberExpressionName,
70
+ [USE_REST_VIORATION_SELECTOR_IN_OPERATOR]: (node) => {
71
+ actionUseRestVioration(node.right)
63
72
  },
73
+ [USE_REST_VIORATION_OBJECT_PATTERN]: actionUseRestVioration,
64
74
  }
65
75
  },
66
76
  }
@@ -0,0 +1,287 @@
1
+ # best-practice-for-unstable-dependencies
2
+
3
+ React Hooksの依存配列に不安定な参照(オブジェクト、配列、関数、ReactNodeなど)を含めることを禁止します。
4
+
5
+ これらの値は参照が頻繁に変わるため、依存配列に含めると不要な再実行や無限ループの原因となります。
6
+
7
+ **デフォルトでは `children` のみを検出します。**他の変数名を検出したい場合は、オプションで追加してください。
8
+
9
+ ## 不安定な参照の種類(オプションで追加可能)
10
+
11
+ - **ReactNode**: `children`(デフォルト)、`icon`、`prefix`、`suffix`など
12
+ - **オブジェクト**: `object`、`config`、`options`、`settings`など
13
+ - **配列**: `items`、`list`、`data`、`records`など
14
+ - **関数**: `callback`、`handler`、`onClick`、`onChange`、`onSubmit`など
15
+
16
+ ## ❌ NG
17
+
18
+ ### children(デフォルトで検出)
19
+
20
+ ```javascript
21
+ useEffect(() => {
22
+ console.log(children)
23
+ }, [children])
24
+ ```
25
+
26
+ ```javascript
27
+ const memoized = useMemo(() => {
28
+ return children.length
29
+ }, [children])
30
+ ```
31
+
32
+ ### オプション追加時の例
33
+
34
+ 以下は `additionalUnstableNames` オプションで変数名を追加した場合の検出例です。
35
+
36
+ **オブジェクト:**
37
+
38
+ ```javascript
39
+ // additionalUnstableNames: ["object"] を設定した場合
40
+ useEffect(() => {
41
+ console.log(object.key)
42
+ }, [object])
43
+ ```
44
+
45
+ **配列:**
46
+
47
+ ```javascript
48
+ // additionalUnstableNames: ["items"] を設定した場合
49
+ const memoized = useMemo(() => {
50
+ return items.map(i => i.value)
51
+ }, [items])
52
+ ```
53
+
54
+ **関数:**
55
+
56
+ ```javascript
57
+ // additionalUnstableNames: ["callback"] を設定した場合
58
+ useEffect(() => {
59
+ callback()
60
+ }, [callback])
61
+ ```
62
+
63
+ ## ✅ OK
64
+
65
+ ### 方法1: refを使用する(最も推奨)
66
+
67
+ 依存配列で再実行する必要がない変数は、refに保存して依存配列から除外します。
68
+
69
+ **children(DOM要素のrefを使う場合):**
70
+
71
+ ```javascript
72
+ const childrenRef = useRef()
73
+
74
+ useEffect(() => {
75
+ console.log(childrenRef.current)
76
+ }, [/* childrenを含めない */])
77
+
78
+ ...
79
+
80
+ return (
81
+ <Any ref={childrenRef}>{children}</Any>
82
+ )
83
+ ```
84
+
85
+ refは任意の値を保持でき、更新してもコンポーネントの再レンダリングをトリガーしません。
86
+
87
+ ```javascript
88
+ const valueRef = useRef()
89
+ valueRef.current = value // レンダリングごとに最新の値に更新
90
+
91
+ useEffect(() => {
92
+ // valueRef.currentを使用
93
+ console.log(valueRef.current)
94
+ }, [/* valueを含めない */])
95
+ ```
96
+
97
+ ### 方法2: MutationObserverを使用する
98
+
99
+ `children` の変更を検知する必要がある場合は、DOM要素にrefを設定してMutationObserverで監視します。
100
+
101
+ ```javascript
102
+ const containerRef = useRef()
103
+
104
+ useEffect(() => {
105
+ const observer = new MutationObserver(() => {
106
+ // children内のDOM要素が追加/削除されたときの処理
107
+ console.log('子要素が変更されました')
108
+ })
109
+
110
+ if (containerRef.current) {
111
+ // containerRef内の子要素の変更を監視
112
+ observer.observe(containerRef.current, {
113
+ childList: true, // 直接の子要素の追加/削除を監視
114
+ subtree: true, // 子孫要素の変更も監視(必要に応じて)
115
+ })
116
+ }
117
+
118
+ return () => observer.disconnect()
119
+ }, []) // childrenは依存配列に含めない
120
+
121
+ ...
122
+
123
+ return (
124
+ <div ref={containerRef}>
125
+ {children}
126
+ </div>
127
+ )
128
+ ```
129
+
130
+ ### 方法3: プリミティブ値のみを依存配列に含める
131
+
132
+ オブジェクトや配列の場合は、必要なプリミティブ値のみを依存配列に含めます。
133
+
134
+ ```javascript
135
+ // ❌ 配列全体を依存配列に含める
136
+ useEffect(() => {
137
+ console.log(items.length)
138
+ }, [items])
139
+
140
+ // ✅ 必要な値のみを依存配列に含める
141
+ useEffect(() => {
142
+ console.log(items.length)
143
+ }, [items.length])
144
+ ```
145
+
146
+ **スプレッド構文を使っている場合:**
147
+
148
+ スプレッド構文(`...config`など)で展開している箇所は、実際に必要な値のみをベタ書きできないか検証してください。
149
+
150
+ ```javascript
151
+ // ❌ オブジェクト全体を展開
152
+ useEffect(() => {
153
+ const newConfig = { ...config, newProp: value }
154
+ doSomething(newConfig)
155
+ }, [config, value])
156
+
157
+ // ✅ 必要な値のみをベタ書き
158
+ useEffect(() => {
159
+ const newConfig = {
160
+ apiUrl: config.apiUrl,
161
+ timeout: config.timeout,
162
+ newProp: value
163
+ }
164
+ doSomething(newConfig)
165
+ }, [config.apiUrl, config.timeout, value])
166
+ ```
167
+
168
+ **ヒント:** 多くの場合、スプレッド構文で展開しているオブジェクトは、実際には一部のプロパティしか使っていないことがあります。使用箇所を確認して、必要な値のみを明示的に指定することで、依存配列を最小限に抑えられます。配列の場合も、実際に必要なのは`length`や特定のインデックスの値だけかもしれません。
169
+
170
+ ### 方法4: useCallbackやuseMemoでメモ化する
171
+
172
+ 不安定な参照を安定化させるために、useCallbackやuseMemoでラップします。
173
+
174
+ **関数の場合(useCallback):**
175
+
176
+ ```javascript
177
+ // ❌ 関数を直接依存配列に含める
178
+ useEffect(() => {
179
+ callback()
180
+ }, [callback])
181
+
182
+ // ✅ useCallbackでメモ化
183
+ const memoizedCallback = useCallback(callback, [/* callbackの依存関係 */])
184
+
185
+ useEffect(() => {
186
+ memoizedCallback()
187
+ }, [memoizedCallback])
188
+ ```
189
+
190
+ **オブジェクトの場合(useMemo):**
191
+
192
+ ```javascript
193
+ // ❌ オブジェクトを直接依存配列に含める
194
+ useEffect(() => {
195
+ console.log(config.apiUrl)
196
+ }, [config])
197
+
198
+ // ✅ useMemoでメモ化
199
+ const memoizedConfig = useMemo(() => config, [/* configの依存関係 */])
200
+
201
+ useEffect(() => {
202
+ console.log(memoizedConfig.apiUrl)
203
+ }, [memoizedConfig])
204
+ ```
205
+
206
+ **注意:** メモ化は依存関係が明確な場合に有効です。依存関係が不明確な場合は、方法1のrefを使用することを推奨します。
207
+
208
+ ## オプション
209
+
210
+ ### additionalUnstableNames
211
+
212
+ デフォルトでは `children` のみをチェックしますが、他の変数名を追加できます。
213
+
214
+ ```javascript
215
+ {
216
+ "smarthr/best-practice-for-unstable-dependencies": ["error", {
217
+ "additionalUnstableNames": ["icon", "prefix", "object", "items", "callback"]
218
+ }]
219
+ }
220
+ ```
221
+
222
+ ### additionalTargetHooks
223
+
224
+ デフォルトでは `useEffect`, `useLayoutEffect`, `useCallback`, `useMemo` をチェックしますが、カスタムフックを追加できます。
225
+
226
+ ```javascript
227
+ {
228
+ "smarthr/best-practice-for-unstable-dependencies": ["error", {
229
+ "additionalTargetHooks": ["useCustomHook", "useMyEffect"]
230
+ }]
231
+ }
232
+ ```
233
+
234
+ ## 検出対象のHooks(デフォルト)
235
+
236
+ - `useEffect`
237
+ - `useCallback`
238
+ - `useMemo`
239
+ - `useLayoutEffect`
240
+
241
+ ## 使用例
242
+
243
+ ### 不安定な参照の追加
244
+
245
+ プロジェクトでよく使われる不安定な参照を追加することで、チーム全体で一貫したコードを書くことができます。
246
+
247
+ ```javascript
248
+ {
249
+ "smarthr/best-practice-for-unstable-dependencies": ["error", {
250
+ "additionalUnstableNames": [
251
+ // ReactNode
252
+ "icon",
253
+ "prefix",
254
+ "suffix",
255
+ // オブジェクト
256
+ "object",
257
+ "config",
258
+ "options",
259
+ "settings",
260
+ // 配列
261
+ "items",
262
+ "list",
263
+ "data",
264
+ "records",
265
+ // 関数
266
+ "callback",
267
+ "handler",
268
+ "onClick",
269
+ "onChange",
270
+ "onSubmit"
271
+ ]
272
+ }]
273
+ }
274
+ ```
275
+
276
+ ### カスタムフックの追加
277
+
278
+ プロジェクト固有のカスタムフックも検出対象に追加できます。
279
+
280
+ ```javascript
281
+ {
282
+ "smarthr/best-practice-for-unstable-dependencies": ["error", {
283
+ "additionalUnstableNames": ["icon", "items"],
284
+ "additionalTargetHooks": ["useCustomEffect", "useMyMemo"]
285
+ }]
286
+ }
287
+ ```
@@ -0,0 +1,116 @@
1
+ const SCHEMA = [
2
+ {
3
+ type: 'object',
4
+ properties: {
5
+ additionalUnstableNames: {
6
+ type: 'array',
7
+ items: { type: 'string' },
8
+ default: [],
9
+ },
10
+ additionalTargetHooks: {
11
+ type: 'array',
12
+ items: { type: 'string' },
13
+ default: [],
14
+ },
15
+ },
16
+ additionalProperties: false,
17
+ },
18
+ ]
19
+
20
+ const DOLLAR_SIGN_REGEX = /\$/g
21
+ const DEFAULT_UNSTABLE_NAMES = ['children']
22
+ const DEFAULT_TARGET_HOOKS = ['useEffect', 'useLayoutEffect', 'useCallback', 'useMemo']
23
+
24
+ const DETAIL_LINK = `
25
+ - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-unstable-dependencies`
26
+
27
+ /**
28
+ * 名前の配列から正規表現を生成する($をエスケープして処理)
29
+ * @param {string[]} names - 名前の配列
30
+ * @returns {RegExp} 生成された正規表現
31
+ */
32
+ function buildRegex(names) {
33
+ const pattern = names.reduce((acc, name, i) =>
34
+ acc + (i > 0 ? '|' : '') + name.replace(DOLLAR_SIGN_REGEX, '\\$'), '')
35
+ return new RegExp(`^(${pattern})$`)
36
+ }
37
+
38
+ /**
39
+ * 依存配列内の識別子を取得する
40
+ * @param {object} dependenciesArray - 依存配列のArrayExpressionノード
41
+ * @returns {Array<{node: object, name: string}>} 識別子の配列
42
+ */
43
+ function getDependencyIdentifiers(dependenciesArray) {
44
+ const identifiers = []
45
+
46
+ if (dependenciesArray.type === 'ArrayExpression') {
47
+ for (const element of dependenciesArray.elements) {
48
+ if (element?.type === 'Identifier') {
49
+ // [children]
50
+ identifiers.push({
51
+ node: element,
52
+ name: element.name,
53
+ })
54
+ }
55
+ }
56
+ }
57
+
58
+ return identifiers
59
+ }
60
+
61
+ /**
62
+ * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
63
+ */
64
+ module.exports = {
65
+ meta: {
66
+ type: 'problem',
67
+ schema: SCHEMA,
68
+ messages: {
69
+ unstableDependency: '依存配列に不安定な参照と予想される"{{name}}"が含まれています。オブジェクトやReactNodeなどの参照は頻繁に変わるため、不要な再実行や無限ループの原因となります。{{detailLink}}',
70
+ },
71
+ },
72
+ create(context) {
73
+ const options = context.options[0] || {}
74
+ const unstableNames = [...DEFAULT_UNSTABLE_NAMES, ...(options.additionalUnstableNames || [])]
75
+ const targetHooks = [...DEFAULT_TARGET_HOOKS, ...(options.additionalTargetHooks || [])]
76
+
77
+ const unstableNamesRegex = buildRegex(unstableNames)
78
+ const targetHooksRegex = buildRegex(targetHooks)
79
+
80
+ return {
81
+ CallExpression(node) {
82
+ // 対象のHooksかチェック
83
+ if (
84
+ node.callee.type !== 'Identifier' ||
85
+ !targetHooksRegex.test(node.callee.name)
86
+ ) {
87
+ return
88
+ }
89
+
90
+ // 第2引数(依存配列)を取得
91
+ const dependenciesArray = node.arguments[1]
92
+ if (!dependenciesArray) {
93
+ return
94
+ }
95
+
96
+ // 依存配列内の識別子を取得
97
+ const identifiers = getDependencyIdentifiers(dependenciesArray)
98
+
99
+ // 不安定な参照と予想される名前が含まれているかチェック
100
+ for (const identifier of identifiers) {
101
+ if (unstableNamesRegex.test(identifier.name)) {
102
+ context.report({
103
+ node: identifier.node,
104
+ messageId: 'unstableDependency',
105
+ data: {
106
+ name: identifier.name,
107
+ detailLink: DETAIL_LINK,
108
+ },
109
+ })
110
+ }
111
+ }
112
+ },
113
+ }
114
+ },
115
+ }
116
+ module.exports.schema = SCHEMA
@@ -6,6 +6,9 @@ const ruleTester = new RuleTester({
6
6
  parserOptions: {
7
7
  ecmaVersion: 2020,
8
8
  sourceType: 'module',
9
+ ecmaFeatures: {
10
+ jsx: true,
11
+ },
9
12
  },
10
13
  },
11
14
  })
@@ -405,6 +408,26 @@ ruleTester.run('best-practice-for-lazy-variable', rule, {
405
408
  }
406
409
  `,
407
410
  },
411
+ // JSX: 複数回使用されている(useSectionWrapperの引数とJSXタグ名)
412
+ {
413
+ code: `
414
+ const Component = as || 'div'
415
+ const Wrapper = useSectionWrapper(Component)
416
+ const body = <Component {...rest} ref={ref} className={actualClassName} />
417
+ `,
418
+ },
419
+ // JSX: if文の中でJSXタグとして使用、その前にも使用(2回使用)
420
+ {
421
+ code: `
422
+ function test() {
423
+ const Component = as || 'div'
424
+ const Wrapper = useSectionWrapper(Component)
425
+ if (condition) {
426
+ return <Component {...rest} ref={ref} />
427
+ }
428
+ }
429
+ `,
430
+ },
408
431
  ],
409
432
  invalid: [
410
433
  // ネストした早期return
@@ -465,6 +488,34 @@ ruleTester.run('best-practice-for-lazy-variable', rule, {
465
488
  },
466
489
  ],
467
490
  },
491
+ // JSX: if文の中でのみJSXタグとして使用(1回のみなので移動対象)
492
+ {
493
+ code: `
494
+ function test() {
495
+ const Component = as || 'div'
496
+ someCode()
497
+ if (condition) {
498
+ return <Component {...rest} ref={ref} />
499
+ }
500
+ }
501
+ `,
502
+ output: `
503
+ function test() {
504
+ someCode()
505
+ if (condition) {
506
+ const Component = as || 'div'
507
+ return <Component {...rest} ref={ref} />
508
+ }
509
+ }
510
+ `,
511
+ options: [{ fix: true }],
512
+ errors: [
513
+ {
514
+ messageId: 'moveToLazy',
515
+ data: { name: 'Component' },
516
+ },
517
+ ],
518
+ },
468
519
  // else内で使用
469
520
  {
470
521
  code: `
@@ -106,6 +106,13 @@ ruleTester.run('best-practice-for-no-unnecessary-variable', rule, {
106
106
  }
107
107
  `,
108
108
  },
109
+ // React Hooks除外(一度しか使用されていない場合でも、export { xxx as yyy } パターンでなければ除外)
110
+ {
111
+ code: `
112
+ const data = useFormContext()
113
+ console.log(data)
114
+ `,
115
+ },
109
116
  // await式除外
110
117
  {
111
118
  code: `
@@ -299,6 +306,24 @@ ruleTester.run('best-practice-for-no-unnecessary-variable', rule, {
299
306
  `,
300
307
  options: [{ maxComplexity: 3 }],
301
308
  },
309
+ // JSXIdentifier: JSXタグ名として使用されている場合はインライン化できない
310
+ {
311
+ code: `
312
+ function MyComponent({ as, rest }) {
313
+ const Component = as || 'div'
314
+ return <Component {...rest} />
315
+ }
316
+ `,
317
+ },
318
+ // JSXIdentifier: 複雑さが低くてもJSXタグ名はインライン化できない
319
+ {
320
+ code: `
321
+ function MyComponent() {
322
+ const Tag = 'div'
323
+ return <Tag>content</Tag>
324
+ }
325
+ `,
326
+ },
302
327
  ],
303
328
  invalid: [
304
329
  // 基本パターン
@@ -1106,5 +1131,57 @@ ruleTester.run('best-practice-for-no-unnecessary-variable', rule, {
1106
1131
  },
1107
1132
  ],
1108
1133
  },
1134
+ // export { xxx as yyy } パターン(単純な値の場合はエラーになる)
1135
+ {
1136
+ code: `
1137
+ const _foo = 123
1138
+ export { _foo as foo }
1139
+ `,
1140
+ errors: [
1141
+ {
1142
+ messageId: 'exportDirectly',
1143
+ data: { name: '_foo', exportedName: 'foo' },
1144
+ },
1145
+ ],
1146
+ },
1147
+ // export { xxx as yyy } パターン(関数値の参照の場合はエラーになる)
1148
+ {
1149
+ code: `
1150
+ const _useFormContext = useFormContext
1151
+ export { _useFormContext as useFormContext }
1152
+ `,
1153
+ errors: [
1154
+ {
1155
+ messageId: 'exportDirectly',
1156
+ data: { name: '_useFormContext', exportedName: 'useFormContext' },
1157
+ },
1158
+ ],
1159
+ },
1160
+ // export { xxx as yyy } パターン(React Hooks呼び出しの場合でも除外されずエラーになる)
1161
+ {
1162
+ code: `
1163
+ const _useFormContext = useFormContext()
1164
+ export { _useFormContext as useFormContext }
1165
+ `,
1166
+ errors: [
1167
+ {
1168
+ messageId: 'exportDirectly',
1169
+ data: { name: '_useFormContext', exportedName: 'useFormContext' },
1170
+ },
1171
+ ],
1172
+ },
1173
+ // export { xxx } パターン(asなし)
1174
+ {
1175
+ code: `
1176
+ const useFormContext = hoge
1177
+ export { useFormContext }
1178
+ `,
1179
+ errors: [
1180
+ {
1181
+ messageId: 'exportDirectly',
1182
+ data: { name: 'useFormContext', exportedName: 'useFormContext' },
1183
+ },
1184
+ ],
1185
+ },
1109
1186
  ],
1110
1187
  })
@@ -39,6 +39,8 @@ ruleTester.run('best-practice-for-rest-parameters', rule, {
39
39
  }
40
40
  ` },
41
41
  { code: `const removeIdAttr = ({ id: _id, ...rest }) => rest` },
42
+ // in演算子のleftにrestがあるケースは検知しない(rightのみ検知)
43
+ { code: `const hoge = (...rest) => { if (rest in obj) return null }` },
42
44
  ],
43
45
  invalid: [
44
46
  { code: `const hoge = ({ ...rest }) => {}`, errors: [ { message: `意味のない残余引数のため、単一の引数に変更してください${DETAIL_LINK}` } ] },
@@ -54,6 +56,17 @@ ruleTester.run('best-practice-for-rest-parameters', rule, {
54
56
  { code: `const hoge = rest.hoge`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
55
57
  { code: `const hoge = anyRest.hoge.fuga`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
56
58
  { code: `const { any } = rest`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
59
+ // in演算子の右辺も内部属性へのアクセスなのでエラー
60
+ { code: `const hoge = (...rest) => { if ('userMap' in rest) return null }`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
61
+ { code: `const hoge = ({ id, ...rest }) => 'key' in rest`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
62
+ // in演算子とプロパティアクセスの両方でエラー(2つ)
63
+ { code: `const hoge = (...rest) => { if ('userMap' in rest) return rest.userMap }`, errors: [ { message: ERROR_REST_CHILD_REF }, { message: ERROR_REST_CHILD_REF } ] },
64
+ // 極端なケース: rest in rest(rightのrestのみエラー)
65
+ { code: `const hoge = (...rest) => { if (rest in rest) return null }`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
66
+ // hogeRest in rest(rightのrestにエラー)
67
+ { code: `const hoge = (...rest) => { if (hogeRest in rest) return null }`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
68
+ // rest in hogeRest(rightのhogeRestにエラー)
69
+ { code: `const hoge = ({ id, ...rest }) => { if (rest in hogeRest) return null }`, errors: [ { message: ERROR_REST_CHILD_REF } ] },
57
70
  ]
58
71
  })
59
72
 
@@ -0,0 +1,284 @@
1
+ const rule = require('../rules/best-practice-for-unstable-dependencies')
2
+ const RuleTester = require('eslint').RuleTester
3
+
4
+ const ruleTester = new RuleTester({
5
+ languageOptions: {
6
+ parserOptions: {
7
+ ecmaVersion: 2020,
8
+ sourceType: 'module',
9
+ ecmaFeatures: {
10
+ jsx: true,
11
+ },
12
+ },
13
+ },
14
+ })
15
+
16
+ const DETAIL_LINK = `
17
+ - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-unstable-dependencies`
18
+
19
+ ruleTester.run('best-practice-for-unstable-dependencies', rule, {
20
+ valid: [
21
+ // 依存配列なし
22
+ {
23
+ code: `
24
+ useEffect(() => {
25
+ console.log('mounted')
26
+ })
27
+ `,
28
+ },
29
+ // 空の依存配列
30
+ {
31
+ code: `
32
+ useEffect(() => {
33
+ console.log('mounted')
34
+ }, [])
35
+ `,
36
+ },
37
+ // childrenを含まない依存配列
38
+ {
39
+ code: `
40
+ useEffect(() => {
41
+ console.log(value)
42
+ }, [value])
43
+ `,
44
+ },
45
+ // useMemo
46
+ {
47
+ code: `
48
+ const memoized = useMemo(() => {
49
+ return value * 2
50
+ }, [value])
51
+ `,
52
+ },
53
+ // useCallback
54
+ {
55
+ code: `
56
+ const handleClick = useCallback(() => {
57
+ console.log('clicked')
58
+ }, [])
59
+ `,
60
+ },
61
+ // useLayoutEffect
62
+ {
63
+ code: `
64
+ useLayoutEffect(() => {
65
+ console.log('layout')
66
+ }, [value])
67
+ `,
68
+ },
69
+ // iconを追加で指定、childrenは含まれていない
70
+ {
71
+ code: `
72
+ useEffect(() => {
73
+ console.log(value)
74
+ }, [value])
75
+ `,
76
+ options: [{ additionalUnstableNames: ['icon'] }],
77
+ },
78
+ // カスタムフックを追加で指定、childrenは含まれていない
79
+ {
80
+ code: `
81
+ useCustom(() => {
82
+ console.log(value)
83
+ }, [value])
84
+ `,
85
+ options: [{ additionalTargetHooks: ['useCustom'] }],
86
+ },
87
+ ],
88
+ invalid: [
89
+ // useEffect with children
90
+ {
91
+ code: `
92
+ useEffect(() => {
93
+ console.log(children)
94
+ }, [children])
95
+ `,
96
+ errors: [
97
+ {
98
+ messageId: 'unstableDependency',
99
+ data: { name: 'children', detailLink: DETAIL_LINK },
100
+ },
101
+ ],
102
+ },
103
+ // useCallback with children
104
+ {
105
+ code: `
106
+ const handleClick = useCallback(() => {
107
+ console.log(children)
108
+ }, [children])
109
+ `,
110
+ errors: [
111
+ {
112
+ messageId: 'unstableDependency',
113
+ data: { name: 'children', detailLink: DETAIL_LINK },
114
+ },
115
+ ],
116
+ },
117
+ // useMemo with children
118
+ {
119
+ code: `
120
+ const value = useMemo(() => {
121
+ return children.length
122
+ }, [children])
123
+ `,
124
+ errors: [
125
+ {
126
+ messageId: 'unstableDependency',
127
+ data: { name: 'children', detailLink: DETAIL_LINK },
128
+ },
129
+ ],
130
+ },
131
+ // useLayoutEffect with children
132
+ {
133
+ code: `
134
+ useLayoutEffect(() => {
135
+ console.log(children)
136
+ }, [children])
137
+ `,
138
+ errors: [
139
+ {
140
+ messageId: 'unstableDependency',
141
+ data: { name: 'children', detailLink: DETAIL_LINK },
142
+ },
143
+ ],
144
+ },
145
+ // 複数の依存関係の中にchildrenがある
146
+ {
147
+ code: `
148
+ useEffect(() => {
149
+ console.log(value, children)
150
+ }, [value, children])
151
+ `,
152
+ errors: [
153
+ {
154
+ messageId: 'unstableDependency',
155
+ data: { name: 'children', detailLink: DETAIL_LINK },
156
+ },
157
+ ],
158
+ },
159
+ // カスタム設定でiconを指定
160
+ {
161
+ code: `
162
+ useEffect(() => {
163
+ console.log(icon)
164
+ }, [icon])
165
+ `,
166
+ options: [{ additionalUnstableNames: ['icon'] }],
167
+ errors: [
168
+ {
169
+ messageId: 'unstableDependency',
170
+ data: { name: 'icon', detailLink: DETAIL_LINK },
171
+ },
172
+ ],
173
+ },
174
+ // カスタム設定で複数指定
175
+ {
176
+ code: `
177
+ useEffect(() => {
178
+ console.log(icon, prefix)
179
+ }, [icon, prefix])
180
+ `,
181
+ options: [{ additionalUnstableNames: ['icon', 'prefix'] }],
182
+ errors: [
183
+ {
184
+ messageId: 'unstableDependency',
185
+ data: { name: 'icon', detailLink: DETAIL_LINK },
186
+ },
187
+ {
188
+ messageId: 'unstableDependency',
189
+ data: { name: 'prefix', detailLink: DETAIL_LINK },
190
+ },
191
+ ],
192
+ },
193
+ // オブジェクト(object)を検出
194
+ {
195
+ code: `
196
+ useEffect(() => {
197
+ console.log(object.key)
198
+ }, [object])
199
+ `,
200
+ options: [{ additionalUnstableNames: ['object'] }],
201
+ errors: [
202
+ {
203
+ messageId: 'unstableDependency',
204
+ data: { name: 'object', detailLink: DETAIL_LINK },
205
+ },
206
+ ],
207
+ },
208
+ // 配列(items)を検出
209
+ {
210
+ code: `
211
+ const memoized = useMemo(() => {
212
+ return items.map(i => i.value)
213
+ }, [items])
214
+ `,
215
+ options: [{ additionalUnstableNames: ['items'] }],
216
+ errors: [
217
+ {
218
+ messageId: 'unstableDependency',
219
+ data: { name: 'items', detailLink: DETAIL_LINK },
220
+ },
221
+ ],
222
+ },
223
+ // 関数(callback)を検出
224
+ {
225
+ code: `
226
+ useEffect(() => {
227
+ callback()
228
+ }, [callback])
229
+ `,
230
+ options: [{ additionalUnstableNames: ['callback'] }],
231
+ errors: [
232
+ {
233
+ messageId: 'unstableDependency',
234
+ data: { name: 'callback', detailLink: DETAIL_LINK },
235
+ },
236
+ ],
237
+ },
238
+ // カスタムフック(useCustom)でchildrenを検出
239
+ {
240
+ code: `
241
+ useCustom(() => {
242
+ console.log(children)
243
+ }, [children])
244
+ `,
245
+ options: [{ additionalTargetHooks: ['useCustom'] }],
246
+ errors: [
247
+ {
248
+ messageId: 'unstableDependency',
249
+ data: { name: 'children', detailLink: DETAIL_LINK },
250
+ },
251
+ ],
252
+ },
253
+ // 複数のカスタムフックを指定
254
+ {
255
+ code: `
256
+ useCustom1(() => {
257
+ console.log(children)
258
+ }, [children])
259
+ `,
260
+ options: [{ additionalTargetHooks: ['useCustom1', 'useCustom2'] }],
261
+ errors: [
262
+ {
263
+ messageId: 'unstableDependency',
264
+ data: { name: 'children', detailLink: DETAIL_LINK },
265
+ },
266
+ ],
267
+ },
268
+ // カスタムフックと追加の不安定な名前を併用
269
+ {
270
+ code: `
271
+ useCustom(() => {
272
+ console.log(icon)
273
+ }, [icon])
274
+ `,
275
+ options: [{ additionalTargetHooks: ['useCustom'], additionalUnstableNames: ['icon'] }],
276
+ errors: [
277
+ {
278
+ messageId: 'unstableDependency',
279
+ data: { name: 'icon', detailLink: DETAIL_LINK },
280
+ },
281
+ ],
282
+ },
283
+ ],
284
+ })