@prairielearn/eslint-plugin 3.1.1 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/rules/aws-client-mandatory-config.js.map +1 -1
  7. package/dist/rules/aws-client-shared-config.js.map +1 -1
  8. package/dist/rules/jsx-no-dollar-interpolation.js.map +1 -1
  9. package/dist/rules/no-current-target-in-callback.d.ts +4 -4
  10. package/dist/rules/no-current-target-in-callback.d.ts.map +1 -1
  11. package/dist/rules/no-current-target-in-callback.js +189 -34
  12. package/dist/rules/no-current-target-in-callback.js.map +1 -1
  13. package/dist/rules/no-hydrate-reslocals.d.ts +4 -0
  14. package/dist/rules/no-hydrate-reslocals.d.ts.map +1 -0
  15. package/dist/rules/no-hydrate-reslocals.js +96 -0
  16. package/dist/rules/no-hydrate-reslocals.js.map +1 -0
  17. package/dist/rules/no-unused-sql-blocks.js.map +1 -1
  18. package/dist/rules/safe-db-types.js.map +1 -1
  19. package/dist/tests/no-current-target-in-callback.test.d.ts.map +1 -1
  20. package/dist/tests/no-current-target-in-callback.test.js +67 -10
  21. package/dist/tests/no-current-target-in-callback.test.js.map +1 -1
  22. package/dist/tests/no-hydrate-reslocals.test.d.ts +2 -0
  23. package/dist/tests/no-hydrate-reslocals.test.d.ts.map +1 -0
  24. package/dist/tests/no-hydrate-reslocals.test.js +91 -0
  25. package/dist/tests/no-hydrate-reslocals.test.js.map +1 -0
  26. package/dist/utils.js.map +1 -1
  27. package/package.json +11 -8
  28. package/src/index.ts +2 -0
  29. package/src/rules/no-current-target-in-callback.ts +273 -39
  30. package/src/rules/no-hydrate-reslocals.ts +102 -0
  31. package/src/tests/no-current-target-in-callback.test.ts +67 -10
  32. 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,EAAW;IAChF,+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;AAAA,CACd;AAED;;;;;;;;;;GAUG;AACH,gDAAuD,IAAS,EAAE;IAChE,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,CAAC;YAC1C,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;QAAA,CACF,CAAC,CAAC;IACL,CAAC;IAED,OAAO,WAAW,CAAC;AAAA,CACpB","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"]}
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": "3.1.1",
3
+ "version": "4.1.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/PrairieLearn/PrairieLearn.git",
@@ -15,18 +15,21 @@
15
15
  "dev": "tsgo --watch --preserveWatchOutput",
16
16
  "test": "vitest run --coverage"
17
17
  },
18
+ "peerDependencies": {
19
+ "eslint": "^10.0.0"
20
+ },
18
21
  "dependencies": {
19
- "@typescript-eslint/utils": "^8.54.0",
22
+ "@typescript-eslint/utils": "^8.58.0",
20
23
  "typescript": "^5.9.3"
21
24
  },
22
25
  "devDependencies": {
23
26
  "@prairielearn/tsconfig": "^2.0.0",
24
- "@types/node": "^24.10.9",
25
- "@typescript-eslint/rule-tester": "^8.54.0",
26
- "@typescript-eslint/types": "^8.54.0",
27
- "@typescript/native-preview": "^7.0.0-dev.20260203.1",
28
- "@vitest/coverage-v8": "^4.0.18",
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",
29
32
  "tsx": "^4.21.0",
30
- "vitest": "^4.0.18"
33
+ "vitest": "^4.1.2"
31
34
  }
32
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 inside a nested
5
- * callback function within a React event handler. This is problematic because
6
- * React may execute callbacks (like those passed to setState) asynchronously,
7
- * at which point `currentTarget` may already be nullified.
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 inside a callback may fail because currentTarget can be nullified before the callback runs. Destructure currentTarget at the start of the event handler instead.',
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
- // Track event handler parameters and their scopes
32
- const eventHandlerParams = new Map<TSESTree.Node, TSESTree.Identifier>();
31
+ const trackedEventFunctions = new Map<TSESTree.Node, TSESTree.Identifier>();
32
+ const visitorKeys = context.sourceCode.visitorKeys;
33
33
 
34
- /** Check if a node is inside a nested function relative to the event handler */
35
- function isInsideNestedFunction(
34
+ function isFunctionLike(
36
35
  node: TSESTree.Node,
37
- eventHandlerFunction: TSESTree.Node,
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
- let current: TSESTree.Node | undefined = node.parent;
40
- let foundNestedFunction = false;
101
+ if (callbackIndex !== 0) {
102
+ return false;
103
+ }
41
104
 
42
- while (current && current !== eventHandlerFunction) {
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
- current.type === 'ArrowFunctionExpression' ||
45
- current.type === 'FunctionExpression' ||
46
- current.type === 'FunctionDeclaration'
132
+ callbackIndex !== -1 &&
133
+ (isKnownDeferredCallee(handlerParent.callee) ||
134
+ isKnownEventBoundaryCallback(handlerParent, callbackIndex))
47
135
  ) {
48
- foundNestedFunction = true;
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 foundNestedFunction && current === eventHandlerFunction;
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
- ): TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | null {
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.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
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: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
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: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
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
- if (isJSXEventHandler(node)) {
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
- if (isJSXEventHandler(node)) {
123
- const eventParam = getEventParameter(node);
124
- if (eventParam) {
125
- eventHandlerParams.set(node, eventParam);
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 eventHandlerParams) {
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
- // Check if the access is inside a nested function within the event handler
154
- if (isInsideNestedFunction(node, eventHandler)) {
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
- eventHandlerParams.delete(node);
393
+ trackedEventFunctions.delete(node);
169
394
  },
170
395
  'JSXAttribute > JSXExpressionContainer > FunctionExpression:exit': (
171
396
  node: TSESTree.FunctionExpression,
172
397
  ) => {
173
- eventHandlerParams.delete(node);
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
  });