oxlint-plugin-react-doctor 0.2.18-dev.8d13ba3 → 0.2.18-dev.abbb042

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4609,11 +4609,17 @@ const isCleanupFunctionLike = (node, knownCleanupFunctionNames, knownBoundSubscr
4609
4609
  if (!isFunctionLike$2(node)) return false;
4610
4610
  return containsReleaseLikeCall(node.body, knownCleanupFunctionNames, knownBoundSubscriptionNames);
4611
4611
  };
4612
- const isCleanupReturn = (returnedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames) => {
4612
+ const isCleanupReturn = (returnedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames, options = {}) => {
4613
4613
  if (!returnedValue) return false;
4614
4614
  const unwrappedValue = unwrapChainExpression$2(returnedValue);
4615
- if (isNodeOfType(unwrappedValue, "Identifier")) return knownCleanupFunctionNames.has(unwrappedValue.name);
4615
+ if (isNodeOfType(unwrappedValue, "Literal") && unwrappedValue.value === null) return false;
4616
+ if (isNodeOfType(unwrappedValue, "Identifier")) {
4617
+ if (unwrappedValue.name === "undefined") return false;
4618
+ if (knownCleanupFunctionNames.has(unwrappedValue.name)) return true;
4619
+ return options.allowOpaqueReturn === true && !knownBoundSubscriptionNames.has(unwrappedValue.name);
4620
+ }
4616
4621
  if (isCleanupReturningSubscribeLikeCallExpression(unwrappedValue)) return true;
4622
+ if (options.allowOpaqueReturn === true && !isSubscribeLikeCallExpression(unwrappedValue)) return true;
4617
4623
  if (isCleanupFunctionLike(unwrappedValue, knownCleanupFunctionNames, knownBoundSubscriptionNames)) return true;
4618
4624
  return false;
4619
4625
  };
@@ -4634,12 +4640,14 @@ const findSubscribeLikeUsages = (callback) => {
4634
4640
  if (isNodeOfType(child.callee, "Identifier") && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(child.callee.name)) {
4635
4641
  usages.push({
4636
4642
  kind: "timer",
4643
+ node: child,
4637
4644
  resourceName: child.callee.name
4638
4645
  });
4639
4646
  return;
4640
4647
  }
4641
4648
  if (isNodeOfType(child.callee, "MemberExpression") && isNodeOfType(child.callee.property, "Identifier") && SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name)) usages.push({
4642
4649
  kind: "subscribe",
4650
+ node: child,
4643
4651
  resourceName: child.callee.property.name
4644
4652
  });
4645
4653
  });
@@ -4686,7 +4694,20 @@ const collectCleanupBindings = (effectCallback) => {
4686
4694
  });
4687
4695
  return bindings;
4688
4696
  };
