@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.
- package/CHANGELOG.md +21 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -19
- package/dist/index.js.map +1 -1
- package/dist/rules/aws-client-mandatory-config.d.ts.map +1 -1
- package/dist/rules/aws-client-mandatory-config.js +4 -6
- package/dist/rules/aws-client-mandatory-config.js.map +1 -1
- package/dist/rules/aws-client-shared-config.d.ts.map +1 -1
- package/dist/rules/aws-client-shared-config.js +4 -6
- package/dist/rules/aws-client-shared-config.js.map +1 -1
- package/dist/rules/jsx-no-dollar-interpolation.d.ts.map +1 -1
- package/dist/rules/jsx-no-dollar-interpolation.js +2 -4
- 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 +191 -38
- 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 +94 -0
- package/dist/rules/no-hydrate-reslocals.js.map +1 -0
- package/dist/rules/no-unused-sql-blocks.d.ts.map +1 -1
- package/dist/rules/no-unused-sql-blocks.js +4 -39
- package/dist/rules/no-unused-sql-blocks.js.map +1 -1
- package/dist/rules/require-trpc-permission-middleware.d.ts +4 -0
- package/dist/rules/require-trpc-permission-middleware.d.ts.map +1 -0
- package/dist/rules/require-trpc-permission-middleware.js +133 -0
- package/dist/rules/require-trpc-permission-middleware.js.map +1 -0
- package/dist/rules/safe-db-types.d.ts +1 -1
- package/dist/rules/safe-db-types.d.ts.map +1 -1
- package/dist/rules/safe-db-types.js +6 -40
- package/dist/rules/safe-db-types.js.map +1 -1
- package/dist/tests/aws-client-mandatory-config.test.d.ts.map +1 -1
- package/dist/tests/aws-client-mandatory-config.test.js +8 -13
- package/dist/tests/aws-client-mandatory-config.test.js.map +1 -1
- package/dist/tests/aws-client-shared-config.test.d.ts.map +1 -1
- package/dist/tests/aws-client-shared-config.test.js +8 -13
- package/dist/tests/aws-client-shared-config.test.js.map +1 -1
- package/dist/tests/jsx-no-dollar-interpolation.test.d.ts.map +1 -1
- package/dist/tests/jsx-no-dollar-interpolation.test.js +8 -13
- package/dist/tests/jsx-no-dollar-interpolation.test.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 +75 -23
- 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 +86 -0
- package/dist/tests/no-hydrate-reslocals.test.js.map +1 -0
- package/dist/tests/require-trpc-permission-middleware.test.d.ts +2 -0
- package/dist/tests/require-trpc-permission-middleware.test.d.ts.map +1 -0
- package/dist/tests/require-trpc-permission-middleware.test.js +106 -0
- package/dist/tests/require-trpc-permission-middleware.test.js.map +1 -0
- package/dist/utils.js +1 -4
- package/dist/utils.js.map +1 -1
- package/package.json +11 -10
- package/src/index.ts +7 -1
- package/src/rules/no-current-target-in-callback.ts +273 -39
- package/src/rules/no-hydrate-reslocals.ts +102 -0
- package/src/rules/require-trpc-permission-middleware.ts +168 -0
- package/src/tests/aws-client-mandatory-config.test.ts +1 -1
- package/src/tests/aws-client-shared-config.test.ts +1 -1
- package/src/tests/jsx-no-dollar-interpolation.test.ts +1 -1
- package/src/tests/no-current-target-in-callback.test.ts +67 -10
- package/src/tests/no-hydrate-reslocals.test.ts +89 -0
- package/src/tests/require-trpc-permission-middleware.test.ts +109 -0
- package/tsconfig.json +1 -7
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/eslint-plugin",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
23
|
-
"typescript": "^
|
|
23
|
+
"@typescript-eslint/utils": "^8.59.2",
|
|
24
|
+
"typescript": "^6.0.3"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
|
-
"@prairielearn/tsconfig": "^2.
|
|
27
|
-
"@types/node": "^24.
|
|
28
|
-
"@typescript-eslint/rule-tester": "^8.
|
|
29
|
-
"@typescript-eslint/types": "^8.
|
|
30
|
-
"@typescript/native-preview": "
|
|
31
|
-
"@vitest/coverage-v8": "^4.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|