@blumintinc/eslint-plugin-blumint 1.20.42 → 1.20.43
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/lib/index.js +1 -1
- package/lib/rules/enforce-callback-memo.js +9 -0
- package/lib/utils/ASTHelpers.d.ts +41 -0
- package/lib/utils/ASTHelpers.js +146 -0
- package/package.json +1 -1
- package/release-manifest.json +14 -0
package/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const createRule_1 = require("../utils/createRule");
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
5
6
|
exports.default = (0, createRule_1.createRule)({
|
|
6
7
|
name: 'enforce-callback-memo',
|
|
7
8
|
meta: {
|
|
@@ -194,6 +195,14 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
194
195
|
node.value.type !== utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
|
|
195
196
|
return;
|
|
196
197
|
}
|
|
198
|
+
// The only remediation this rule offers is a hook call, which is legal
|
|
199
|
+
// solely inside a component or a hook. Module-scope JSX, a plain helper
|
|
200
|
+
// that happens to build JSX, and JSX rendered from a test body are all
|
|
201
|
+
// outside any render path, so wrapping there would throw
|
|
202
|
+
// "Invalid hook call" while saving no re-render.
|
|
203
|
+
if (!ASTHelpers_1.ASTHelpers.isInsideComponentOrHook(node, context)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
197
206
|
// Props of JSX built inside a useMemo factory inherit the memo's stability
|
|
198
207
|
if (isInsideUseMemoFactory(node)) {
|
|
199
208
|
return;
|
|
@@ -61,6 +61,47 @@ export declare class ASTHelpers {
|
|
|
61
61
|
* parenthesized expressions to get to the underlying expression.
|
|
62
62
|
*/
|
|
63
63
|
static unwrapTSAssertions(node: TSESTree.Node): TSESTree.Node;
|
|
64
|
+
/**
|
|
65
|
+
* Calls that wrap a component/hook definition without renaming it, so the
|
|
66
|
+
* binding they are assigned to still names the wrapped function
|
|
67
|
+
* (`const Component = memo(() => ...)`).
|
|
68
|
+
*/
|
|
69
|
+
private static readonly TRANSPARENT_WRAPPER_CALLEES;
|
|
70
|
+
private static isFunctionNode;
|
|
71
|
+
private static isTransparentWrapperCall;
|
|
72
|
+
private static staticPropertyName;
|
|
73
|
+
/**
|
|
74
|
+
* Resolves the name a function is known by: its own identifier, or the
|
|
75
|
+
* binding it is assigned to (variable, object property, class field,
|
|
76
|
+
* assignment target). Returns null only when the function is truly
|
|
77
|
+
* anonymous, e.g. an inline callback argument such as `items.map(() => ...)`.
|
|
78
|
+
*/
|
|
79
|
+
static inferFunctionName(node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration): string | null;
|
|
80
|
+
/**
|
|
81
|
+
* React's universal convention: only PascalCase-initial identifiers are
|
|
82
|
+
* components, and only `use`-prefixed ones are hooks. A camelCase name is a
|
|
83
|
+
* plain helper or a render-prop callback.
|
|
84
|
+
*/
|
|
85
|
+
private static isComponentOrHookName;
|
|
86
|
+
/**
|
|
87
|
+
* Reports whether a node sits anywhere inside a React component or hook, so a
|
|
88
|
+
* rule whose remediation is a hook call (useCallback/useMemo/useState) can
|
|
89
|
+
* stay silent where that call would be a Rules-of-Hooks violation: module
|
|
90
|
+
* scope, a plain helper function, or a test body such as `it(() => ...)`.
|
|
91
|
+
*
|
|
92
|
+
* The whole enclosing-function ancestry is consulted, not just the nearest
|
|
93
|
+
* function: a `.map()` render callback inside a component is still a render
|
|
94
|
+
* path and must stay reportable.
|
|
95
|
+
*
|
|
96
|
+
* Classification is name-first. A function the developer named is judged by
|
|
97
|
+
* that name alone — `buildTree` is not a component even though it returns
|
|
98
|
+
* JSX, because the name is an explicit signal about its role. Only a truly
|
|
99
|
+
* anonymous function falls back to "does it return JSX", which is what makes
|
|
100
|
+
* `memo(() => <div />)` a component. That fallback is suppressed when some
|
|
101
|
+
* enclosing function carries a non-component name, since a callback nested in
|
|
102
|
+
* a plain helper is no more of a render path than the helper itself.
|
|
103
|
+
*/
|
|
104
|
+
static isInsideComponentOrHook(node: TSESTree.Node, context?: Readonly<TSESLint.RuleContext<string, readonly unknown[]>>): boolean;
|
|
64
105
|
/**
|
|
65
106
|
* Helper to get ancestors of a node in a way that is compatible with both ESLint v8 and v9.
|
|
66
107
|
* In ESLint v9, context.getAncestors() is deprecated and moved to context.sourceCode.getAncestors(node).
|
package/lib/utils/ASTHelpers.js
CHANGED
|
@@ -661,6 +661,140 @@ class ASTHelpers {
|
|
|
661
661
|
}
|
|
662
662
|
return inner;
|
|
663
663
|
}
|
|
664
|
+
static isFunctionNode(node) {
|
|
665
|
+
return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
666
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
667
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
|
|
668
|
+
}
|
|
669
|
+
static isTransparentWrapperCall(node) {
|
|
670
|
+
const { callee } = node;
|
|
671
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
672
|
+
return this.TRANSPARENT_WRAPPER_CALLEES.has(callee.name);
|
|
673
|
+
}
|
|
674
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
675
|
+
!callee.computed &&
|
|
676
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
677
|
+
this.TRANSPARENT_WRAPPER_CALLEES.has(callee.property.name));
|
|
678
|
+
}
|
|
679
|
+
static staticPropertyName(key, computed) {
|
|
680
|
+
if (computed) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
684
|
+
return key.name;
|
|
685
|
+
}
|
|
686
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
687
|
+
return key.value;
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Resolves the name a function is known by: its own identifier, or the
|
|
693
|
+
* binding it is assigned to (variable, object property, class field,
|
|
694
|
+
* assignment target). Returns null only when the function is truly
|
|
695
|
+
* anonymous, e.g. an inline callback argument such as `items.map(() => ...)`.
|
|
696
|
+
*/
|
|
697
|
+
static inferFunctionName(node) {
|
|
698
|
+
if (node.id?.name) {
|
|
699
|
+
return node.id.name;
|
|
700
|
+
}
|
|
701
|
+
let child = node;
|
|
702
|
+
let parent = node.parent;
|
|
703
|
+
while (parent) {
|
|
704
|
+
switch (parent.type) {
|
|
705
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
706
|
+
return parent.id.type === utils_1.AST_NODE_TYPES.Identifier
|
|
707
|
+
? parent.id.name
|
|
708
|
+
: null;
|
|
709
|
+
case utils_1.AST_NODE_TYPES.Property:
|
|
710
|
+
return this.staticPropertyName(parent.key, parent.computed);
|
|
711
|
+
case utils_1.AST_NODE_TYPES.PropertyDefinition:
|
|
712
|
+
case utils_1.AST_NODE_TYPES.MethodDefinition:
|
|
713
|
+
return this.staticPropertyName(parent.key, parent.computed);
|
|
714
|
+
case utils_1.AST_NODE_TYPES.AssignmentExpression: {
|
|
715
|
+
const { left } = parent;
|
|
716
|
+
if (left.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
717
|
+
return left.name;
|
|
718
|
+
}
|
|
719
|
+
if (left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
720
|
+
return this.staticPropertyName(left.property, left.computed);
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
725
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
726
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
727
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
728
|
+
child = parent;
|
|
729
|
+
parent = parent.parent;
|
|
730
|
+
continue;
|
|
731
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
732
|
+
// Only step through wrappers that preserve identity; an arbitrary
|
|
733
|
+
// callback argument (`items.map(fn)`) is not named by whatever the
|
|
734
|
+
// call's result is assigned to.
|
|
735
|
+
if (parent.arguments.includes(child) &&
|
|
736
|
+
this.isTransparentWrapperCall(parent)) {
|
|
737
|
+
child = parent;
|
|
738
|
+
parent = parent.parent;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
default:
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* React's universal convention: only PascalCase-initial identifiers are
|
|
750
|
+
* components, and only `use`-prefixed ones are hooks. A camelCase name is a
|
|
751
|
+
* plain helper or a render-prop callback.
|
|
752
|
+
*/
|
|
753
|
+
static isComponentOrHookName(name) {
|
|
754
|
+
return /^[A-Z]/.test(name) || /^use[A-Z0-9_]/.test(name);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Reports whether a node sits anywhere inside a React component or hook, so a
|
|
758
|
+
* rule whose remediation is a hook call (useCallback/useMemo/useState) can
|
|
759
|
+
* stay silent where that call would be a Rules-of-Hooks violation: module
|
|
760
|
+
* scope, a plain helper function, or a test body such as `it(() => ...)`.
|
|
761
|
+
*
|
|
762
|
+
* The whole enclosing-function ancestry is consulted, not just the nearest
|
|
763
|
+
* function: a `.map()` render callback inside a component is still a render
|
|
764
|
+
* path and must stay reportable.
|
|
765
|
+
*
|
|
766
|
+
* Classification is name-first. A function the developer named is judged by
|
|
767
|
+
* that name alone — `buildTree` is not a component even though it returns
|
|
768
|
+
* JSX, because the name is an explicit signal about its role. Only a truly
|
|
769
|
+
* anonymous function falls back to "does it return JSX", which is what makes
|
|
770
|
+
* `memo(() => <div />)` a component. That fallback is suppressed when some
|
|
771
|
+
* enclosing function carries a non-component name, since a callback nested in
|
|
772
|
+
* a plain helper is no more of a render path than the helper itself.
|
|
773
|
+
*/
|
|
774
|
+
static isInsideComponentOrHook(node, context) {
|
|
775
|
+
const anonymousFunctions = [];
|
|
776
|
+
let hasNamedNonComponent = false;
|
|
777
|
+
let current = node.parent;
|
|
778
|
+
while (current) {
|
|
779
|
+
if (this.isFunctionNode(current)) {
|
|
780
|
+
const name = this.inferFunctionName(current);
|
|
781
|
+
if (name === null) {
|
|
782
|
+
anonymousFunctions.push(current);
|
|
783
|
+
}
|
|
784
|
+
else if (this.isComponentOrHookName(name)) {
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
hasNamedNonComponent = true;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
current = current.parent;
|
|
792
|
+
}
|
|
793
|
+
if (hasNamedNonComponent) {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
return anonymousFunctions.some((fn) => this.returnsJSX(fn, context));
|
|
797
|
+
}
|
|
664
798
|
/**
|
|
665
799
|
* Helper to get ancestors of a node in a way that is compatible with both ESLint v8 and v9.
|
|
666
800
|
* In ESLint v9, context.getAncestors() is deprecated and moved to context.sourceCode.getAncestors(node).
|
|
@@ -671,5 +805,17 @@ class ASTHelpers {
|
|
|
671
805
|
(context.getAncestors ? context.getAncestors() : []));
|
|
672
806
|
}
|
|
673
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Calls that wrap a component/hook definition without renaming it, so the
|
|
810
|
+
* binding they are assigned to still names the wrapped function
|
|
811
|
+
* (`const Component = memo(() => ...)`).
|
|
812
|
+
*/
|
|
813
|
+
ASTHelpers.TRANSPARENT_WRAPPER_CALLEES = new Set([
|
|
814
|
+
'forwardRef',
|
|
815
|
+
'memo',
|
|
816
|
+
'observer',
|
|
817
|
+
'useCallback',
|
|
818
|
+
'useMemo',
|
|
819
|
+
]);
|
|
674
820
|
exports.ASTHelpers = ASTHelpers;
|
|
675
821
|
//# sourceMappingURL=ASTHelpers.js.map
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.43",
|
|
4
|
+
"date": "2026-07-31T04:38:54.532Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-callback-memo",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1496
|
|
11
|
+
],
|
|
12
|
+
"summary": "only report inside a component or hook (closes #1496)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "1.20.42",
|
|
4
18
|
"date": "2026-07-31T04:16:11.179Z",
|