4689
- const effectHasCleanupRelease = (callback) => {
4697
+ const getRangeStart = (node) => {
4698
+ const rangeStart = node.range?.[0];
4699
+ return typeof rangeStart === "number" ? rangeStart : null;
4700
+ };
4701
+ const cleanupReturnRunsAfterUsage = (returnStatement, usages) => {
4702
+ if (returnStatement.argument && isCleanupReturningSubscribeLikeCallExpression(returnStatement.argument)) return true;
4703
+ const returnStart = getRangeStart(returnStatement);
4704
+ if (returnStart === null) return true;
4705
+ return usages.some((usage) => {
4706
+ const usageStart = getRangeStart(usage.node);
4707
+ return usageStart === null || usageStart < returnStart;
4708
+ });
4709
+ };
4710
+ const effectHasCleanupReturn = (callback, usages) => {
4690
4711
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return false;
4691
4712
  if (!isNodeOfType(callback.body, "BlockStatement")) return isCleanupReturningSubscribeLikeCallExpression(callback.body);
4692
4713
  const cleanupBindings = collectCleanupBindings(callback);
@@ -4694,7 +4715,8 @@ const effectHasCleanupRelease = (callback) => {
4694
4715
  walkInsideStatementBlocks(callback.body, (child) => {
4695
4716
  if (didFindCleanupReturn) return;
4696
4717
  if (!isNodeOfType(child, "ReturnStatement")) return;
4697
- if (isCleanupReturn(child.argument, cleanupBindings.cleanupFunctionNames, cleanupBindings.subscriptionNames)) didFindCleanupReturn = true;
4718
+ if (!cleanupReturnRunsAfterUsage(child, usages)) return;
4719
+ if (isCleanupReturn(child.argument, cleanupBindings.cleanupFunctionNames, cleanupBindings.subscriptionNames, { allowOpaqueReturn: true })) didFindCleanupReturn = true;
4698
4720
  });
4699
4721
  return didFindCleanupReturn;
4700
4722
  };
@@ -4710,7 +4732,7 @@ const effectNeedsCleanup = defineRule({
4710
4732
  if (!callback) return;
4711
4733
  const usages = findSubscribeLikeUsages(callback);
4712
4734
  if (usages.length === 0) return;
4713
- if (effectHasCleanupRelease(callback)) return;
4735
+ if (effectHasCleanupReturn(callback, usages)) return;
4714
4736
  const firstUsage = usages[0];
4715
4737
  const verb = firstUsage.kind === "timer" ? "schedules" : "subscribes via";
4716
4738
  context.report({
@@ -9703,6 +9725,7 @@ const jsxNoConstructedContextValues = defineRule({
9703
9725
  title: "Unstable context provider value",
9704
9726
  tags: ["react-jsx-only"],
9705
9727
  severity: "warn",
9728
+ disabledBy: ["react-compiler"],
9706
9729
  recommendation: "Wrap the context value in `useMemo`, or move it outside the component.",
9707
9730
  category: "Performance",
9708
9731
  create: (context) => {
@@ -20179,6 +20202,12 @@ const getReactDoctorStringSetting = (settings, settingName) => {
20179
20202
  const settingValue = readOwnPropertyValue(bag, settingName);
20180
20203
  return typeof settingValue === "string" ? settingValue : void 0;
20181
20204
  };
20205
+ const getReactDoctorNumberSetting = (settings, settingName) => {
20206
+ const bag = readReactDoctorSettingsBag(settings);
20207
+ if (!bag) return void 0;
20208
+ const settingValue = readOwnPropertyValue(bag, settingName);
20209
+ return typeof settingValue === "number" && Number.isFinite(settingValue) ? settingValue : void 0;
20210
+ };
20182
20211
  const getReactDoctorStringArraySetting = (settings, settingName) => {
20183
20212
  const bag = readReactDoctorSettingsBag(settings);
20184
20213
  if (!bag) return [];
@@ -23651,6 +23680,35 @@ const noUsememoSimpleExpression = defineRule({
23651
23680
  });
23652
23681
  //#endregion
23653
23682
  //#region src/plugin/rules/design/no-wide-letter-spacing.ts
23683
+ const getJsxAttributeStringValue = (attributeValue) => {
23684
+ if (!attributeValue) return null;
23685
+ if (isNodeOfType(attributeValue, "Literal") && typeof attributeValue.value === "string") return attributeValue.value;
23686
+ if (isNodeOfType(attributeValue, "JSXExpressionContainer")) {
23687
+ const expression = attributeValue.expression;
23688
+ if (isNodeOfType(expression, "Literal") && typeof expression.value === "string") return expression.value;
23689
+ }
23690
+ return null;
23691
+ };
23692
+ const isTruthyBooleanJsxAttribute = (attributeValue) => {
23693
+ if (attributeValue === null) return true;
23694
+ if (isNodeOfType(attributeValue, "JSXExpressionContainer")) {
23695
+ const expression = attributeValue.expression;
23696
+ return isNodeOfType(expression, "Literal") && expression.value === true;
23697
+ }
23698
+ return false;
23699
+ };
23700
+ const hasUppercaseSiblingProp = (styleAttribute) => {
23701
+ const openingElement = styleAttribute.parent;
23702
+ if (!openingElement || !isNodeOfType(openingElement, "JSXOpeningElement")) return false;
23703
+ for (const attribute of openingElement.attributes ?? []) {
23704
+ if (!isNodeOfType(attribute, "JSXAttribute")) continue;
23705
+ if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue;
23706
+ const attributeName = attribute.name.name;
23707
+ if (attributeName === "uppercase" && isTruthyBooleanJsxAttribute(attribute.value)) return true;
23708
+ if (attributeName === "textTransform" && getJsxAttributeStringValue(attribute.value) === "uppercase") return true;
23709
+ }
23710
+ return false;
23711
+ };
23654
23712
  const noWideLetterSpacing = defineRule({
23655
23713
  id: "no-wide-letter-spacing",
23656
23714
  title: "Wide letter spacing on body text",
@@ -23660,6 +23718,7 @@ const noWideLetterSpacing = defineRule({
23660
23718
  create: (context) => ({ JSXAttribute(node) {
23661
23719
  const expression = getInlineStyleExpression(node);
23662
23720
  if (!expression) return;
23721
+ if (hasUppercaseSiblingProp(node)) return;
23663
23722
  let isUppercase = false;
23664
23723
  let letterSpacingProperty = null;
23665
23724
  let letterSpacingEm = null;
@@ -24822,6 +24881,7 @@ const preferModuleScopeStaticValue = defineRule({
24822
24881
  tags: ["test-noise"],
24823
24882
  severity: "warn",
24824
24883
  category: "Architecture",
24884
+ disabledBy: ["react-compiler"],
24825
24885
  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
24886
  create: (context) => ({ VariableDeclarator(node) {
24827
24887
  if (!isNodeOfType(node.id, "Identifier")) return;
@@ -26778,76 +26838,22 @@ const rerenderTransitionsScroll = defineRule({
26778
26838
  } })
26779
26839
  });
26780
26840
  //#endregion
26841
+ //#region src/plugin/utils/define-retired-rule.ts
26842
+ const defineRetiredRule = (rule) => defineRule({
26843
+ ...rule,
26844
+ defaultEnabled: false,
26845
+ lifecycle: "retired",
26846
+ create: () => ({})
26847
+ });
26848
+ //#endregion
26781
26849
  //#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({
26850
+ const rnAnimateLayoutProperty = defineRetiredRule({
26829
26851
  id: "rn-animate-layout-property",
26830
26852
  title: "Animating a layout property",
26831
26853
  tags: ["test-noise"],
26832
26854
  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
- } })
26855
+ severity: "warn",
26856
+ recommendation: "Reanimated useAnimatedStyle runs on the UI thread; layout-affecting properties driven by animation helpers, interpolate, or shared values are valid."
26851
26857
  });
26852
26858
  //#endregion
26853
26859
  //#region src/plugin/rules/react-native/rn-animation-reaction-as-derived.ts
@@ -27052,7 +27058,6 @@ const LEGACY_EXPO_PACKAGE_REPLACEMENTS = new Map([
27052
27058
  ["expo-av", "expo-audio for audio and expo-video for video"],
27053
27059
  ["expo-permissions", "the permissions API in each module (e.g. Camera.requestPermissionsAsync())"],
27054
27060
  ["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
27061
  ["react-native-fast-image", "expo-image (drop-in with caching, placeholders, and crossfades)"]
27057
27062
  ]);
27058
27063
  const EXPO_UI_MODULE_SOURCES = new Set([
@@ -27207,6 +27212,10 @@ const RECYCLABLE_LIST_PACKAGES = {
27207
27212
  LegendList: ["@legendapp/list"]
27208
27213
  };
27209
27214
  const SIZING_HINT_ATTRIBUTE_NAMES = new Set(["estimatedItemSize", "estimatedListSize"]);
27215
+ const isFlashListV2OrNewer = (context) => {
27216
+ const flashListMajorVersion = getReactDoctorNumberSetting(context.settings, "shopifyFlashListMajorVersion");
27217
+ return flashListMajorVersion !== void 0 && flashListMajorVersion >= 2;
27218
+ };
27210
27219
  const isEmptyArrayLiteral = (node) => {
27211
27220
  if (!isNodeOfType(node.value, "JSXExpressionContainer")) return false;
27212
27221
  const expression = node.value.expression;
@@ -27228,6 +27237,7 @@ const rnListMissingEstimatedItemSize = defineRule({
27228
27237
  break;
27229
27238
  }
27230
27239
  if (!canonicalRecyclerName) return;
27240
+ if (canonicalRecyclerName === "FlashList" && isFlashListV2OrNewer(context)) return;
27231
27241
  let hasSizingHint = false;
27232
27242
  let dataIsEmptyLiteral = false;
27233
27243
  let hasDataProp = false;
@@ -28170,37 +28180,14 @@ const rnNoSingleElementStyleArray = defineRule({
28170
28180
  } })
28171
28181
  });
28172
28182
  //#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
28183
  //#region src/plugin/rules/react-native/rn-prefer-content-inset-adjustment.ts
28183
- const rnPreferContentInsetAdjustment = defineRule({
28184
+ const rnPreferContentInsetAdjustment = defineRetiredRule({
28184
28185
  id: "rn-prefer-content-inset-adjustment",
28185
- title: "SafeAreaView instead of contentInsetAdjustment",
28186
+ title: "Manual safe-area inset adjustment",
28186
28187
  tags: ["test-noise"],
28187
28188
  requires: ["react-native"],
28188
28189
  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
- } })
28190
+ recommendation: "Prefer native content inset adjustment only when it replaces manual inset plumbing; SafeAreaView wrappers are valid and intentionally ignored."
28204
28191
  });
28205
28192
  //#endregion
28206
28193
  //#region src/react-native-dependency-names.ts
@@ -28586,6 +28573,15 @@ const rnPressableSharedValueMutation = defineRule({
28586
28573
  }
28587
28574
  });
28588
28575
  //#endregion
28576
+ //#region src/plugin/rules/react-native/utils/scrollview_names.ts
28577
+ const SCROLLVIEW_NAMES = new Set([
28578
+ "ScrollView",
28579
+ "FlatList",
28580
+ "SectionList",
28581
+ "VirtualizedList",
28582
+ "KeyboardAwareScrollView"
28583
+ ]);
28584
+ //#endregion
28589
28585
  //#region src/plugin/rules/react-native/rn-scrollview-dynamic-padding.ts
28590
28586
  const rnScrollviewDynamicPadding = defineRule({
28591
28587
  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.8d13ba3",
3
+ "version": "0.2.18-dev.abbb042",
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",