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.
@@ -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: "@react-native-async-storage/async-storage",
346
- Picker: "@react-native-picker/picker",
347
- PickerIOS: "@react-native-picker/picker",
348
- DatePickerIOS: "@react-native-community/datetimepicker",
349
- DatePickerAndroid: "@react-native-community/datetimepicker",
350
- ProgressBarAndroid: "a community alternative",
351
- ProgressViewIOS: "a community alternative",
352
- SafeAreaView: "react-native-safe-area-context",
353
- Slider: "@react-native-community/slider",
354
- ViewPagerAndroid: "react-native-pager-view",
355
- WebView: "react-native-webview",
356
- NetInfo: "@react-native-community/netinfo",
357
- CameraRoll: "@react-native-camera-roll/camera-roll",
358
- Clipboard: "@react-native-clipboard/clipboard",
359
- ImageEditor: "@react-native-community/image-editor",
360
- MaskedViewIOS: "@react-native-masked-view/masked-view"
361
- };
362
- const LEGACY_EXPO_PACKAGE_REPLACEMENTS = {
363
- "expo-av": "expo-audio for audio and expo-video for video",
364
- "expo-permissions": "the permissions API in each module (e.g. Camera.requestPermissionsAsync())",
365
- "@expo/vector-icons": "expo-symbols or expo-image (see https://docs.expo.dev/versions/latest/sdk/symbols/)"
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
- forwardRef: "forwardRef is no longer needed on React 19+ refs are regular props on function components; remove forwardRef and pass ref directly",
670
- useContext: "useContext is superseded by `use()` on React 19+ — `use()` reads context conditionally inside hooks, branches, and loops; switch to `import { use } from 'react'`"
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
- if (node.source?.value !== "react") return;
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 = REACT_19_DEPRECATED_MESSAGES[importedName];
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) reactNamespaceBindings.add(localName);
1054
+ if (localName) namespaceBindings.add(localName);
691
1055
  }
692
1056
  }
693
1057
  },
