@prairielearn/eslint-plugin 4.0.0 → 4.2.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 (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.d.ts +14 -9
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +18 -19
  5. package/dist/index.js.map +1 -1
  6. package/dist/rules/aws-client-mandatory-config.d.ts.map +1 -1
  7. package/dist/rules/aws-client-mandatory-config.js +4 -6
  8. package/dist/rules/aws-client-mandatory-config.js.map +1 -1
  9. package/dist/rules/aws-client-shared-config.d.ts.map +1 -1
  10. package/dist/rules/aws-client-shared-config.js +4 -6
  11. package/dist/rules/aws-client-shared-config.js.map +1 -1
  12. package/dist/rules/jsx-no-dollar-interpolation.d.ts.map +1 -1
  13. package/dist/rules/jsx-no-dollar-interpolation.js +2 -4
  14. package/dist/rules/jsx-no-dollar-interpolation.js.map +1 -1
  15. package/dist/rules/no-current-target-in-callback.d.ts +4 -4
  16. package/dist/rules/no-current-target-in-callback.d.ts.map +1 -1
  17. package/dist/rules/no-current-target-in-callback.js +191 -38
  18. package/dist/rules/no-current-target-in-callback.js.map +1 -1
  19. package/dist/rules/no-hydrate-reslocals.d.ts +4 -0
  20. package/dist/rules/no-hydrate-reslocals.d.ts.map +1 -0
  21. package/dist/rules/no-hydrate-reslocals.js +94 -0
  22. package/dist/rules/no-hydrate-reslocals.js.map +1 -0
  23. package/dist/rules/no-unused-sql-blocks.d.ts.map +1 -1
  24. package/dist/rules/no-unused-sql-blocks.js +4 -39
  25. package/dist/rules/no-unused-sql-blocks.js.map +1 -1
  26. package/dist/rules/require-trpc-permission-middleware.d.ts +4 -0
  27. package/dist/rules/require-trpc-permission-middleware.d.ts.map +1 -0
  28. package/dist/rules/require-trpc-permission-middleware.js +133 -0
  29. package/dist/rules/require-trpc-permission-middleware.js.map +1 -0
  30. package/dist/rules/safe-db-types.d.ts +1 -1
  31. package/dist/rules/safe-db-types.d.ts.map +1 -1
  32. package/dist/rules/safe-db-types.js +6 -40
  33. package/dist/rules/safe-db-types.js.map +1 -1
  34. package/dist/tests/aws-client-mandatory-config.test.d.ts.map +1 -1
  35. package/dist/tests/aws-client-mandatory-config.test.js +8 -13
  36. package/dist/tests/aws-client-mandatory-config.test.js.map +1 -1
  37. package/dist/tests/aws-client-shared-config.test.d.ts.map +1 -1
  38. package/dist/tests/aws-client-shared-config.test.js +8 -13
  39. package/dist/tests/aws-client-shared-config.test.js.map +1 -1
  40. package/dist/tests/jsx-no-dollar-interpolation.test.d.ts.map +1 -1
  41. package/dist/tests/jsx-no-dollar-interpolation.test.js +8 -13
  42. package/dist/tests/jsx-no-dollar-interpolation.test.js.map +1 -1
  43. package/dist/tests/no-current-target-in-callback.test.d.ts.map +1 -1
  44. package/dist/tests/no-current-target-in-callback.test.js +75 -23
  45. package/dist/tests/no-current-target-in-callback.test.js.map +1 -1
  46. package/dist/tests/no-hydrate-reslocals.test.d.ts +2 -0
  47. package/dist/tests/no-hydrate-reslocals.test.d.ts.map +1 -0
  48. package/dist/tests/no-hydrate-reslocals.test.js +86 -0
  49. package/dist/tests/no-hydrate-reslocals.test.js.map +1 -0
  50. package/dist/tests/require-trpc-permission-middleware.test.d.ts +2 -0
  51. package/dist/tests/require-trpc-permission-middleware.test.d.ts.map +1 -0
  52. package/dist/tests/require-trpc-permission-middleware.test.js +106 -0
  53. package/dist/tests/require-trpc-permission-middleware.test.js.map +1 -0
  54. package/dist/utils.js +1 -4
  55. package/dist/utils.js.map +1 -1
  56. package/package.json +11 -10
  57. package/src/index.ts +7 -1
  58. package/src/rules/no-current-target-in-callback.ts +273 -39
  59. package/src/rules/no-hydrate-reslocals.ts +102 -0
  60. package/src/rules/require-trpc-permission-middleware.ts +168 -0
  61. package/src/tests/aws-client-mandatory-config.test.ts +1 -1
  62. package/src/tests/aws-client-shared-config.test.ts +1 -1
  63. package/src/tests/jsx-no-dollar-interpolation.test.ts +1 -1
  64. package/src/tests/no-current-target-in-callback.test.ts +67 -10
  65. package/src/tests/no-hydrate-reslocals.test.ts +89 -0
  66. package/src/tests/require-trpc-permission-middleware.test.ts +109 -0
  67. package/tsconfig.json +1 -7
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@prairielearn/eslint-plugin",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
+ "type": "module",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "https://github.com/PrairieLearn/PrairieLearn.git",
@@ -19,17 +20,17 @@
19
20
  "eslint": "^10.0.0"
20
21
  },
21
22
  "dependencies": {
22
- "@typescript-eslint/utils": "^8.56.1",
23
- "typescript": "^5.9.3"
23
+ "@typescript-eslint/utils": "^8.59.2",
24
+ "typescript": "^6.0.3"
24
25
  },
25
26
  "devDependencies": {
26
- "@prairielearn/tsconfig": "^2.0.0",
27
- "@types/node": "^24.11.0",
28
- "@typescript-eslint/rule-tester": "^8.56.1",
29
- "@typescript-eslint/types": "^8.56.1",
30
- "@typescript/native-preview": "^7.0.0-dev.20260302.1",
31
- "@vitest/coverage-v8": "^4.0.18",
27
+ "@prairielearn/tsconfig": "^2.1.0",
28
+ "@types/node": "^24.12.2",
29
+ "@typescript-eslint/rule-tester": "^8.59.2",
30
+ "@typescript-eslint/types": "^8.59.2",
31
+ "@typescript/native-preview": "beta",
32
+ "@vitest/coverage-v8": "^4.1.5",
32
33
  "tsx": "^4.21.0",
33
- "vitest": "^4.0.18"
34
+ "vitest": "^4.1.5"
34
35
  }
35
36
  }
package/src/index.ts CHANGED
@@ -2,14 +2,20 @@ 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';
7
+ import requireTrpcPermissionMiddleware from './rules/require-trpc-permission-middleware.js';
6
8
  import safeDbTypes from './rules/safe-db-types.js';
7
9
 
8
- export const rules = {
10
+ const rules = {
9
11
  'aws-client-mandatory-config': awsClientMandatoryConfig,
10
12
  'aws-client-shared-config': awsClientSharedConfig,
11
13
  'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,
12
14
  'no-current-target-in-callback': noCurrentTargetInCallback,
15
+ 'no-hydrate-reslocals': noHydrateResLocals,
13
16
  'no-unused-sql-blocks': noUnusedSqlBlocks,
17
+ 'require-trpc-permission-middleware': requireTrpcPermissionMiddleware,
14
18
  'safe-db-types': safeDbTypes,
15
19
  };
20
+
21
+ export default { rules };
@@ -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
+ });