oxlint-plugin-react-doctor 0.2.18-dev.caf46a7 → 0.2.18-dev.cbdff62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +657 -0
  2. package/dist/index.js +157 -104
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import path from "node:path";
1
+ import * as path from "node:path";
2
2
  import { analyze } from "eslint-scope";
3
3
  import * as eslintVisitorKeys from "eslint-visitor-keys";
4
- import fs from "node:fs";
4
+ import * as fs from "node:fs";
5
5
  import { parseSync } from "oxc-parser";
6
6
  //#region src/plugin/utils/is-testlike-filename.ts
7
7
  const NON_PRODUCTION_PATH_SEGMENTS = [
@@ -3954,6 +3954,7 @@ const noEmDashInJsxText = defineRule({
3954
3954
  title: "Em dash in JSX text",
3955
3955
  tags: ["design", "test-noise"],
3956
3956
  severity: "warn",
3957
+ defaultEnabled: false,
3957
3958
  category: "Architecture",
3958
3959
  recommendation: "Replace em dashes in UI text with commas, colons, semicolons, or parentheses so the copy reads less like AI output.",
3959
3960
  create: (context) => ({ JSXText(jsxTextNode) {
@@ -4000,6 +4001,7 @@ const noRedundantPaddingAxes = defineRule({
4000
4001
  title: "Redundant padding axes",
4001
4002
  tags: ["design", "test-noise"],
4002
4003
  severity: "warn",
4004
+ defaultEnabled: false,
4003
4005
  category: "Architecture",
4004
4006
  recommendation: "Collapse `px-N py-N` to `p-N` when both sides match. Keep them split only when one side changes at a breakpoint (`py-2 md:py-3`).",
4005
4007
  create: (context) => ({ JSXAttribute(jsxAttribute) {
@@ -4023,6 +4025,7 @@ const noRedundantSizeAxes = defineRule({
4023
4025
  requires: ["tailwind:3.4"],
4024
4026
  tags: ["design", "test-noise"],
4025
4027
  severity: "warn",
4028
+ defaultEnabled: false,
4026
4029
  category: "Architecture",
4027
4030
  recommendation: "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both sides match.",
4028
4031
  create: (context) => ({ JSXAttribute(jsxAttribute) {
@@ -4046,6 +4049,7 @@ const noSpaceOnFlexChildren = defineRule({
4046
4049
  title: "space-* utility on flex children",
4047
4050
  tags: ["design", "test-noise"],
4048
4051
  severity: "warn",
4052
+ defaultEnabled: false,
4049
4053
  category: "Architecture",
4050
4054
  recommendation: "Use `gap-*` on the flex or grid parent. `space-x-*` and `space-y-*` leave gaps when a child is hidden, miss spacing on wrapped lines, and don't flip in right-to-left layouts.",
4051
4055
  create: (context) => ({ JSXAttribute(jsxAttribute) {
@@ -4079,6 +4083,7 @@ const noThreePeriodEllipsis = defineRule({
4079
4083
  title: "Three dots instead of ellipsis",
4080
4084
  tags: ["design", "test-noise"],
4081
4085
  severity: "warn",
4086
+ defaultEnabled: false,
4082
4087
  category: "Architecture",
4083
4088
  recommendation: "Use the real ellipsis \"…\" (or `…`) instead of three dots. Good for labels like \"Rename…\" and \"Loading…\".",
4084
4089
  create: (context) => ({ JSXText(jsxTextNode) {
@@ -4138,6 +4143,7 @@ const noVagueButtonLabel = defineRule({
4138
4143
  title: "Vague button label",
4139
4144
  tags: ["design", "test-noise"],
4140
4145
  severity: "warn",
4146
+ defaultEnabled: false,
4141
4147
  recommendation: "Name the action: \"Save changes\" instead of \"Continue\", \"Send invite\" instead of \"Submit\". The label is the button's accessible name.",
4142
4148
  create: (context) => ({ JSXElement(jsxElementNode) {
4143
4149
  const tagName = getOpeningElementTagName(jsxElementNode.openingElement);
@@ -4609,11 +4615,17 @@ const isCleanupFunctionLike = (node, knownCleanupFunctionNames, knownBoundSubscr
4609
4615
  if (!isFunctionLike$2(node)) return false;
4610
4616
  return containsReleaseLikeCall(node.body, knownCleanupFunctionNames, knownBoundSubscriptionNames);
4611
4617
  };
4612
- const isCleanupReturn = (returnedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames) => {
4618
+ const isCleanupReturn = (returnedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames, options = {}) => {
4613
4619
  if (!returnedValue) return false;
4614
4620
  const unwrappedValue = unwrapChainExpression$2(returnedValue);
4615
- if (isNodeOfType(unwrappedValue, "Identifier")) return knownCleanupFunctionNames.has(unwrappedValue.name);
4621
+ if (isNodeOfType(unwrappedValue, "Literal") && unwrappedValue.value === null) return false;
4622
+ if (isNodeOfType(unwrappedValue, "Identifier")) {
4623
+ if (unwrappedValue.name === "undefined") return false;
4624
+ if (knownCleanupFunctionNames.has(unwrappedValue.name)) return true;
4625
+ return options.allowOpaqueReturn === true && !knownBoundSubscriptionNames.has(unwrappedValue.name);
4626
+ }
4616
4627
  if (isCleanupReturningSubscribeLikeCallExpression(unwrappedValue)) return true;
4628
+ if (options.allowOpaqueReturn === true && !isSubscribeLikeCallExpression(unwrappedValue)) return true;
4617
4629
  if (isCleanupFunctionLike(unwrappedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames)) return true;
4618
4630
  return false;
4619
4631
  };
@@ -4634,12 +4646,14 @@ const findSubscribeLikeUsages = (callback) => {
4634
4646
  if (isNodeOfType(child.callee, "Identifier") && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(child.callee.name)) {
4635
4647
  usages.push({
4636
4648
  kind: "timer",
4649
+ node: child,
4637
4650
  resourceName: child.callee.name
4638
4651
  });
4639
4652
  return;
4640
4653
  }
4641
4654
  if (isNodeOfType(child.callee, "MemberExpression") && isNodeOfType(child.callee.property, "Identifier") && SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name)) usages.push({
4642
4655
  kind: "subscribe",
4656
+ node: child,
4643
4657
  resourceName: child.callee.property.name
4644
4658
  });
4645
4659
  });
@@ -4686,7 +4700,20 @@ const collectCleanupBindings = (effectCallback) => {
4686
4700
  });
4687
4701
  return bindings;
4688
4702
  };
4689
- const effectHasCleanupRelease = (callback) => {
4703
+ const getRangeStart = (node) => {
4704
+ const rangeStart = node.range?.[0];
4705
+ return typeof rangeStart === "number" ? rangeStart : null;
4706
+ };
4707
+ const cleanupReturnRunsAfterUsage = (returnStatement, usages) => {
4708
+ if (returnStatement.argument && isCleanupReturningSubscribeLikeCallExpression(returnStatement.argument)) return true;
4709
+ const returnStart = getRangeStart(returnStatement);
4710
+ if (returnStart === null) return true;
4711
+ return usages.some((usage) => {
4712
+ const usageStart = getRangeStart(usage.node);
4713
+ return usageStart === null || usageStart < returnStart;
4714
+ });
4715
+ };
4716
+ const effectHasCleanupReturn = (callback, usages) => {
4690
4717
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return false;
4691
4718
  if (!isNodeOfType(callback.body, "BlockStatement")) return isCleanupReturningSubscribeLikeCallExpression(callback.body);
4692
4719
  const cleanupBindings = collectCleanupBindings(callback);
@@ -4694,7 +4721,8 @@ const effectHasCleanupRelease = (callback) => {
4694
4721
  walkInsideStatementBlocks(callback.body, (child) => {
4695
4722
  if (didFindCleanupReturn) return;
4696
4723
  if (!isNodeOfType(child, "ReturnStatement")) return;
4697
- if (isCleanupReturn(child.argument, cleanupBindings.cleanupFunctionNames, cleanupBindings.subscriptionNames)) didFindCleanupReturn = true;
4724
+ if (!cleanupReturnRunsAfterUsage(child, usages)) return;
4725
+ if (isCleanupReturn(child.argument, cleanupBindings.cleanupFunctionNames, cleanupBindings.subscriptionNames, { allowOpaqueReturn: true })) didFindCleanupReturn = true;
4698
4726
  });
4699
4727
  return didFindCleanupReturn;
4700
4728
  };
@@ -4710,7 +4738,7 @@ const effectNeedsCleanup = defineRule({
4710
4738
  if (!callback) return;
4711
4739
  const usages = findSubscribeLikeUsages(callback);
4712
4740
  if (usages.length === 0) return;
4713
- if (effectHasCleanupRelease(callback)) return;
4741
+ if (effectHasCleanupReturn(callback, usages)) return;
4714
4742
  const firstUsage = usages[0];
4715
4743
  const verb = firstUsage.kind === "timer" ? "schedules" : "subscribes via";
4716
4744
  context.report({
@@ -9703,6 +9731,7 @@ const jsxNoConstructedContextValues = defineRule({
9703
9731
  title: "Unstable context provider value",
9704
9732
  tags: ["react-jsx-only"],
9705
9733
  severity: "warn",
9734
+ disabledBy: ["react-compiler"],
9706
9735
  recommendation: "Wrap the context value in `useMemo`, or move it outside the component.",
9707
9736
  category: "Performance",
9708
9737
  create: (context) => {
@@ -11785,6 +11814,7 @@ const jsxPascalCase = defineRule({
11785
11814
  id: "jsx-pascal-case",
11786
11815
  title: "Component name not PascalCase",
11787
11816
  severity: "warn",
11817
+ defaultEnabled: false,
11788
11818
  tags: ["test-noise"],
11789
11819
  recommendation: "Rename custom JSX components to PascalCase.",
11790
11820
  category: "Architecture",
@@ -14243,6 +14273,7 @@ const noArrayIndexKey = defineRule({
14243
14273
  id: "no-array-index-key",
14244
14274
  title: "Array index used as a key",
14245
14275
  severity: "warn",
14276
+ defaultEnabled: false,
14246
14277
  recommendation: "Use a stable `key` from your data instead of the array index.",
14247
14278
  category: "Performance",
14248
14279
  create: (context) => ({
@@ -14755,6 +14786,37 @@ const isSetterIdentifier = (name) => SETTER_PATTERN.test(name);
14755
14786
  //#region src/plugin/utils/is-setter-call.ts
14756
14787
  const isSetterCall = (node) => isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier") && isSetterIdentifier(node.callee.name);
14757
14788
  //#endregion
14789
+ //#region src/plugin/utils/is-hook-binding-in-scope.ts
14790
+ const isHookBindingInScope = (node, query) => {
14791
+ const { bindingName, hookName, destructureIndex } = query;
14792
+ let cursor = node;
14793
+ while (cursor) {
14794
+ if (isNodeOfType(cursor, "BlockStatement") || isNodeOfType(cursor, "Program")) for (const statement of cursor.body ?? []) {
14795
+ if (!isNodeOfType(statement, "VariableDeclaration")) continue;
14796
+ for (const declarator of statement.declarations ?? []) {
14797
+ if (!isNodeOfType(declarator.init, "CallExpression")) continue;
14798
+ if (!isHookCall$1(declarator.init, hookName)) continue;
14799
+ if (destructureIndex !== void 0) {
14800
+ if (!isNodeOfType(declarator.id, "ArrayPattern")) continue;
14801
+ const elements = declarator.id.elements ?? [];
14802
+ if (elements.length <= destructureIndex) continue;
14803
+ const element = elements[destructureIndex];
14804
+ if (isNodeOfType(element, "Identifier") && element.name === bindingName) return true;
14805
+ } else if (isNodeOfType(declarator.id, "Identifier") && declarator.id.name === bindingName) return true;
14806
+ }
14807
+ }
14808
+ cursor = cursor.parent ?? null;
14809
+ }
14810
+ return false;
14811
+ };
14812
+ //#endregion
14813
+ //#region src/plugin/utils/is-use-state-setter-in-scope.ts
14814
+ const isUseStateSetterInScope = (node, setterName) => isHookBindingInScope(node, {
14815
+ bindingName: setterName,
14816
+ hookName: "useState",
14817
+ destructureIndex: 1
14818
+ });
14819
+ //#endregion
14758
14820
  //#region src/plugin/rules/state-and-effects/no-cascading-set-state.ts
14759
14821
  const isAsyncFunctionLike = (node) => {
14760
14822
  if (isNodeOfType(node, "ArrowFunctionExpression") || isNodeOfType(node, "FunctionExpression") || isNodeOfType(node, "FunctionDeclaration")) return Boolean(node.async);
@@ -14796,7 +14858,7 @@ const countMaxPathSetStateCalls = (node) => {
14796
14858
  const finallyCount = node.finalizer ? countMaxPathSetStateCalls(node.finalizer) : 0;
14797
14859
  return Math.max(tryCount, catchCount) + finallyCount;
14798
14860
  }
14799
- if (isSetterCall(node)) {
14861
+ if (isNodeOfType(node, "CallExpression") && isSetterCall(node) && isNodeOfType(node.callee, "Identifier") && isUseStateSetterInScope(node, node.callee.name)) {
14800
14862
  let nestedSettersInArgs = 0;
14801
14863
  for (const argument of node.arguments ?? []) nestedSettersInArgs += countMaxPathSetStateCalls(argument);
14802
14864
  return 1 + nestedSettersInArgs;
@@ -15382,6 +15444,7 @@ const noDarkModeGlow = defineRule({
15382
15444
  title: "Colored glow on dark background",
15383
15445
  tags: ["design", "test-noise"],
15384
15446
  severity: "warn",
15447
+ defaultEnabled: false,
15385
15448
  recommendation: "Use a subtle `box-shadow` in neutral colors for depth, or a faint `border`. Colored glows on dark backgrounds look overdone.",
15386
15449
  create: (context) => ({ JSXAttribute(node) {
15387
15450
  const expression = getInlineStyleExpression(node);
@@ -15416,6 +15479,7 @@ const noDefaultProps = defineRule({
15416
15479
  requires: ["react:19"],
15417
15480
  tags: ["test-noise"],
15418
15481
  severity: "warn",
15482
+ defaultEnabled: false,
15419
15483
  recommendation: "React 19 drops `Component.defaultProps` for function components. Set the defaults in the destructured props instead: `function Foo({ size = \"md\", variant = \"primary\" })` instead of `Foo.defaultProps = { size: \"md\", variant: \"primary\" }`.",
15420
15484
  create: (context) => ({ AssignmentExpression(node) {
15421
15485
  if (node.operator !== "=") return;
@@ -15595,7 +15659,11 @@ const noDerivedStateEffect = defineRule({
15595
15659
  if (statements.length === 0) return;
15596
15660
  if (!statements.every((statement) => {
15597
15661
  if (!isNodeOfType(statement, "ExpressionStatement")) return false;
15598
- return isSetterCall(statement.expression);
15662
+ const expression = statement.expression;
15663
+ if (!isSetterCall(expression)) return false;
15664
+ if (!isNodeOfType(expression, "CallExpression")) return false;
15665
+ if (!isNodeOfType(expression.callee, "Identifier")) return false;
15666
+ return isUseStateSetterInScope(expression, expression.callee.name);
15599
15667
  })) return;
15600
15668
  let allArgumentsDeriveFromDeps = true;
15601
15669
  let hasAnyDependencyReference = false;
@@ -17588,6 +17656,7 @@ const noGenericHandlerNames = defineRule({
17588
17656
  id: "no-generic-handler-names",
17589
17657
  title: "Vague event handler name",
17590
17658
  severity: "warn",
17659
+ defaultEnabled: false,
17591
17660
  tags: ["test-noise"],
17592
17661
  recommendation: "Rename it to say what it does. For example `handleSubmit` could be `saveUserProfile`, and `handleClick` could be `toggleSidebar`.",
17593
17662
  create: (context) => ({ JSXAttribute(node) {
@@ -17789,6 +17858,7 @@ const noGradientText = defineRule({
17789
17858
  title: "Gradient text is hard to read",
17790
17859
  tags: ["design", "test-noise"],
17791
17860
  severity: "warn",
17861
+ defaultEnabled: false,
17792
17862
  recommendation: "Use a solid text color so it stays readable. For emphasis, change the weight, size, or color instead of using a gradient.",
17793
17863
  create: (context) => ({
17794
17864
  JSXAttribute(node) {
@@ -18075,6 +18145,7 @@ const noJustifiedText = defineRule({
18075
18145
  title: "Justified text without hyphens",
18076
18146
  tags: ["test-noise"],
18077
18147
  severity: "warn",
18148
+ defaultEnabled: false,
18078
18149
  category: "Accessibility",
18079
18150
  recommendation: "Use `text-align: left` for body text. If you must justify, add `hyphens: auto` and `overflow-wrap: break-word`.",
18080
18151
  create: (context) => ({ JSXAttribute(node) {
@@ -20179,6 +20250,12 @@ const getReactDoctorStringSetting = (settings, settingName) => {
20179
20250
  const settingValue = readOwnPropertyValue(bag, settingName);
20180
20251
  return typeof settingValue === "string" ? settingValue : void 0;
20181
20252
  };
20253
+ const getReactDoctorNumberSetting = (settings, settingName) => {
20254
+ const bag = readReactDoctorSettingsBag(settings);
20255
+ if (!bag) return void 0;
20256
+ const settingValue = readOwnPropertyValue(bag, settingName);
20257
+ return typeof settingValue === "number" && Number.isFinite(settingValue) ? settingValue : void 0;
20258
+ };
20182
20259
  const getReactDoctorStringArraySetting = (settings, settingName) => {
20183
20260
  const bag = readReactDoctorSettingsBag(settings);
20184
20261
  if (!bag) return [];
@@ -20316,6 +20393,7 @@ const noPropTypes = defineRule({
20316
20393
  requires: ["react:19"],
20317
20394
  tags: ["test-noise"],
20318
20395
  severity: "warn",
20396
+ defaultEnabled: false,
20319
20397
  recommendation: "React 19 ignores `Component.propTypes`, so invalid props pass silently. Describe props with TypeScript types and add real runtime checks (or schema parsing) only where data can actually be wrong. Only runs on React 19+ projects.",
20320
20398
  create: (context) => ({
20321
20399
  AssignmentExpression(node) {
@@ -20344,6 +20422,7 @@ const noPureBlackBackground = defineRule({
20344
20422
  title: "Pure black background",
20345
20423
  tags: ["design", "test-noise"],
20346
20424
  severity: "warn",
20425
+ defaultEnabled: false,
20347
20426
  recommendation: "Nudge the background slightly toward your brand color, like `#0a0a0f` or Tailwind's `bg-gray-950`. Pure black looks harsh on modern screens.",
20348
20427
  create: (context) => ({
20349
20428
  JSXAttribute(node) {
@@ -21773,6 +21852,7 @@ const noSideTabBorder = defineRule({
21773
21852
  title: "Thick one-sided border",
21774
21853
  tags: ["design", "test-noise"],
21775
21854
  severity: "warn",
21855
+ defaultEnabled: false,
21776
21856
  recommendation: "Use a softer accent like an inset box-shadow, a background, or a thin border-bottom instead of a thick one-sided border.",
21777
21857
  create: (context) => ({
21778
21858
  JSXAttribute(node) {
@@ -23651,15 +23731,46 @@ const noUsememoSimpleExpression = defineRule({
23651
23731
  });
23652
23732
  //#endregion
23653
23733
  //#region src/plugin/rules/design/no-wide-letter-spacing.ts
23734
+ const getJsxAttributeStringValue = (attributeValue) => {
23735
+ if (!attributeValue) return null;
23736
+ if (isNodeOfType(attributeValue, "Literal") && typeof attributeValue.value === "string") return attributeValue.value;
23737
+ if (isNodeOfType(attributeValue, "JSXExpressionContainer")) {
23738
+ const expression = attributeValue.expression;
23739
+ if (isNodeOfType(expression, "Literal") && typeof expression.value === "string") return expression.value;
23740
+ }
23741
+ return null;
23742
+ };
23743
+ const isTruthyBooleanJsxAttribute = (attributeValue) => {
23744
+ if (attributeValue === null) return true;
23745
+ if (isNodeOfType(attributeValue, "JSXExpressionContainer")) {
23746
+ const expression = attributeValue.expression;
23747
+ return isNodeOfType(expression, "Literal") && expression.value === true;
23748
+ }
23749
+ return false;
23750
+ };
23751
+ const hasUppercaseSiblingProp = (styleAttribute) => {
23752
+ const openingElement = styleAttribute.parent;
23753
+ if (!openingElement || !isNodeOfType(openingElement, "JSXOpeningElement")) return false;
23754
+ for (const attribute of openingElement.attributes ?? []) {
23755
+ if (!isNodeOfType(attribute, "JSXAttribute")) continue;
23756
+ if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue;
23757
+ const attributeName = attribute.name.name;
23758
+ if (attributeName === "uppercase" && isTruthyBooleanJsxAttribute(attribute.value)) return true;
23759
+ if (attributeName === "textTransform" && getJsxAttributeStringValue(attribute.value) === "uppercase") return true;
23760
+ }
23761
+ return false;
23762
+ };
23654
23763
  const noWideLetterSpacing = defineRule({
23655
23764
  id: "no-wide-letter-spacing",
23656
23765
  title: "Wide letter spacing on body text",
23657
23766
  severity: "warn",
23767
+ defaultEnabled: false,
23658
23768
  tags: ["test-noise"],
23659
23769
  recommendation: "Save wide letter-spacing (over 0.05em) for short uppercase labels, nav items, and buttons, not body text.",
23660
23770
  create: (context) => ({ JSXAttribute(node) {
23661
23771
  const expression = getInlineStyleExpression(node);
23662
23772
  if (!expression) return;
23773
+ if (hasUppercaseSiblingProp(node)) return;
23663
23774
  let isUppercase = false;
23664
23775
  let letterSpacingProperty = null;
23665
23776
  let letterSpacingEm = null;
@@ -23737,6 +23848,7 @@ const noZIndex9999 = defineRule({
23737
23848
  title: "Excessively high z-index",
23738
23849
  tags: ["test-noise"],
23739
23850
  severity: "warn",
23851
+ defaultEnabled: false,
23740
23852
  recommendation: "Pick a small z-index scale, like dropdown 10, modal 20, toast 30. To layer something on top, use `isolation: isolate` instead of bigger numbers.",
23741
23853
  create: (context) => ({
23742
23854
  JSXAttribute(node) {
@@ -24512,6 +24624,7 @@ const preferEs6Class = defineRule({
24512
24624
  id: "prefer-es6-class",
24513
24625
  title: "createClass instead of ES6 class",
24514
24626
  severity: "warn",
24627
+ defaultEnabled: false,
24515
24628
  recommendation: "Pick one component style for the whole codebase: `class extends React.Component` (default) or `createReactClass` (legacy).",
24516
24629
  category: "Architecture",
24517
24630
  create: (context) => {
@@ -24822,6 +24935,7 @@ const preferModuleScopeStaticValue = defineRule({
24822
24935
  tags: ["test-noise"],
24823
24936
  severity: "warn",
24824
24937
  category: "Architecture",
24938
+ disabledBy: ["react-compiler"],
24825
24939
  recommendation: "Move the value above the component, at the top of the file. It doesn't use local state, so rebuilding it each update is wasted and makes it look new every time.",
24826
24940
  create: (context) => ({ VariableDeclarator(node) {
24827
24941
  if (!isNodeOfType(node.id, "Identifier")) return;
@@ -25889,7 +26003,9 @@ const renderingHydrationNoFlicker = defineRule({
25889
26003
  const bodyStatements = isNodeOfType(callback.body, "BlockStatement") ? callback.body.body : [callback.body];
25890
26004
  if (!bodyStatements || bodyStatements.length !== 1) return;
25891
26005
  const soleStatement = bodyStatements[0];
25892
- if (isNodeOfType(soleStatement, "ExpressionStatement") && isSetterCall(soleStatement.expression)) context.report({
26006
+ if (!isNodeOfType(soleStatement, "ExpressionStatement")) return;
26007
+ const expression = soleStatement.expression;
26008
+ if (isSetterCall(expression) && isNodeOfType(expression, "CallExpression") && isNodeOfType(expression.callee, "Identifier") && isUseStateSetterInScope(expression, expression.callee.name)) context.report({
25893
26009
  node,
25894
26010
  message: "This flashes for your users because useEffect(setState, []) runs after the first paint, so use useSyncExternalStore, or add suppressHydrationWarning"
25895
26011
  });
@@ -26412,6 +26528,7 @@ const rerenderFunctionalSetstate = defineRule({
26412
26528
  if (!isSetterCall(node)) return;
26413
26529
  if (!node.arguments?.length) return;
26414
26530
  if (!isNodeOfType(node.callee, "Identifier")) return;
26531
+ if (!isUseStateSetterInScope(node, node.callee.name)) return;
26415
26532
  const calleeName = node.callee.name;
26416
26533
  const argument = node.arguments[0];
26417
26534
  const expectedStateName = deriveStateVariableName(calleeName);
@@ -26746,7 +26863,7 @@ const handlerCallsSetState = (handler) => {
26746
26863
  let setStateCall = null;
26747
26864
  walkAst(handler.body, (child) => {
26748
26865
  if (setStateCall) return;
26749
- if (isNodeOfType(child, "CallExpression") && isNodeOfType(child.callee, "Identifier") && /^set[A-Z]/.test(child.callee.name)) setStateCall = child;
26866
+ if (isNodeOfType(child, "CallExpression") && isNodeOfType(child.callee, "Identifier") && /^set[A-Z]/.test(child.callee.name) && isUseStateSetterInScope(child, child.callee.name)) setStateCall = child;
26750
26867
  });
26751
26868
  return setStateCall;
26752
26869
  };
@@ -26778,76 +26895,22 @@ const rerenderTransitionsScroll = defineRule({
26778
26895
  } })
26779
26896
  });
26780
26897
  //#endregion
26898
+ //#region src/plugin/utils/define-retired-rule.ts
26899
+ const defineRetiredRule = (rule) => defineRule({
26900
+ ...rule,
26901
+ defaultEnabled: false,
26902
+ lifecycle: "retired",
26903
+ create: () => ({})
26904
+ });
26905
+ //#endregion
26781
26906
  //#region src/plugin/rules/react-native/rn-animate-layout-property.ts
26782
- const REANIMATED_LAYOUT_KEYS = new Set([
26783
- "width",
26784
- "height",
26785
- "top",
26786
- "left",
26787
- "right",
26788
- "bottom",
26789
- "minWidth",
26790
- "minHeight",
26791
- "maxWidth",
26792
- "maxHeight",
26793
- "margin",
26794
- "marginTop",
26795
- "marginBottom",
26796
- "marginLeft",
26797
- "marginRight",
26798
- "marginHorizontal",
26799
- "marginVertical",
26800
- "padding",
26801
- "paddingTop",
26802
- "paddingBottom",
26803
- "paddingLeft",
26804
- "paddingRight",
26805
- "paddingHorizontal",
26806
- "paddingVertical",
26807
- "flex",
26808
- "flexBasis",
26809
- "flexGrow",
26810
- "flexShrink",
26811
- "borderWidth",
26812
- "borderTopWidth",
26813
- "borderBottomWidth",
26814
- "borderLeftWidth",
26815
- "borderRightWidth",
26816
- "fontSize",
26817
- "lineHeight",
26818
- "letterSpacing"
26819
- ]);
26820
- const findReturnedObject = (callback) => {
26821
- if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return null;
26822
- const body = callback.body;
26823
- if (isNodeOfType(body, "ObjectExpression")) return body;
26824
- if (!isNodeOfType(body, "BlockStatement")) return null;
26825
- for (const stmt of body.body ?? []) if (isNodeOfType(stmt, "ReturnStatement") && isNodeOfType(stmt.argument, "ObjectExpression")) return stmt.argument;
26826
- return null;
26827
- };
26828
- const rnAnimateLayoutProperty = defineRule({
26907
+ const rnAnimateLayoutProperty = defineRetiredRule({
26829
26908
  id: "rn-animate-layout-property",
26830
26909
  title: "Animating a layout property",
26831
26910
  tags: ["test-noise"],
26832
26911
  requires: ["react-native"],
26833
- severity: "error",
26834
- recommendation: "Animating size or position props runs on the JS thread and can stutter. Animate `transform: [{ translateX/Y }, { scale }]` or `opacity` instead, which run on the GPU.",
26835
- create: (context) => ({ CallExpression(node) {
26836
- if (!isNodeOfType(node.callee, "Identifier") || node.callee.name !== "useAnimatedStyle") return;
26837
- const callback = node.arguments?.[0];
26838
- if (!callback) return;
26839
- const returnedObject = findReturnedObject(callback);
26840
- if (!returnedObject) return;
26841
- for (const property of returnedObject.properties ?? []) {
26842
- if (!isNodeOfType(property, "Property")) continue;
26843
- if (!isNodeOfType(property.key, "Identifier")) continue;
26844
- if (!REANIMATED_LAYOUT_KEYS.has(property.key.name)) continue;
26845
- context.report({
26846
- node: property,
26847
- message: `Your users see stutter when useAnimatedStyle animates "${property.key.name}" on the layout thread.`
26848
- });
26849
- }
26850
- } })
26912
+ severity: "warn",
26913
+ recommendation: "Reanimated useAnimatedStyle runs on the UI thread; layout-affecting properties driven by animation helpers, interpolate, or shared values are valid."
26851
26914
  });
26852
26915
  //#endregion
26853
26916
  //#region src/plugin/rules/react-native/rn-animation-reaction-as-derived.ts
@@ -27052,7 +27115,6 @@ const LEGACY_EXPO_PACKAGE_REPLACEMENTS = new Map([
27052
27115
  ["expo-av", "expo-audio for audio and expo-video for video"],
27053
27116
  ["expo-permissions", "the permissions API in each module (e.g. Camera.requestPermissionsAsync())"],
27054
27117
  ["expo-app-loading", "expo-splash-screen"],
27055
- ["expo-linear-gradient", "the `backgroundImage` CSS gradient style prop (New Architecture) or expo-linear-gradient's successor"],
27056
27118
  ["react-native-fast-image", "expo-image (drop-in with caching, placeholders, and crossfades)"]
27057
27119
  ]);
27058
27120
  const EXPO_UI_MODULE_SOURCES = new Set([
@@ -27207,6 +27269,10 @@ const RECYCLABLE_LIST_PACKAGES = {
27207
27269
  LegendList: ["@legendapp/list"]
27208
27270
  };
27209
27271
  const SIZING_HINT_ATTRIBUTE_NAMES = new Set(["estimatedItemSize", "estimatedListSize"]);
27272
+ const isFlashListV2OrNewer = (context) => {
27273
+ const flashListMajorVersion = getReactDoctorNumberSetting(context.settings, "shopifyFlashListMajorVersion");
27274
+ return flashListMajorVersion !== void 0 && flashListMajorVersion >= 2;
27275
+ };
27210
27276
  const isEmptyArrayLiteral = (node) => {
27211
27277
  if (!isNodeOfType(node.value, "JSXExpressionContainer")) return false;
27212
27278
  const expression = node.value.expression;
@@ -27228,6 +27294,7 @@ const rnListMissingEstimatedItemSize = defineRule({
27228
27294
  break;
27229
27295
  }
27230
27296
  if (!canonicalRecyclerName) return;
27297
+ if (canonicalRecyclerName === "FlashList" && isFlashListV2OrNewer(context)) return;
27231
27298
  let hasSizingHint = false;
27232
27299
  let dataIsEmptyLiteral = false;
27233
27300
  let hasDataProp = false;
@@ -28044,7 +28111,7 @@ const findSetStateInBody = (body) => {
28044
28111
  let setStateCallNode = null;
28045
28112
  walkAst(body, (child) => {
28046
28113
  if (setStateCallNode) return;
28047
- if (isNodeOfType(child, "CallExpression") && isNodeOfType(child.callee, "Identifier") && SET_STATE_PATTERN.test(child.callee.name)) setStateCallNode = child;
28114
+ if (isNodeOfType(child, "CallExpression") && isNodeOfType(child.callee, "Identifier") && SET_STATE_PATTERN.test(child.callee.name) && isUseStateSetterInScope(child, child.callee.name)) setStateCallNode = child;
28048
28115
  });
28049
28116
  return setStateCallNode;
28050
28117
  };
@@ -28170,37 +28237,14 @@ const rnNoSingleElementStyleArray = defineRule({
28170
28237
  } })
28171
28238
  });
28172
28239
  //#endregion
28173
- //#region src/plugin/rules/react-native/utils/scrollview_names.ts
28174
- const SCROLLVIEW_NAMES = new Set([
28175
- "ScrollView",
28176
- "FlatList",
28177
- "SectionList",
28178
- "VirtualizedList",
28179
- "KeyboardAwareScrollView"
28180
- ]);
28181
- //#endregion
28182
28240
  //#region src/plugin/rules/react-native/rn-prefer-content-inset-adjustment.ts
28183
- const rnPreferContentInsetAdjustment = defineRule({
28241
+ const rnPreferContentInsetAdjustment = defineRetiredRule({
28184
28242
  id: "rn-prefer-content-inset-adjustment",
28185
- title: "SafeAreaView instead of contentInsetAdjustment",
28243
+ title: "Manual safe-area inset adjustment",
28186
28244
  tags: ["test-noise"],
28187
28245
  requires: ["react-native"],
28188
28246
  severity: "warn",
28189
- recommendation: "Drop the SafeAreaView wrapper and set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView so the OS handles the safe area.",
28190
- create: (context) => ({ JSXElement(node) {
28191
- if (resolveJsxElementName(node.openingElement) !== "SafeAreaView") return;
28192
- for (const child of node.children ?? []) {
28193
- if (!isNodeOfType(child, "JSXElement")) continue;
28194
- const childName = resolveJsxElementName(child.openingElement);
28195
- if (!childName || !SCROLLVIEW_NAMES.has(childName)) continue;
28196
- if (childName === "KeyboardAwareScrollView") continue;
28197
- context.report({
28198
- node,
28199
- message: `Your users render an extra wrapper view from <SafeAreaView> around <${childName}>.`
28200
- });
28201
- return;
28202
- }
28203
- } })
28247
+ recommendation: "Prefer native content inset adjustment only when it replaces manual inset plumbing; SafeAreaView wrappers are valid and intentionally ignored."
28204
28248
  });
28205
28249
  //#endregion
28206
28250
  //#region src/react-native-dependency-names.ts
@@ -28586,6 +28630,15 @@ const rnPressableSharedValueMutation = defineRule({
28586
28630
  }
28587
28631
  });
28588
28632
  //#endregion
28633
+ //#region src/plugin/rules/react-native/utils/scrollview_names.ts
28634
+ const SCROLLVIEW_NAMES = new Set([
28635
+ "ScrollView",
28636
+ "FlatList",
28637
+ "SectionList",
28638
+ "VirtualizedList",
28639
+ "KeyboardAwareScrollView"
28640
+ ]);
28641
+ //#endregion
28589
28642
  //#region src/plugin/rules/react-native/rn-scrollview-dynamic-padding.ts
28590
28643
  const rnScrollviewDynamicPadding = defineRule({
28591
28644
  id: "rn-scrollview-dynamic-padding",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.2.18-dev.caf46a7",
3
+ "version": "0.2.18-dev.cbdff62",
4
4
  "description": "oxlint plugin for React Doctor: diagnose React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",