@prairielearn/eslint-plugin 4.0.0 → 4.1.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 +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/rules/aws-client-mandatory-config.js.map +1 -1
- package/dist/rules/aws-client-shared-config.js.map +1 -1
- package/dist/rules/jsx-no-dollar-interpolation.js.map +1 -1
- package/dist/rules/no-current-target-in-callback.d.ts +4 -4
- package/dist/rules/no-current-target-in-callback.d.ts.map +1 -1
- package/dist/rules/no-current-target-in-callback.js +189 -34
- package/dist/rules/no-current-target-in-callback.js.map +1 -1
- package/dist/rules/no-hydrate-reslocals.d.ts +4 -0
- package/dist/rules/no-hydrate-reslocals.d.ts.map +1 -0
- package/dist/rules/no-hydrate-reslocals.js +96 -0
- package/dist/rules/no-hydrate-reslocals.js.map +1 -0
- package/dist/rules/no-unused-sql-blocks.js.map +1 -1
- package/dist/rules/safe-db-types.js.map +1 -1
- package/dist/tests/no-current-target-in-callback.test.d.ts.map +1 -1
- package/dist/tests/no-current-target-in-callback.test.js +67 -10
- package/dist/tests/no-current-target-in-callback.test.js.map +1 -1
- package/dist/tests/no-hydrate-reslocals.test.d.ts +2 -0
- package/dist/tests/no-hydrate-reslocals.test.d.ts.map +1 -0
- package/dist/tests/no-hydrate-reslocals.test.js +91 -0
- package/dist/tests/no-hydrate-reslocals.test.js.map +1 -0
- package/dist/utils.js.map +1 -1
- package/package.json +8 -8
- package/src/index.ts +2 -0
- package/src/rules/no-current-target-in-callback.ts +273 -39
- package/src/rules/no-hydrate-reslocals.ts +102 -0
- package/src/tests/no-current-target-in-callback.test.ts +67 -10
- package/src/tests/no-hydrate-reslocals.test.ts +89 -0
package/dist/utils.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";;;AAAA;;;GAGG;AACH,SAAS,kBAAkB,CAAC,cAAsB,EAAE,WAAmB
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";;;AAAA;;;GAGG;AACH,SAAS,kBAAkB,CAAC,cAAsB,EAAE,WAAmB;IACrE,+DAA+D;IAC/D,IAAI,cAAc,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,8EAA8E;IAC9E,MAAM,UAAU,GAAG,WAAW,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;IAC/D,MAAM,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IACvE,IAAI,cAAc,CAAC,WAAW,EAAE,KAAK,iBAAiB,EAAE,CAAC;QACvD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;GAUG;AACH,gDAAuD,IAAS;IAC9D,MAAM,WAAW,GAAG,IAAI,GAAG,EAAU,CAAC;IAEtC,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC;IACvC,IAAI,YAAY,CAAC,UAAU,CAAC,kBAAkB,CAAC,EAAE,CAAC;QAChD,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,SAAc,EAAE,EAAE;YACzC,IAAI,SAAS,CAAC,IAAI,KAAK,iBAAiB,EAAE,CAAC;gBACzC,MAAM,aAAa,GAAG,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAC9C,IAAI,kBAAkB,CAAC,aAAa,EAAE,YAAY,CAAC,EAAE,CAAC;oBACpD,WAAW,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;gBACjC,CAAC;YACH,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC","sourcesContent":["/**\n * Determines if the given identifier name corresponds to a client from the\n * given package.\n */\nfunction isIdentifierClient(identifierName: string, packageName: string): boolean {\n // If the identifier ends with \"Client\", include it in the set.\n if (identifierName.endsWith('Client')) {\n return true;\n }\n\n // If the identifier matches the package name directly, include it in the set.\n const clientName = packageName.replace('@aws-sdk/client-', '');\n const packageIdentifier = clientName.replaceAll('-', '').toLowerCase();\n if (identifierName.toLowerCase() === packageIdentifier) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Retrieves the names of AWS clients specified by the given import declaration.\n *\n * For instance, if the import declaration is:\n *\n * ```ts\n * import { S3, S3Client } from '@aws-sdk/client-s3';\n * ```\n *\n * then this function will return a set containing the strings \"S3\" and \"S3Client\".\n */\nexport function getAwsClientNamesFromImportDeclaration(node: any) {\n const clientNames = new Set<string>();\n\n const importSource = node.source.value;\n if (importSource.startsWith('@aws-sdk/client-')) {\n node.specifiers.forEach((specifier: any) => {\n if (specifier.type === 'ImportSpecifier') {\n const specifierName = specifier.imported.name;\n if (isIdentifierClient(specifierName, importSource)) {\n clientNames.add(specifierName);\n }\n }\n });\n }\n\n return clientNames;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/eslint-plugin",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/PrairieLearn/PrairieLearn.git",
|
|
@@ -19,17 +19,17 @@
|
|
|
19
19
|
"eslint": "^10.0.0"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@typescript-eslint/utils": "^8.
|
|
22
|
+
"@typescript-eslint/utils": "^8.58.0",
|
|
23
23
|
"typescript": "^5.9.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@prairielearn/tsconfig": "^2.0.0",
|
|
27
|
-
"@types/node": "^24.
|
|
28
|
-
"@typescript-eslint/rule-tester": "^8.
|
|
29
|
-
"@typescript-eslint/types": "^8.
|
|
30
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
31
|
-
"@vitest/coverage-v8": "^4.
|
|
27
|
+
"@types/node": "^24.12.2",
|
|
28
|
+
"@typescript-eslint/rule-tester": "^8.58.0",
|
|
29
|
+
"@typescript-eslint/types": "^8.58.0",
|
|
30
|
+
"@typescript/native-preview": "^7.0.0-dev.20260305.1",
|
|
31
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
32
32
|
"tsx": "^4.21.0",
|
|
33
|
-
"vitest": "^4.
|
|
33
|
+
"vitest": "^4.1.2"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import awsClientMandatoryConfig from './rules/aws-client-mandatory-config.js';
|
|
|
2
2
|
import awsClientSharedConfig from './rules/aws-client-shared-config.js';
|
|
3
3
|
import jsxNoDollarInterpolation from './rules/jsx-no-dollar-interpolation.js';
|
|
4
4
|
import noCurrentTargetInCallback from './rules/no-current-target-in-callback.js';
|
|
5
|
+
import noHydrateResLocals from './rules/no-hydrate-reslocals.js';
|
|
5
6
|
import noUnusedSqlBlocks from './rules/no-unused-sql-blocks.js';
|
|
6
7
|
import safeDbTypes from './rules/safe-db-types.js';
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ export const rules = {
|
|
|
10
11
|
'aws-client-shared-config': awsClientSharedConfig,
|
|
11
12
|
'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,
|
|
12
13
|
'no-current-target-in-callback': noCurrentTargetInCallback,
|
|
14
|
+
'no-hydrate-reslocals': noHydrateResLocals,
|
|
13
15
|
'no-unused-sql-blocks': noUnusedSqlBlocks,
|
|
14
16
|
'safe-db-types': safeDbTypes,
|
|
15
17
|
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* This rule detects when `event.currentTarget` is accessed
|
|
5
|
-
* callback
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This rule detects when `event.currentTarget` is accessed after an `await` or
|
|
5
|
+
* inside a callback passed to a likely deferred API within a React event
|
|
6
|
+
* handler. Those callbacks may run after the handler returns, at which point
|
|
7
|
+
* `currentTarget` may already be nullified.
|
|
8
8
|
*
|
|
9
9
|
* Bad:
|
|
10
10
|
* ```tsx
|
|
@@ -21,46 +21,197 @@ export default ESLintUtils.RuleCreator.withoutDocs({
|
|
|
21
21
|
type: 'problem',
|
|
22
22
|
messages: {
|
|
23
23
|
noCurrentTargetInCallback:
|
|
24
|
-
'Accessing event.currentTarget
|
|
24
|
+
'Accessing event.currentTarget after an async boundary may fail because currentTarget can be nullified before the access runs. Destructure currentTarget at the start of the event handler instead.',
|
|
25
25
|
},
|
|
26
26
|
schema: [],
|
|
27
27
|
},
|
|
28
28
|
defaultOptions: [],
|
|
29
29
|
|
|
30
30
|
create(context) {
|
|
31
|
-
|
|
32
|
-
const
|
|
31
|
+
const trackedEventFunctions = new Map<TSESTree.Node, TSESTree.Identifier>();
|
|
32
|
+
const visitorKeys = context.sourceCode.visitorKeys;
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
function isInsideNestedFunction(
|
|
34
|
+
function isFunctionLike(
|
|
36
35
|
node: TSESTree.Node,
|
|
37
|
-
|
|
36
|
+
): node is
|
|
37
|
+
| TSESTree.ArrowFunctionExpression
|
|
38
|
+
| TSESTree.FunctionExpression
|
|
39
|
+
| TSESTree.FunctionDeclaration {
|
|
40
|
+
return (
|
|
41
|
+
node.type === 'ArrowFunctionExpression' ||
|
|
42
|
+
node.type === 'FunctionExpression' ||
|
|
43
|
+
node.type === 'FunctionDeclaration'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isNode(value: unknown): value is TSESTree.Node {
|
|
48
|
+
return typeof value === 'object' && value !== null && 'type' in value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getChildNodes(node: TSESTree.Node): TSESTree.Node[] {
|
|
52
|
+
const children: TSESTree.Node[] = [];
|
|
53
|
+
|
|
54
|
+
for (const key of visitorKeys[node.type] ?? []) {
|
|
55
|
+
const value: unknown = node[key as keyof TSESTree.Node];
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
for (const child of value) {
|
|
59
|
+
if (isNode(child)) {
|
|
60
|
+
children.push(child);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else if (isNode(value)) {
|
|
64
|
+
children.push(value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return children;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isKnownDeferredCallee(callee: TSESTree.Expression | TSESTree.Super): boolean {
|
|
72
|
+
if (callee.type === 'Super') {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (callee.type === 'Identifier') {
|
|
77
|
+
return (
|
|
78
|
+
// e.g. setTimeout, setInterval, setImmediate, etc.
|
|
79
|
+
/^set[A-Z0-9_]/.test(callee.name) ||
|
|
80
|
+
callee.name === 'queueMicrotask' ||
|
|
81
|
+
callee.name === 'requestAnimationFrame' ||
|
|
82
|
+
callee.name === 'requestIdleCallback'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
|
|
87
|
+
return (
|
|
88
|
+
callee.property.name === 'then' ||
|
|
89
|
+
callee.property.name === 'catch' ||
|
|
90
|
+
callee.property.name === 'finally'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isKnownEventBoundaryCallback(
|
|
98
|
+
callExpression: TSESTree.CallExpression,
|
|
99
|
+
callbackIndex: number,
|
|
38
100
|
): boolean {
|
|
39
|
-
|
|
40
|
-
|
|
101
|
+
if (callbackIndex !== 0) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
41
104
|
|
|
42
|
-
|
|
105
|
+
if (
|
|
106
|
+
callExpression.callee.type === 'Identifier' &&
|
|
107
|
+
callExpression.callee.name === 'handleSubmit'
|
|
108
|
+
) {
|
|
109
|
+
// react-hook-form invokes this callback after its own submit handling,
|
|
110
|
+
// so treat it as an event boundary like other deferred callbacks.
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a node is inside a nested function that is passed to a callback
|
|
119
|
+
* API that may invoke it after the event handler returns.
|
|
120
|
+
*/
|
|
121
|
+
function isInsideDeferredCallback(
|
|
122
|
+
node: TSESTree.Node,
|
|
123
|
+
eventHandlerFunction: TSESTree.Node,
|
|
124
|
+
): boolean {
|
|
125
|
+
// Check if the event handler itself is in a deferred/boundary context
|
|
126
|
+
const handlerParent = eventHandlerFunction.parent;
|
|
127
|
+
if (handlerParent?.type === 'CallExpression') {
|
|
128
|
+
const callbackIndex = handlerParent.arguments.indexOf(
|
|
129
|
+
eventHandlerFunction as TSESTree.CallExpressionArgument,
|
|
130
|
+
);
|
|
43
131
|
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
132
|
+
callbackIndex !== -1 &&
|
|
133
|
+
(isKnownDeferredCallee(handlerParent.callee) ||
|
|
134
|
+
isKnownEventBoundaryCallback(handlerParent, callbackIndex))
|
|
47
135
|
) {
|
|
48
|
-
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let current: TSESTree.Node | undefined = node;
|
|
141
|
+
|
|
142
|
+
while (current && current !== eventHandlerFunction) {
|
|
143
|
+
if (isFunctionLike(current)) {
|
|
144
|
+
const parent = current.parent;
|
|
145
|
+
if (current.type !== 'FunctionDeclaration' && parent?.type === 'CallExpression') {
|
|
146
|
+
const callbackIndex = parent.arguments.indexOf(current);
|
|
147
|
+
if (
|
|
148
|
+
callbackIndex !== -1 &&
|
|
149
|
+
(isKnownDeferredCallee(parent.callee) ||
|
|
150
|
+
isKnownEventBoundaryCallback(parent, callbackIndex))
|
|
151
|
+
) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
49
155
|
}
|
|
50
156
|
current = current.parent;
|
|
51
157
|
}
|
|
52
158
|
|
|
53
|
-
return
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Check if the function contains an await before this access, ignoring nested functions */
|
|
163
|
+
function hasPriorAwaitInSameFunction(
|
|
164
|
+
node: TSESTree.Node,
|
|
165
|
+
eventHandlerFunction: TSESTree.Node,
|
|
166
|
+
): boolean {
|
|
167
|
+
if (
|
|
168
|
+
!('body' in eventHandlerFunction) ||
|
|
169
|
+
!eventHandlerFunction.body ||
|
|
170
|
+
Array.isArray(eventHandlerFunction.body)
|
|
171
|
+
) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let foundPriorAwait = false;
|
|
176
|
+
|
|
177
|
+
const visit = (current: TSESTree.Node) => {
|
|
178
|
+
if (foundPriorAwait || current.range[0] >= node.range[0]) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (current !== eventHandlerFunction && isFunctionLike(current)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (current.type === 'AwaitExpression') {
|
|
187
|
+
foundPriorAwait = true;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const child of getChildNodes(current)) {
|
|
192
|
+
visit(child);
|
|
193
|
+
if (foundPriorAwait) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
visit(eventHandlerFunction.body);
|
|
200
|
+
return foundPriorAwait;
|
|
54
201
|
}
|
|
55
202
|
|
|
56
203
|
/** Find the function that contains this node */
|
|
57
204
|
function findContainingFunction(
|
|
58
205
|
node: TSESTree.Node,
|
|
59
|
-
):
|
|
206
|
+
):
|
|
207
|
+
| TSESTree.ArrowFunctionExpression
|
|
208
|
+
| TSESTree.FunctionExpression
|
|
209
|
+
| TSESTree.FunctionDeclaration
|
|
210
|
+
| null {
|
|
60
211
|
let current: TSESTree.Node | undefined = node.parent;
|
|
61
212
|
|
|
62
213
|
while (current) {
|
|
63
|
-
if (current
|
|
214
|
+
if (isFunctionLike(current)) {
|
|
64
215
|
return current;
|
|
65
216
|
}
|
|
66
217
|
current = current.parent;
|
|
@@ -71,7 +222,10 @@ export default ESLintUtils.RuleCreator.withoutDocs({
|
|
|
71
222
|
|
|
72
223
|
/** Check if a function is a JSX event handler */
|
|
73
224
|
function isJSXEventHandler(
|
|
74
|
-
func:
|
|
225
|
+
func:
|
|
226
|
+
| TSESTree.ArrowFunctionExpression
|
|
227
|
+
| TSESTree.FunctionExpression
|
|
228
|
+
| TSESTree.FunctionDeclaration,
|
|
75
229
|
): boolean {
|
|
76
230
|
const parent = func.parent;
|
|
77
231
|
|
|
@@ -90,7 +244,10 @@ export default ESLintUtils.RuleCreator.withoutDocs({
|
|
|
90
244
|
|
|
91
245
|
/** Get the first parameter of an event handler if it looks like an event */
|
|
92
246
|
function getEventParameter(
|
|
93
|
-
func:
|
|
247
|
+
func:
|
|
248
|
+
| TSESTree.ArrowFunctionExpression
|
|
249
|
+
| TSESTree.FunctionExpression
|
|
250
|
+
| TSESTree.FunctionDeclaration,
|
|
94
251
|
): TSESTree.Identifier | null {
|
|
95
252
|
const firstParam = func.params[0];
|
|
96
253
|
|
|
@@ -104,27 +261,93 @@ export default ESLintUtils.RuleCreator.withoutDocs({
|
|
|
104
261
|
return null;
|
|
105
262
|
}
|
|
106
263
|
|
|
264
|
+
function isReactEventType(typeAnnotation: TSESTree.TypeNode): boolean {
|
|
265
|
+
if (typeAnnotation.type === 'TSTypeReference') {
|
|
266
|
+
if (
|
|
267
|
+
typeAnnotation.typeName.type === 'TSQualifiedName' &&
|
|
268
|
+
typeAnnotation.typeName.left.type === 'Identifier' &&
|
|
269
|
+
typeAnnotation.typeName.left.name === 'React' &&
|
|
270
|
+
typeAnnotation.typeName.right.type === 'Identifier' &&
|
|
271
|
+
typeAnnotation.typeName.right.name.endsWith('Event')
|
|
272
|
+
) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (
|
|
277
|
+
typeAnnotation.typeName.type === 'Identifier' &&
|
|
278
|
+
(typeAnnotation.typeName.name === 'SyntheticEvent' ||
|
|
279
|
+
typeAnnotation.typeName.name.endsWith('Event'))
|
|
280
|
+
) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isTypedReactEventHandler(
|
|
289
|
+
func:
|
|
290
|
+
| TSESTree.ArrowFunctionExpression
|
|
291
|
+
| TSESTree.FunctionExpression
|
|
292
|
+
| TSESTree.FunctionDeclaration,
|
|
293
|
+
): boolean {
|
|
294
|
+
const firstParam = func.params[0];
|
|
295
|
+
if (!firstParam || firstParam.type !== 'Identifier' || !firstParam.typeAnnotation) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return isReactEventType(firstParam.typeAnnotation.typeAnnotation);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function trackIfEventHandler(
|
|
303
|
+
node:
|
|
304
|
+
| TSESTree.ArrowFunctionExpression
|
|
305
|
+
| TSESTree.FunctionExpression
|
|
306
|
+
| TSESTree.FunctionDeclaration,
|
|
307
|
+
) {
|
|
308
|
+
if (isJSXEventHandler(node) || isTypedReactEventHandler(node)) {
|
|
309
|
+
const eventParam = getEventParameter(node);
|
|
310
|
+
if (eventParam) {
|
|
311
|
+
trackedEventFunctions.set(node, eventParam);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// react-hook-form: handleSubmit((_data, e) => { ... })
|
|
317
|
+
// The event is the second parameter, and the entire callback is deferred.
|
|
318
|
+
const parent = node.parent;
|
|
319
|
+
if (
|
|
320
|
+
parent?.type === 'CallExpression' &&
|
|
321
|
+
parent.arguments[0] === node &&
|
|
322
|
+
isKnownEventBoundaryCallback(parent, 0)
|
|
323
|
+
) {
|
|
324
|
+
const secondParam = node.params[1];
|
|
325
|
+
if (secondParam?.type === 'Identifier') {
|
|
326
|
+
trackedEventFunctions.set(node, secondParam);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
107
331
|
return {
|
|
108
332
|
// When we enter a JSX event handler, track its event parameter
|
|
109
333
|
'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression': (
|
|
110
334
|
node: TSESTree.ArrowFunctionExpression,
|
|
111
335
|
) => {
|
|
112
|
-
|
|
113
|
-
const eventParam = getEventParameter(node);
|
|
114
|
-
if (eventParam) {
|
|
115
|
-
eventHandlerParams.set(node, eventParam);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
336
|
+
trackIfEventHandler(node);
|
|
118
337
|
},
|
|
119
338
|
'JSXAttribute > JSXExpressionContainer > FunctionExpression': (
|
|
120
339
|
node: TSESTree.FunctionExpression,
|
|
121
340
|
) => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
341
|
+
trackIfEventHandler(node);
|
|
342
|
+
},
|
|
343
|
+
ArrowFunctionExpression(node: TSESTree.ArrowFunctionExpression) {
|
|
344
|
+
trackIfEventHandler(node);
|
|
345
|
+
},
|
|
346
|
+
FunctionExpression(node: TSESTree.FunctionExpression) {
|
|
347
|
+
trackIfEventHandler(node);
|
|
348
|
+
},
|
|
349
|
+
FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
|
|
350
|
+
trackIfEventHandler(node);
|
|
128
351
|
},
|
|
129
352
|
|
|
130
353
|
// Check for .currentTarget access
|
|
@@ -146,12 +369,14 @@ export default ESLintUtils.RuleCreator.withoutDocs({
|
|
|
146
369
|
if (!containingFunction) return;
|
|
147
370
|
|
|
148
371
|
// Check each tracked event handler to see if this access is problematic
|
|
149
|
-
for (const [eventHandler, eventParam] of
|
|
372
|
+
for (const [eventHandler, eventParam] of trackedEventFunctions) {
|
|
150
373
|
// Check if this is accessing the event parameter
|
|
151
374
|
if (objectName !== eventParam.name) continue;
|
|
152
375
|
|
|
153
|
-
|
|
154
|
-
|
|
376
|
+
if (
|
|
377
|
+
isInsideDeferredCallback(node, eventHandler) ||
|
|
378
|
+
hasPriorAwaitInSameFunction(node, eventHandler)
|
|
379
|
+
) {
|
|
155
380
|
context.report({
|
|
156
381
|
node,
|
|
157
382
|
messageId: 'noCurrentTargetInCallback',
|
|
@@ -165,12 +390,21 @@ export default ESLintUtils.RuleCreator.withoutDocs({
|
|
|
165
390
|
'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression:exit': (
|
|
166
391
|
node: TSESTree.ArrowFunctionExpression,
|
|
167
392
|
) => {
|
|
168
|
-
|
|
393
|
+
trackedEventFunctions.delete(node);
|
|
169
394
|
},
|
|
170
395
|
'JSXAttribute > JSXExpressionContainer > FunctionExpression:exit': (
|
|
171
396
|
node: TSESTree.FunctionExpression,
|
|
172
397
|
) => {
|
|
173
|
-
|
|
398
|
+
trackedEventFunctions.delete(node);
|
|
399
|
+
},
|
|
400
|
+
'ArrowFunctionExpression:exit'(node: TSESTree.ArrowFunctionExpression) {
|
|
401
|
+
trackedEventFunctions.delete(node);
|
|
402
|
+
},
|
|
403
|
+
'FunctionExpression:exit'(node: TSESTree.FunctionExpression) {
|
|
404
|
+
trackedEventFunctions.delete(node);
|
|
405
|
+
},
|
|
406
|
+
'FunctionDeclaration:exit'(node: TSESTree.FunctionDeclaration) {
|
|
407
|
+
trackedEventFunctions.delete(node);
|
|
174
408
|
},
|
|
175
409
|
};
|
|
176
410
|
},
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* All props on a hydrated component are serialized with superjson and shipped
|
|
5
|
+
* to the browser. Passing `resLocals` or `locals` (or spreading them) would
|
|
6
|
+
* leak the entire server-side `res.locals` object — CSRF tokens, auth data,
|
|
7
|
+
* authz objects, full DB rows, etc.
|
|
8
|
+
*
|
|
9
|
+
* This rule forbids those prop names (and `{...res.locals}` / `{...resLocals}`
|
|
10
|
+
* / `{...locals}` spreads) on the child of `<Hydrate>` and on the JSX argument
|
|
11
|
+
* to `hydrateHtml(...)`. Extract the specific fields you need (e.g. via
|
|
12
|
+
* `extractPageContext`) and pass them as individual props instead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HYDRATE_COMPONENT_NAME = 'Hydrate';
|
|
16
|
+
const HYDRATE_FUNCTION_NAME = 'hydrateHtml';
|
|
17
|
+
const FORBIDDEN_NAMES = new Set(['resLocals', 'locals']);
|
|
18
|
+
|
|
19
|
+
function extractSingleJsxChild(children: TSESTree.JSXChild[]): TSESTree.JSXElement | null {
|
|
20
|
+
const nonWhitespace = children.filter((child) => {
|
|
21
|
+
if (child.type === 'JSXText') return child.value.trim().length > 0;
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
if (nonWhitespace.length !== 1 || nonWhitespace[0].type !== 'JSXElement') {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return nonWhitespace[0];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function spreadArgumentDescribesResLocals(argument: TSESTree.Expression): boolean {
|
|
31
|
+
if (argument.type === 'Identifier') {
|
|
32
|
+
return FORBIDDEN_NAMES.has(argument.name);
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
argument.type === 'MemberExpression' &&
|
|
36
|
+
!argument.computed &&
|
|
37
|
+
argument.property.type === 'Identifier' &&
|
|
38
|
+
argument.property.name === 'locals'
|
|
39
|
+
) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default ESLintUtils.RuleCreator.withoutDocs({
|
|
46
|
+
meta: {
|
|
47
|
+
type: 'problem',
|
|
48
|
+
messages: {
|
|
49
|
+
forbiddenProp:
|
|
50
|
+
'Do not pass "{{name}}" to a hydrated component; props are serialized and sent to the client, so this would leak the full server-side res.locals. Pass the specific fields the component needs instead.',
|
|
51
|
+
forbiddenSpread:
|
|
52
|
+
'Do not spread res.locals onto a hydrated component; props are serialized and sent to the client. Pass the specific fields the component needs instead.',
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
},
|
|
56
|
+
defaultOptions: [],
|
|
57
|
+
|
|
58
|
+
create(context) {
|
|
59
|
+
function checkHydratedElement(element: TSESTree.JSXElement) {
|
|
60
|
+
for (const attribute of element.openingElement.attributes) {
|
|
61
|
+
if (attribute.type === 'JSXAttribute') {
|
|
62
|
+
if (attribute.name.type === 'JSXIdentifier' && FORBIDDEN_NAMES.has(attribute.name.name)) {
|
|
63
|
+
context.report({
|
|
64
|
+
node: attribute,
|
|
65
|
+
messageId: 'forbiddenProp',
|
|
66
|
+
data: { name: attribute.name.name },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} else if (attribute.type === 'JSXSpreadAttribute') {
|
|
70
|
+
if (spreadArgumentDescribesResLocals(attribute.argument)) {
|
|
71
|
+
context.report({
|
|
72
|
+
node: attribute,
|
|
73
|
+
messageId: 'forbiddenSpread',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
JSXElement(node) {
|
|
82
|
+
const opening = node.openingElement.name;
|
|
83
|
+
if (opening.type !== 'JSXIdentifier' || opening.name !== HYDRATE_COMPONENT_NAME) return;
|
|
84
|
+
|
|
85
|
+
const child = extractSingleJsxChild(node.children);
|
|
86
|
+
if (!child) return;
|
|
87
|
+
|
|
88
|
+
checkHydratedElement(child);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
CallExpression(node) {
|
|
92
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== HYDRATE_FUNCTION_NAME) return;
|
|
93
|
+
if (node.arguments.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const arg = node.arguments[0];
|
|
96
|
+
if (arg.type !== 'JSXElement') return;
|
|
97
|
+
|
|
98
|
+
checkHydratedElement(arg);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
@@ -38,10 +38,40 @@ ruleTester.run('no-current-target-in-callback', rule, {
|
|
|
38
38
|
{
|
|
39
39
|
code: '<Component render={(e) => setFoo(() => e.currentTarget)} />',
|
|
40
40
|
},
|
|
41
|
+
// Synchronous nested callbacks are safe
|
|
42
|
+
{
|
|
43
|
+
code: '<input onChange={(e) => items.map(() => e.currentTarget.value)} />',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
code: `const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
47
|
+
fields.find((field) => field.name === e.currentTarget.name);
|
|
48
|
+
};`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
code: `const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
52
|
+
const cursorPosition = run(() => e.currentTarget.selectionStart ?? 0);
|
|
53
|
+
return cursorPosition;
|
|
54
|
+
};`,
|
|
55
|
+
},
|
|
41
56
|
// Direct setState without callback
|
|
42
57
|
{
|
|
43
58
|
code: '<input onChange={(e) => setChecks({ value: e.currentTarget.checked })} />',
|
|
44
59
|
},
|
|
60
|
+
// Capturing currentTarget before await is safe
|
|
61
|
+
{
|
|
62
|
+
code: `const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
63
|
+
const form = e.currentTarget;
|
|
64
|
+
await trigger();
|
|
65
|
+
form.submit();
|
|
66
|
+
};`,
|
|
67
|
+
},
|
|
68
|
+
// Destructuring currentTarget before await is safe
|
|
69
|
+
{
|
|
70
|
+
code: `const handleSubmit = async ({ currentTarget }: React.SubmitEvent<HTMLFormElement>) => {
|
|
71
|
+
await trigger();
|
|
72
|
+
currentTarget.submit();
|
|
73
|
+
};`,
|
|
74
|
+
},
|
|
45
75
|
// Event target (not currentTarget) - different issue, not covered by this rule
|
|
46
76
|
{
|
|
47
77
|
code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.target.checked }))} />',
|
|
@@ -79,21 +109,11 @@ ruleTester.run('no-current-target-in-callback', rule, {
|
|
|
79
109
|
code: '<button onClick={(e) => setFoo((prev) => ({ ...prev, clicked: e.currentTarget.id }))} />',
|
|
80
110
|
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
81
111
|
},
|
|
82
|
-
// Nested arrow function in array method
|
|
83
|
-
{
|
|
84
|
-
code: '<input onChange={(e) => items.map(() => e.currentTarget.value)} />',
|
|
85
|
-
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
86
|
-
},
|
|
87
112
|
// Function expression callback
|
|
88
113
|
{
|
|
89
114
|
code: '<input onChange={(e) => setChecks(function(c) { return { ...c, value: e.currentTarget.checked }; })} />',
|
|
90
115
|
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
91
116
|
},
|
|
92
|
-
// Deeply nested
|
|
93
|
-
{
|
|
94
|
-
code: '<input onChange={(e) => outer(() => inner(() => e.currentTarget.value))} />',
|
|
95
|
-
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
96
|
-
},
|
|
97
117
|
// With block body
|
|
98
118
|
{
|
|
99
119
|
code: `<input onChange={(e) => {
|
|
@@ -103,5 +123,42 @@ ruleTester.run('no-current-target-in-callback', rule, {
|
|
|
103
123
|
}} />`,
|
|
104
124
|
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
105
125
|
},
|
|
126
|
+
// Promise callbacks are deferred
|
|
127
|
+
{
|
|
128
|
+
code: `<input onChange={(e) => {
|
|
129
|
+
Promise.resolve().then(() => e.currentTarget.value);
|
|
130
|
+
}} />`,
|
|
131
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
132
|
+
},
|
|
133
|
+
// Timer callbacks are deferred
|
|
134
|
+
{
|
|
135
|
+
code: `<input onChange={(e) => {
|
|
136
|
+
setTimeout(() => e.currentTarget.value, 0);
|
|
137
|
+
}} />`,
|
|
138
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
139
|
+
},
|
|
140
|
+
// Access after await in a typed event handler
|
|
141
|
+
{
|
|
142
|
+
code: `const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
143
|
+
await trigger();
|
|
144
|
+
e.currentTarget.submit();
|
|
145
|
+
};`,
|
|
146
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
147
|
+
},
|
|
148
|
+
// Access after await in an inline JSX handler
|
|
149
|
+
{
|
|
150
|
+
code: `<form onSubmit={async (e) => {
|
|
151
|
+
await trigger();
|
|
152
|
+
e.currentTarget.submit();
|
|
153
|
+
}} />`,
|
|
154
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
155
|
+
},
|
|
156
|
+
// react-hook-form handleSubmit callbacks are also after the original event boundary
|
|
157
|
+
{
|
|
158
|
+
code: `const onSubmit = handleSubmit((_data, e) => {
|
|
159
|
+
e.currentTarget.submit();
|
|
160
|
+
});`,
|
|
161
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
162
|
+
},
|
|
106
163
|
],
|
|
107
164
|
});
|