react-doctor 0.0.47 → 0.1.1
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/README.md +124 -233
- package/dist/cli.js +941 -226
- package/dist/eslint-plugin.d.ts +57 -0
- package/dist/eslint-plugin.js +6965 -0
- package/dist/index.d.ts +33 -2
- package/dist/index.js +908 -398
- package/dist/react-doctor-plugin.js +2010 -320
- package/package.json +9 -13
- package/dist/browser-BOxs7MrK.js +0 -359
- package/dist/browser-Dcq3yn-p.d.ts +0 -146
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -2
- package/dist/worker.d.ts +0 -2
- package/dist/worker.js +0 -2
|
@@ -245,10 +245,38 @@ const TRIVIAL_INITIALIZER_NAMES = new Set([
|
|
|
245
245
|
"parseInt",
|
|
246
246
|
"parseFloat"
|
|
247
247
|
]);
|
|
248
|
+
const TRIVIAL_DERIVATION_CALLEE_NAMES = new Set([
|
|
249
|
+
"Boolean",
|
|
250
|
+
"String",
|
|
251
|
+
"Number",
|
|
252
|
+
"Array",
|
|
253
|
+
"Object",
|
|
254
|
+
"parseInt",
|
|
255
|
+
"parseFloat",
|
|
256
|
+
"isNaN",
|
|
257
|
+
"isFinite",
|
|
258
|
+
"BigInt",
|
|
259
|
+
"Symbol"
|
|
260
|
+
]);
|
|
261
|
+
const BUILTIN_GLOBAL_NAMESPACE_NAMES = new Set([
|
|
262
|
+
"Math",
|
|
263
|
+
"Date",
|
|
264
|
+
"JSON",
|
|
265
|
+
"Object",
|
|
266
|
+
"Array",
|
|
267
|
+
"Number",
|
|
268
|
+
"String",
|
|
269
|
+
"Boolean",
|
|
270
|
+
"RegExp",
|
|
271
|
+
"Symbol",
|
|
272
|
+
"BigInt",
|
|
273
|
+
"Reflect"
|
|
274
|
+
]);
|
|
248
275
|
const SETTER_PATTERN = /^set[A-Z]/;
|
|
249
276
|
const RENDER_FUNCTION_PATTERN = /^render[A-Z]/;
|
|
250
277
|
const UPPERCASE_PATTERN = /^[A-Z]/;
|
|
251
278
|
const PAGE_FILE_PATTERN = /\/page\.(tsx?|jsx?)$/;
|
|
279
|
+
const REACT_HANDLER_PROP_PATTERN = /^on[A-Z]/;
|
|
252
280
|
const PAGE_OR_LAYOUT_FILE_PATTERN = /\/(page|layout)\.(tsx?|jsx?)$/;
|
|
253
281
|
const INTERNAL_PAGE_PATH_PATTERN = /\/(?:(?:\((?:dashboard|admin|settings|account|internal|manage|console|portal|auth|onboarding|app|ee|protected)\))|(?:dashboard|admin|settings|account|internal|manage|console|portal))\//i;
|
|
254
282
|
const TEST_FILE_PATTERN = /\.(?:test|spec|stories)\.[tj]sx?$/;
|
|
@@ -282,6 +310,17 @@ const MUTATION_METHOD_NAMES = new Set([
|
|
|
282
310
|
"set",
|
|
283
311
|
"append"
|
|
284
312
|
]);
|
|
313
|
+
const MUTATING_ARRAY_METHODS = new Set([
|
|
314
|
+
"push",
|
|
315
|
+
"pop",
|
|
316
|
+
"shift",
|
|
317
|
+
"unshift",
|
|
318
|
+
"splice",
|
|
319
|
+
"sort",
|
|
320
|
+
"reverse",
|
|
321
|
+
"fill",
|
|
322
|
+
"copyWithin"
|
|
323
|
+
]);
|
|
285
324
|
const MUTATING_HTTP_METHODS = new Set([
|
|
286
325
|
"POST",
|
|
287
326
|
"PUT",
|
|
@@ -307,6 +346,112 @@ const HOOKS_WITH_DEPS = new Set([
|
|
|
307
346
|
"useMemo",
|
|
308
347
|
"useCallback"
|
|
309
348
|
]);
|
|
349
|
+
const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
|
|
350
|
+
"setTimeout",
|
|
351
|
+
"setInterval",
|
|
352
|
+
"requestAnimationFrame",
|
|
353
|
+
"requestIdleCallback",
|
|
354
|
+
"queueMicrotask"
|
|
355
|
+
]);
|
|
356
|
+
const TIMER_CALLEE_NAMES_REQUIRING_CLEANUP = new Set(["setInterval", "setTimeout"]);
|
|
357
|
+
const TIMER_CLEANUP_CALLEE_NAMES = new Set(["clearInterval", "clearTimeout"]);
|
|
358
|
+
const MUTABLE_GLOBAL_ROOTS = new Set([
|
|
359
|
+
"location",
|
|
360
|
+
"window",
|
|
361
|
+
"document",
|
|
362
|
+
"navigator",
|
|
363
|
+
"history",
|
|
364
|
+
"screen",
|
|
365
|
+
"performance"
|
|
366
|
+
]);
|
|
367
|
+
const SUBSCRIPTION_METHOD_NAMES = new Set([
|
|
368
|
+
"subscribe",
|
|
369
|
+
"addEventListener",
|
|
370
|
+
"addListener",
|
|
371
|
+
"on",
|
|
372
|
+
"watch",
|
|
373
|
+
"listen",
|
|
374
|
+
"sub"
|
|
375
|
+
]);
|
|
376
|
+
const UNSUBSCRIPTION_METHOD_NAMES = new Set([
|
|
377
|
+
"unsubscribe",
|
|
378
|
+
"removeEventListener",
|
|
379
|
+
"removeListener",
|
|
380
|
+
"off",
|
|
381
|
+
"unwatch",
|
|
382
|
+
"unlisten",
|
|
383
|
+
"unsub"
|
|
384
|
+
]);
|
|
385
|
+
const CLEANUP_LIKE_RELEASE_CALLEE_NAMES = new Set([
|
|
386
|
+
...UNSUBSCRIPTION_METHOD_NAMES,
|
|
387
|
+
"cleanup",
|
|
388
|
+
"dispose",
|
|
389
|
+
"destroy",
|
|
390
|
+
"teardown"
|
|
391
|
+
]);
|
|
392
|
+
const EXTERNAL_SYNC_MEMBER_METHOD_NAMES = new Set([
|
|
393
|
+
...SUBSCRIPTION_METHOD_NAMES,
|
|
394
|
+
"connect",
|
|
395
|
+
"disconnect",
|
|
396
|
+
"open",
|
|
397
|
+
"close",
|
|
398
|
+
"fetch",
|
|
399
|
+
"post",
|
|
400
|
+
"put",
|
|
401
|
+
"patch"
|
|
402
|
+
]);
|
|
403
|
+
const EXTERNAL_SYNC_HTTP_CLIENT_RECEIVERS = new Set([
|
|
404
|
+
...FETCH_MEMBER_OBJECTS,
|
|
405
|
+
"api",
|
|
406
|
+
"client",
|
|
407
|
+
"http",
|
|
408
|
+
"fetcher"
|
|
409
|
+
]);
|
|
410
|
+
const EXTERNAL_SYNC_AMBIGUOUS_HTTP_METHOD_NAMES = new Set([
|
|
411
|
+
"get",
|
|
412
|
+
"head",
|
|
413
|
+
"options",
|
|
414
|
+
"delete"
|
|
415
|
+
]);
|
|
416
|
+
const EXTERNAL_SYNC_DIRECT_CALLEE_NAMES = new Set([...FETCH_CALLEE_NAMES, ...TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES]);
|
|
417
|
+
const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([
|
|
418
|
+
"IntersectionObserver",
|
|
419
|
+
"MutationObserver",
|
|
420
|
+
"ResizeObserver",
|
|
421
|
+
"PerformanceObserver"
|
|
422
|
+
]);
|
|
423
|
+
const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
|
|
424
|
+
...FETCH_CALLEE_NAMES,
|
|
425
|
+
"post",
|
|
426
|
+
"put",
|
|
427
|
+
"patch",
|
|
428
|
+
"navigate",
|
|
429
|
+
"navigateTo",
|
|
430
|
+
"showNotification",
|
|
431
|
+
"toast",
|
|
432
|
+
"alert",
|
|
433
|
+
"confirm",
|
|
434
|
+
"logVisit",
|
|
435
|
+
"captureEvent"
|
|
436
|
+
]);
|
|
437
|
+
const EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS = new Set([
|
|
438
|
+
"post",
|
|
439
|
+
"put",
|
|
440
|
+
"patch",
|
|
441
|
+
"delete",
|
|
442
|
+
"navigate",
|
|
443
|
+
"capture",
|
|
444
|
+
"track",
|
|
445
|
+
"logEvent"
|
|
446
|
+
]);
|
|
447
|
+
const EVENT_TRIGGERED_NAVIGATION_METHOD_NAMES = new Set(["push", "replace"]);
|
|
448
|
+
const NAVIGATION_RECEIVER_NAMES = new Set([
|
|
449
|
+
"router",
|
|
450
|
+
"navigation",
|
|
451
|
+
"navigator",
|
|
452
|
+
"history",
|
|
453
|
+
"location"
|
|
454
|
+
]);
|
|
310
455
|
const CHAINABLE_ITERATION_METHODS = new Set([
|
|
311
456
|
"map",
|
|
312
457
|
"filter",
|
|
@@ -341,29 +486,29 @@ const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
|
341
486
|
"Description",
|
|
342
487
|
"Body"
|
|
343
488
|
]);
|
|
344
|
-
const DEPRECATED_RN_MODULE_REPLACEMENTS =
|
|
345
|
-
AsyncStorage
|
|
346
|
-
Picker
|
|
347
|
-
PickerIOS
|
|
348
|
-
DatePickerIOS
|
|
349
|
-
DatePickerAndroid
|
|
350
|
-
ProgressBarAndroid
|
|
351
|
-
ProgressViewIOS
|
|
352
|
-
SafeAreaView
|
|
353
|
-
Slider
|
|
354
|
-
ViewPagerAndroid
|
|
355
|
-
WebView
|
|
356
|
-
NetInfo
|
|
357
|
-
CameraRoll
|
|
358
|
-
Clipboard
|
|
359
|
-
ImageEditor
|
|
360
|
-
MaskedViewIOS
|
|
361
|
-
|
|
362
|
-
const LEGACY_EXPO_PACKAGE_REPLACEMENTS =
|
|
363
|
-
"expo-av"
|
|
364
|
-
"expo-permissions"
|
|
365
|
-
"@expo/vector-icons"
|
|
366
|
-
|
|
489
|
+
const DEPRECATED_RN_MODULE_REPLACEMENTS = new Map([
|
|
490
|
+
["AsyncStorage", "@react-native-async-storage/async-storage"],
|
|
491
|
+
["Picker", "@react-native-picker/picker"],
|
|
492
|
+
["PickerIOS", "@react-native-picker/picker"],
|
|
493
|
+
["DatePickerIOS", "@react-native-community/datetimepicker"],
|
|
494
|
+
["DatePickerAndroid", "@react-native-community/datetimepicker"],
|
|
495
|
+
["ProgressBarAndroid", "a community alternative"],
|
|
496
|
+
["ProgressViewIOS", "a community alternative"],
|
|
497
|
+
["SafeAreaView", "react-native-safe-area-context"],
|
|
498
|
+
["Slider", "@react-native-community/slider"],
|
|
499
|
+
["ViewPagerAndroid", "react-native-pager-view"],
|
|
500
|
+
["WebView", "react-native-webview"],
|
|
501
|
+
["NetInfo", "@react-native-community/netinfo"],
|
|
502
|
+
["CameraRoll", "@react-native-camera-roll/camera-roll"],
|
|
503
|
+
["Clipboard", "@react-native-clipboard/clipboard"],
|
|
504
|
+
["ImageEditor", "@react-native-community/image-editor"],
|
|
505
|
+
["MaskedViewIOS", "@react-native-masked-view/masked-view"]
|
|
506
|
+
]);
|
|
507
|
+
const LEGACY_EXPO_PACKAGE_REPLACEMENTS = new Map([
|
|
508
|
+
["expo-av", "expo-audio for audio and expo-video for video"],
|
|
509
|
+
["expo-permissions", "the permissions API in each module (e.g. Camera.requestPermissionsAsync())"],
|
|
510
|
+
["@expo/vector-icons", "expo-symbols or expo-image (see https://docs.expo.dev/versions/latest/sdk/symbols/)"]
|
|
511
|
+
]);
|
|
367
512
|
const REACT_NATIVE_LIST_COMPONENTS = new Set([
|
|
368
513
|
"FlatList",
|
|
369
514
|
"SectionList",
|
|
@@ -385,6 +530,87 @@ const BOUNCE_ANIMATION_NAMES = new Set([
|
|
|
385
530
|
"spring"
|
|
386
531
|
]);
|
|
387
532
|
const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
|
|
533
|
+
const HEADING_TAG_NAMES = new Set([
|
|
534
|
+
"h1",
|
|
535
|
+
"h2",
|
|
536
|
+
"h3",
|
|
537
|
+
"h4",
|
|
538
|
+
"h5",
|
|
539
|
+
"h6"
|
|
540
|
+
]);
|
|
541
|
+
const HEAVY_HEADING_TAILWIND_WEIGHTS = new Set([
|
|
542
|
+
"font-bold",
|
|
543
|
+
"font-extrabold",
|
|
544
|
+
"font-black"
|
|
545
|
+
]);
|
|
546
|
+
const TAILWIND_DEFAULT_PALETTE_NAMES = [
|
|
547
|
+
"indigo",
|
|
548
|
+
"gray",
|
|
549
|
+
"slate"
|
|
550
|
+
];
|
|
551
|
+
const TAILWIND_DEFAULT_PALETTE_STOPS = [
|
|
552
|
+
"50",
|
|
553
|
+
"100",
|
|
554
|
+
"200",
|
|
555
|
+
"300",
|
|
556
|
+
"400",
|
|
557
|
+
"500",
|
|
558
|
+
"600",
|
|
559
|
+
"700",
|
|
560
|
+
"800",
|
|
561
|
+
"900",
|
|
562
|
+
"950"
|
|
563
|
+
];
|
|
564
|
+
const TAILWIND_PALETTE_UTILITY_PREFIXES = [
|
|
565
|
+
"text",
|
|
566
|
+
"bg",
|
|
567
|
+
"border",
|
|
568
|
+
"ring",
|
|
569
|
+
"fill",
|
|
570
|
+
"stroke",
|
|
571
|
+
"from",
|
|
572
|
+
"to",
|
|
573
|
+
"via",
|
|
574
|
+
"decoration",
|
|
575
|
+
"divide",
|
|
576
|
+
"outline",
|
|
577
|
+
"placeholder",
|
|
578
|
+
"caret",
|
|
579
|
+
"accent",
|
|
580
|
+
"shadow"
|
|
581
|
+
];
|
|
582
|
+
const VAGUE_BUTTON_LABELS = new Set([
|
|
583
|
+
"continue",
|
|
584
|
+
"submit",
|
|
585
|
+
"ok",
|
|
586
|
+
"okay",
|
|
587
|
+
"click here",
|
|
588
|
+
"here",
|
|
589
|
+
"yes",
|
|
590
|
+
"no",
|
|
591
|
+
"go",
|
|
592
|
+
"done"
|
|
593
|
+
]);
|
|
594
|
+
const ELLIPSIS_EXCLUDED_TAG_NAMES = new Set([
|
|
595
|
+
"code",
|
|
596
|
+
"pre",
|
|
597
|
+
"kbd",
|
|
598
|
+
"samp",
|
|
599
|
+
"var",
|
|
600
|
+
"tt"
|
|
601
|
+
]);
|
|
602
|
+
const PADDING_HORIZONTAL_AXIS_PATTERN = /(?:^|\s)(-?)px-(\d+(?:\.\d+)?|\[[^\]]+\])(?=$|[\s:])/g;
|
|
603
|
+
const PADDING_VERTICAL_AXIS_PATTERN = /(?:^|\s)(-?)py-(\d+(?:\.\d+)?|\[[^\]]+\])(?=$|[\s:])/g;
|
|
604
|
+
const SIZE_WIDTH_AXIS_PATTERN = /(?:^|\s)(-?)w-(\d+(?:\.\d+)?|\[[^\]]+\])(?=$|[\s:])/g;
|
|
605
|
+
const SIZE_HEIGHT_AXIS_PATTERN = /(?:^|\s)(-?)h-(\d+(?:\.\d+)?|\[[^\]]+\])(?=$|[\s:])/g;
|
|
606
|
+
const FLEX_OR_GRID_DISPLAY_TOKENS = new Set([
|
|
607
|
+
"flex",
|
|
608
|
+
"inline-flex",
|
|
609
|
+
"grid",
|
|
610
|
+
"inline-grid"
|
|
611
|
+
]);
|
|
612
|
+
const SPACE_AXIS_PATTERN = /(?:^|\s)(?:-)?space-(x|y)-(\d+(?:\.\d+)?|\[[^\]]+\])(?=$|[\s:])/;
|
|
613
|
+
const TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN = /[A-Za-z]\.\.\./;
|
|
388
614
|
//#endregion
|
|
389
615
|
//#region src/plugin/helpers.ts
|
|
390
616
|
const walkAst = (node, visitor) => {
|
|
@@ -398,10 +624,60 @@ const walkAst = (node, visitor) => {
|
|
|
398
624
|
} else if (child && typeof child === "object" && child.type) walkAst(child, visitor);
|
|
399
625
|
}
|
|
400
626
|
};
|
|
627
|
+
const walkInsideStatementBlocks = (node, visitor) => {
|
|
628
|
+
if (!node || typeof node !== "object") return;
|
|
629
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return;
|
|
630
|
+
visitor(node);
|
|
631
|
+
for (const key of Object.keys(node)) {
|
|
632
|
+
if (key === "parent") continue;
|
|
633
|
+
const child = node[key];
|
|
634
|
+
if (Array.isArray(child)) {
|
|
635
|
+
for (const item of child) if (item && typeof item === "object" && item.type) walkInsideStatementBlocks(item, visitor);
|
|
636
|
+
} else if (child && typeof child === "object" && child.type) walkInsideStatementBlocks(child, visitor);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
401
639
|
const isSetterIdentifier = (name) => SETTER_PATTERN.test(name);
|
|
402
640
|
const isSetterCall = (node) => node.type === "CallExpression" && node.callee?.type === "Identifier" && isSetterIdentifier(node.callee.name);
|
|
403
641
|
const isUppercaseName = (name) => UPPERCASE_PATTERN.test(name);
|
|
404
642
|
const isMemberProperty = (node, propertyName) => node.type === "MemberExpression" && node.property?.type === "Identifier" && node.property.name === propertyName;
|
|
643
|
+
const getRootIdentifierName = (node, options) => {
|
|
644
|
+
if (!node) return null;
|
|
645
|
+
if (node.type === "Identifier") return node.name;
|
|
646
|
+
const followCallChains = options?.followCallChains === true;
|
|
647
|
+
let cursor = node;
|
|
648
|
+
while (cursor) {
|
|
649
|
+
if (cursor.type === "MemberExpression") {
|
|
650
|
+
cursor = cursor.object;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (followCallChains && cursor.type === "CallExpression") {
|
|
654
|
+
const callee = cursor.callee;
|
|
655
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
656
|
+
cursor = callee.object;
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
return cursor?.type === "Identifier" ? cursor.name : null;
|
|
662
|
+
};
|
|
663
|
+
const areExpressionsStructurallyEqual = (a, b) => {
|
|
664
|
+
if (!a || !b) return a === b;
|
|
665
|
+
if (a.type !== b.type) return false;
|
|
666
|
+
if (a.type === "Identifier") return a.name === b.name;
|
|
667
|
+
if (a.type === "Literal") return a.value === b.value;
|
|
668
|
+
if (a.type === "MemberExpression") {
|
|
669
|
+
if (a.computed !== b.computed) return false;
|
|
670
|
+
return areExpressionsStructurallyEqual(a.object, b.object) && areExpressionsStructurallyEqual(a.property, b.property);
|
|
671
|
+
}
|
|
672
|
+
if (a.type === "CallExpression") {
|
|
673
|
+
if (!areExpressionsStructurallyEqual(a.callee, b.callee)) return false;
|
|
674
|
+
const argumentsA = a.arguments ?? [];
|
|
675
|
+
const argumentsB = b.arguments ?? [];
|
|
676
|
+
if (argumentsA.length !== argumentsB.length) return false;
|
|
677
|
+
return argumentsA.every((argument, index) => areExpressionsStructurallyEqual(argument, argumentsB[index]));
|
|
678
|
+
}
|
|
679
|
+
return false;
|
|
680
|
+
};
|
|
405
681
|
const getEffectCallback = (node) => {
|
|
406
682
|
if (!node.arguments?.length) return null;
|
|
407
683
|
const callback = node.arguments[0];
|
|
@@ -545,6 +821,94 @@ const extractDestructuredPropNames = (params) => {
|
|
|
545
821
|
for (const param of params) collectPatternNames(param, propNames);
|
|
546
822
|
return propNames;
|
|
547
823
|
};
|
|
824
|
+
const isFunctionLikeVariableDeclarator = (node) => {
|
|
825
|
+
if (node.type !== "VariableDeclarator") return false;
|
|
826
|
+
return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
|
|
827
|
+
};
|
|
828
|
+
const createComponentPropStackTracker = (callbacks) => {
|
|
829
|
+
const propParamStack = [];
|
|
830
|
+
const isPropName = (name) => {
|
|
831
|
+
for (let frameIndex = propParamStack.length - 1; frameIndex >= 0; frameIndex--) {
|
|
832
|
+
const frame = propParamStack[frameIndex];
|
|
833
|
+
if (frame.size === 0) return false;
|
|
834
|
+
if (frame.has(name)) return true;
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
};
|
|
838
|
+
const getCurrentPropNames = () => {
|
|
839
|
+
for (let frameIndex = propParamStack.length - 1; frameIndex >= 0; frameIndex--) {
|
|
840
|
+
const frame = propParamStack[frameIndex];
|
|
841
|
+
if (frame.size === 0) return /* @__PURE__ */ new Set();
|
|
842
|
+
return frame;
|
|
843
|
+
}
|
|
844
|
+
return /* @__PURE__ */ new Set();
|
|
845
|
+
};
|
|
846
|
+
return {
|
|
847
|
+
isPropName,
|
|
848
|
+
getCurrentPropNames,
|
|
849
|
+
visitors: {
|
|
850
|
+
FunctionDeclaration(node) {
|
|
851
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) {
|
|
852
|
+
propParamStack.push(/* @__PURE__ */ new Set());
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
propParamStack.push(extractDestructuredPropNames(node.params ?? []));
|
|
856
|
+
callbacks?.onComponentEnter?.(node.body);
|
|
857
|
+
},
|
|
858
|
+
"FunctionDeclaration:exit"() {
|
|
859
|
+
propParamStack.pop();
|
|
860
|
+
},
|
|
861
|
+
VariableDeclarator(node) {
|
|
862
|
+
if (isComponentAssignment(node)) {
|
|
863
|
+
propParamStack.push(extractDestructuredPropNames(node.init?.params ?? []));
|
|
864
|
+
callbacks?.onComponentEnter?.(node.init?.body);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (isFunctionLikeVariableDeclarator(node)) propParamStack.push(/* @__PURE__ */ new Set());
|
|
868
|
+
},
|
|
869
|
+
"VariableDeclarator:exit"(node) {
|
|
870
|
+
if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) propParamStack.pop();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
};
|
|
875
|
+
const createComponentBindingStackTracker = (callbacks) => {
|
|
876
|
+
const componentBindingStack = [];
|
|
877
|
+
const isInsideComponent = () => componentBindingStack.length > 0;
|
|
878
|
+
const isBoundName = (name) => {
|
|
879
|
+
for (let frameIndex = componentBindingStack.length - 1; frameIndex >= 0; frameIndex--) if (componentBindingStack[frameIndex].has(name)) return true;
|
|
880
|
+
return false;
|
|
881
|
+
};
|
|
882
|
+
const addBindingToCurrentFrame = (name) => {
|
|
883
|
+
if (componentBindingStack.length === 0) return;
|
|
884
|
+
componentBindingStack[componentBindingStack.length - 1].add(name);
|
|
885
|
+
};
|
|
886
|
+
return {
|
|
887
|
+
isInsideComponent,
|
|
888
|
+
isBoundName,
|
|
889
|
+
addBindingToCurrentFrame,
|
|
890
|
+
visitors: {
|
|
891
|
+
FunctionDeclaration(node) {
|
|
892
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
893
|
+
componentBindingStack.push(/* @__PURE__ */ new Set());
|
|
894
|
+
},
|
|
895
|
+
"FunctionDeclaration:exit"(node) {
|
|
896
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
897
|
+
componentBindingStack.pop();
|
|
898
|
+
},
|
|
899
|
+
VariableDeclarator(node) {
|
|
900
|
+
if (isComponentAssignment(node)) {
|
|
901
|
+
componentBindingStack.push(/* @__PURE__ */ new Set());
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
callbacks?.onVariableDeclarator?.(node);
|
|
905
|
+
},
|
|
906
|
+
"VariableDeclarator:exit"(node) {
|
|
907
|
+
if (isComponentAssignment(node)) componentBindingStack.pop();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
};
|
|
548
912
|
//#endregion
|
|
549
913
|
//#region src/plugin/rules/architecture.ts
|
|
550
914
|
const noGenericHandlerNames = { create: (context) => ({ JSXAttribute(node) {
|
|
@@ -665,20 +1029,20 @@ const noManyBooleanProps = { create: (context) => {
|
|
|
665
1029
|
}
|
|
666
1030
|
};
|
|
667
1031
|
} };
|
|
668
|
-
const REACT_19_DEPRECATED_MESSAGES = {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
};
|
|
672
|
-
const noReact19DeprecatedApis = { create: (context) => {
|
|
673
|
-
const reactNamespaceBindings = /* @__PURE__ */ new Set();
|
|
1032
|
+
const REACT_19_DEPRECATED_MESSAGES = new Map([["forwardRef", "forwardRef is no longer needed on React 19+ — refs are regular props on function components; remove forwardRef and pass ref directly"], ["useContext", "useContext is superseded by `use()` on React 19+ — `use()` reads context conditionally inside hooks, branches, and loops; switch to `import { use } from 'react'`"]]);
|
|
1033
|
+
const createDeprecatedReactImportRule = ({ source, messages, handleExtraSource }) => ({ create: (context) => {
|
|
1034
|
+
const namespaceBindings = /* @__PURE__ */ new Set();
|
|
674
1035
|
return {
|
|
675
1036
|
ImportDeclaration(node) {
|
|
676
|
-
|
|
1037
|
+
const sourceValue = node.source?.value;
|
|
1038
|
+
if (typeof sourceValue !== "string") return;
|
|
1039
|
+
if (handleExtraSource?.(node, context)) return;
|
|
1040
|
+
if (sourceValue !== source) return;
|
|
677
1041
|
for (const specifier of node.specifiers ?? []) {
|
|
678
1042
|
if (specifier.type === "ImportSpecifier") {
|
|
679
1043
|
const importedName = specifier.imported?.name;
|
|
680
1044
|
if (!importedName) continue;
|
|
681
|
-
const message =
|
|
1045
|
+
const message = messages.get(importedName);
|
|
682
1046
|
if (message) context.report({
|
|
683
1047
|
node: specifier,
|
|
684
1048
|
message
|
|
@@ -687,24 +1051,28 @@ const noReact19DeprecatedApis = { create: (context) => {
|
|
|
687
1051
|
}
|
|
688
1052
|
if (specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") {
|
|
689
1053
|
const localName = specifier.local?.name;
|
|
690
|
-
if (localName)
|
|
1054
|
+
if (localName) namespaceBindings.add(localName);
|
|
691
1055
|
}
|
|
692
1056
|
}
|
|
693
1057
|
},
|
|
694
1058
|
MemberExpression(node) {
|
|
695
|
-
if (
|
|
1059
|
+
if (namespaceBindings.size === 0) return;
|
|
696
1060
|
if (node.computed) return;
|
|
697
1061
|
if (node.object?.type !== "Identifier") return;
|
|
698
|
-
if (!
|
|
1062
|
+
if (!namespaceBindings.has(node.object.name)) return;
|
|
699
1063
|
if (node.property?.type !== "Identifier") return;
|
|
700
|
-
const message =
|
|
1064
|
+
const message = messages.get(node.property.name);
|
|
701
1065
|
if (message) context.report({
|
|
702
1066
|
node,
|
|
703
1067
|
message
|
|
704
1068
|
});
|
|
705
1069
|
}
|
|
706
1070
|
};
|
|
707
|
-
} };
|
|
1071
|
+
} });
|
|
1072
|
+
const noReact19DeprecatedApis = createDeprecatedReactImportRule({
|
|
1073
|
+
source: "react",
|
|
1074
|
+
messages: REACT_19_DEPRECATED_MESSAGES
|
|
1075
|
+
});
|
|
708
1076
|
const RENDER_PROP_PATTERN = /^render[A-Z]/;
|
|
709
1077
|
const noRenderPropChildren = { create: (context) => ({ JSXOpeningElement(node) {
|
|
710
1078
|
const renderPropAttrs = [];
|
|
@@ -804,6 +1172,148 @@ const reactCompilerDestructureMethod = { create: (context) => {
|
|
|
804
1172
|
}
|
|
805
1173
|
};
|
|
806
1174
|
} };
|
|
1175
|
+
const LEGACY_LIFECYCLE_REPLACEMENTS = new Map([
|
|
1176
|
+
["componentWillMount", "Move side effects to `componentDidMount`; move initial state to `constructor`"],
|
|
1177
|
+
["componentWillReceiveProps", "Move side effects to `componentDidUpdate` (compare prevProps); move pure state derivation to the static `getDerivedStateFromProps`"],
|
|
1178
|
+
["componentWillUpdate", "Move DOM reads to `getSnapshotBeforeUpdate` (passes the value to `componentDidUpdate`); move other work to `componentDidUpdate`"]
|
|
1179
|
+
]);
|
|
1180
|
+
const stripUnsafePrefix = (name) => {
|
|
1181
|
+
if (name.startsWith("UNSAFE_")) return {
|
|
1182
|
+
baseName: name.slice(7),
|
|
1183
|
+
hasUnsafePrefix: true
|
|
1184
|
+
};
|
|
1185
|
+
return {
|
|
1186
|
+
baseName: name,
|
|
1187
|
+
hasUnsafePrefix: false
|
|
1188
|
+
};
|
|
1189
|
+
};
|
|
1190
|
+
const buildLegacyLifecycleMessage = (originalName) => {
|
|
1191
|
+
const { baseName, hasUnsafePrefix } = stripUnsafePrefix(originalName);
|
|
1192
|
+
const replacement = LEGACY_LIFECYCLE_REPLACEMENTS.get(baseName);
|
|
1193
|
+
if (!replacement) return null;
|
|
1194
|
+
return `${hasUnsafePrefix ? `\`${originalName}\` is removed in React 19 (the UNSAFE_ prefix only silences the React 18 warning, it doesn't fix the concurrent-mode hazard).` : `\`${originalName}\` is removed in React 19 and warns in React 18.3.1.`} ${replacement}.`;
|
|
1195
|
+
};
|
|
1196
|
+
const noLegacyClassLifecycles = { create: (context) => {
|
|
1197
|
+
const checkMember = (memberNode) => {
|
|
1198
|
+
if (!memberNode) return;
|
|
1199
|
+
if (memberNode.type !== "MethodDefinition" && memberNode.type !== "PropertyDefinition") return;
|
|
1200
|
+
if (memberNode.key?.type !== "Identifier") return;
|
|
1201
|
+
const message = buildLegacyLifecycleMessage(memberNode.key.name);
|
|
1202
|
+
if (message) context.report({
|
|
1203
|
+
node: memberNode.key,
|
|
1204
|
+
message
|
|
1205
|
+
});
|
|
1206
|
+
};
|
|
1207
|
+
return { ClassBody(node) {
|
|
1208
|
+
for (const member of node.body ?? []) checkMember(member);
|
|
1209
|
+
} };
|
|
1210
|
+
} };
|
|
1211
|
+
const LEGACY_CONTEXT_NAMES = new Set([
|
|
1212
|
+
"childContextTypes",
|
|
1213
|
+
"contextTypes",
|
|
1214
|
+
"getChildContext"
|
|
1215
|
+
]);
|
|
1216
|
+
const buildLegacyContextMessage = (memberName) => {
|
|
1217
|
+
if (memberName === "childContextTypes" || memberName === "getChildContext") return `${memberName} is part of the legacy context API (REMOVED in React 19). Replace the provider with \`createContext\` + \`<MyContext.Provider value={...}>\` and consume via \`useContext()\` (or \`use()\` on React 19+) — every consumer must migrate together`;
|
|
1218
|
+
return "contextTypes is part of the legacy context API (REMOVED in React 19). Replace with `static contextType = MyContext` (single context) or read the modern context with `useContext()` / `use()` from a function component — coordinate with the provider's migration";
|
|
1219
|
+
};
|
|
1220
|
+
const isInsideClassBody = (node) => {
|
|
1221
|
+
let current = node.parent;
|
|
1222
|
+
while (current) {
|
|
1223
|
+
if (current.type === "ClassBody") return true;
|
|
1224
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") return false;
|
|
1225
|
+
current = current.parent;
|
|
1226
|
+
}
|
|
1227
|
+
return false;
|
|
1228
|
+
};
|
|
1229
|
+
const noLegacyContextApi = { create: (context) => {
|
|
1230
|
+
const checkMember = (memberNode) => {
|
|
1231
|
+
if (!memberNode) return;
|
|
1232
|
+
if (memberNode.type !== "MethodDefinition" && memberNode.type !== "PropertyDefinition") return;
|
|
1233
|
+
if (memberNode.key?.type !== "Identifier") return;
|
|
1234
|
+
if (!LEGACY_CONTEXT_NAMES.has(memberNode.key.name)) return;
|
|
1235
|
+
context.report({
|
|
1236
|
+
node: memberNode.key,
|
|
1237
|
+
message: buildLegacyContextMessage(memberNode.key.name)
|
|
1238
|
+
});
|
|
1239
|
+
};
|
|
1240
|
+
return {
|
|
1241
|
+
ClassBody(node) {
|
|
1242
|
+
for (const member of node.body ?? []) checkMember(member);
|
|
1243
|
+
},
|
|
1244
|
+
AssignmentExpression(node) {
|
|
1245
|
+
if (node.operator !== "=") return;
|
|
1246
|
+
const left = node.left;
|
|
1247
|
+
if (left?.type !== "MemberExpression") return;
|
|
1248
|
+
if (left.computed) return;
|
|
1249
|
+
if (left.property?.type !== "Identifier") return;
|
|
1250
|
+
if (!LEGACY_CONTEXT_NAMES.has(left.property.name)) return;
|
|
1251
|
+
if (left.object?.type !== "Identifier") return;
|
|
1252
|
+
if (!isUppercaseName(left.object.name)) return;
|
|
1253
|
+
if (isInsideClassBody(node)) return;
|
|
1254
|
+
context.report({
|
|
1255
|
+
node: left,
|
|
1256
|
+
message: buildLegacyContextMessage(left.property.name)
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
} };
|
|
1261
|
+
const noDefaultProps = { create: (context) => ({ AssignmentExpression(node) {
|
|
1262
|
+
if (node.operator !== "=") return;
|
|
1263
|
+
const left = node.left;
|
|
1264
|
+
if (left?.type !== "MemberExpression") return;
|
|
1265
|
+
if (left.computed) return;
|
|
1266
|
+
if (left.property?.type !== "Identifier" || left.property.name !== "defaultProps") return;
|
|
1267
|
+
if (left.object?.type !== "Identifier") return;
|
|
1268
|
+
if (!isUppercaseName(left.object.name)) return;
|
|
1269
|
+
context.report({
|
|
1270
|
+
node: left,
|
|
1271
|
+
message: `${left.object.name}.defaultProps — React 19 removes \`defaultProps\` for function components and discourages it for class components. Move defaults into the destructured props parameter (e.g. \`function ${left.object.name}({ size = "md", ...rest })\`) so the rule applies cleanly to both shapes`
|
|
1272
|
+
});
|
|
1273
|
+
} }) };
|
|
1274
|
+
const REACT_DOM_DEPRECATED_MESSAGES = new Map([
|
|
1275
|
+
["render", "ReactDOM.render is the legacy root API — switch to `import { createRoot } from 'react-dom/client'` and call `createRoot(container).render(...)` (REMOVED in React 19)"],
|
|
1276
|
+
["hydrate", "ReactDOM.hydrate is the legacy SSR API — switch to `import { hydrateRoot } from 'react-dom/client'` and call `hydrateRoot(container, <App />)` (REMOVED in React 19)"],
|
|
1277
|
+
["unmountComponentAtNode", "ReactDOM.unmountComponentAtNode no longer works on roots created with `createRoot` — keep a reference to the root and call `root.unmount()` instead (REMOVED in React 19)"],
|
|
1278
|
+
["findDOMNode", "ReactDOM.findDOMNode crawls the rendered tree and breaks composition — accept a ref directly and read `ref.current` (REMOVED in React 19)"]
|
|
1279
|
+
]);
|
|
1280
|
+
const REACT_DOM_TEST_UTILS_REPLACEMENTS = new Map([
|
|
1281
|
+
["act", "`import { act } from 'react'` instead"],
|
|
1282
|
+
["Simulate", "`fireEvent` from `@testing-library/react` instead"],
|
|
1283
|
+
["renderIntoDocument", "`render` from `@testing-library/react` instead"],
|
|
1284
|
+
["findRenderedDOMComponentWithTag", "`getByRole` / `getByTestId` from `@testing-library/react`"],
|
|
1285
|
+
["findRenderedDOMComponentWithClass", "`getByRole` or `container.querySelector` from RTL"],
|
|
1286
|
+
["scryRenderedDOMComponentsWithTag", "`getAllByRole` from `@testing-library/react`"]
|
|
1287
|
+
]);
|
|
1288
|
+
const buildTestUtilsMessage = (importedName) => {
|
|
1289
|
+
const replacement = REACT_DOM_TEST_UTILS_REPLACEMENTS.get(importedName);
|
|
1290
|
+
return `react-dom/test-utils is removed in React 19. ${replacement ? `Use ${replacement}.` : "Switch to `act` from `react` or the equivalent in `@testing-library/react`."}`;
|
|
1291
|
+
};
|
|
1292
|
+
const reportTestUtilsImports = (node, context) => {
|
|
1293
|
+
for (const specifier of node.specifiers ?? []) {
|
|
1294
|
+
if (specifier.type === "ImportSpecifier") {
|
|
1295
|
+
const importedName = specifier.imported?.name ?? "default";
|
|
1296
|
+
context.report({
|
|
1297
|
+
node: specifier,
|
|
1298
|
+
message: buildTestUtilsMessage(importedName)
|
|
1299
|
+
});
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
context.report({
|
|
1303
|
+
node: specifier,
|
|
1304
|
+
message: "react-dom/test-utils is removed in React 19. Use `act` from `react` and `fireEvent` / `render` from `@testing-library/react` instead"
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
const noReactDomDeprecatedApis = createDeprecatedReactImportRule({
|
|
1309
|
+
source: "react-dom",
|
|
1310
|
+
messages: REACT_DOM_DEPRECATED_MESSAGES,
|
|
1311
|
+
handleExtraSource: (node, context) => {
|
|
1312
|
+
if (node.source?.value !== "react-dom/test-utils") return false;
|
|
1313
|
+
reportTestUtilsImports(node, context);
|
|
1314
|
+
return true;
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
807
1317
|
//#endregion
|
|
808
1318
|
//#region src/plugin/rules/bundle-size.ts
|
|
809
1319
|
const noBarrelImport = { create: (context) => {
|
|
@@ -1076,12 +1586,12 @@ const isBackgroundDark = (bgValue) => {
|
|
|
1076
1586
|
if (!parsed) return false;
|
|
1077
1587
|
return parsed.red <= 35 && parsed.green <= 35 && parsed.blue <= 35;
|
|
1078
1588
|
};
|
|
1079
|
-
const BORDER_SIDE_KEYS =
|
|
1080
|
-
borderLeft
|
|
1081
|
-
borderRight
|
|
1082
|
-
borderInlineStart
|
|
1083
|
-
borderInlineEnd
|
|
1084
|
-
|
|
1589
|
+
const BORDER_SIDE_KEYS = new Map([
|
|
1590
|
+
["borderLeft", "left"],
|
|
1591
|
+
["borderRight", "right"],
|
|
1592
|
+
["borderInlineStart", "left"],
|
|
1593
|
+
["borderInlineEnd", "right"]
|
|
1594
|
+
]);
|
|
1085
1595
|
const BORDER_SIDE_WIDTH_KEYS = new Set([
|
|
1086
1596
|
"borderLeftWidth",
|
|
1087
1597
|
"borderRightWidth",
|
|
@@ -1171,7 +1681,8 @@ const noSideTabBorder = { create: (context) => ({
|
|
|
1171
1681
|
for (const property of expression.properties ?? []) {
|
|
1172
1682
|
const key = getStylePropertyKey(property);
|
|
1173
1683
|
if (!key) continue;
|
|
1174
|
-
|
|
1684
|
+
const sideLabel = BORDER_SIDE_KEYS.get(key);
|
|
1685
|
+
if (sideLabel !== void 0) {
|
|
1175
1686
|
const value = getStylePropertyStringValue(property);
|
|
1176
1687
|
if (!value) continue;
|
|
1177
1688
|
const widthMatch = value.match(/^(\d+)px\s+solid/);
|
|
@@ -1181,7 +1692,7 @@ const noSideTabBorder = { create: (context) => ({
|
|
|
1181
1692
|
const width = parseInt(widthMatch[1], 10);
|
|
1182
1693
|
if (width >= threshold) context.report({
|
|
1183
1694
|
node: property,
|
|
1184
|
-
message: `Thick one-sided border (${
|
|
1695
|
+
message: `Thick one-sided border (${sideLabel}: ${width}px) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
|
|
1185
1696
|
});
|
|
1186
1697
|
}
|
|
1187
1698
|
if (BORDER_SIDE_WIDTH_KEYS.has(key)) {
|
|
@@ -1507,10 +2018,7 @@ const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1507
2018
|
message: `Array index "${indexName}" used as key — causes bugs when list is reordered or filtered`
|
|
1508
2019
|
});
|
|
1509
2020
|
} }) };
|
|
1510
|
-
const PREVENT_DEFAULT_ELEMENTS =
|
|
1511
|
-
form: ["onSubmit"],
|
|
1512
|
-
a: ["onClick"]
|
|
1513
|
-
};
|
|
2021
|
+
const PREVENT_DEFAULT_ELEMENTS = new Map([["form", ["onSubmit"]], ["a", ["onClick"]]]);
|
|
1514
2022
|
const containsPreventDefaultCall = (node) => {
|
|
1515
2023
|
let didFindPreventDefault = false;
|
|
1516
2024
|
walkAst(node, (child) => {
|
|
@@ -1526,7 +2034,7 @@ const buildPreventDefaultMessage = (elementName) => {
|
|
|
1526
2034
|
const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
|
|
1527
2035
|
const elementName = node.name?.type === "JSXIdentifier" ? node.name.name : null;
|
|
1528
2036
|
if (!elementName) return;
|
|
1529
|
-
const targetEventProps = PREVENT_DEFAULT_ELEMENTS
|
|
2037
|
+
const targetEventProps = PREVENT_DEFAULT_ELEMENTS.get(elementName);
|
|
1530
2038
|
if (!targetEventProps) return;
|
|
1531
2039
|
for (const targetEventProp of targetEventProps) {
|
|
1532
2040
|
const eventAttribute = findJsxAttribute(node.attributes ?? [], targetEventProp);
|
|
@@ -1599,6 +2107,98 @@ const renderingSvgPrecision = { create: (context) => ({ JSXAttribute(node) {
|
|
|
1599
2107
|
message: `SVG ${node.name.name} attribute uses 4+ decimal precision — truncate to 1–2 decimals to shrink markup with no visible difference`
|
|
1600
2108
|
});
|
|
1601
2109
|
} }) };
|
|
2110
|
+
const UNCONTROLLED_INPUT_TAGS = new Set([
|
|
2111
|
+
"input",
|
|
2112
|
+
"textarea",
|
|
2113
|
+
"select"
|
|
2114
|
+
]);
|
|
2115
|
+
const VALUE_BYPASS_INPUT_TYPES = new Set([
|
|
2116
|
+
"hidden",
|
|
2117
|
+
"checkbox",
|
|
2118
|
+
"radio"
|
|
2119
|
+
]);
|
|
2120
|
+
const VALUE_PARTNER_ATTRIBUTES = ["onChange", "readOnly"];
|
|
2121
|
+
const getInputTypeLiteral = (attributes) => {
|
|
2122
|
+
const typeAttribute = findJsxAttribute(attributes, "type");
|
|
2123
|
+
if (!typeAttribute || typeAttribute.value?.type !== "Literal") return null;
|
|
2124
|
+
const value = typeAttribute.value.value;
|
|
2125
|
+
return typeof value === "string" ? value : null;
|
|
2126
|
+
};
|
|
2127
|
+
const isUseStateUndefinedInitializer = (init) => {
|
|
2128
|
+
if (!init || init.type !== "CallExpression") return false;
|
|
2129
|
+
if (!isHookCall(init, "useState")) return false;
|
|
2130
|
+
const args = init.arguments ?? [];
|
|
2131
|
+
if (args.length === 0) return true;
|
|
2132
|
+
const firstArgument = args[0];
|
|
2133
|
+
return firstArgument?.type === "Identifier" && firstArgument.name === "undefined";
|
|
2134
|
+
};
|
|
2135
|
+
const collectUndefinedInitialStateNames = (componentBody) => {
|
|
2136
|
+
const stateNames = /* @__PURE__ */ new Set();
|
|
2137
|
+
if (componentBody?.type !== "BlockStatement") return stateNames;
|
|
2138
|
+
for (const statement of componentBody.body ?? []) {
|
|
2139
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
2140
|
+
for (const declarator of statement.declarations ?? []) {
|
|
2141
|
+
if (declarator.id?.type !== "ArrayPattern") continue;
|
|
2142
|
+
const valueElement = declarator.id.elements?.[0];
|
|
2143
|
+
if (valueElement?.type !== "Identifier") continue;
|
|
2144
|
+
if (!isUseStateUndefinedInitializer(declarator.init)) continue;
|
|
2145
|
+
stateNames.add(valueElement.name);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return stateNames;
|
|
2149
|
+
};
|
|
2150
|
+
const hasJsxSpreadAttribute = (attributes) => attributes.some((attribute) => attribute.type === "JSXSpreadAttribute");
|
|
2151
|
+
const noUncontrolledInput = { create: (context) => {
|
|
2152
|
+
const checkComponent = (componentBody) => {
|
|
2153
|
+
if (!componentBody) return;
|
|
2154
|
+
const undefinedInitialStateNames = componentBody.type === "BlockStatement" ? collectUndefinedInitialStateNames(componentBody) : /* @__PURE__ */ new Set();
|
|
2155
|
+
walkAst(componentBody, (child) => {
|
|
2156
|
+
if (child.type !== "JSXOpeningElement") return;
|
|
2157
|
+
if (child.name?.type !== "JSXIdentifier") return;
|
|
2158
|
+
const tagName = child.name.name;
|
|
2159
|
+
if (!UNCONTROLLED_INPUT_TAGS.has(tagName)) return;
|
|
2160
|
+
const attributes = child.attributes ?? [];
|
|
2161
|
+
if (hasJsxSpreadAttribute(attributes)) return;
|
|
2162
|
+
const valueAttribute = findJsxAttribute(attributes, "value");
|
|
2163
|
+
if (!valueAttribute) return;
|
|
2164
|
+
if (tagName === "input") {
|
|
2165
|
+
const inputType = getInputTypeLiteral(attributes);
|
|
2166
|
+
if (inputType !== null && VALUE_BYPASS_INPUT_TYPES.has(inputType)) return;
|
|
2167
|
+
}
|
|
2168
|
+
const hasAllowedPartner = VALUE_PARTNER_ATTRIBUTES.some((partnerAttributeName) => findJsxAttribute(attributes, partnerAttributeName));
|
|
2169
|
+
if (valueAttribute.value?.type === "JSXExpressionContainer" && valueAttribute.value.expression?.type === "Identifier" && undefinedInitialStateNames.has(valueAttribute.value.expression.name)) {
|
|
2170
|
+
const stateName = valueAttribute.value.expression.name;
|
|
2171
|
+
const partnerHint = hasAllowedPartner ? "Initialize useState with an explicit value" : "Initialize useState with an explicit value AND add onChange (or readOnly)";
|
|
2172
|
+
context.report({
|
|
2173
|
+
node: child,
|
|
2174
|
+
message: `<${tagName} value={${stateName}}> — "${stateName}" is initialized as undefined (uncontrolled), then becomes controlled on first set; React warns about this flip. ${partnerHint} (e.g. \`useState("")\`)`
|
|
2175
|
+
});
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (findJsxAttribute(attributes, "defaultValue")) {
|
|
2179
|
+
context.report({
|
|
2180
|
+
node: child,
|
|
2181
|
+
message: `<${tagName}> sets both \`value\` and \`defaultValue\` — defaultValue is ignored on a controlled input; remove one`
|
|
2182
|
+
});
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
if (!hasAllowedPartner) context.report({
|
|
2186
|
+
node: child,
|
|
2187
|
+
message: `<${tagName} value={...}> with no \`onChange\` or \`readOnly\` — React renders this as a silently read-only field`
|
|
2188
|
+
});
|
|
2189
|
+
});
|
|
2190
|
+
};
|
|
2191
|
+
return {
|
|
2192
|
+
FunctionDeclaration(node) {
|
|
2193
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
2194
|
+
checkComponent(node.body);
|
|
2195
|
+
},
|
|
2196
|
+
VariableDeclarator(node) {
|
|
2197
|
+
if (!isComponentAssignment(node)) return;
|
|
2198
|
+
checkComponent(node.init?.body);
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
} };
|
|
1602
2202
|
//#endregion
|
|
1603
2203
|
//#region src/plugin/rules/js-performance.ts
|
|
1604
2204
|
const jsCombineIterations = { create: (context) => ({ CallExpression(node) {
|
|
@@ -2565,23 +3165,23 @@ const rerenderMemoBeforeEarlyReturn = { create: (context) => {
|
|
|
2565
3165
|
const NONDETERMINISTIC_RENDER_PATTERNS = [
|
|
2566
3166
|
{
|
|
2567
3167
|
display: "new Date()",
|
|
2568
|
-
matches: (
|
|
3168
|
+
matches: (node) => node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "Date"
|
|
2569
3169
|
},
|
|
2570
3170
|
{
|
|
2571
3171
|
display: "Date.now()",
|
|
2572
|
-
matches: (
|
|
3172
|
+
matches: (node) => node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Date" && node.callee.property?.type === "Identifier" && node.callee.property.name === "now"
|
|
2573
3173
|
},
|
|
2574
3174
|
{
|
|
2575
3175
|
display: "Math.random()",
|
|
2576
|
-
matches: (
|
|
3176
|
+
matches: (node) => node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Math" && node.callee.property?.type === "Identifier" && node.callee.property.name === "random"
|
|
2577
3177
|
},
|
|
2578
3178
|
{
|
|
2579
3179
|
display: "performance.now()",
|
|
2580
|
-
matches: (
|
|
3180
|
+
matches: (node) => node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "performance" && node.callee.property?.type === "Identifier" && node.callee.property.name === "now"
|
|
2581
3181
|
},
|
|
2582
3182
|
{
|
|
2583
3183
|
display: "crypto.randomUUID()",
|
|
2584
|
-
matches: (
|
|
3184
|
+
matches: (node) => node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "crypto" && node.callee.property?.type === "Identifier" && node.callee.property.name === "randomUUID"
|
|
2585
3185
|
}
|
|
2586
3186
|
];
|
|
2587
3187
|
const findOpeningElementOfChild = (jsxNode) => {
|
|
@@ -2664,7 +3264,7 @@ const renderingHydrationMismatchTime = { create: (context) => ({ JSXExpressionCo
|
|
|
2664
3264
|
}
|
|
2665
3265
|
});
|
|
2666
3266
|
} }) };
|
|
2667
|
-
const collectIdentifierNames = (node, into) => {
|
|
3267
|
+
const collectIdentifierNames$1 = (node, into) => {
|
|
2668
3268
|
if (!node) return;
|
|
2669
3269
|
walkAst(node, (child) => {
|
|
2670
3270
|
if (child.type === "Identifier") into.add(child.name);
|
|
@@ -2697,10 +3297,10 @@ const asyncDeferAwait = { create: (context) => {
|
|
|
2697
3297
|
const nextStatement = statements[statementIndex + 1];
|
|
2698
3298
|
if (!isEarlyReturnIfStatement(nextStatement)) continue;
|
|
2699
3299
|
const testIdentifiers = /* @__PURE__ */ new Set();
|
|
2700
|
-
collectIdentifierNames(nextStatement.test, testIdentifiers);
|
|
3300
|
+
collectIdentifierNames$1(nextStatement.test, testIdentifiers);
|
|
2701
3301
|
if ([...awaitedBindingNames].some((name) => testIdentifiers.has(name))) continue;
|
|
2702
3302
|
const consequentIdentifiers = /* @__PURE__ */ new Set();
|
|
2703
|
-
collectIdentifierNames(nextStatement.consequent, consequentIdentifiers);
|
|
3303
|
+
collectIdentifierNames$1(nextStatement.consequent, consequentIdentifiers);
|
|
2704
3304
|
if ([...awaitedBindingNames].some((name) => consequentIdentifiers.has(name))) continue;
|
|
2705
3305
|
context.report({
|
|
2706
3306
|
node: currentStatement,
|
|
@@ -2792,73 +3392,313 @@ const rerenderDerivedStateFromHook = { create: (context) => {
|
|
|
2792
3392
|
};
|
|
2793
3393
|
} };
|
|
2794
3394
|
//#endregion
|
|
2795
|
-
//#region src/plugin/rules/react-
|
|
2796
|
-
const
|
|
2797
|
-
|
|
2798
|
-
if (
|
|
2799
|
-
if (
|
|
2800
|
-
|
|
3395
|
+
//#region src/plugin/rules/react-ui.ts
|
|
3396
|
+
const getOpeningElementTagName = (openingElement) => {
|
|
3397
|
+
if (!openingElement) return null;
|
|
3398
|
+
if (openingElement.name?.type === "JSXIdentifier") return openingElement.name.name;
|
|
3399
|
+
if (openingElement.name?.type === "JSXMemberExpression") {
|
|
3400
|
+
let cursor = openingElement.name;
|
|
3401
|
+
while (cursor.type === "JSXMemberExpression") cursor = cursor.property;
|
|
3402
|
+
if (cursor?.type === "JSXIdentifier") return cursor.name;
|
|
3403
|
+
}
|
|
2801
3404
|
return null;
|
|
2802
3405
|
};
|
|
2803
|
-
const
|
|
2804
|
-
|
|
2805
|
-
if (
|
|
2806
|
-
if (
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
const getRawTextDescription = (child) => {
|
|
2811
|
-
if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
|
|
2812
|
-
if (child.type === "JSXExpressionContainer" && child.expression) {
|
|
2813
|
-
const expression = child.expression;
|
|
2814
|
-
if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
|
|
2815
|
-
if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
|
|
2816
|
-
if (expression.type === "TemplateLiteral") return "template literal";
|
|
3406
|
+
const getClassNameLiteral = (classAttribute) => {
|
|
3407
|
+
if (!classAttribute.value) return null;
|
|
3408
|
+
if (classAttribute.value.type === "Literal" && typeof classAttribute.value.value === "string") return classAttribute.value.value;
|
|
3409
|
+
if (classAttribute.value.type === "JSXExpressionContainer") {
|
|
3410
|
+
const expression = classAttribute.value.expression;
|
|
3411
|
+
if (expression?.type === "Literal" && typeof expression.value === "string") return expression.value;
|
|
3412
|
+
if (expression?.type === "TemplateLiteral" && expression.quasis?.length === 1) return expression.quasis[0].value?.raw ?? null;
|
|
2817
3413
|
}
|
|
2818
|
-
return
|
|
3414
|
+
return null;
|
|
2819
3415
|
};
|
|
2820
|
-
const
|
|
2821
|
-
|
|
2822
|
-
|
|
3416
|
+
const tokenizeClassName = (classNameValue) => classNameValue.split(/\s+/).filter(Boolean);
|
|
3417
|
+
const getInlineStyleObjectExpression = (jsxAttribute) => {
|
|
3418
|
+
if (jsxAttribute.name?.type !== "JSXIdentifier" || jsxAttribute.name.name !== "style") return null;
|
|
3419
|
+
if (jsxAttribute.value?.type !== "JSXExpressionContainer") return null;
|
|
3420
|
+
const expression = jsxAttribute.value.expression;
|
|
3421
|
+
if (expression?.type !== "ObjectExpression") return null;
|
|
3422
|
+
return expression;
|
|
2823
3423
|
};
|
|
2824
|
-
const
|
|
2825
|
-
|
|
2826
|
-
return
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
3424
|
+
const getStylePropertyKeyName = (objectProperty) => {
|
|
3425
|
+
if (objectProperty.type !== "Property") return null;
|
|
3426
|
+
if (objectProperty.key?.type === "Identifier") return objectProperty.key.name;
|
|
3427
|
+
if (objectProperty.key?.type === "Literal" && typeof objectProperty.key.value === "string") return objectProperty.key.value;
|
|
3428
|
+
return null;
|
|
3429
|
+
};
|
|
3430
|
+
const getStylePropertyNumericValue = (objectProperty) => {
|
|
3431
|
+
const valueNode = objectProperty.value;
|
|
3432
|
+
if (!valueNode) return null;
|
|
3433
|
+
if (valueNode.type === "Literal" && typeof valueNode.value === "number") return valueNode.value;
|
|
3434
|
+
if (valueNode.type === "Literal" && typeof valueNode.value === "string") {
|
|
3435
|
+
const parsed = parseFloat(valueNode.value);
|
|
3436
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
3437
|
+
}
|
|
3438
|
+
return null;
|
|
3439
|
+
};
|
|
3440
|
+
const noBoldHeading = { create: (context) => ({ JSXOpeningElement(openingNode) {
|
|
3441
|
+
const tagName = getOpeningElementTagName(openingNode);
|
|
3442
|
+
if (!tagName || !HEADING_TAG_NAMES.has(tagName)) return;
|
|
3443
|
+
const classAttribute = findJsxAttribute(openingNode.attributes ?? [], "className");
|
|
3444
|
+
if (classAttribute) {
|
|
3445
|
+
const classNameLiteral = getClassNameLiteral(classAttribute);
|
|
3446
|
+
if (classNameLiteral) {
|
|
3447
|
+
for (const tailwindWeightToken of HEAVY_HEADING_TAILWIND_WEIGHTS) if (new RegExp(`(?:^|\\s)${tailwindWeightToken}(?:$|\\s|:)`).test(classNameLiteral)) {
|
|
2836
3448
|
context.report({
|
|
2837
|
-
node:
|
|
2838
|
-
message:
|
|
3449
|
+
node: classAttribute,
|
|
3450
|
+
message: `${tailwindWeightToken} on <${tagName}> crushes counter shapes at display sizes — use font-semibold (600) or font-medium (500)`
|
|
2839
3451
|
});
|
|
3452
|
+
return;
|
|
2840
3453
|
}
|
|
2841
3454
|
}
|
|
2842
|
-
};
|
|
2843
|
-
} };
|
|
2844
|
-
const rnNoDeprecatedModules = { create: (context) => ({ ImportDeclaration(node) {
|
|
2845
|
-
if (node.source?.value !== "react-native") return;
|
|
2846
|
-
for (const specifier of node.specifiers ?? []) {
|
|
2847
|
-
if (specifier.type !== "ImportSpecifier") continue;
|
|
2848
|
-
const importedName = specifier.imported?.name;
|
|
2849
|
-
if (!importedName) continue;
|
|
2850
|
-
const replacement = DEPRECATED_RN_MODULE_REPLACEMENTS[importedName];
|
|
2851
|
-
if (!replacement) continue;
|
|
2852
|
-
context.report({
|
|
2853
|
-
node: specifier,
|
|
2854
|
-
message: `"${importedName}" was removed from react-native — use ${replacement} instead`
|
|
2855
|
-
});
|
|
2856
3455
|
}
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
const
|
|
2860
|
-
if (
|
|
2861
|
-
for (const
|
|
3456
|
+
const styleAttribute = findJsxAttribute(openingNode.attributes ?? [], "style");
|
|
3457
|
+
if (!styleAttribute) return;
|
|
3458
|
+
const styleObject = getInlineStyleObjectExpression(styleAttribute);
|
|
3459
|
+
if (!styleObject) return;
|
|
3460
|
+
for (const objectProperty of styleObject.properties ?? []) {
|
|
3461
|
+
if (getStylePropertyKeyName(objectProperty) !== "fontWeight") continue;
|
|
3462
|
+
const numericWeight = getStylePropertyNumericValue(objectProperty);
|
|
3463
|
+
if (numericWeight !== null && numericWeight >= 700) {
|
|
3464
|
+
context.report({
|
|
3465
|
+
node: objectProperty,
|
|
3466
|
+
message: `fontWeight: ${numericWeight} on <${tagName}> crushes counter shapes at display sizes — use 500 or 600`
|
|
3467
|
+
});
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
} }) };
|
|
3472
|
+
const collectAxisShorthandPairs = (classNameValue, horizontalPattern, verticalPattern) => {
|
|
3473
|
+
const horizontalValues = /* @__PURE__ */ new Set();
|
|
3474
|
+
for (const horizontalMatch of classNameValue.matchAll(horizontalPattern)) horizontalValues.add(`${horizontalMatch[1]}${horizontalMatch[2]}`);
|
|
3475
|
+
const matchedPairs = [];
|
|
3476
|
+
for (const verticalMatch of classNameValue.matchAll(verticalPattern)) {
|
|
3477
|
+
const verticalValue = `${verticalMatch[1]}${verticalMatch[2]}`;
|
|
3478
|
+
if (horizontalValues.has(verticalValue)) matchedPairs.push({ value: verticalValue });
|
|
3479
|
+
}
|
|
3480
|
+
return matchedPairs;
|
|
3481
|
+
};
|
|
3482
|
+
const hasResponsivePrefix = (classNameValue, axisPrefix) => new RegExp(`(?:^|\\s)\\w+:${axisPrefix}-`).test(classNameValue);
|
|
3483
|
+
const noRedundantPaddingAxes = { create: (context) => ({ JSXAttribute(jsxAttribute) {
|
|
3484
|
+
if (jsxAttribute.name?.type !== "JSXIdentifier" || jsxAttribute.name.name !== "className") return;
|
|
3485
|
+
const classNameLiteral = getClassNameLiteral(jsxAttribute);
|
|
3486
|
+
if (!classNameLiteral) return;
|
|
3487
|
+
if (hasResponsivePrefix(classNameLiteral, "px") || hasResponsivePrefix(classNameLiteral, "py")) return;
|
|
3488
|
+
const matchedPairs = collectAxisShorthandPairs(classNameLiteral, PADDING_HORIZONTAL_AXIS_PATTERN, PADDING_VERTICAL_AXIS_PATTERN);
|
|
3489
|
+
if (matchedPairs.length === 0) return;
|
|
3490
|
+
for (const matchedPair of matchedPairs) context.report({
|
|
3491
|
+
node: jsxAttribute,
|
|
3492
|
+
message: `px-${matchedPair.value} py-${matchedPair.value} → use the shorthand p-${matchedPair.value}`
|
|
3493
|
+
});
|
|
3494
|
+
} }) };
|
|
3495
|
+
const noRedundantSizeAxes = { create: (context) => ({ JSXAttribute(jsxAttribute) {
|
|
3496
|
+
if (jsxAttribute.name?.type !== "JSXIdentifier" || jsxAttribute.name.name !== "className") return;
|
|
3497
|
+
const classNameLiteral = getClassNameLiteral(jsxAttribute);
|
|
3498
|
+
if (!classNameLiteral) return;
|
|
3499
|
+
if (hasResponsivePrefix(classNameLiteral, "w") || hasResponsivePrefix(classNameLiteral, "h")) return;
|
|
3500
|
+
const matchedPairs = collectAxisShorthandPairs(classNameLiteral, SIZE_WIDTH_AXIS_PATTERN, SIZE_HEIGHT_AXIS_PATTERN);
|
|
3501
|
+
if (matchedPairs.length === 0) return;
|
|
3502
|
+
for (const matchedPair of matchedPairs) context.report({
|
|
3503
|
+
node: jsxAttribute,
|
|
3504
|
+
message: `w-${matchedPair.value} h-${matchedPair.value} → use the shorthand size-${matchedPair.value} (Tailwind v3.4+)`
|
|
3505
|
+
});
|
|
3506
|
+
} }) };
|
|
3507
|
+
const noSpaceOnFlexChildren = { create: (context) => ({ JSXAttribute(jsxAttribute) {
|
|
3508
|
+
if (jsxAttribute.name?.type !== "JSXIdentifier" || jsxAttribute.name.name !== "className") return;
|
|
3509
|
+
const classNameLiteral = getClassNameLiteral(jsxAttribute);
|
|
3510
|
+
if (!classNameLiteral) return;
|
|
3511
|
+
const tokens = tokenizeClassName(classNameLiteral);
|
|
3512
|
+
let hasFlexOrGridLayout = false;
|
|
3513
|
+
for (const token of tokens) {
|
|
3514
|
+
const lastSegment = token.includes(":") ? token.slice(token.lastIndexOf(":") + 1) : token;
|
|
3515
|
+
if (FLEX_OR_GRID_DISPLAY_TOKENS.has(lastSegment)) {
|
|
3516
|
+
hasFlexOrGridLayout = true;
|
|
3517
|
+
break;
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
if (!hasFlexOrGridLayout) return;
|
|
3521
|
+
const spaceMatch = classNameLiteral.match(SPACE_AXIS_PATTERN);
|
|
3522
|
+
if (!spaceMatch) return;
|
|
3523
|
+
const spaceAxis = spaceMatch[1];
|
|
3524
|
+
const spaceValue = spaceMatch[2];
|
|
3525
|
+
context.report({
|
|
3526
|
+
node: jsxAttribute,
|
|
3527
|
+
message: `space-${spaceAxis}-${spaceValue} on a flex/grid parent — use gap-${spaceAxis}-${spaceValue} instead. Per-sibling margins phantom-gap on conditional render and don't mirror in RTL`
|
|
3528
|
+
});
|
|
3529
|
+
} }) };
|
|
3530
|
+
const isInsideExcludedAncestor = (jsxTextNode) => {
|
|
3531
|
+
let cursor = jsxTextNode.parent;
|
|
3532
|
+
while (cursor) {
|
|
3533
|
+
if (cursor.type === "JSXElement") {
|
|
3534
|
+
const tagName = getOpeningElementTagName(cursor.openingElement);
|
|
3535
|
+
if (tagName && ELLIPSIS_EXCLUDED_TAG_NAMES.has(tagName.toLowerCase())) return true;
|
|
3536
|
+
const translateAttribute = findJsxAttribute(cursor.openingElement?.attributes ?? [], "translate");
|
|
3537
|
+
if (translateAttribute?.value?.type === "Literal" && translateAttribute.value.value === "no") return true;
|
|
3538
|
+
}
|
|
3539
|
+
cursor = cursor.parent;
|
|
3540
|
+
}
|
|
3541
|
+
return false;
|
|
3542
|
+
};
|
|
3543
|
+
const noEmDashInJsxText = { create: (context) => ({ JSXText(jsxTextNode) {
|
|
3544
|
+
if (!(typeof jsxTextNode.value === "string" ? jsxTextNode.value : "").includes("—")) return;
|
|
3545
|
+
if (isInsideExcludedAncestor(jsxTextNode)) return;
|
|
3546
|
+
context.report({
|
|
3547
|
+
node: jsxTextNode,
|
|
3548
|
+
message: "Em dash (—) in JSX text reads as model output — replace with comma, colon, semicolon, or parentheses"
|
|
3549
|
+
});
|
|
3550
|
+
} }) };
|
|
3551
|
+
const noThreePeriodEllipsis = { create: (context) => ({ JSXText(jsxTextNode) {
|
|
3552
|
+
const textValue = typeof jsxTextNode.value === "string" ? jsxTextNode.value : "";
|
|
3553
|
+
if (!TRAILING_THREE_PERIOD_ELLIPSIS_PATTERN.test(textValue)) return;
|
|
3554
|
+
if (isInsideExcludedAncestor(jsxTextNode)) return;
|
|
3555
|
+
context.report({
|
|
3556
|
+
node: jsxTextNode,
|
|
3557
|
+
message: "Three-period ellipsis (\"...\") in JSX text — use the actual ellipsis character \"…\" (or `…`)"
|
|
3558
|
+
});
|
|
3559
|
+
} }) };
|
|
3560
|
+
const buildDefaultPaletteRegex = () => {
|
|
3561
|
+
const utilityPrefixGroup = TAILWIND_PALETTE_UTILITY_PREFIXES.join("|");
|
|
3562
|
+
const paletteNameGroup = TAILWIND_DEFAULT_PALETTE_NAMES.join("|");
|
|
3563
|
+
const paletteStopGroup = TAILWIND_DEFAULT_PALETTE_STOPS.join("|");
|
|
3564
|
+
return new RegExp(`(?:^|\\s|:)(${utilityPrefixGroup})-(${paletteNameGroup})-(${paletteStopGroup})(?=$|[\\s:/])`, "g");
|
|
3565
|
+
};
|
|
3566
|
+
const DEFAULT_PALETTE_REGEX = buildDefaultPaletteRegex();
|
|
3567
|
+
const noDefaultTailwindPalette = { create: (context) => ({ JSXAttribute(jsxAttribute) {
|
|
3568
|
+
if (jsxAttribute.name?.type !== "JSXIdentifier" || jsxAttribute.name.name !== "className") return;
|
|
3569
|
+
const classNameLiteral = getClassNameLiteral(jsxAttribute);
|
|
3570
|
+
if (!classNameLiteral) return;
|
|
3571
|
+
const reportedTokens = /* @__PURE__ */ new Set();
|
|
3572
|
+
for (const paletteMatch of classNameLiteral.matchAll(DEFAULT_PALETTE_REGEX)) {
|
|
3573
|
+
const matchedToken = `${paletteMatch[1]}-${paletteMatch[2]}-${paletteMatch[3]}`;
|
|
3574
|
+
if (reportedTokens.has(matchedToken)) continue;
|
|
3575
|
+
reportedTokens.add(matchedToken);
|
|
3576
|
+
const replacementSuggestion = paletteMatch[2] === "indigo" ? "use your project's brand color or zinc/neutral/stone" : "use zinc (true neutral), neutral (warmer), or stone (warmest)";
|
|
3577
|
+
context.report({
|
|
3578
|
+
node: jsxAttribute,
|
|
3579
|
+
message: `${matchedToken} reads as the Tailwind template default — ${replacementSuggestion}`
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
} }) };
|
|
3583
|
+
const isButtonLikeTagName = (tagName) => {
|
|
3584
|
+
if (tagName === "button") return true;
|
|
3585
|
+
if (tagName === "Button") return true;
|
|
3586
|
+
return false;
|
|
3587
|
+
};
|
|
3588
|
+
const collectJsxLabelText = (jsxElementNode) => {
|
|
3589
|
+
const childList = jsxElementNode.children ?? [];
|
|
3590
|
+
if (childList.length === 0) return null;
|
|
3591
|
+
const collectedFragments = [];
|
|
3592
|
+
for (const childNode of childList) {
|
|
3593
|
+
if (childNode.type === "JSXText") {
|
|
3594
|
+
collectedFragments.push(typeof childNode.value === "string" ? childNode.value : "");
|
|
3595
|
+
continue;
|
|
3596
|
+
}
|
|
3597
|
+
if (childNode.type === "JSXExpressionContainer") {
|
|
3598
|
+
const expression = childNode.expression;
|
|
3599
|
+
if (expression?.type === "Literal" && typeof expression.value === "string") {
|
|
3600
|
+
collectedFragments.push(expression.value);
|
|
3601
|
+
continue;
|
|
3602
|
+
}
|
|
3603
|
+
if (expression?.type === "TemplateLiteral" && expression.quasis?.length === 1) {
|
|
3604
|
+
const rawTemplate = expression.quasis[0].value?.raw;
|
|
3605
|
+
if (typeof rawTemplate === "string" && expression.expressions.length === 0) {
|
|
3606
|
+
collectedFragments.push(rawTemplate);
|
|
3607
|
+
continue;
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
return null;
|
|
3611
|
+
}
|
|
3612
|
+
if (childNode.type === "JSXFragment") {
|
|
3613
|
+
const fragmentLabel = collectJsxLabelText(childNode);
|
|
3614
|
+
if (fragmentLabel === null) return null;
|
|
3615
|
+
collectedFragments.push(fragmentLabel);
|
|
3616
|
+
continue;
|
|
3617
|
+
}
|
|
3618
|
+
if (childNode.type === "JSXElement") return null;
|
|
3619
|
+
}
|
|
3620
|
+
return collectedFragments.join("").trim();
|
|
3621
|
+
};
|
|
3622
|
+
const noVagueButtonLabel = { create: (context) => ({ JSXElement(jsxElementNode) {
|
|
3623
|
+
const tagName = getOpeningElementTagName(jsxElementNode.openingElement);
|
|
3624
|
+
if (!tagName || !isButtonLikeTagName(tagName)) return;
|
|
3625
|
+
const labelText = collectJsxLabelText(jsxElementNode);
|
|
3626
|
+
if (!labelText) return;
|
|
3627
|
+
const normalizedLabel = labelText.toLowerCase().replace(/[.!?…]+$/, "").trim();
|
|
3628
|
+
if (!VAGUE_BUTTON_LABELS.has(normalizedLabel)) return;
|
|
3629
|
+
context.report({
|
|
3630
|
+
node: jsxElementNode.openingElement ?? jsxElementNode,
|
|
3631
|
+
message: `Vague button label "${labelText}" — name the action ("Save changes", "Send invite", "Delete account") so screen readers and hesitant users know what happens`
|
|
3632
|
+
});
|
|
3633
|
+
} }) };
|
|
3634
|
+
//#endregion
|
|
3635
|
+
//#region src/plugin/rules/react-native.ts
|
|
3636
|
+
const resolveJsxElementName = (openingElement) => {
|
|
3637
|
+
const elementName = openingElement?.name;
|
|
3638
|
+
if (!elementName) return null;
|
|
3639
|
+
if (elementName.type === "JSXIdentifier") return elementName.name;
|
|
3640
|
+
if (elementName.type === "JSXMemberExpression") return elementName.property?.name ?? null;
|
|
3641
|
+
return null;
|
|
3642
|
+
};
|
|
3643
|
+
const truncateText = (text) => text.length > 30 ? `${text.slice(0, 30)}...` : text;
|
|
3644
|
+
const isRawTextContent = (child) => {
|
|
3645
|
+
if (child.type === "JSXText") return Boolean(child.value?.trim());
|
|
3646
|
+
if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
|
|
3647
|
+
const expression = child.expression;
|
|
3648
|
+
return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
|
|
3649
|
+
};
|
|
3650
|
+
const getRawTextDescription = (child) => {
|
|
3651
|
+
if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
|
|
3652
|
+
if (child.type === "JSXExpressionContainer" && child.expression) {
|
|
3653
|
+
const expression = child.expression;
|
|
3654
|
+
if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
|
|
3655
|
+
if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
|
|
3656
|
+
if (expression.type === "TemplateLiteral") return "template literal";
|
|
3657
|
+
}
|
|
3658
|
+
return "text content";
|
|
3659
|
+
};
|
|
3660
|
+
const isTextHandlingComponent = (elementName) => {
|
|
3661
|
+
if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
|
|
3662
|
+
return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
|
|
3663
|
+
};
|
|
3664
|
+
const rnNoRawText = { create: (context) => {
|
|
3665
|
+
let isDomComponentFile = false;
|
|
3666
|
+
return {
|
|
3667
|
+
Program(programNode) {
|
|
3668
|
+
isDomComponentFile = hasDirective(programNode, "use dom");
|
|
3669
|
+
},
|
|
3670
|
+
JSXElement(node) {
|
|
3671
|
+
if (isDomComponentFile) return;
|
|
3672
|
+
const elementName = resolveJsxElementName(node.openingElement);
|
|
3673
|
+
if (elementName && isTextHandlingComponent(elementName)) return;
|
|
3674
|
+
for (const child of node.children ?? []) {
|
|
3675
|
+
if (!isRawTextContent(child)) continue;
|
|
3676
|
+
context.report({
|
|
3677
|
+
node: child,
|
|
3678
|
+
message: `Raw ${getRawTextDescription(child)} outside a <Text> component — this will crash on React Native`
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
3683
|
+
} };
|
|
3684
|
+
const rnNoDeprecatedModules = { create: (context) => ({ ImportDeclaration(node) {
|
|
3685
|
+
if (node.source?.value !== "react-native") return;
|
|
3686
|
+
for (const specifier of node.specifiers ?? []) {
|
|
3687
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
3688
|
+
const importedName = specifier.imported?.name;
|
|
3689
|
+
if (!importedName) continue;
|
|
3690
|
+
const replacement = DEPRECATED_RN_MODULE_REPLACEMENTS.get(importedName);
|
|
3691
|
+
if (!replacement) continue;
|
|
3692
|
+
context.report({
|
|
3693
|
+
node: specifier,
|
|
3694
|
+
message: `"${importedName}" was removed from react-native — use ${replacement} instead`
|
|
3695
|
+
});
|
|
3696
|
+
}
|
|
3697
|
+
} }) };
|
|
3698
|
+
const rnNoLegacyExpoPackages = { create: (context) => ({ ImportDeclaration(node) {
|
|
3699
|
+
const source = node.source?.value;
|
|
3700
|
+
if (typeof source !== "string") return;
|
|
3701
|
+
for (const [packageName, replacement] of LEGACY_EXPO_PACKAGE_REPLACEMENTS) if (source === packageName || source.startsWith(`${packageName}/`)) {
|
|
2862
3702
|
context.report({
|
|
2863
3703
|
node,
|
|
2864
3704
|
message: `"${packageName}" is deprecated — use ${replacement}`
|
|
@@ -3765,24 +4605,6 @@ const DERIVING_ARRAY_METHODS = new Set([
|
|
|
3765
4605
|
"map",
|
|
3766
4606
|
"slice"
|
|
3767
4607
|
]);
|
|
3768
|
-
const getRootIdentifierName = (node) => {
|
|
3769
|
-
let cursor = node;
|
|
3770
|
-
while (cursor && (cursor.type === "MemberExpression" || cursor.type === "CallExpression")) if (cursor.type === "MemberExpression") cursor = cursor.object;
|
|
3771
|
-
else if (cursor.type === "CallExpression") {
|
|
3772
|
-
const callee = cursor.callee;
|
|
3773
|
-
if (callee?.type === "MemberExpression") cursor = callee.object;
|
|
3774
|
-
else return null;
|
|
3775
|
-
}
|
|
3776
|
-
return cursor?.type === "Identifier" ? cursor.name : null;
|
|
3777
|
-
};
|
|
3778
|
-
const expressionDerivesFromIdentifier = (node, identifierName) => {
|
|
3779
|
-
if (node.type !== "CallExpression") return false;
|
|
3780
|
-
const callee = node.callee;
|
|
3781
|
-
if (callee?.type !== "MemberExpression") return false;
|
|
3782
|
-
if (callee.property?.type !== "Identifier") return false;
|
|
3783
|
-
if (!DERIVING_ARRAY_METHODS.has(callee.property.name)) return false;
|
|
3784
|
-
return getRootIdentifierName(callee) === identifierName;
|
|
3785
|
-
};
|
|
3786
4608
|
const serverDedupProps = { create: (context) => ({ JSXOpeningElement(node) {
|
|
3787
4609
|
const identifierAttributes = /* @__PURE__ */ new Map();
|
|
3788
4610
|
const derivedAttributes = [];
|
|
@@ -3794,14 +4616,15 @@ const serverDedupProps = { create: (context) => ({ JSXOpeningElement(node) {
|
|
|
3794
4616
|
if (!expression) continue;
|
|
3795
4617
|
if (expression.type === "Identifier") identifierAttributes.set(expression.name, attr.name.name);
|
|
3796
4618
|
else if (expression.type === "CallExpression") {
|
|
3797
|
-
const
|
|
3798
|
-
if (
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
4619
|
+
const derivingMethod = getDerivingMethodName(expression);
|
|
4620
|
+
if (!derivingMethod || !DERIVING_ARRAY_METHODS.has(derivingMethod)) continue;
|
|
4621
|
+
const root = getRootIdentifierName(expression, { followCallChains: true });
|
|
4622
|
+
if (!root) continue;
|
|
4623
|
+
derivedAttributes.push({
|
|
4624
|
+
propName: attr.name.name,
|
|
4625
|
+
rootName: root,
|
|
4626
|
+
node: attr
|
|
4627
|
+
});
|
|
3805
4628
|
}
|
|
3806
4629
|
}
|
|
3807
4630
|
for (const derived of derivedAttributes) {
|
|
@@ -4311,6 +5134,34 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
|
|
|
4311
5134
|
} }) };
|
|
4312
5135
|
//#endregion
|
|
4313
5136
|
//#region src/plugin/rules/state-and-effects.ts
|
|
5137
|
+
const collectValueIdentifierNames = (node, into) => {
|
|
5138
|
+
if (!node || typeof node !== "object") return;
|
|
5139
|
+
if (node.type === "CallExpression") {
|
|
5140
|
+
if (node.callee?.type === "MemberExpression") {
|
|
5141
|
+
const rootName = getRootIdentifierName(node.callee);
|
|
5142
|
+
if (!rootName || !BUILTIN_GLOBAL_NAMESPACE_NAMES.has(rootName)) collectValueIdentifierNames(node.callee.object, into);
|
|
5143
|
+
}
|
|
5144
|
+
for (const argument of node.arguments ?? []) collectValueIdentifierNames(argument, into);
|
|
5145
|
+
return;
|
|
5146
|
+
}
|
|
5147
|
+
if (node.type === "MemberExpression") {
|
|
5148
|
+
const rootName = getRootIdentifierName(node);
|
|
5149
|
+
if (!rootName || !BUILTIN_GLOBAL_NAMESPACE_NAMES.has(rootName)) collectValueIdentifierNames(node.object, into);
|
|
5150
|
+
if (node.computed) collectValueIdentifierNames(node.property, into);
|
|
5151
|
+
return;
|
|
5152
|
+
}
|
|
5153
|
+
if (node.type === "Identifier") {
|
|
5154
|
+
into.push(node.name);
|
|
5155
|
+
return;
|
|
5156
|
+
}
|
|
5157
|
+
for (const key of Object.keys(node)) {
|
|
5158
|
+
if (key === "parent" || key === "type") continue;
|
|
5159
|
+
const child = node[key];
|
|
5160
|
+
if (Array.isArray(child)) {
|
|
5161
|
+
for (const item of child) if (item && typeof item === "object" && item.type) collectValueIdentifierNames(item, into);
|
|
5162
|
+
} else if (child && typeof child === "object" && child.type) collectValueIdentifierNames(child, into);
|
|
5163
|
+
}
|
|
5164
|
+
};
|
|
4314
5165
|
const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
|
|
4315
5166
|
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
4316
5167
|
const callback = getEffectCallback(node);
|
|
@@ -4327,23 +5178,41 @@ const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
|
|
|
4327
5178
|
})) return;
|
|
4328
5179
|
let allArgumentsDeriveFromDeps = true;
|
|
4329
5180
|
let hasAnyDependencyReference = false;
|
|
5181
|
+
let hasExpensiveDerivation = false;
|
|
4330
5182
|
for (const statement of statements) {
|
|
4331
5183
|
const setStateArguments = statement.expression.arguments;
|
|
4332
5184
|
if (!setStateArguments?.length) continue;
|
|
4333
|
-
const
|
|
5185
|
+
const valueIdentifierNames = [];
|
|
5186
|
+
collectValueIdentifierNames(setStateArguments[0], valueIdentifierNames);
|
|
4334
5187
|
walkAst(setStateArguments[0], (child) => {
|
|
4335
|
-
if (child.type
|
|
5188
|
+
if (child.type !== "CallExpression") return;
|
|
5189
|
+
if (child.callee?.type === "MemberExpression") {
|
|
5190
|
+
const rootName = getRootIdentifierName(child.callee);
|
|
5191
|
+
if (rootName && BUILTIN_GLOBAL_NAMESPACE_NAMES.has(rootName)) return;
|
|
5192
|
+
hasExpensiveDerivation = true;
|
|
5193
|
+
return;
|
|
5194
|
+
}
|
|
5195
|
+
if (child.callee?.type === "Identifier") {
|
|
5196
|
+
const calleeName = child.callee.name;
|
|
5197
|
+
if (!TRIVIAL_DERIVATION_CALLEE_NAMES.has(calleeName) && !isSetterIdentifier(calleeName)) hasExpensiveDerivation = true;
|
|
5198
|
+
}
|
|
4336
5199
|
});
|
|
4337
|
-
const nonSetterIdentifiers =
|
|
5200
|
+
const nonSetterIdentifiers = valueIdentifierNames.filter((name) => !isSetterIdentifier(name));
|
|
4338
5201
|
if (nonSetterIdentifiers.some((name) => dependencyNames.has(name))) hasAnyDependencyReference = true;
|
|
4339
5202
|
if (nonSetterIdentifiers.some((name) => !dependencyNames.has(name))) {
|
|
4340
5203
|
allArgumentsDeriveFromDeps = false;
|
|
4341
5204
|
break;
|
|
4342
5205
|
}
|
|
4343
5206
|
}
|
|
4344
|
-
if (allArgumentsDeriveFromDeps)
|
|
5207
|
+
if (!allArgumentsDeriveFromDeps) return;
|
|
5208
|
+
if (hasExpensiveDerivation) hasAnyDependencyReference = true;
|
|
5209
|
+
let message;
|
|
5210
|
+
if (!hasAnyDependencyReference) message = "State reset in useEffect — use a key prop to reset component state when props change";
|
|
5211
|
+
else if (hasExpensiveDerivation) message = "Derived state in useEffect — wrap the calculation in useMemo([deps]) (or compute it directly during render if it isn't expensive)";
|
|
5212
|
+
else message = "Derived state in useEffect — compute during render instead";
|
|
5213
|
+
context.report({
|
|
4345
5214
|
node,
|
|
4346
|
-
message
|
|
5215
|
+
message
|
|
4347
5216
|
});
|
|
4348
5217
|
} }) };
|
|
4349
5218
|
const noFetchInEffect = { create: (context) => ({ CallExpression(node) {
|
|
@@ -4375,47 +5244,22 @@ const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
|
4375
5244
|
const statements = getCallbackStatements(callback);
|
|
4376
5245
|
if (statements.length !== 1) return;
|
|
4377
5246
|
const soleStatement = statements[0];
|
|
4378
|
-
if (soleStatement.type
|
|
5247
|
+
if (soleStatement.type !== "IfStatement") return;
|
|
5248
|
+
const rootIdentifierName = getRootIdentifierName(soleStatement.test);
|
|
5249
|
+
if (!rootIdentifierName || !dependencyNames.has(rootIdentifierName)) return;
|
|
5250
|
+
context.report({
|
|
4379
5251
|
node,
|
|
4380
5252
|
message: "useEffect simulating an event handler — move logic to an actual event handler instead"
|
|
4381
5253
|
});
|
|
4382
5254
|
} }) };
|
|
4383
5255
|
const noDerivedUseState = { create: (context) => {
|
|
4384
|
-
const
|
|
4385
|
-
const isPropName = (name) => {
|
|
4386
|
-
for (let stackIndex = componentPropStack.length - 1; stackIndex >= 0; stackIndex--) if (componentPropStack[stackIndex].has(name)) return true;
|
|
4387
|
-
return false;
|
|
4388
|
-
};
|
|
4389
|
-
const isFunctionLikeVariableDeclarator = (node) => {
|
|
4390
|
-
if (node.type !== "VariableDeclarator") return false;
|
|
4391
|
-
return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
|
|
4392
|
-
};
|
|
5256
|
+
const propStackTracker = createComponentPropStackTracker();
|
|
4393
5257
|
return {
|
|
4394
|
-
|
|
4395
|
-
if (!node.id?.name || !isUppercaseName(node.id.name)) {
|
|
4396
|
-
componentPropStack.push(/* @__PURE__ */ new Set());
|
|
4397
|
-
return;
|
|
4398
|
-
}
|
|
4399
|
-
componentPropStack.push(extractDestructuredPropNames(node.params ?? []));
|
|
4400
|
-
},
|
|
4401
|
-
"FunctionDeclaration:exit"() {
|
|
4402
|
-
componentPropStack.pop();
|
|
4403
|
-
},
|
|
4404
|
-
VariableDeclarator(node) {
|
|
4405
|
-
if (isComponentAssignment(node)) {
|
|
4406
|
-
componentPropStack.push(extractDestructuredPropNames(node.init?.params ?? []));
|
|
4407
|
-
return;
|
|
4408
|
-
}
|
|
4409
|
-
if (isFunctionLikeVariableDeclarator(node)) componentPropStack.push(/* @__PURE__ */ new Set());
|
|
4410
|
-
},
|
|
4411
|
-
"VariableDeclarator:exit"(node) {
|
|
4412
|
-
if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) componentPropStack.pop();
|
|
4413
|
-
},
|
|
5258
|
+
...propStackTracker.visitors,
|
|
4414
5259
|
CallExpression(node) {
|
|
4415
5260
|
if (!isHookCall(node, "useState") || !node.arguments?.length) return;
|
|
4416
|
-
if (componentPropStack.length === 0) return;
|
|
4417
5261
|
const initializer = node.arguments[0];
|
|
4418
|
-
if (initializer.type === "Identifier" && isPropName(initializer.name)) {
|
|
5262
|
+
if (initializer.type === "Identifier" && propStackTracker.isPropName(initializer.name)) {
|
|
4419
5263
|
context.report({
|
|
4420
5264
|
node,
|
|
4421
5265
|
message: `useState initialized from prop "${initializer.name}" — if this value should stay in sync with the prop, derive it during render instead`
|
|
@@ -4423,11 +5267,8 @@ const noDerivedUseState = { create: (context) => {
|
|
|
4423
5267
|
return;
|
|
4424
5268
|
}
|
|
4425
5269
|
if (initializer.type === "MemberExpression" && !initializer.computed) {
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
while (cursor?.type === "MemberExpression") cursor = cursor.object;
|
|
4429
|
-
if (cursor?.type === "Identifier") rootIdentifierName = cursor.name;
|
|
4430
|
-
if (rootIdentifierName && isPropName(rootIdentifierName)) context.report({
|
|
5270
|
+
const rootIdentifierName = getRootIdentifierName(initializer);
|
|
5271
|
+
if (rootIdentifierName && propStackTracker.isPropName(rootIdentifierName)) context.report({
|
|
4431
5272
|
node,
|
|
4432
5273
|
message: `useState initialized from prop "${rootIdentifierName}" — if this value should stay in sync with the prop, derive it during render instead`
|
|
4433
5274
|
});
|
|
@@ -4505,6 +5346,25 @@ const rerenderFunctionalSetstate = { create: (context) => ({ CallExpression(node
|
|
|
4505
5346
|
node,
|
|
4506
5347
|
message: `${calleeName}(${display}) — use functional update to avoid stale closures (and reading the post-increment value bug)`
|
|
4507
5348
|
});
|
|
5349
|
+
return;
|
|
5350
|
+
}
|
|
5351
|
+
if (expectedStateName && argument.type === "ArrayExpression") {
|
|
5352
|
+
if ((argument.elements ?? []).some((element) => element?.type === "SpreadElement" && element.argument?.type === "Identifier" && element.argument.name === expectedStateName)) {
|
|
5353
|
+
context.report({
|
|
5354
|
+
node,
|
|
5355
|
+
message: `${calleeName}([...${expectedStateName}, ...]) — use functional update \`${calleeName}(prev => [...prev, ...])\` to avoid stale closures`
|
|
5356
|
+
});
|
|
5357
|
+
return;
|
|
5358
|
+
}
|
|
5359
|
+
}
|
|
5360
|
+
if (expectedStateName && argument.type === "ObjectExpression") {
|
|
5361
|
+
if ((argument.properties ?? []).some((property) => property?.type === "SpreadElement" && property.argument?.type === "Identifier" && property.argument.name === expectedStateName)) {
|
|
5362
|
+
context.report({
|
|
5363
|
+
node,
|
|
5364
|
+
message: `${calleeName}({ ...${expectedStateName}, ... }) — use functional update \`${calleeName}(prev => ({ ...prev, ... }))\` to avoid stale closures`
|
|
5365
|
+
});
|
|
5366
|
+
return;
|
|
5367
|
+
}
|
|
4508
5368
|
}
|
|
4509
5369
|
} }) };
|
|
4510
5370
|
const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
@@ -4521,108 +5381,57 @@ const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
|
4521
5381
|
node: element,
|
|
4522
5382
|
message: "Array literal in useEffect deps — creates new reference every render, causing infinite re-runs"
|
|
4523
5383
|
});
|
|
5384
|
+
if (element.type === "ArrowFunctionExpression" || element.type === "FunctionExpression") context.report({
|
|
5385
|
+
node: element,
|
|
5386
|
+
message: "Inline function in useEffect deps — creates a new function reference every render, causing infinite re-runs. Hoist it out of the component or wrap it with useCallback"
|
|
5387
|
+
});
|
|
4524
5388
|
}
|
|
4525
5389
|
} }) };
|
|
4526
5390
|
const noPropCallbackInEffect = { create: (context) => {
|
|
4527
|
-
const
|
|
4528
|
-
const enterComponentParams = (params) => {
|
|
4529
|
-
const propNames = extractDestructuredPropNames(params ?? []);
|
|
4530
|
-
componentPropParamStack.push(propNames);
|
|
4531
|
-
};
|
|
4532
|
-
const isPropName = (name) => {
|
|
4533
|
-
for (let stackIndex = componentPropParamStack.length - 1; stackIndex >= 0; stackIndex--) if (componentPropParamStack[stackIndex].has(name)) return true;
|
|
4534
|
-
return false;
|
|
4535
|
-
};
|
|
4536
|
-
const isFunctionLikeVariableDeclarator = (node) => {
|
|
4537
|
-
if (node.type !== "VariableDeclarator") return false;
|
|
4538
|
-
return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
|
|
4539
|
-
};
|
|
5391
|
+
const propStackTracker = createComponentPropStackTracker();
|
|
4540
5392
|
return {
|
|
4541
|
-
|
|
4542
|
-
if (!node.id?.name || !isUppercaseName(node.id.name)) {
|
|
4543
|
-
componentPropParamStack.push(/* @__PURE__ */ new Set());
|
|
4544
|
-
return;
|
|
4545
|
-
}
|
|
4546
|
-
enterComponentParams(node.params);
|
|
4547
|
-
},
|
|
4548
|
-
"FunctionDeclaration:exit"() {
|
|
4549
|
-
componentPropParamStack.pop();
|
|
4550
|
-
},
|
|
4551
|
-
VariableDeclarator(node) {
|
|
4552
|
-
if (isComponentAssignment(node)) {
|
|
4553
|
-
enterComponentParams(node.init?.params);
|
|
4554
|
-
return;
|
|
4555
|
-
}
|
|
4556
|
-
if (isFunctionLikeVariableDeclarator(node)) componentPropParamStack.push(/* @__PURE__ */ new Set());
|
|
4557
|
-
},
|
|
4558
|
-
"VariableDeclarator:exit"(node) {
|
|
4559
|
-
if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) componentPropParamStack.pop();
|
|
4560
|
-
},
|
|
5393
|
+
...propStackTracker.visitors,
|
|
4561
5394
|
CallExpression(node) {
|
|
4562
5395
|
if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
|
|
4563
|
-
if (componentPropParamStack.length === 0) return;
|
|
4564
5396
|
const callback = getEffectCallback(node);
|
|
4565
5397
|
if (!callback) return;
|
|
4566
5398
|
const depsNode = node.arguments[1];
|
|
4567
5399
|
if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
if (
|
|
4572
|
-
if (
|
|
4573
|
-
|
|
5400
|
+
if (!depsNode.elements.some((element) => element?.type === "Identifier" && !propStackTracker.isPropName(element.name))) return;
|
|
5401
|
+
const reportedNodes = /* @__PURE__ */ new Set();
|
|
5402
|
+
walkInsideStatementBlocks(callback.body, (child) => {
|
|
5403
|
+
if (child.type !== "CallExpression") return;
|
|
5404
|
+
if (child.callee?.type !== "Identifier") return;
|
|
5405
|
+
const calleeName = child.callee.name;
|
|
5406
|
+
if (!propStackTracker.isPropName(calleeName)) return;
|
|
5407
|
+
if (reportedNodes.has(child)) return;
|
|
5408
|
+
reportedNodes.add(child);
|
|
4574
5409
|
context.report({
|
|
4575
|
-
node:
|
|
4576
|
-
message: `useEffect calls prop callback "${
|
|
5410
|
+
node: child,
|
|
5411
|
+
message: `useEffect calls prop callback "${calleeName}" with local state in deps — this is the "lift state via callback" anti-pattern; lift state into a shared Provider so both sides read the same source`
|
|
4577
5412
|
});
|
|
4578
|
-
}
|
|
5413
|
+
});
|
|
4579
5414
|
}
|
|
4580
5415
|
};
|
|
4581
5416
|
} };
|
|
4582
5417
|
const noEffectEventInDeps = { create: (context) => {
|
|
4583
|
-
const
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
return
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
};
|
|
4591
|
-
const exitComponent = () => {
|
|
4592
|
-
componentBindingStack.pop();
|
|
4593
|
-
};
|
|
5418
|
+
const componentBindings = createComponentBindingStackTracker({ onVariableDeclarator: (declaratorNode) => {
|
|
5419
|
+
if (declaratorNode.id?.type !== "Identifier") return;
|
|
5420
|
+
const initializer = declaratorNode.init;
|
|
5421
|
+
if (!initializer || initializer.type !== "CallExpression") return;
|
|
5422
|
+
if (!isHookCall(initializer, "useEffectEvent")) return;
|
|
5423
|
+
componentBindings.addBindingToCurrentFrame(declaratorNode.id.name);
|
|
5424
|
+
} });
|
|
4594
5425
|
return {
|
|
4595
|
-
|
|
4596
|
-
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
4597
|
-
enterComponent();
|
|
4598
|
-
},
|
|
4599
|
-
"FunctionDeclaration:exit"(node) {
|
|
4600
|
-
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
4601
|
-
exitComponent();
|
|
4602
|
-
},
|
|
4603
|
-
VariableDeclarator(node) {
|
|
4604
|
-
if (isComponentAssignment(node)) {
|
|
4605
|
-
enterComponent();
|
|
4606
|
-
return;
|
|
4607
|
-
}
|
|
4608
|
-
if (componentBindingStack.length === 0) return;
|
|
4609
|
-
if (node.id?.type !== "Identifier") return;
|
|
4610
|
-
const init = node.init;
|
|
4611
|
-
if (!init || init.type !== "CallExpression") return;
|
|
4612
|
-
if (!isHookCall(init, "useEffectEvent")) return;
|
|
4613
|
-
componentBindingStack[componentBindingStack.length - 1].add(node.id.name);
|
|
4614
|
-
},
|
|
4615
|
-
"VariableDeclarator:exit"(node) {
|
|
4616
|
-
if (isComponentAssignment(node)) exitComponent();
|
|
4617
|
-
},
|
|
5426
|
+
...componentBindings.visitors,
|
|
4618
5427
|
CallExpression(node) {
|
|
4619
5428
|
if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
|
|
4620
|
-
if (
|
|
5429
|
+
if (!componentBindings.isInsideComponent()) return;
|
|
4621
5430
|
const depsNode = node.arguments[1];
|
|
4622
5431
|
if (depsNode.type !== "ArrayExpression") return;
|
|
4623
5432
|
for (const element of depsNode.elements ?? []) {
|
|
4624
5433
|
if (element?.type !== "Identifier") continue;
|
|
4625
|
-
if (
|
|
5434
|
+
if (componentBindings.isBoundName(element.name)) context.report({
|
|
4626
5435
|
node: element,
|
|
4627
5436
|
message: `"${element.name}" is from useEffectEvent and must not be in the deps array — its identity is intentionally unstable; call it inside the effect without listing it`
|
|
4628
5437
|
});
|
|
@@ -4667,25 +5476,55 @@ const collectReturnExpressions = (componentBody) => {
|
|
|
4667
5476
|
}
|
|
4668
5477
|
return returns;
|
|
4669
5478
|
};
|
|
4670
|
-
const
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
5479
|
+
const collectIdentifierNames = (expression) => {
|
|
5480
|
+
const names = /* @__PURE__ */ new Set();
|
|
5481
|
+
walkAst(expression, (child) => {
|
|
5482
|
+
if (child.type === "Identifier") names.add(child.name);
|
|
5483
|
+
});
|
|
5484
|
+
return names;
|
|
5485
|
+
};
|
|
5486
|
+
const buildLocalDependencyGraph = (componentBody) => {
|
|
5487
|
+
const graph = /* @__PURE__ */ new Map();
|
|
5488
|
+
if (componentBody?.type !== "BlockStatement") return graph;
|
|
5489
|
+
const declaredNames = /* @__PURE__ */ new Set();
|
|
5490
|
+
for (const statement of componentBody.body ?? []) {
|
|
5491
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
5492
|
+
for (const declarator of statement.declarations ?? []) {
|
|
5493
|
+
if (!declarator.init) continue;
|
|
5494
|
+
const dependencyNames = collectIdentifierNames(declarator.init);
|
|
5495
|
+
declaredNames.clear();
|
|
5496
|
+
collectPatternNames(declarator.id, declaredNames);
|
|
5497
|
+
for (const declaredName of declaredNames) {
|
|
5498
|
+
const existing = graph.get(declaredName);
|
|
5499
|
+
if (existing === void 0) graph.set(declaredName, new Set(dependencyNames));
|
|
5500
|
+
else for (const dependencyName of dependencyNames) existing.add(dependencyName);
|
|
5501
|
+
}
|
|
5502
|
+
}
|
|
4680
5503
|
}
|
|
5504
|
+
return graph;
|
|
4681
5505
|
};
|
|
4682
|
-
const
|
|
4683
|
-
|
|
4684
|
-
walkAst(expression, (child) => {
|
|
4685
|
-
if (
|
|
4686
|
-
if (child.type === "Identifier" && child.name === name) didRead = true;
|
|
5506
|
+
const collectRenderReachableNames = (returnExpressions) => {
|
|
5507
|
+
const names = /* @__PURE__ */ new Set();
|
|
5508
|
+
for (const expression of returnExpressions) walkAst(expression, (child) => {
|
|
5509
|
+
if (child.type === "Identifier") names.add(child.name);
|
|
4687
5510
|
});
|
|
4688
|
-
return
|
|
5511
|
+
return names;
|
|
5512
|
+
};
|
|
5513
|
+
const expandTransitiveDependencies = (seedNames, dependencyGraph) => {
|
|
5514
|
+
const reachable = new Set(seedNames);
|
|
5515
|
+
const queue = Array.from(seedNames);
|
|
5516
|
+
while (queue.length > 0) {
|
|
5517
|
+
const currentName = queue.pop();
|
|
5518
|
+
if (currentName === void 0) continue;
|
|
5519
|
+
const dependencyNames = dependencyGraph.get(currentName);
|
|
5520
|
+
if (!dependencyNames) continue;
|
|
5521
|
+
for (const dependencyName of dependencyNames) {
|
|
5522
|
+
if (reachable.has(dependencyName)) continue;
|
|
5523
|
+
reachable.add(dependencyName);
|
|
5524
|
+
queue.push(dependencyName);
|
|
5525
|
+
}
|
|
5526
|
+
}
|
|
5527
|
+
return reachable;
|
|
4689
5528
|
};
|
|
4690
5529
|
const rerenderStateOnlyInHandlers = { create: (context) => {
|
|
4691
5530
|
const checkComponent = (componentBody) => {
|
|
@@ -4694,8 +5533,10 @@ const rerenderStateOnlyInHandlers = { create: (context) => {
|
|
|
4694
5533
|
if (bindings.length === 0) return;
|
|
4695
5534
|
const returnExpressions = collectReturnExpressions(componentBody);
|
|
4696
5535
|
if (returnExpressions.length === 0) return;
|
|
5536
|
+
const dependencyGraph = buildLocalDependencyGraph(componentBody);
|
|
5537
|
+
const renderReachableNames = expandTransitiveDependencies(collectRenderReachableNames(returnExpressions), dependencyGraph);
|
|
4697
5538
|
for (const binding of bindings) {
|
|
4698
|
-
if (
|
|
5539
|
+
if (renderReachableNames.has(binding.valueName)) continue;
|
|
4699
5540
|
let setterCalled = false;
|
|
4700
5541
|
walkAst(componentBody, (child) => {
|
|
4701
5542
|
if (setterCalled) return;
|
|
@@ -4719,12 +5560,6 @@ const rerenderStateOnlyInHandlers = { create: (context) => {
|
|
|
4719
5560
|
}
|
|
4720
5561
|
};
|
|
4721
5562
|
} };
|
|
4722
|
-
const SUBSCRIPTION_METHOD_NAMES = new Set([
|
|
4723
|
-
"addEventListener",
|
|
4724
|
-
"subscribe",
|
|
4725
|
-
"on",
|
|
4726
|
-
"addListener"
|
|
4727
|
-
]);
|
|
4728
5563
|
const advancedEventHandlerRefs = { create: (context) => ({ CallExpression(node) {
|
|
4729
5564
|
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
4730
5565
|
if ((node.arguments?.length ?? 0) < 2) return;
|
|
@@ -4812,55 +5647,897 @@ const isInsideEventHandler = (node, handlerBindingNames) => {
|
|
|
4812
5647
|
}
|
|
4813
5648
|
return false;
|
|
4814
5649
|
};
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
5650
|
+
const rerenderDeferReadsHook = { create: (context) => {
|
|
5651
|
+
const checkComponent = (componentBody) => {
|
|
5652
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
5653
|
+
const bindings = findHookCallBindings(componentBody);
|
|
5654
|
+
if (bindings.length === 0) return;
|
|
5655
|
+
const handlerBindingNames = collectHandlerBindingNames(componentBody);
|
|
5656
|
+
for (const binding of bindings) {
|
|
5657
|
+
const referenceLocations = [];
|
|
5658
|
+
walkAst(componentBody, (child) => {
|
|
5659
|
+
if (child === binding.declarator.id) return;
|
|
5660
|
+
if (child.type === "Identifier" && child.name === binding.valueName) referenceLocations.push(child);
|
|
5661
|
+
});
|
|
5662
|
+
if (referenceLocations.length === 0) continue;
|
|
5663
|
+
if (!referenceLocations.every((ref) => isInsideEventHandler(ref, handlerBindingNames))) continue;
|
|
5664
|
+
context.report({
|
|
5665
|
+
node: binding.declarator,
|
|
5666
|
+
message: `${binding.hookName}() return is only read inside event handlers — defer the read into the handler (e.g. \`new URL(window.location.href).searchParams\`) so the component doesn't re-render on every URL change`
|
|
5667
|
+
});
|
|
5668
|
+
}
|
|
5669
|
+
};
|
|
5670
|
+
return {
|
|
5671
|
+
FunctionDeclaration(node) {
|
|
5672
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
5673
|
+
checkComponent(node.body);
|
|
5674
|
+
},
|
|
5675
|
+
VariableDeclarator(node) {
|
|
5676
|
+
if (!isComponentAssignment(node)) return;
|
|
5677
|
+
checkComponent(node.init?.body);
|
|
5678
|
+
}
|
|
5679
|
+
};
|
|
5680
|
+
} };
|
|
5681
|
+
const collectFunctionLocalBindings = (functionNode) => {
|
|
5682
|
+
const localBindings = /* @__PURE__ */ new Set();
|
|
5683
|
+
for (const param of functionNode.params ?? []) collectPatternNames(param, localBindings);
|
|
5684
|
+
if (functionNode.body?.type === "BlockStatement") for (const statement of functionNode.body.body ?? []) {
|
|
5685
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
5686
|
+
for (const declarator of statement.declarations ?? []) collectPatternNames(declarator.id, localBindings);
|
|
5687
|
+
}
|
|
5688
|
+
return localBindings;
|
|
5689
|
+
};
|
|
5690
|
+
const isFunctionLikeNode = (node) => node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression";
|
|
5691
|
+
const walkComponentRespectingShadows = (node, shadowedStateNames, visit) => {
|
|
5692
|
+
if (!node || typeof node !== "object") return;
|
|
5693
|
+
let nextShadowedStateNames = shadowedStateNames;
|
|
5694
|
+
if (isFunctionLikeNode(node)) {
|
|
5695
|
+
const localBindings = collectFunctionLocalBindings(node);
|
|
5696
|
+
if (localBindings.size > 0) {
|
|
5697
|
+
const merged = new Set(shadowedStateNames);
|
|
5698
|
+
for (const localName of localBindings) merged.add(localName);
|
|
5699
|
+
nextShadowedStateNames = merged;
|
|
5700
|
+
}
|
|
5701
|
+
}
|
|
5702
|
+
visit(node, shadowedStateNames);
|
|
5703
|
+
for (const key of Object.keys(node)) {
|
|
5704
|
+
if (key === "parent") continue;
|
|
5705
|
+
const child = node[key];
|
|
5706
|
+
if (Array.isArray(child)) {
|
|
5707
|
+
for (const item of child) if (item && typeof item === "object" && item.type) walkComponentRespectingShadows(item, nextShadowedStateNames, visit);
|
|
5708
|
+
} else if (child && typeof child === "object" && child.type) walkComponentRespectingShadows(child, nextShadowedStateNames, visit);
|
|
5709
|
+
}
|
|
5710
|
+
};
|
|
5711
|
+
const noDirectStateMutation = { create: (context) => {
|
|
5712
|
+
const checkComponent = (componentBody) => {
|
|
5713
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
5714
|
+
const bindings = collectUseStateBindings(componentBody);
|
|
5715
|
+
if (bindings.length === 0) return;
|
|
5716
|
+
const stateValueToSetter = new Map(bindings.map((binding) => [binding.valueName, binding.setterName]));
|
|
5717
|
+
walkComponentRespectingShadows(componentBody, /* @__PURE__ */ new Set(), (child, currentlyShadowed) => {
|
|
5718
|
+
if (child.type === "AssignmentExpression") {
|
|
5719
|
+
if (child.left?.type !== "MemberExpression") return;
|
|
5720
|
+
const rootName = getRootIdentifierName(child.left);
|
|
5721
|
+
if (!rootName || !stateValueToSetter.has(rootName)) return;
|
|
5722
|
+
if (currentlyShadowed.has(rootName)) return;
|
|
5723
|
+
const setterName = stateValueToSetter.get(rootName);
|
|
5724
|
+
context.report({
|
|
5725
|
+
node: child,
|
|
5726
|
+
message: `Direct property assignment on useState value "${rootName}" — call ${setterName} with a new value; React only re-renders on a new reference`
|
|
5727
|
+
});
|
|
5728
|
+
return;
|
|
5729
|
+
}
|
|
5730
|
+
if (child.type === "CallExpression") {
|
|
5731
|
+
const callee = child.callee;
|
|
5732
|
+
if (callee?.type !== "MemberExpression") return;
|
|
5733
|
+
if (callee.property?.type !== "Identifier") return;
|
|
5734
|
+
const methodName = callee.property.name;
|
|
5735
|
+
if (!MUTATING_ARRAY_METHODS.has(methodName)) return;
|
|
5736
|
+
const rootName = getRootIdentifierName(callee.object);
|
|
5737
|
+
if (!rootName || !stateValueToSetter.has(rootName)) return;
|
|
5738
|
+
if (currentlyShadowed.has(rootName)) return;
|
|
5739
|
+
const setterName = stateValueToSetter.get(rootName);
|
|
5740
|
+
context.report({
|
|
5741
|
+
node: child,
|
|
5742
|
+
message: `In-place mutation of useState value "${rootName}" via .${methodName}() — call ${setterName} with a new array; React only re-renders on a new reference`
|
|
5743
|
+
});
|
|
5744
|
+
}
|
|
5745
|
+
});
|
|
5746
|
+
};
|
|
5747
|
+
return {
|
|
5748
|
+
FunctionDeclaration(node) {
|
|
5749
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
5750
|
+
checkComponent(node.body);
|
|
5751
|
+
},
|
|
5752
|
+
VariableDeclarator(node) {
|
|
5753
|
+
if (!isComponentAssignment(node)) return;
|
|
5754
|
+
checkComponent(node.init?.body);
|
|
5755
|
+
}
|
|
5756
|
+
};
|
|
5757
|
+
} };
|
|
5758
|
+
const isUnconditionalSetterCallStatement = (statement, setterNames) => {
|
|
5759
|
+
if (statement.type !== "ExpressionStatement") return null;
|
|
5760
|
+
const expression = statement.expression;
|
|
5761
|
+
if (expression?.type !== "CallExpression") return null;
|
|
5762
|
+
const callee = expression.callee;
|
|
5763
|
+
if (callee?.type !== "Identifier") return null;
|
|
5764
|
+
if (!setterNames.has(callee.name)) return null;
|
|
5765
|
+
return expression;
|
|
5766
|
+
};
|
|
5767
|
+
const noSetStateInRender = { create: (context) => {
|
|
5768
|
+
const checkComponent = (componentBody) => {
|
|
5769
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
5770
|
+
const setterNames = new Set(collectUseStateBindings(componentBody).map((binding) => binding.setterName));
|
|
5771
|
+
if (setterNames.size === 0) return;
|
|
5772
|
+
for (const statement of componentBody.body ?? []) {
|
|
5773
|
+
const setterCall = isUnconditionalSetterCallStatement(statement, setterNames);
|
|
5774
|
+
if (!setterCall) continue;
|
|
5775
|
+
const setterIdentifierName = setterCall.callee.name;
|
|
5776
|
+
context.report({
|
|
5777
|
+
node: setterCall,
|
|
5778
|
+
message: `${setterIdentifierName}() called unconditionally at the top of render — causes an infinite re-render loop. Move into a useEffect or an event handler. (To derive state from props, guard the call: \`if (prev !== prop) ${setterIdentifierName}(prop)\`)`
|
|
5779
|
+
});
|
|
5780
|
+
}
|
|
5781
|
+
};
|
|
5782
|
+
return {
|
|
5783
|
+
FunctionDeclaration(node) {
|
|
5784
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
5785
|
+
checkComponent(node.body);
|
|
5786
|
+
},
|
|
5787
|
+
VariableDeclarator(node) {
|
|
5788
|
+
if (!isComponentAssignment(node)) return;
|
|
5789
|
+
checkComponent(node.init?.body);
|
|
5790
|
+
}
|
|
5791
|
+
};
|
|
5792
|
+
} };
|
|
5793
|
+
const findUseEffectsInComponent = (componentBody) => {
|
|
5794
|
+
const effectCalls = [];
|
|
5795
|
+
if (componentBody?.type !== "BlockStatement") return effectCalls;
|
|
5796
|
+
for (const statement of componentBody.body ?? []) walkAst(statement, (child) => {
|
|
5797
|
+
if (child.type === "CallExpression" && isHookCall(child, EFFECT_HOOK_NAMES)) effectCalls.push(child);
|
|
5798
|
+
});
|
|
5799
|
+
return effectCalls;
|
|
5800
|
+
};
|
|
5801
|
+
const findSubscriptionCall = (effectBodyStatements) => {
|
|
5802
|
+
for (const statement of effectBodyStatements) {
|
|
5803
|
+
if (statement.type === "VariableDeclaration") for (const declarator of statement.declarations ?? []) {
|
|
5804
|
+
const init = declarator.init;
|
|
5805
|
+
if (init?.type !== "CallExpression") continue;
|
|
5806
|
+
if (init.callee?.type !== "MemberExpression") continue;
|
|
5807
|
+
if (init.callee.property?.type !== "Identifier") continue;
|
|
5808
|
+
if (!SUBSCRIPTION_METHOD_NAMES.has(init.callee.property.name)) continue;
|
|
5809
|
+
return {
|
|
5810
|
+
call: init,
|
|
5811
|
+
boundUnsubscribeName: declarator.id?.type === "Identifier" ? declarator.id.name : null
|
|
5812
|
+
};
|
|
5813
|
+
}
|
|
5814
|
+
if (statement.type === "ExpressionStatement") {
|
|
5815
|
+
const expression = statement.expression;
|
|
5816
|
+
if (expression?.type !== "CallExpression") continue;
|
|
5817
|
+
if (expression.callee?.type !== "MemberExpression") continue;
|
|
5818
|
+
if (expression.callee.property?.type !== "Identifier") continue;
|
|
5819
|
+
if (!SUBSCRIPTION_METHOD_NAMES.has(expression.callee.property.name)) continue;
|
|
5820
|
+
return {
|
|
5821
|
+
call: expression,
|
|
5822
|
+
boundUnsubscribeName: null
|
|
5823
|
+
};
|
|
5824
|
+
}
|
|
5825
|
+
}
|
|
5826
|
+
return null;
|
|
5827
|
+
};
|
|
5828
|
+
const getSubscriptionHandlerArgument = (subscribeCall, effectBodyStatements) => {
|
|
5829
|
+
for (const argument of subscribeCall.arguments ?? []) {
|
|
5830
|
+
if (argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression") return argument;
|
|
5831
|
+
if (argument.type === "Identifier") for (const statement of effectBodyStatements) {
|
|
5832
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
5833
|
+
for (const declarator of statement.declarations ?? []) {
|
|
5834
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
5835
|
+
if (declarator.id.name !== argument.name) continue;
|
|
5836
|
+
const init = declarator.init;
|
|
5837
|
+
if (init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression") return init;
|
|
5838
|
+
}
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5841
|
+
return null;
|
|
5842
|
+
};
|
|
5843
|
+
const getSingleSetterCallFromHandler = (handler) => {
|
|
5844
|
+
const handlerStatements = getCallbackStatements(handler);
|
|
5845
|
+
if (handlerStatements.length !== 1) return null;
|
|
5846
|
+
const onlyStatement = handlerStatements[0];
|
|
5847
|
+
const expression = onlyStatement.type === "ExpressionStatement" ? onlyStatement.expression : onlyStatement;
|
|
5848
|
+
if (expression?.type !== "CallExpression") return null;
|
|
5849
|
+
if (expression.callee?.type !== "Identifier") return null;
|
|
5850
|
+
if (!isSetterIdentifier(expression.callee.name)) return null;
|
|
5851
|
+
if (!expression.arguments?.length) return null;
|
|
5852
|
+
return {
|
|
5853
|
+
setterName: expression.callee.name,
|
|
5854
|
+
setterArgument: expression.arguments[0]
|
|
5855
|
+
};
|
|
5856
|
+
};
|
|
5857
|
+
const cleanupReleasesSubscription = (effectBodyStatements, boundUnsubscribeName) => {
|
|
5858
|
+
const lastStatement = effectBodyStatements[effectBodyStatements.length - 1];
|
|
5859
|
+
if (lastStatement?.type !== "ReturnStatement") return false;
|
|
5860
|
+
const knownBoundReleaseNames = /* @__PURE__ */ new Set();
|
|
5861
|
+
if (boundUnsubscribeName) knownBoundReleaseNames.add(boundUnsubscribeName);
|
|
5862
|
+
return isCleanupReturn(lastStatement.argument, knownBoundReleaseNames);
|
|
5863
|
+
};
|
|
5864
|
+
const preferUseSyncExternalStore = { create: (context) => {
|
|
5865
|
+
const checkComponent = (componentBody) => {
|
|
5866
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
5867
|
+
const useStateBindings = collectUseStateBindings(componentBody);
|
|
5868
|
+
if (useStateBindings.length === 0) return;
|
|
5869
|
+
const useStateInitializerByValueName = /* @__PURE__ */ new Map();
|
|
5870
|
+
for (const binding of useStateBindings) {
|
|
5871
|
+
const initializerArgument = binding.declarator.init?.arguments?.[0];
|
|
5872
|
+
if (!initializerArgument) continue;
|
|
5873
|
+
if ((initializerArgument.type === "ArrowFunctionExpression" || initializerArgument.type === "FunctionExpression") && initializerArgument.body?.type !== "BlockStatement") useStateInitializerByValueName.set(binding.valueName, initializerArgument.body);
|
|
5874
|
+
else useStateInitializerByValueName.set(binding.valueName, initializerArgument);
|
|
5875
|
+
}
|
|
5876
|
+
const setterNameToValueName = /* @__PURE__ */ new Map();
|
|
5877
|
+
for (const binding of useStateBindings) setterNameToValueName.set(binding.setterName, binding.valueName);
|
|
5878
|
+
for (const effectCall of findUseEffectsInComponent(componentBody)) {
|
|
5879
|
+
if ((effectCall.arguments?.length ?? 0) < 2) continue;
|
|
5880
|
+
const depsNode = effectCall.arguments[1];
|
|
5881
|
+
if (depsNode.type !== "ArrayExpression") continue;
|
|
5882
|
+
if ((depsNode.elements?.length ?? 0) !== 0) continue;
|
|
5883
|
+
const callback = getEffectCallback(effectCall);
|
|
5884
|
+
if (!callback || callback.body?.type !== "BlockStatement") continue;
|
|
5885
|
+
const effectBodyStatements = callback.body.body ?? [];
|
|
5886
|
+
if (effectBodyStatements.length < 2) continue;
|
|
5887
|
+
const subscription = findSubscriptionCall(effectBodyStatements);
|
|
5888
|
+
if (!subscription) continue;
|
|
5889
|
+
const handler = getSubscriptionHandlerArgument(subscription.call, effectBodyStatements);
|
|
5890
|
+
if (!handler) continue;
|
|
5891
|
+
const setterPayload = getSingleSetterCallFromHandler(handler);
|
|
5892
|
+
if (!setterPayload) continue;
|
|
5893
|
+
const valueName = setterNameToValueName.get(setterPayload.setterName);
|
|
5894
|
+
if (!valueName) continue;
|
|
5895
|
+
const useStateInitializer = useStateInitializerByValueName.get(valueName);
|
|
5896
|
+
if (!useStateInitializer) continue;
|
|
5897
|
+
if (!areExpressionsStructurallyEqual(useStateInitializer, setterPayload.setterArgument)) continue;
|
|
5898
|
+
if (!cleanupReleasesSubscription(effectBodyStatements, subscription.boundUnsubscribeName)) continue;
|
|
5899
|
+
const matchingBinding = useStateBindings.find((binding) => binding.valueName === valueName);
|
|
5900
|
+
context.report({
|
|
5901
|
+
node: matchingBinding?.declarator ?? effectCall,
|
|
5902
|
+
message: `useState "${valueName}" is synchronized with an external store via useEffect — replace this useState + useEffect pair with useSyncExternalStore(subscribe, getSnapshot) to avoid tearing during concurrent renders`
|
|
5903
|
+
});
|
|
5904
|
+
}
|
|
5905
|
+
};
|
|
5906
|
+
return {
|
|
5907
|
+
FunctionDeclaration(node) {
|
|
5908
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
5909
|
+
checkComponent(node.body);
|
|
5910
|
+
},
|
|
5911
|
+
VariableDeclarator(node) {
|
|
5912
|
+
if (!isComponentAssignment(node)) return;
|
|
5913
|
+
checkComponent(node.init?.body);
|
|
5914
|
+
}
|
|
5915
|
+
};
|
|
5916
|
+
} };
|
|
5917
|
+
const SENTINEL_IDENTIFIER_NAMES = new Set([
|
|
5918
|
+
"undefined",
|
|
5919
|
+
"NaN",
|
|
5920
|
+
"null"
|
|
5921
|
+
]);
|
|
5922
|
+
const isSentinelIdentifier = (node) => node?.type === "Identifier" && SENTINEL_IDENTIFIER_NAMES.has(node.name);
|
|
5923
|
+
const getTriggerGuardRootName = (testNode) => {
|
|
5924
|
+
if (!testNode) return null;
|
|
5925
|
+
if (testNode.type === "Identifier") return testNode.name;
|
|
5926
|
+
if (testNode.type === "BinaryExpression") {
|
|
5927
|
+
if (![
|
|
5928
|
+
"!==",
|
|
5929
|
+
"===",
|
|
5930
|
+
"!=",
|
|
5931
|
+
"=="
|
|
5932
|
+
].includes(testNode.operator)) return null;
|
|
5933
|
+
for (const side of [testNode.left, testNode.right]) if (side?.type === "Identifier" && !isSentinelIdentifier(side)) return side.name;
|
|
5934
|
+
return null;
|
|
5935
|
+
}
|
|
5936
|
+
if (testNode.type === "MemberExpression" && testNode.property?.type === "Identifier" && testNode.property.name === "length") {
|
|
5937
|
+
if (testNode.object?.type === "Identifier") return testNode.object.name;
|
|
5938
|
+
}
|
|
5939
|
+
if (testNode.type === "UnaryExpression" && testNode.operator === "!") return getTriggerGuardRootName(testNode.argument);
|
|
5940
|
+
return null;
|
|
5941
|
+
};
|
|
5942
|
+
const findTriggeredSideEffectCalleeName = (consequentNode) => {
|
|
5943
|
+
let foundCalleeName = null;
|
|
5944
|
+
walkAst(consequentNode, (child) => {
|
|
5945
|
+
if (foundCalleeName) return false;
|
|
5946
|
+
if (child.type !== "CallExpression") return;
|
|
5947
|
+
const callee = child.callee;
|
|
5948
|
+
if (callee?.type === "Identifier" && EVENT_TRIGGERED_SIDE_EFFECT_CALLEES.has(callee.name)) {
|
|
5949
|
+
foundCalleeName = callee.name;
|
|
5950
|
+
return;
|
|
5951
|
+
}
|
|
5952
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier") {
|
|
5953
|
+
const propertyName = callee.property.name;
|
|
5954
|
+
const isUnambiguousMethod = EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS.has(propertyName);
|
|
5955
|
+
const isNavigationMethod = EVENT_TRIGGERED_NAVIGATION_METHOD_NAMES.has(propertyName);
|
|
5956
|
+
if (!isUnambiguousMethod && !isNavigationMethod) return;
|
|
5957
|
+
const rootName = getRootIdentifierName(callee);
|
|
5958
|
+
if (isNavigationMethod && (rootName === null || !NAVIGATION_RECEIVER_NAMES.has(rootName))) return;
|
|
5959
|
+
foundCalleeName = rootName ? `${rootName}.${propertyName}` : propertyName;
|
|
5960
|
+
}
|
|
5961
|
+
});
|
|
5962
|
+
return foundCalleeName;
|
|
5963
|
+
};
|
|
5964
|
+
const collectHandlerOnlyWriteStateNames = (componentBody, useStateBindings, handlerBindingNames) => {
|
|
5965
|
+
const handlerOnlyWriteStateNames = /* @__PURE__ */ new Set();
|
|
5966
|
+
for (const binding of useStateBindings) {
|
|
5967
|
+
let didFindAnySetterCall = false;
|
|
5968
|
+
let areAllSetterCallsInHandlers = true;
|
|
5969
|
+
walkAst(componentBody, (child) => {
|
|
5970
|
+
if (!areAllSetterCallsInHandlers) return false;
|
|
5971
|
+
if (child.type !== "CallExpression") return;
|
|
5972
|
+
if (child.callee?.type !== "Identifier") return;
|
|
5973
|
+
if (child.callee.name !== binding.setterName) return;
|
|
5974
|
+
didFindAnySetterCall = true;
|
|
5975
|
+
if (!isInsideEventHandler(child, handlerBindingNames)) areAllSetterCallsInHandlers = false;
|
|
5976
|
+
});
|
|
5977
|
+
if (didFindAnySetterCall && areAllSetterCallsInHandlers) handlerOnlyWriteStateNames.add(binding.valueName);
|
|
5978
|
+
}
|
|
5979
|
+
return handlerOnlyWriteStateNames;
|
|
5980
|
+
};
|
|
5981
|
+
const noEventTriggerState = { create: (context) => {
|
|
5982
|
+
const checkComponent = (componentBody) => {
|
|
5983
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
5984
|
+
const useStateBindings = collectUseStateBindings(componentBody);
|
|
5985
|
+
if (useStateBindings.length === 0) return;
|
|
5986
|
+
const handlerOnlyWriteStateNames = collectHandlerOnlyWriteStateNames(componentBody, useStateBindings, collectHandlerBindingNames(componentBody));
|
|
5987
|
+
if (handlerOnlyWriteStateNames.size === 0) return;
|
|
5988
|
+
const returnExpressions = collectReturnExpressions(componentBody);
|
|
5989
|
+
const dependencyGraph = buildLocalDependencyGraph(componentBody);
|
|
5990
|
+
const renderReachableNames = expandTransitiveDependencies(collectRenderReachableNames(returnExpressions), dependencyGraph);
|
|
5991
|
+
walkAst(componentBody, (effectCall) => {
|
|
5992
|
+
if (effectCall.type !== "CallExpression") return;
|
|
5993
|
+
if (!isHookCall(effectCall, EFFECT_HOOK_NAMES)) return;
|
|
5994
|
+
if ((effectCall.arguments?.length ?? 0) < 2) return;
|
|
5995
|
+
const depsNode = effectCall.arguments[1];
|
|
5996
|
+
if (depsNode.type !== "ArrayExpression") return;
|
|
5997
|
+
if ((depsNode.elements?.length ?? 0) !== 1) return;
|
|
5998
|
+
const depElement = depsNode.elements[0];
|
|
5999
|
+
if (depElement?.type !== "Identifier") return;
|
|
6000
|
+
if (!handlerOnlyWriteStateNames.has(depElement.name)) return;
|
|
6001
|
+
if (renderReachableNames.has(depElement.name)) return;
|
|
6002
|
+
const callback = getEffectCallback(effectCall);
|
|
6003
|
+
if (!callback) return;
|
|
6004
|
+
const bodyStatements = getCallbackStatements(callback);
|
|
6005
|
+
if (bodyStatements.length !== 1) return;
|
|
6006
|
+
const soleStatement = bodyStatements[0];
|
|
6007
|
+
if (soleStatement.type !== "IfStatement") return;
|
|
6008
|
+
if (getTriggerGuardRootName(soleStatement.test) !== depElement.name) return;
|
|
6009
|
+
const sideEffectCalleeName = findTriggeredSideEffectCalleeName(soleStatement.consequent);
|
|
6010
|
+
if (!sideEffectCalleeName) return;
|
|
6011
|
+
context.report({
|
|
6012
|
+
node: effectCall,
|
|
6013
|
+
message: `useState "${depElement.name}" exists only to schedule "${sideEffectCalleeName}(...)" from a useEffect — call "${sideEffectCalleeName}(...)" directly inside the event handler that sets it, and delete the state`
|
|
6014
|
+
});
|
|
6015
|
+
});
|
|
6016
|
+
};
|
|
6017
|
+
return {
|
|
6018
|
+
FunctionDeclaration(node) {
|
|
6019
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
6020
|
+
checkComponent(node.body);
|
|
6021
|
+
},
|
|
6022
|
+
VariableDeclarator(node) {
|
|
6023
|
+
if (!isComponentAssignment(node)) return;
|
|
6024
|
+
checkComponent(node.init?.body);
|
|
6025
|
+
}
|
|
6026
|
+
};
|
|
6027
|
+
} };
|
|
6028
|
+
const findTopLevelEffectCalls = (componentBody) => {
|
|
6029
|
+
const effectCalls = [];
|
|
6030
|
+
if (componentBody?.type !== "BlockStatement") return effectCalls;
|
|
6031
|
+
for (const statement of componentBody.body ?? []) {
|
|
6032
|
+
if (statement.type !== "ExpressionStatement") continue;
|
|
6033
|
+
const expression = statement.expression;
|
|
6034
|
+
if (expression?.type !== "CallExpression") continue;
|
|
6035
|
+
if (!isHookCall(expression, EFFECT_HOOK_NAMES)) continue;
|
|
6036
|
+
effectCalls.push(expression);
|
|
6037
|
+
}
|
|
6038
|
+
return effectCalls;
|
|
6039
|
+
};
|
|
6040
|
+
const collectDepIdentifierNames = (effectNode) => {
|
|
6041
|
+
const depNames = /* @__PURE__ */ new Set();
|
|
6042
|
+
const depsNode = effectNode.arguments?.[1];
|
|
6043
|
+
if (depsNode?.type !== "ArrayExpression") return depNames;
|
|
6044
|
+
for (const element of depsNode.elements ?? []) if (element?.type === "Identifier") depNames.add(element.name);
|
|
6045
|
+
return depNames;
|
|
6046
|
+
};
|
|
6047
|
+
const collectWrittenStateNamesInEffect = (effectCallback, setterToStateName) => {
|
|
6048
|
+
const writtenStateNames = /* @__PURE__ */ new Set();
|
|
6049
|
+
walkInsideStatementBlocks(effectCallback.body, (child) => {
|
|
6050
|
+
if (child.type !== "CallExpression") return;
|
|
6051
|
+
if (child.callee?.type !== "Identifier") return;
|
|
6052
|
+
const stateName = setterToStateName.get(child.callee.name);
|
|
6053
|
+
if (stateName) writtenStateNames.add(stateName);
|
|
6054
|
+
});
|
|
6055
|
+
return writtenStateNames;
|
|
6056
|
+
};
|
|
6057
|
+
const isFunctionShapedReturn = (returnedValue) => {
|
|
6058
|
+
if (returnedValue.type === "ArrowFunctionExpression" || returnedValue.type === "FunctionExpression") return true;
|
|
6059
|
+
if (returnedValue.type === "CallExpression") return true;
|
|
6060
|
+
if (returnedValue.type === "Identifier") return true;
|
|
6061
|
+
return false;
|
|
6062
|
+
};
|
|
6063
|
+
const isExternalSyncEffect = (effectCallback) => {
|
|
6064
|
+
if (effectCallback.body?.type === "BlockStatement") {
|
|
6065
|
+
const statements = effectCallback.body.body ?? [];
|
|
6066
|
+
for (const statement of statements) if (statement.type === "ReturnStatement" && statement.argument && isFunctionShapedReturn(statement.argument)) return true;
|
|
6067
|
+
}
|
|
6068
|
+
let didFindExternalCall = false;
|
|
6069
|
+
walkAst(effectCallback, (child) => {
|
|
6070
|
+
if (didFindExternalCall) return false;
|
|
6071
|
+
if (child.type === "NewExpression") {
|
|
6072
|
+
const constructor = child.callee;
|
|
6073
|
+
if (constructor?.type === "Identifier" && EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS.has(constructor.name)) didFindExternalCall = true;
|
|
6074
|
+
return;
|
|
6075
|
+
}
|
|
6076
|
+
if (child.type === "AssignmentExpression") {
|
|
6077
|
+
if (child.left?.type === "MemberExpression" && child.left.property?.type === "Identifier" && child.left.property.name === "current") didFindExternalCall = true;
|
|
6078
|
+
return;
|
|
6079
|
+
}
|
|
6080
|
+
if (child.type !== "CallExpression") return;
|
|
6081
|
+
if (child.callee?.type === "Identifier" && EXTERNAL_SYNC_DIRECT_CALLEE_NAMES.has(child.callee.name)) {
|
|
6082
|
+
didFindExternalCall = true;
|
|
6083
|
+
return;
|
|
6084
|
+
}
|
|
6085
|
+
if (child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier") {
|
|
6086
|
+
const propertyName = child.callee.property.name;
|
|
6087
|
+
if (EXTERNAL_SYNC_MEMBER_METHOD_NAMES.has(propertyName)) {
|
|
6088
|
+
didFindExternalCall = true;
|
|
6089
|
+
return;
|
|
6090
|
+
}
|
|
6091
|
+
if (EXTERNAL_SYNC_AMBIGUOUS_HTTP_METHOD_NAMES.has(propertyName)) {
|
|
6092
|
+
const receiverRootName = getRootIdentifierName(child.callee.object);
|
|
6093
|
+
if (receiverRootName !== null && EXTERNAL_SYNC_HTTP_CLIENT_RECEIVERS.has(receiverRootName)) didFindExternalCall = true;
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
});
|
|
6097
|
+
return didFindExternalCall;
|
|
6098
|
+
};
|
|
6099
|
+
const noEffectChain = { create: (context) => {
|
|
6100
|
+
const checkComponent = (componentBody) => {
|
|
6101
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
6102
|
+
const useStateBindings = collectUseStateBindings(componentBody);
|
|
6103
|
+
if (useStateBindings.length === 0) return;
|
|
6104
|
+
const setterToStateName = /* @__PURE__ */ new Map();
|
|
6105
|
+
for (const binding of useStateBindings) setterToStateName.set(binding.setterName, binding.valueName);
|
|
6106
|
+
const effectInfos = [];
|
|
6107
|
+
for (const effectCall of findTopLevelEffectCalls(componentBody)) {
|
|
6108
|
+
const callback = getEffectCallback(effectCall);
|
|
6109
|
+
if (!callback) continue;
|
|
6110
|
+
effectInfos.push({
|
|
6111
|
+
node: effectCall,
|
|
6112
|
+
depNames: collectDepIdentifierNames(effectCall),
|
|
6113
|
+
writtenStateNames: collectWrittenStateNamesInEffect(callback, setterToStateName),
|
|
6114
|
+
isExternalSync: isExternalSyncEffect(callback)
|
|
6115
|
+
});
|
|
6116
|
+
}
|
|
6117
|
+
if (effectInfos.length < 2) return;
|
|
6118
|
+
const reportedNodes = /* @__PURE__ */ new Set();
|
|
6119
|
+
for (const writerEffect of effectInfos) {
|
|
6120
|
+
if (writerEffect.isExternalSync) continue;
|
|
6121
|
+
if (writerEffect.writtenStateNames.size === 0) continue;
|
|
6122
|
+
for (const readerEffect of effectInfos) {
|
|
6123
|
+
if (readerEffect === writerEffect) continue;
|
|
6124
|
+
if (readerEffect.isExternalSync) continue;
|
|
6125
|
+
if (readerEffect.depNames.size === 0) continue;
|
|
6126
|
+
let chainedStateName = null;
|
|
6127
|
+
for (const writtenName of writerEffect.writtenStateNames) if (readerEffect.depNames.has(writtenName)) {
|
|
6128
|
+
chainedStateName = writtenName;
|
|
6129
|
+
break;
|
|
6130
|
+
}
|
|
6131
|
+
if (!chainedStateName) continue;
|
|
6132
|
+
if (reportedNodes.has(readerEffect.node)) continue;
|
|
6133
|
+
reportedNodes.add(readerEffect.node);
|
|
6134
|
+
context.report({
|
|
6135
|
+
node: readerEffect.node,
|
|
6136
|
+
message: `useEffect reacts to "${chainedStateName}" which is set by another useEffect — chains of effects add an extra render per link and become rigid as code evolves. Compute what you can during render and write all related state inside the event handler that originally fires the chain`
|
|
6137
|
+
});
|
|
6138
|
+
}
|
|
6139
|
+
}
|
|
6140
|
+
};
|
|
6141
|
+
return {
|
|
6142
|
+
FunctionDeclaration(node) {
|
|
6143
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
6144
|
+
checkComponent(node.body);
|
|
6145
|
+
},
|
|
6146
|
+
VariableDeclarator(node) {
|
|
6147
|
+
if (!isComponentAssignment(node)) return;
|
|
6148
|
+
checkComponent(node.init?.body);
|
|
6149
|
+
}
|
|
6150
|
+
};
|
|
6151
|
+
} };
|
|
6152
|
+
const collectUseRefBindingNames = (componentBody) => {
|
|
6153
|
+
const useRefBindings = /* @__PURE__ */ new Set();
|
|
6154
|
+
if (componentBody?.type !== "BlockStatement") return useRefBindings;
|
|
6155
|
+
for (const statement of componentBody.body ?? []) {
|
|
6156
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
6157
|
+
for (const declarator of statement.declarations ?? []) {
|
|
6158
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
6159
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
6160
|
+
if (!isHookCall(declarator.init, "useRef")) continue;
|
|
6161
|
+
useRefBindings.add(declarator.id.name);
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
6164
|
+
return useRefBindings;
|
|
6165
|
+
};
|
|
6166
|
+
const findMutableDepIssue = (depElement, useRefBindingNames) => {
|
|
6167
|
+
if (depElement.type !== "MemberExpression") return null;
|
|
6168
|
+
if (depElement.property?.type === "Identifier" && depElement.property.name === "current" && !depElement.computed && depElement.object?.type === "Identifier" && useRefBindingNames.has(depElement.object.name)) return {
|
|
6169
|
+
kind: "ref-current",
|
|
6170
|
+
rootName: depElement.object.name
|
|
6171
|
+
};
|
|
6172
|
+
const rootName = getRootIdentifierName(depElement);
|
|
6173
|
+
if (rootName !== null && MUTABLE_GLOBAL_ROOTS.has(rootName)) return {
|
|
6174
|
+
kind: "global",
|
|
6175
|
+
rootName
|
|
6176
|
+
};
|
|
6177
|
+
return null;
|
|
6178
|
+
};
|
|
6179
|
+
const getPropRootName = (expression, propNames) => {
|
|
6180
|
+
const rootName = getRootIdentifierName(expression, { followCallChains: true });
|
|
6181
|
+
return rootName !== null && propNames.has(rootName) ? rootName : null;
|
|
6182
|
+
};
|
|
6183
|
+
const findSubscribeLikeUsages = (callback) => {
|
|
6184
|
+
const usages = [];
|
|
6185
|
+
let cleanupArgument = null;
|
|
6186
|
+
if (callback.body?.type === "BlockStatement") {
|
|
6187
|
+
const callbackStatements = callback.body.body ?? [];
|
|
6188
|
+
const lastCallbackStatement = callbackStatements[callbackStatements.length - 1];
|
|
6189
|
+
if (lastCallbackStatement?.type === "ReturnStatement" && lastCallbackStatement.argument) cleanupArgument = lastCallbackStatement.argument;
|
|
6190
|
+
}
|
|
6191
|
+
walkAst(callback, (child) => {
|
|
6192
|
+
if (child === cleanupArgument) return false;
|
|
6193
|
+
if (child.type !== "CallExpression") return;
|
|
6194
|
+
if (child.callee?.type === "Identifier" && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(child.callee.name)) {
|
|
6195
|
+
usages.push({
|
|
6196
|
+
kind: "timer",
|
|
6197
|
+
resourceName: child.callee.name
|
|
6198
|
+
});
|
|
6199
|
+
return;
|
|
6200
|
+
}
|
|
6201
|
+
if (child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name)) usages.push({
|
|
6202
|
+
kind: "subscribe",
|
|
6203
|
+
resourceName: child.callee.property.name
|
|
6204
|
+
});
|
|
6205
|
+
});
|
|
6206
|
+
return usages;
|
|
6207
|
+
};
|
|
6208
|
+
const isSubscribeLikeCallExpression = (node) => {
|
|
6209
|
+
if (node?.type !== "CallExpression") return false;
|
|
6210
|
+
if (node.callee?.type !== "MemberExpression") return false;
|
|
6211
|
+
if (node.callee.property?.type !== "Identifier") return false;
|
|
6212
|
+
return SUBSCRIPTION_METHOD_NAMES.has(node.callee.property.name);
|
|
6213
|
+
};
|
|
6214
|
+
const collectReleasableBindingNames = (effectCallback) => {
|
|
6215
|
+
const releasableNames = /* @__PURE__ */ new Set();
|
|
6216
|
+
if (effectCallback.body?.type !== "BlockStatement") return releasableNames;
|
|
6217
|
+
for (const statement of effectCallback.body.body ?? []) {
|
|
6218
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
6219
|
+
for (const declarator of statement.declarations ?? []) {
|
|
6220
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
6221
|
+
const init = declarator.init;
|
|
6222
|
+
if (!init || init.type !== "CallExpression") continue;
|
|
6223
|
+
if (isSubscribeLikeCallExpression(init)) {
|
|
6224
|
+
releasableNames.add(declarator.id.name);
|
|
6225
|
+
continue;
|
|
6226
|
+
}
|
|
6227
|
+
if (init.callee?.type === "Identifier" && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(init.callee.name)) releasableNames.add(declarator.id.name);
|
|
6228
|
+
}
|
|
6229
|
+
}
|
|
6230
|
+
return releasableNames;
|
|
6231
|
+
};
|
|
6232
|
+
const isReleaseLikeCall = (callNode, knownBoundReleaseNames) => {
|
|
6233
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
6234
|
+
const callee = callNode.callee;
|
|
6235
|
+
if (callee?.type === "Identifier") {
|
|
6236
|
+
if (TIMER_CLEANUP_CALLEE_NAMES.has(callee.name)) return true;
|
|
6237
|
+
if (CLEANUP_LIKE_RELEASE_CALLEE_NAMES.has(callee.name)) return true;
|
|
6238
|
+
if (knownBoundReleaseNames.has(callee.name)) return true;
|
|
6239
|
+
return false;
|
|
6240
|
+
}
|
|
6241
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier") return UNSUBSCRIPTION_METHOD_NAMES.has(callee.property.name);
|
|
6242
|
+
return false;
|
|
6243
|
+
};
|
|
6244
|
+
const containsReleaseLikeCall = (node, knownBoundReleaseNames) => {
|
|
6245
|
+
let didFindRelease = false;
|
|
6246
|
+
walkAst(node, (child) => {
|
|
6247
|
+
if (didFindRelease) return false;
|
|
6248
|
+
if (isReleaseLikeCall(child, knownBoundReleaseNames)) {
|
|
6249
|
+
didFindRelease = true;
|
|
6250
|
+
return false;
|
|
6251
|
+
}
|
|
6252
|
+
});
|
|
6253
|
+
return didFindRelease;
|
|
6254
|
+
};
|
|
6255
|
+
const isCleanupReturn = (returnedValue, knownBoundReleaseNames) => {
|
|
6256
|
+
if (!returnedValue) return false;
|
|
6257
|
+
if (returnedValue.type === "Identifier") return knownBoundReleaseNames.has(returnedValue.name);
|
|
6258
|
+
if (isSubscribeLikeCallExpression(returnedValue)) return true;
|
|
6259
|
+
if (returnedValue.type === "ArrowFunctionExpression" || returnedValue.type === "FunctionExpression") return containsReleaseLikeCall(returnedValue, knownBoundReleaseNames);
|
|
6260
|
+
return false;
|
|
6261
|
+
};
|
|
6262
|
+
const effectHasCleanupRelease = (callback) => {
|
|
6263
|
+
if (callback.body?.type !== "BlockStatement") return isSubscribeLikeCallExpression(callback.body);
|
|
6264
|
+
const knownBoundReleaseNames = collectReleasableBindingNames(callback);
|
|
6265
|
+
let didFindCleanupReturn = false;
|
|
6266
|
+
walkInsideStatementBlocks(callback.body, (child) => {
|
|
6267
|
+
if (didFindCleanupReturn) return;
|
|
6268
|
+
if (child.type !== "ReturnStatement") return;
|
|
6269
|
+
if (isCleanupReturn(child.argument, knownBoundReleaseNames)) didFindCleanupReturn = true;
|
|
6270
|
+
});
|
|
6271
|
+
return didFindCleanupReturn;
|
|
6272
|
+
};
|
|
6273
|
+
const effectNeedsCleanup = { create: (context) => ({ CallExpression(node) {
|
|
6274
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
6275
|
+
const callback = getEffectCallback(node);
|
|
6276
|
+
if (!callback) return;
|
|
6277
|
+
const usages = findSubscribeLikeUsages(callback);
|
|
6278
|
+
if (usages.length === 0) return;
|
|
6279
|
+
if (effectHasCleanupRelease(callback)) return;
|
|
6280
|
+
const firstUsage = usages[0];
|
|
6281
|
+
const verb = firstUsage.kind === "timer" ? "schedules" : "subscribes via";
|
|
6282
|
+
const release = firstUsage.kind === "timer" ? `clear${firstUsage.resourceName === "setInterval" ? "Interval" : "Timeout"}(...)` : "the matching remove/unsubscribe call";
|
|
6283
|
+
context.report({
|
|
6284
|
+
node,
|
|
6285
|
+
message: `useEffect ${verb} \`${firstUsage.resourceName}(...)\` but never returns a cleanup — leaks the registration on every re-run and on unmount. Return a cleanup function that calls ${release}`
|
|
6286
|
+
});
|
|
6287
|
+
} }) };
|
|
6288
|
+
const noMirrorPropEffect = { create: (context) => {
|
|
6289
|
+
const checkComponent = (componentBody) => {
|
|
6290
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
6291
|
+
const propNames = propStackTracker.getCurrentPropNames();
|
|
6292
|
+
if (propNames.size === 0) return;
|
|
6293
|
+
const mirrorBindings = [];
|
|
6294
|
+
for (const statement of componentBody.body ?? []) {
|
|
6295
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
6296
|
+
for (const declarator of statement.declarations ?? []) {
|
|
6297
|
+
if (declarator.id?.type !== "ArrayPattern") continue;
|
|
6298
|
+
const elements = declarator.id.elements ?? [];
|
|
6299
|
+
if (elements.length < 2) continue;
|
|
6300
|
+
const valueElement = elements[0];
|
|
6301
|
+
const setterElement = elements[1];
|
|
6302
|
+
if (valueElement?.type !== "Identifier" || setterElement?.type !== "Identifier" || !isSetterIdentifier(setterElement.name)) continue;
|
|
6303
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
6304
|
+
if (!isHookCall(declarator.init, "useState")) continue;
|
|
6305
|
+
const initializer = declarator.init.arguments?.[0];
|
|
6306
|
+
if (!initializer) continue;
|
|
6307
|
+
const propRootName = getPropRootName(initializer, propNames);
|
|
6308
|
+
if (!propRootName) continue;
|
|
6309
|
+
mirrorBindings.push({
|
|
6310
|
+
valueName: valueElement.name,
|
|
6311
|
+
setterName: setterElement.name,
|
|
6312
|
+
initializer,
|
|
6313
|
+
propRootName
|
|
6314
|
+
});
|
|
6315
|
+
}
|
|
6316
|
+
}
|
|
6317
|
+
if (mirrorBindings.length === 0) return;
|
|
6318
|
+
for (const statement of componentBody.body ?? []) {
|
|
6319
|
+
if (statement.type !== "ExpressionStatement") continue;
|
|
6320
|
+
const effectCall = statement.expression;
|
|
6321
|
+
if (effectCall?.type !== "CallExpression") continue;
|
|
6322
|
+
if (!isHookCall(effectCall, EFFECT_HOOK_NAMES)) continue;
|
|
6323
|
+
if ((effectCall.arguments?.length ?? 0) < 2) continue;
|
|
6324
|
+
const depsNode = effectCall.arguments[1];
|
|
6325
|
+
if (depsNode.type !== "ArrayExpression") continue;
|
|
6326
|
+
const depIdentifierNames = /* @__PURE__ */ new Set();
|
|
6327
|
+
for (const element of depsNode.elements ?? []) if (element?.type === "Identifier") depIdentifierNames.add(element.name);
|
|
6328
|
+
if (depIdentifierNames.size === 0) continue;
|
|
6329
|
+
const callback = getEffectCallback(effectCall);
|
|
6330
|
+
if (!callback) continue;
|
|
6331
|
+
const bodyStatements = getCallbackStatements(callback);
|
|
6332
|
+
if (bodyStatements.length !== 1) continue;
|
|
6333
|
+
const onlyStatement = bodyStatements[0];
|
|
6334
|
+
const expression = onlyStatement.type === "ExpressionStatement" ? onlyStatement.expression : onlyStatement;
|
|
6335
|
+
if (expression?.type !== "CallExpression") continue;
|
|
6336
|
+
if (expression.callee?.type !== "Identifier") continue;
|
|
6337
|
+
if (!isSetterIdentifier(expression.callee.name)) continue;
|
|
6338
|
+
if (!expression.arguments?.length) continue;
|
|
6339
|
+
const setterArgument = expression.arguments[0];
|
|
6340
|
+
const matchedBinding = mirrorBindings.find((binding) => binding.setterName === expression.callee.name && depIdentifierNames.has(binding.propRootName) && areExpressionsStructurallyEqual(binding.initializer, setterArgument));
|
|
6341
|
+
if (!matchedBinding) continue;
|
|
6342
|
+
context.report({
|
|
6343
|
+
node: effectCall,
|
|
6344
|
+
message: `useState "${matchedBinding.valueName}" is mirrored from prop "${matchedBinding.propRootName}" via this effect — delete both the useState and the effect, and read the prop directly in render`
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
};
|
|
6348
|
+
const propStackTracker = createComponentPropStackTracker({ onComponentEnter: checkComponent });
|
|
6349
|
+
return propStackTracker.visitors;
|
|
6350
|
+
} };
|
|
6351
|
+
const noMutableInDeps = { create: (context) => {
|
|
6352
|
+
const checkComponent = (componentBody) => {
|
|
6353
|
+
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
6354
|
+
const useRefBindingNames = collectUseRefBindingNames(componentBody);
|
|
6355
|
+
walkAst(componentBody, (child) => {
|
|
6356
|
+
if (child.type !== "CallExpression") return;
|
|
6357
|
+
if (!isHookCall(child, HOOKS_WITH_DEPS)) return;
|
|
6358
|
+
if ((child.arguments?.length ?? 0) < 2) return;
|
|
6359
|
+
const depsNode = child.arguments[1];
|
|
6360
|
+
if (depsNode.type !== "ArrayExpression") return;
|
|
6361
|
+
for (const element of depsNode.elements ?? []) {
|
|
6362
|
+
if (!element) continue;
|
|
6363
|
+
const issue = findMutableDepIssue(element, useRefBindingNames);
|
|
6364
|
+
if (!issue) continue;
|
|
6365
|
+
if (issue.kind === "ref-current") context.report({
|
|
6366
|
+
node: element,
|
|
6367
|
+
message: `"${issue.rootName}.current" in deps — refs are mutable and don't trigger re-renders, so React won't re-run this effect when it changes. Read the ref inside the effect body instead`
|
|
6368
|
+
});
|
|
6369
|
+
else context.report({
|
|
6370
|
+
node: element,
|
|
6371
|
+
message: `Mutable global "${issue.rootName}.*" in deps — values like \`location.pathname\` can change without triggering a re-render, so they can't drive effect re-runs. Subscribe with useSyncExternalStore or read inside the effect`
|
|
6372
|
+
});
|
|
6373
|
+
}
|
|
6374
|
+
});
|
|
6375
|
+
};
|
|
6376
|
+
return {
|
|
6377
|
+
FunctionDeclaration(node) {
|
|
6378
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
6379
|
+
checkComponent(node.body);
|
|
6380
|
+
},
|
|
6381
|
+
VariableDeclarator(node) {
|
|
6382
|
+
if (!isComponentAssignment(node)) return;
|
|
6383
|
+
checkComponent(node.init?.body);
|
|
6384
|
+
}
|
|
6385
|
+
};
|
|
6386
|
+
} };
|
|
6387
|
+
const collectFunctionTypedLocalBindings = (componentBody) => {
|
|
6388
|
+
const functionTypedLocals = /* @__PURE__ */ new Set();
|
|
6389
|
+
if (componentBody?.type !== "BlockStatement") return functionTypedLocals;
|
|
6390
|
+
for (const statement of componentBody.body ?? []) {
|
|
6391
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
6392
|
+
for (const declarator of statement.declarations ?? []) {
|
|
6393
|
+
if (declarator.id?.type !== "Identifier") continue;
|
|
6394
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
6395
|
+
if (!isHookCall(declarator.init, "useCallback")) continue;
|
|
6396
|
+
functionTypedLocals.add(declarator.id.name);
|
|
6397
|
+
}
|
|
6398
|
+
}
|
|
6399
|
+
return functionTypedLocals;
|
|
6400
|
+
};
|
|
6401
|
+
const findEnclosingFunctionInsideEffect = (identifierNode, effectCallback) => {
|
|
6402
|
+
let cursor = identifierNode.parent ?? null;
|
|
6403
|
+
while (cursor && cursor !== effectCallback) {
|
|
6404
|
+
if (cursor.type === "ArrowFunctionExpression" || cursor.type === "FunctionExpression" || cursor.type === "FunctionDeclaration") return cursor;
|
|
6405
|
+
cursor = cursor.parent ?? null;
|
|
6406
|
+
}
|
|
6407
|
+
return null;
|
|
6408
|
+
};
|
|
6409
|
+
const isCallExpressionWithSubHandlerCallee = (callExpression) => {
|
|
6410
|
+
if (callExpression?.type !== "CallExpression") return false;
|
|
6411
|
+
const callee = callExpression.callee;
|
|
6412
|
+
if (callee?.type === "Identifier" && TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES.has(callee.name)) return true;
|
|
6413
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && SUBSCRIPTION_METHOD_NAMES.has(callee.property.name)) return true;
|
|
6414
|
+
return false;
|
|
6415
|
+
};
|
|
6416
|
+
const getSubHandlerCalleeName = (callExpression) => {
|
|
6417
|
+
if (callExpression?.type !== "CallExpression") return null;
|
|
6418
|
+
const callee = callExpression.callee;
|
|
6419
|
+
if (callee?.type === "Identifier") return callee.name;
|
|
6420
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier") return callee.property.name;
|
|
6421
|
+
return null;
|
|
6422
|
+
};
|
|
6423
|
+
const getEnclosingFunctionBindingName = (enclosingFunction) => {
|
|
6424
|
+
if (enclosingFunction.type === "FunctionDeclaration" && enclosingFunction.id?.type === "Identifier") return enclosingFunction.id.name;
|
|
6425
|
+
const directParent = enclosingFunction.parent;
|
|
6426
|
+
if (directParent?.type === "VariableDeclarator" && directParent.id?.type === "Identifier") return directParent.id.name;
|
|
6427
|
+
if (directParent?.type === "AssignmentExpression" && directParent.right === enclosingFunction && directParent.left?.type === "Identifier") return directParent.left.name;
|
|
6428
|
+
return null;
|
|
6429
|
+
};
|
|
6430
|
+
const findSubHandlerForEnclosingFunction = (enclosingFunction, effectCallback) => {
|
|
6431
|
+
const directParent = enclosingFunction.parent;
|
|
6432
|
+
if (directParent?.type === "CallExpression" && directParent.arguments?.includes(enclosingFunction) && isCallExpressionWithSubHandlerCallee(directParent)) return directParent;
|
|
6433
|
+
const localName = getEnclosingFunctionBindingName(enclosingFunction);
|
|
6434
|
+
if (localName === null) return null;
|
|
6435
|
+
let matchingSubHandlerCall = null;
|
|
6436
|
+
walkAst(effectCallback, (child) => {
|
|
6437
|
+
if (matchingSubHandlerCall) return false;
|
|
6438
|
+
if (child.type !== "CallExpression") return;
|
|
6439
|
+
if (!isCallExpressionWithSubHandlerCallee(child)) return;
|
|
6440
|
+
for (const argument of child.arguments ?? []) if (argument?.type === "Identifier" && argument.name === localName) {
|
|
6441
|
+
matchingSubHandlerCall = child;
|
|
6442
|
+
return false;
|
|
6443
|
+
}
|
|
6444
|
+
});
|
|
6445
|
+
return matchingSubHandlerCall;
|
|
6446
|
+
};
|
|
6447
|
+
const classifyCallableReadsInsideEffect = (callableName, effectCallback) => {
|
|
6448
|
+
let hasAnyRead = false;
|
|
6449
|
+
let allReadsAreInSubHandlers = true;
|
|
6450
|
+
let firstSubHandlerName = null;
|
|
6451
|
+
walkAst(effectCallback, (child) => {
|
|
6452
|
+
if (child.type !== "Identifier") return;
|
|
6453
|
+
if (child.name !== callableName) return;
|
|
6454
|
+
const parent = child.parent;
|
|
6455
|
+
if (parent?.type === "ArrayExpression") return;
|
|
6456
|
+
if (parent?.type === "MemberExpression" && !parent.computed && parent.property === child) return;
|
|
6457
|
+
if (parent?.type === "Property" && !parent.computed && !parent.shorthand && parent.key === child) return;
|
|
6458
|
+
hasAnyRead = true;
|
|
6459
|
+
const enclosingFunction = findEnclosingFunctionInsideEffect(child, effectCallback);
|
|
6460
|
+
if (!enclosingFunction) {
|
|
6461
|
+
allReadsAreInSubHandlers = false;
|
|
6462
|
+
return;
|
|
6463
|
+
}
|
|
6464
|
+
const subHandlerCall = findSubHandlerForEnclosingFunction(enclosingFunction, effectCallback);
|
|
6465
|
+
if (!subHandlerCall) {
|
|
6466
|
+
allReadsAreInSubHandlers = false;
|
|
6467
|
+
return;
|
|
6468
|
+
}
|
|
6469
|
+
if (firstSubHandlerName === null) firstSubHandlerName = getSubHandlerCalleeName(subHandlerCall);
|
|
6470
|
+
});
|
|
6471
|
+
return {
|
|
6472
|
+
hasAnyRead,
|
|
6473
|
+
allReadsAreInSubHandlers,
|
|
6474
|
+
firstSubHandlerName
|
|
6475
|
+
};
|
|
6476
|
+
};
|
|
6477
|
+
//#endregion
|
|
6478
|
+
//#region src/plugin/index.ts
|
|
6479
|
+
const plugin = {
|
|
6480
|
+
meta: { name: "react-doctor" },
|
|
4819
6481
|
rules: {
|
|
4820
6482
|
"no-derived-state-effect": noDerivedStateEffect,
|
|
4821
6483
|
"no-fetch-in-effect": noFetchInEffect,
|
|
6484
|
+
"no-mirror-prop-effect": noMirrorPropEffect,
|
|
6485
|
+
"no-mutable-in-deps": noMutableInDeps,
|
|
4822
6486
|
"no-cascading-set-state": noCascadingSetState,
|
|
6487
|
+
"no-effect-chain": noEffectChain,
|
|
4823
6488
|
"no-effect-event-handler": noEffectEventHandler,
|
|
4824
6489
|
"no-effect-event-in-deps": noEffectEventInDeps,
|
|
6490
|
+
"no-event-trigger-state": noEventTriggerState,
|
|
4825
6491
|
"no-prop-callback-in-effect": noPropCallbackInEffect,
|
|
4826
6492
|
"no-derived-useState": noDerivedUseState,
|
|
4827
|
-
"
|
|
4828
|
-
"
|
|
4829
|
-
"
|
|
4830
|
-
"rerender-dependencies": rerenderDependencies,
|
|
4831
|
-
"rerender-state-only-in-handlers": rerenderStateOnlyInHandlers,
|
|
4832
|
-
"rerender-defer-reads-hook": { create: (context) => {
|
|
6493
|
+
"no-direct-state-mutation": noDirectStateMutation,
|
|
6494
|
+
"no-set-state-in-render": noSetStateInRender,
|
|
6495
|
+
"prefer-use-effect-event": { create: (context) => {
|
|
4833
6496
|
const checkComponent = (componentBody) => {
|
|
4834
6497
|
if (!componentBody || componentBody.type !== "BlockStatement") return;
|
|
4835
|
-
const
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
if (
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
6498
|
+
const functionTypedLocalBindings = collectFunctionTypedLocalBindings(componentBody);
|
|
6499
|
+
for (const statement of componentBody.body ?? []) {
|
|
6500
|
+
if (statement.type !== "ExpressionStatement") continue;
|
|
6501
|
+
const effectCall = statement.expression;
|
|
6502
|
+
if (effectCall?.type !== "CallExpression") continue;
|
|
6503
|
+
if (!isHookCall(effectCall, EFFECT_HOOK_NAMES)) continue;
|
|
6504
|
+
if ((effectCall.arguments?.length ?? 0) < 2) continue;
|
|
6505
|
+
const depsNode = effectCall.arguments[1];
|
|
6506
|
+
if (depsNode.type !== "ArrayExpression") continue;
|
|
6507
|
+
const depElements = depsNode.elements ?? [];
|
|
6508
|
+
if (depElements.length < 2) continue;
|
|
6509
|
+
if (!depElements.every((element) => element?.type === "Identifier")) continue;
|
|
6510
|
+
const callback = getEffectCallback(effectCall);
|
|
6511
|
+
if (!callback) continue;
|
|
6512
|
+
for (const depElement of depElements) {
|
|
6513
|
+
if (!depElement) continue;
|
|
6514
|
+
const depName = depElement.name;
|
|
6515
|
+
const isFunctionTypedPropDep = propStackTracker.isPropName(depName) && REACT_HANDLER_PROP_PATTERN.test(depName);
|
|
6516
|
+
const isFunctionTypedLocalDep = functionTypedLocalBindings.has(depName);
|
|
6517
|
+
if (!isFunctionTypedPropDep && !isFunctionTypedLocalDep) continue;
|
|
6518
|
+
const classification = classifyCallableReadsInsideEffect(depName, callback);
|
|
6519
|
+
if (!classification.hasAnyRead) continue;
|
|
6520
|
+
if (!classification.allReadsAreInSubHandlers) continue;
|
|
6521
|
+
const subHandlerLabel = classification.firstSubHandlerName ? `\`${classification.firstSubHandlerName}\`` : "an async sub-handler";
|
|
6522
|
+
context.report({
|
|
6523
|
+
node: depElement,
|
|
6524
|
+
message: `"${depName}" is read only inside ${subHandlerLabel} — wrap it with useEffectEvent and remove it from the dep array so the effect doesn't re-synchronize on every parent render`
|
|
6525
|
+
});
|
|
6526
|
+
}
|
|
4860
6527
|
}
|
|
4861
6528
|
};
|
|
6529
|
+
const propStackTracker = createComponentPropStackTracker({ onComponentEnter: checkComponent });
|
|
6530
|
+
return propStackTracker.visitors;
|
|
4862
6531
|
} },
|
|
6532
|
+
"prefer-useReducer": preferUseReducer,
|
|
6533
|
+
"prefer-use-sync-external-store": preferUseSyncExternalStore,
|
|
6534
|
+
"rerender-lazy-state-init": rerenderLazyStateInit,
|
|
6535
|
+
"rerender-functional-setstate": rerenderFunctionalSetstate,
|
|
6536
|
+
"rerender-dependencies": rerenderDependencies,
|
|
6537
|
+
"rerender-state-only-in-handlers": rerenderStateOnlyInHandlers,
|
|
6538
|
+
"rerender-defer-reads-hook": rerenderDeferReadsHook,
|
|
4863
6539
|
"advanced-event-handler-refs": advancedEventHandlerRefs,
|
|
6540
|
+
"effect-needs-cleanup": effectNeedsCleanup,
|
|
4864
6541
|
"no-generic-handler-names": noGenericHandlerNames,
|
|
4865
6542
|
"no-giant-component": noGiantComponent,
|
|
4866
6543
|
"no-many-boolean-props": noManyBooleanProps,
|
|
@@ -4869,6 +6546,10 @@ const plugin = {
|
|
|
4869
6546
|
"no-render-in-render": noRenderInRender,
|
|
4870
6547
|
"no-nested-component-definition": noNestedComponentDefinition,
|
|
4871
6548
|
"react-compiler-destructure-method": reactCompilerDestructureMethod,
|
|
6549
|
+
"no-legacy-class-lifecycles": noLegacyClassLifecycles,
|
|
6550
|
+
"no-legacy-context-api": noLegacyContextApi,
|
|
6551
|
+
"no-default-props": noDefaultProps,
|
|
6552
|
+
"no-react-dom-deprecated-apis": noReactDomDeprecatedApis,
|
|
4872
6553
|
"no-usememo-simple-expression": noUsememoSimpleExpression,
|
|
4873
6554
|
"no-layout-property-animation": noLayoutPropertyAnimation,
|
|
4874
6555
|
"rerender-memo-with-default-value": rerenderMemoWithDefaultValue,
|
|
@@ -4903,6 +6584,7 @@ const plugin = {
|
|
|
4903
6584
|
"rendering-conditional-render": renderingConditionalRender,
|
|
4904
6585
|
"rendering-svg-precision": renderingSvgPrecision,
|
|
4905
6586
|
"no-prevent-default": noPreventDefault,
|
|
6587
|
+
"no-uncontrolled-input": noUncontrolledInput,
|
|
4906
6588
|
"no-document-start-view-transition": { create: (context) => ({ CallExpression(node) {
|
|
4907
6589
|
const callee = node.callee;
|
|
4908
6590
|
if (callee?.type !== "MemberExpression") return;
|
|
@@ -5021,7 +6703,15 @@ const plugin = {
|
|
|
5021
6703
|
"no-layout-transition-inline": noLayoutTransitionInline,
|
|
5022
6704
|
"no-disabled-zoom": noDisabledZoom,
|
|
5023
6705
|
"no-outline-none": noOutlineNone,
|
|
5024
|
-
"no-long-transition-duration": noLongTransitionDuration
|
|
6706
|
+
"no-long-transition-duration": noLongTransitionDuration,
|
|
6707
|
+
"design-no-bold-heading": noBoldHeading,
|
|
6708
|
+
"design-no-redundant-padding-axes": noRedundantPaddingAxes,
|
|
6709
|
+
"design-no-redundant-size-axes": noRedundantSizeAxes,
|
|
6710
|
+
"design-no-space-on-flex-children": noSpaceOnFlexChildren,
|
|
6711
|
+
"design-no-em-dash-in-jsx-text": noEmDashInJsxText,
|
|
6712
|
+
"design-no-three-period-ellipsis": noThreePeriodEllipsis,
|
|
6713
|
+
"design-no-default-tailwind-palette": noDefaultTailwindPalette,
|
|
6714
|
+
"design-no-vague-button-label": noVagueButtonLabel
|
|
5025
6715
|
}
|
|
5026
6716
|
};
|
|
5027
6717
|
//#endregion
|