eslint-plugin-react-x 5.18.9 → 5.19.0

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 (2) hide show
  1. package/dist/index.js +161 -23
  2. package/package.json +7 -7
package/dist/index.js CHANGED
@@ -145,7 +145,7 @@ const rules$6 = {
145
145
  //#endregion
146
146
  //#region package.json
147
147
  var name$6 = "eslint-plugin-react-x";
148
- var version = "5.18.9";
148
+ var version = "5.19.0";
149
149
 
150
150
  //#endregion
151
151
  //#region src/utils/create-rule.ts
@@ -1338,6 +1338,14 @@ const NAVIGATION_HOOKS = /* @__PURE__ */ new Set([
1338
1338
  "useNavigation",
1339
1339
  "useRouter"
1340
1340
  ]);
1341
+ function isNodeWithin(node, ancestor) {
1342
+ let current = node;
1343
+ while (current != null) {
1344
+ if (current === ancestor) return true;
1345
+ current = current.parent;
1346
+ }
1347
+ return false;
1348
+ }
1341
1349
  function resolveToFunctionNode(context, node, seen = /* @__PURE__ */ new Set()) {
1342
1350
  const expr = Extract.unwrap(node);
1343
1351
  if (Check.isFunction(expr)) return expr;
@@ -1474,6 +1482,79 @@ function createImmutabilityCollector() {
1474
1482
  };
1475
1483
  }
1476
1484
 
1485
+ //#endregion
1486
+ //#region src/rules/immutability/origins.ts
1487
+ function isComponentPropsDefinition(context, def) {
1488
+ if (def.type !== DefinitionType.Parameter) return false;
1489
+ const fn = def.node;
1490
+ if (!Check.isFunction(fn)) return false;
1491
+ const firstParam = fn.params.at(0);
1492
+ if (firstParam == null || !isNodeWithin(def.name, firstParam)) return false;
1493
+ return core.isFunctionComponentDefinition(context, fn, core.DEFAULT_COMPONENT_DETECTION_HINT);
1494
+ }
1495
+ function getStateHookName(context, init) {
1496
+ const { additionalStateHooks } = getSettingsFromContext(context);
1497
+ if (core.isUseStateLikeCall(init, additionalStateHooks)) return Extract.getCalleeName(init) ?? "useState";
1498
+ if (core.isUseReducerCall(context, init)) return "useReducer";
1499
+ return null;
1500
+ }
1501
+ /**
1502
+ * Classify whether a variable ultimately holds a value that must be treated as
1503
+ * immutable: a component's props, a state value returned from `useState`-like or
1504
+ * `useReducer` calls, or a shallow copy (spread literal) of either.
1505
+ * @param context The rule context.
1506
+ * @param variable The variable to classify.
1507
+ * @param seen Variables already visited during spread recursion.
1508
+ * @returns The frozen origin, or `null` when the variable is not derived from one.
1509
+ */
1510
+ function classifyFrozenOrigin(context, variable, seen = /* @__PURE__ */ new Set()) {
1511
+ if (seen.has(variable)) return null;
1512
+ seen.add(variable);
1513
+ const origin = resolveVariableOrigin(context, variable);
1514
+ const def = origin.defs.length === 1 ? origin.defs[0] : null;
1515
+ if (def == null) return null;
1516
+ if (isComponentPropsDefinition(context, def)) return {
1517
+ kind: "props",
1518
+ name: origin.name
1519
+ };
1520
+ if (def.type !== DefinitionType.Variable) return null;
1521
+ const init = def.node.init == null ? null : Extract.unwrap(def.node.init);
1522
+ if (init == null) return null;
1523
+ switch (init.type) {
1524
+ case AST_NODE_TYPES.CallExpression: {
1525
+ const hook = getStateHookName(context, init);
1526
+ if (hook == null) return null;
1527
+ if (def.node.id.type !== AST_NODE_TYPES.ArrayPattern) return null;
1528
+ const first = def.node.id.elements.at(0);
1529
+ if (first == null || !isNodeWithin(def.name, first)) return null;
1530
+ return {
1531
+ kind: "state",
1532
+ name: origin.name,
1533
+ hook
1534
+ };
1535
+ }
1536
+ case AST_NODE_TYPES.ArrayExpression:
1537
+ case AST_NODE_TYPES.ObjectExpression: {
1538
+ const elements = init.type === AST_NODE_TYPES.ObjectExpression ? init.properties : init.elements;
1539
+ for (const element of elements) {
1540
+ if (element?.type !== AST_NODE_TYPES.SpreadElement) continue;
1541
+ const argument = Extract.unwrap(element.argument);
1542
+ if (!Check.isIdentifier(argument)) continue;
1543
+ const source = findVariable(context.sourceCode.getScope(argument), argument);
1544
+ if (source == null) continue;
1545
+ const inner = classifyFrozenOrigin(context, source, seen);
1546
+ if (inner != null) return {
1547
+ kind: "shallow-copy",
1548
+ name: origin.name,
1549
+ original: inner.name
1550
+ };
1551
+ }
1552
+ return null;
1553
+ }
1554
+ default: return null;
1555
+ }
1556
+ }
1557
+
1477
1558
  //#endregion
1478
1559
  //#region src/rules/immutability/effects.ts
1479
1560
  function isGlobalOrModuleVariable(variable) {
@@ -1505,6 +1586,47 @@ function inferMutableFunctions(context, mutations) {
1505
1586
  }
1506
1587
  return mutableFunctions;
1507
1588
  }
1589
+ function getMutatedObject(mutation) {
1590
+ const target = Extract.unwrap(mutation.target);
1591
+ if (mutation.node.type === AST_NODE_TYPES.CallExpression) return target;
1592
+ return target.type === AST_NODE_TYPES.MemberExpression ? Extract.unwrap(target.object) : target;
1593
+ }
1594
+ function inferDirectMutations(context, mutations) {
1595
+ const directMutations = [];
1596
+ for (const mutation of mutations) {
1597
+ if (mutation.kind !== "value") continue;
1598
+ if (mutation.node.type === AST_NODE_TYPES.CallExpression && isKnownNonMutatingMethodCall(context, mutation.node)) continue;
1599
+ if (isRefMutation(context, mutation)) continue;
1600
+ const variable = findVariable(context.sourceCode.getScope(mutation.root), mutation.root);
1601
+ if (variable == null) continue;
1602
+ const origin = classifyFrozenOrigin(context, variable);
1603
+ if (origin == null) continue;
1604
+ switch (origin.kind) {
1605
+ case "props":
1606
+ directMutations.push({
1607
+ name: origin.name,
1608
+ detail: "It is a prop of this component and must be treated as immutable.",
1609
+ node: mutation.node
1610
+ });
1611
+ break;
1612
+ case "state":
1613
+ directMutations.push({
1614
+ name: origin.name,
1615
+ detail: `It is a state value returned from '${origin.hook}' and must be treated as immutable.`,
1616
+ node: mutation.node
1617
+ });
1618
+ break;
1619
+ case "shallow-copy":
1620
+ if (getMutatedObject(mutation).type !== AST_NODE_TYPES.MemberExpression) continue;
1621
+ directMutations.push({
1622
+ name: origin.name,
1623
+ detail: `It is a shallow copy of '${origin.original}'; mutating nested values through it mutates '${origin.original}' in place.`,
1624
+ node: mutation.node
1625
+ });
1626
+ }
1627
+ }
1628
+ return directMutations;
1629
+ }
1508
1630
 
1509
1631
  //#endregion
1510
1632
  //#region src/rules/immutability/immutability.ts
@@ -1512,9 +1634,10 @@ const RULE_NAME$48 = "immutability";
1512
1634
  var immutability_default = createRule({
1513
1635
  meta: {
1514
1636
  type: "problem",
1515
- docs: { description: "Validates against passing functions that mutate captured local variables into frozen contexts such as JSX props, hook arguments, and hook return values." },
1637
+ docs: { description: "Validates against mutating props, state, and other immutable values, including through functions passed into frozen contexts such as JSX props, hook arguments, and hook return values." },
1516
1638
  messages: {
1517
1639
  default: "This function may (indirectly) reassign or modify '{{name}}' after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.",
1640
+ direct: "Do not mutate '{{name}}' directly. {{detail}}",
1518
1641
  mutates: "This modifies '{{name}}'."
1519
1642
  },
1520
1643
  schema: []
@@ -1531,25 +1654,40 @@ function create$48(context) {
1531
1654
  kind: "hook-return",
1532
1655
  expression
1533
1656
  });
1657
+ const reportedMutations = /* @__PURE__ */ new Set();
1534
1658
  const mutableFunctions = inferMutableFunctions(context, collector.facts.mutations);
1535
- if (mutableFunctions.size === 0) return;
1536
- const reported = /* @__PURE__ */ new Set();
1537
- for (const sink of collector.facts.sinks) {
1538
- const expression = sink.expression;
1539
- if (reported.has(expression)) continue;
1540
- const fn = resolveToFunctionNode(context, expression);
1541
- if (fn == null) continue;
1542
- const mutation = mutableFunctions.get(fn);
1543
- if (mutation == null) continue;
1544
- reported.add(expression);
1545
- context.report({
1546
- data: { name: mutation.name },
1547
- messageId: "default",
1548
- node: expression
1549
- });
1659
+ if (mutableFunctions.size > 0) {
1660
+ const reportedSinks = /* @__PURE__ */ new Set();
1661
+ for (const sink of collector.facts.sinks) {
1662
+ const expression = sink.expression;
1663
+ if (reportedSinks.has(expression)) continue;
1664
+ const fn = resolveToFunctionNode(context, expression);
1665
+ if (fn == null) continue;
1666
+ const mutation = mutableFunctions.get(fn);
1667
+ if (mutation == null) continue;
1668
+ reportedSinks.add(expression);
1669
+ reportedMutations.add(mutation.node);
1670
+ context.report({
1671
+ data: { name: mutation.name },
1672
+ messageId: "default",
1673
+ node: expression
1674
+ });
1675
+ context.report({
1676
+ data: { name: mutation.name },
1677
+ messageId: "mutates",
1678
+ node: mutation.node
1679
+ });
1680
+ }
1681
+ }
1682
+ for (const mutation of inferDirectMutations(context, collector.facts.mutations)) {
1683
+ if (reportedMutations.has(mutation.node)) continue;
1684
+ reportedMutations.add(mutation.node);
1550
1685
  context.report({
1551
- data: { name: mutation.name },
1552
- messageId: "mutates",
1686
+ data: {
1687
+ name: mutation.name,
1688
+ detail: mutation.detail
1689
+ },
1690
+ messageId: "direct",
1553
1691
  node: mutation.node
1554
1692
  });
1555
1693
  }
@@ -8594,19 +8732,19 @@ const finalPlugin = {
8594
8732
  /**
8595
8733
  * Disable rules in `eslint-plugin-react` that conflict with rules in this plugin
8596
8734
  */
8597
- ["disable-conflict-eslint-plugin-react"]: disable_conflict_eslint_plugin_react_exports,
8735
+ ["disable-conflict-eslint-plugin-react"]: createConfig(disable_conflict_eslint_plugin_react_exports),
8598
8736
  /**
8599
8737
  * Disable rules in `eslint-plugin-react-hooks` that conflict with rules in this plugin
8600
8738
  */
8601
- ["disable-conflict-eslint-plugin-react-hooks"]: disable_conflict_eslint_plugin_react_hooks_exports,
8739
+ ["disable-conflict-eslint-plugin-react-hooks"]: createConfig(disable_conflict_eslint_plugin_react_hooks_exports),
8602
8740
  /**
8603
8741
  * Disable experimental rules that might be subject to change in the future
8604
8742
  */
8605
- ["disable-experimental"]: disable_experimental_exports,
8743
+ ["disable-experimental"]: createConfig(disable_experimental_exports),
8606
8744
  /**
8607
8745
  * Disable rules that can be enforced by TypeScript
8608
8746
  */
8609
- ["disable-type-checked"]: disable_type_checked_exports,
8747
+ ["disable-type-checked"]: createConfig(disable_type_checked_exports),
8610
8748
  /**
8611
8749
  * Enforce rules that are recommended by ESLint React for general purpose React + React DOM projects
8612
8750
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-react-x",
3
- "version": "5.18.9",
3
+ "version": "5.19.0",
4
4
  "description": "A set of composable ESLint rules for libraries and frameworks that use React as a UI runtime.",
5
5
  "keywords": [
6
6
  "react",
@@ -36,12 +36,12 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@eslint-react/ast": "5.18.9",
40
- "@eslint-react/core": "5.18.9",
41
- "@eslint-react/eslint": "5.18.9",
42
- "@eslint-react/jsx": "5.18.9",
43
- "@eslint-react/shared": "5.18.9",
44
- "@eslint-react/var": "5.18.9",
39
+ "@eslint-react/ast": "5.19.0",
40
+ "@eslint-react/core": "5.19.0",
41
+ "@eslint-react/eslint": "5.19.0",
42
+ "@eslint-react/jsx": "5.19.0",
43
+ "@eslint-react/shared": "5.19.0",
44
+ "@eslint-react/var": "5.19.0",
45
45
  "@typescript-eslint/scope-manager": "^8.69.0",
46
46
  "@typescript-eslint/type-utils": "^8.69.0",
47
47
  "@typescript-eslint/types": "^8.69.0",