oxlint-plugin-react-doctor 0.2.18-dev.caf46a7 → 0.2.18-dev.d4cc99b
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.d.ts +657 -0
- package/dist/index.js +155 -102
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -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, "
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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")
|
|
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
|
|
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: "
|
|
26834
|
-
recommendation: "
|
|
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 =
|
|
28241
|
+
const rnPreferContentInsetAdjustment = defineRetiredRule({
|
|
28184
28242
|
id: "rn-prefer-content-inset-adjustment",
|
|
28185
|
-
title: "
|
|
28243
|
+
title: "Manual safe-area inset adjustment",
|
|
28186
28244
|
tags: ["test-noise"],
|
|
28187
28245
|
requires: ["react-native"],
|
|
28188
28246
|
severity: "warn",
|
|
28189
|
-
recommendation: "
|
|
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.
|
|
3
|
+
"version": "0.2.18-dev.d4cc99b",
|
|
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",
|