694
1058
  MemberExpression(node) {
695
- if (reactNamespaceBindings.size === 0) return;
1059
+ if (namespaceBindings.size === 0) return;
696
1060
  if (node.computed) return;
697
1061
  if (node.object?.type !== "Identifier") return;
698
- if (!reactNamespaceBindings.has(node.object.name)) return;
1062
+ if (!namespaceBindings.has(node.object.name)) return;
699
1063
  if (node.property?.type !== "Identifier") return;
700
- const message = REACT_19_DEPRECATED_MESSAGES[node.property.name];
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: "left",
1081
- borderRight: "right",
1082
- borderInlineStart: "left",
1083
- borderInlineEnd: "right"
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
- if (key in BORDER_SIDE_KEYS) {
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 (${BORDER_SIDE_KEYS[key]}: ${width}px) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
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[elementName];
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: (n) => n.type === "NewExpression" && n.callee?.type === "Identifier" && n.callee.name === "Date"
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: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "Date" && n.callee.property?.type === "Identifier" && n.callee.property.name === "now"
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: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "Math" && n.callee.property?.type === "Identifier" && n.callee.property.name === "random"
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: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "performance" && n.callee.property?.type === "Identifier" && n.callee.property.name === "now"
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: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "crypto" && n.callee.property?.type === "Identifier" && n.callee.property.name === "randomUUID"
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-native.ts
2796
- const resolveJsxElementName = (openingElement) => {
2797
- const elementName = openingElement?.name;
2798
- if (!elementName) return null;
2799
- if (elementName.type === "JSXIdentifier") return elementName.name;
2800
- if (elementName.type === "JSXMemberExpression") return elementName.property?.name ?? null;
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 truncateText = (text) => text.length > 30 ? `${text.slice(0, 30)}...` : text;
2804
- const isRawTextContent = (child) => {
2805
- if (child.type === "JSXText") return Boolean(child.value?.trim());
2806
- if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
2807
- const expression = child.expression;
2808
- return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
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 "text content";
3414
+ return null;
2819
3415
  };
2820
- const isTextHandlingComponent = (elementName) => {
2821
- if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
2822
- return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
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 rnNoRawText = { create: (context) => {
2825
- let isDomComponentFile = false;
2826
- return {
2827
- Program(programNode) {
2828
- isDomComponentFile = hasDirective(programNode, "use dom");
2829
- },
2830
- JSXElement(node) {
2831
- if (isDomComponentFile) return;
2832
- const elementName = resolveJsxElementName(node.openingElement);
2833
- if (elementName && isTextHandlingComponent(elementName)) return;
2834
- for (const child of node.children ?? []) {
2835
- if (!isRawTextContent(child)) continue;
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: child,
2838
- message: `Raw ${getRawTextDescription(child)} outside a <Text> componentthis will crash on React Native`
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
- const rnNoLegacyExpoPackages = { create: (context) => ({ ImportDeclaration(node) {
2859
- const source = node.source?.value;
2860
- if (typeof source !== "string") return;
2861
- for (const [packageName, replacement] of Object.entries(LEGACY_EXPO_PACKAGE_REPLACEMENTS)) if (source === packageName || source.startsWith(`${packageName}/`)) {
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 `&hellip;`)"
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 root = getRootIdentifierName(expression);
3798
- if (root && DERIVING_ARRAY_METHODS.has(getDerivingMethodName(expression) ?? "")) {
3799
- if (expressionDerivesFromIdentifier(expression, root)) derivedAttributes.push({
3800
- propName: attr.name.name,
3801
- rootName: root,
3802
- node: attr
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 referencedIdentifiers = [];
5185
+ const valueIdentifierNames = [];
5186
+ collectValueIdentifierNames(setStateArguments[0], valueIdentifierNames);
4334
5187
  walkAst(setStateArguments[0], (child) => {
4335
- if (child.type === "Identifier") referencedIdentifiers.push(child.name);
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 = referencedIdentifiers.filter((name) => !isSetterIdentifier(name));
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) context.report({
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: hasAnyDependencyReference ? "Derived state in useEffect — compute during render instead" : "State reset in useEffect — use a key prop to reset component state when props change"
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 === "IfStatement" && soleStatement.test?.type === "Identifier" && dependencyNames.has(soleStatement.test.name)) context.report({
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 componentPropStack = [];
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
- FunctionDeclaration(node) {
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
- let rootIdentifierName = null;
4427
- let cursor = initializer;
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 componentPropParamStack = [];
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
- FunctionDeclaration(node) {
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
- const bodyStatements = getCallbackStatements(callback);
4569
- for (const stmt of bodyStatements) {
4570
- let invokedPropName = null;
4571
- if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression" && stmt.expression.callee?.type === "Identifier" && isPropName(stmt.expression.callee.name)) invokedPropName = stmt.expression.callee.name;
4572
- if (!invokedPropName) continue;
4573
- if (!depsNode.elements.some((element) => element?.type === "Identifier" && !isPropName(element.name))) continue;
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: stmt,
4576
- message: `useEffect calls prop callback "${invokedPropName}" 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`
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 componentBindingStack = [];
4584
- const isEffectEventBinding = (name) => {
4585
- for (let stackIndex = componentBindingStack.length - 1; stackIndex >= 0; stackIndex--) if (componentBindingStack[stackIndex].has(name)) return true;
4586
- return false;
4587
- };
4588
- const enterComponent = () => {
4589
- componentBindingStack.push(/* @__PURE__ */ new Set());
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
- FunctionDeclaration(node) {
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 (componentBindingStack.length === 0) return;
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 (isEffectEventBinding(element.name)) context.report({
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 walkInsideStatementBlocks = (node, visitor) => {
4671
- if (!node || typeof node !== "object") return;
4672
- if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return;
4673
- visitor(node);
4674
- for (const key of Object.keys(node)) {
4675
- if (key === "parent") continue;
4676
- const child = node[key];
4677
- if (Array.isArray(child)) {
4678
- for (const item of child) if (item && typeof item === "object" && item.type) walkInsideStatementBlocks(item, visitor);
4679
- } else if (child && typeof child === "object" && child.type) walkInsideStatementBlocks(child, visitor);
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 expressionReadsName = (expression, name) => {
4683
- let didRead = false;
4684
- walkAst(expression, (child) => {
4685
- if (didRead) return;
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 didRead;
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 (returnExpressions.some((expression) => expressionReadsName(expression, binding.valueName))) continue;
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
- //#endregion
4816
- //#region src/plugin/index.ts
4817
- const plugin = {
4818
- meta: { name: "react-doctor" },
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
- "prefer-useReducer": preferUseReducer,
4828
- "rerender-lazy-state-init": rerenderLazyStateInit,
4829
- "rerender-functional-setstate": rerenderFunctionalSetstate,
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 bindings = findHookCallBindings(componentBody);
4836
- if (bindings.length === 0) return;
4837
- const handlerBindingNames = collectHandlerBindingNames(componentBody);
4838
- for (const binding of bindings) {
4839
- const referenceLocations = [];
4840
- walkAst(componentBody, (child) => {
4841
- if (child === binding.declarator.id) return;
4842
- if (child.type === "Identifier" && child.name === binding.valueName) referenceLocations.push(child);
4843
- });
4844
- if (referenceLocations.length === 0) continue;
4845
- if (!referenceLocations.every((ref) => isInsideEventHandler(ref, handlerBindingNames))) continue;
4846
- context.report({
4847
- node: binding.declarator,
4848
- 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`
4849
- });
4850
- }
4851
- };
4852
- return {
4853
- FunctionDeclaration(node) {
4854
- if (!node.id?.name || !isUppercaseName(node.id.name)) return;
4855
- checkComponent(node.body);
4856
- },
4857
- VariableDeclarator(node) {
4858
- if (!isComponentAssignment(node)) return;
4859
- checkComponent(node.init?.body);
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