oxlint-plugin-react-doctor 0.7.2-dev.08b768b → 0.7.2-dev.11e9c87

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 +155 -350
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -532,12 +532,6 @@ const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
532
532
  ]);
533
533
  const TIMER_CALLEE_NAMES_REQUIRING_CLEANUP = new Set(["setInterval", "setTimeout"]);
534
534
  const TIMER_CLEANUP_CALLEE_NAMES = new Set(["clearInterval", "clearTimeout"]);
535
- const SOCKET_CONSTRUCTOR_NAMES_REQUIRING_CLEANUP = new Set([
536
- "WebSocket",
537
- "EventSource",
538
- "BroadcastChannel",
539
- "RTCPeerConnection"
540
- ]);
541
535
  const MUTABLE_GLOBAL_ROOTS = new Set([
542
536
  "location",
543
537
  "window",
@@ -693,10 +687,7 @@ const GLOBAL_RELEASE_METHOD_NAMES = new Set([
693
687
  "unwatch",
694
688
  "unlisten",
695
689
  "unsub",
696
- "abort",
697
- "disconnect",
698
- "unobserve",
699
- "close"
690
+ "abort"
700
691
  ]);
701
692
  const BOUND_RESOURCE_RELEASE_METHOD_NAMES = new Set([
702
693
  "remove",
@@ -7657,95 +7648,6 @@ const displayName = defineRule({
7657
7648
  }
7658
7649
  });
7659
7650
  //#endregion
7660
- //#region src/plugin/utils/is-react-hook-name.ts
7661
- const isReactHookName = (name) => {
7662
- if (!name.startsWith("use")) return false;
7663
- if (name.length === 3) return true;
7664
- const fourthCharacter = name.charCodeAt(3);
7665
- return fourthCharacter >= 65 && fourthCharacter <= 90 || fourthCharacter >= 48 && fourthCharacter <= 57;
7666
- };
7667
- //#endregion
7668
- //#region src/plugin/utils/is-react-component-or-hook-name.ts
7669
- const isReactComponentOrHookName = (name) => isReactComponentName(name) || isReactHookName(name);
7670
- //#endregion
7671
- //#region src/plugin/utils/component-or-hook-display-name.ts
7672
- const hocWrapperCalleeName = (callee) => {
7673
- if (isNodeOfType(callee, "Identifier")) return callee.name;
7674
- if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) return callee.property.name;
7675
- return null;
7676
- };
7677
- const displayNameFromFunctionBinding = (functionNode) => {
7678
- let current = functionNode;
7679
- for (;;) {
7680
- const parent = current.parent;
7681
- if (parent && isNodeOfType(parent, "CallExpression") && parent.arguments?.[0] === current) {
7682
- const calleeName = hocWrapperCalleeName(parent.callee);
7683
- if (calleeName && COMPONENT_HOC_WRAPPER_NAMES.has(calleeName)) {
7684
- current = parent;
7685
- continue;
7686
- }
7687
- }
7688
- break;
7689
- }
7690
- const binding = current.parent;
7691
- if (binding && isNodeOfType(binding, "VariableDeclarator") && isNodeOfType(binding.id, "Identifier") && binding.init === current) return isReactComponentOrHookName(binding.id.name) ? binding.id.name : null;
7692
- return null;
7693
- };
7694
- const componentOrHookDisplayNameForFunction = (functionNode) => {
7695
- if ((isNodeOfType(functionNode, "FunctionDeclaration") || isNodeOfType(functionNode, "FunctionExpression")) && functionNode.id) return isReactComponentOrHookName(functionNode.id.name) ? functionNode.id.name : null;
7696
- return displayNameFromFunctionBinding(functionNode);
7697
- };
7698
- //#endregion
7699
- //#region src/plugin/utils/find-enclosing-function.ts
7700
- const findEnclosingFunction = (node) => {
7701
- let cursor = node.parent;
7702
- while (cursor) {
7703
- if (isFunctionLike$1(cursor)) return cursor;
7704
- cursor = cursor.parent ?? null;
7705
- }
7706
- return null;
7707
- };
7708
- //#endregion
7709
- //#region src/plugin/utils/enclosing-component-or-hook-name.ts
7710
- const enclosingComponentOrHookName = (node) => {
7711
- const functionNode = findEnclosingFunction(node);
7712
- return functionNode ? componentOrHookDisplayNameForFunction(functionNode) : null;
7713
- };
7714
- //#endregion
7715
- //#region src/plugin/utils/is-result-discarded-call.ts
7716
- const isResultDiscardedCall = (callExpression) => {
7717
- let node = callExpression;
7718
- let parent = node.parent;
7719
- while (parent) {
7720
- if (isNodeOfType(parent, "ExpressionStatement")) return true;
7721
- if (isNodeOfType(parent, "ArrowFunctionExpression") && parent.body === node) return true;
7722
- if (isNodeOfType(parent, "ChainExpression")) {
7723
- node = parent;
7724
- parent = node.parent;
7725
- continue;
7726
- }
7727
- if (isNodeOfType(parent, "LogicalExpression") && parent.right === node) {
7728
- node = parent;
7729
- parent = node.parent;
7730
- continue;
7731
- }
7732
- if (isNodeOfType(parent, "ConditionalExpression") && (parent.consequent === node || parent.alternate === node)) {
7733
- node = parent;
7734
- parent = node.parent;
7735
- continue;
7736
- }
7737
- if (isNodeOfType(parent, "SequenceExpression")) {
7738
- const expressions = parent.expressions ?? [];
7739
- if (expressions[expressions.length - 1] !== node) return true;
7740
- node = parent;
7741
- parent = node.parent;
7742
- continue;
7743
- }
7744
- return false;
7745
- }
7746
- return false;
7747
- };
7748
- //#endregion
7749
7651
  //#region src/plugin/utils/walk-inside-statement-blocks.ts
7750
7652
  const walkInsideStatementBlocks = (node, visitor) => {
7751
7653
  if (!node || typeof node !== "object") return;
@@ -7897,17 +7799,6 @@ const isCleanupReturn = (returnedValue, knownCleanupFunctionNames, knownBoundSub
7897
7799
  };
7898
7800
  //#endregion
7899
7801
  //#region src/plugin/rules/state-and-effects/effect-needs-cleanup.ts
7900
- const OBSERVER_REGISTRATION_METHOD_NAME = "observe";
7901
- const RESOURCE_NOUN_BY_KIND = {
7902
- subscribe: "subscription",
7903
- timer: "timer",
7904
- socket: "connection"
7905
- };
7906
- const isSocketConstruction = (node) => isNodeOfType(node, "NewExpression") && isNodeOfType(node.callee, "Identifier") && SOCKET_CONSTRUCTOR_NAMES_REQUIRING_CLEANUP.has(node.callee.name);
7907
- const isSubscribeOrObserveCall = (node) => {
7908
- if (isSubscribeLikeCallExpression(node)) return true;
7909
- return isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "MemberExpression") && isNodeOfType(node.callee.property, "Identifier") && node.callee.property.name === OBSERVER_REGISTRATION_METHOD_NAME;
7910
- };
7911
7802
  const findSubscribeLikeUsages = (callback) => {
7912
7803
  const usages = [];
7913
7804
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return usages;
@@ -7919,14 +7810,6 @@ const findSubscribeLikeUsages = (callback) => {
7919
7810
  }
7920
7811
  walkAst(callback, (child) => {
7921
7812
  if (child === cleanupArgument && !isSubscribeLikeCallExpression(child)) return false;
7922
- if (isSocketConstruction(child)) {
7923
- usages.push({
7924
- kind: "socket",
7925
- node: child,
7926
- resourceName: isNodeOfType(child.callee, "Identifier") ? child.callee.name : "WebSocket"
7927
- });
7928
- return;
7929
- }
7930
7813
  if (!isNodeOfType(child, "CallExpression")) return;
7931
7814
  if (isNodeOfType(child.callee, "Identifier") && TIMER_CALLEE_NAMES_REQUIRING_CLEANUP.has(child.callee.name)) {
7932
7815
  usages.push({
@@ -7936,7 +7819,7 @@ const findSubscribeLikeUsages = (callback) => {
7936
7819
  });
7937
7820
  return;
7938
7821
  }
7939
- if (isNodeOfType(child.callee, "MemberExpression") && isNodeOfType(child.callee.property, "Identifier") && (SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name) || child.callee.property.name === OBSERVER_REGISTRATION_METHOD_NAME)) usages.push({
7822
+ if (isNodeOfType(child.callee, "MemberExpression") && isNodeOfType(child.callee.property, "Identifier") && SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name)) usages.push({
7940
7823
  kind: "subscribe",
7941
7824
  node: child,
7942
7825
  resourceName: child.callee.property.name
@@ -7959,12 +7842,7 @@ const collectCleanupBindings = (effectCallback) => {
7959
7842
  const bindingName = declarator.id.name;
7960
7843
  bindings.effectScopeVariableNames.add(bindingName);
7961
7844
  const init = declarator.init;
7962
- if (!init) continue;
7963
- if (isSocketConstruction(init)) {
7964
- bindings.subscriptionNames.add(bindingName);
7965
- continue;
7966
- }
7967
- if (!isNodeOfType(init, "CallExpression")) continue;
7845
+ if (!init || !isNodeOfType(init, "CallExpression")) continue;
7968
7846
  if (isSubscribeLikeCallExpression(init)) {
7969
7847
  bindings.subscriptionNames.add(bindingName);
7970
7848
  if (isCleanupReturningSubscribeLikeCallExpression(init)) bindings.cleanupFunctionNames.add(bindingName);
@@ -8016,168 +7894,26 @@ const effectHasCleanupReturn = (callback, usages) => {
8016
7894
  });
8017
7895
  return didFindCleanupReturn;
8018
7896
  };
8019
- const EMPTY_NAME_SET$1 = /* @__PURE__ */ new Set();
8020
- const hasSelfReleasingListenerOptions = (node) => isNodeOfType(node, "CallExpression") && (node.arguments ?? []).some((argument) => isNodeOfType(argument, "ObjectExpression") && (argument.properties ?? []).some((property) => isNodeOfType(property, "Property") && isNodeOfType(property.key, "Identifier") && (property.key.name === "signal" || property.key.name === "once" && isNodeOfType(property.value, "Literal") && property.value.value === true)));
8021
- const PAIRED_RELEASE_VERB_NAMES_BY_REGISTRATION_VERB = new Map([
8022
- ["addEventListener", new Set(["removeEventListener", "abort"])],
8023
- ["addListener", new Set([
8024
- "removeListener",
8025
- "off",
8026
- "abort"
8027
- ])],
8028
- ["on", new Set([
8029
- "off",
8030
- "removeListener",
8031
- "on"
8032
- ])],
8033
- ["subscribe", new Set(["unsubscribe", "unsub"])],
8034
- ["sub", new Set(["unsub", "unsubscribe"])],
8035
- ["watch", new Set(["unwatch", "close"])],
8036
- ["listen", new Set(["unlisten", "close"])],
8037
- [OBSERVER_REGISTRATION_METHOD_NAME, new Set(["disconnect", "unobserve"])]
8038
- ]);
8039
- const UNIVERSAL_RELEASE_VERB_NAMES = new Set([
8040
- "cleanup",
8041
- "dispose",
8042
- "destroy",
8043
- "teardown"
8044
- ]);
8045
- const SOCKET_RELEASE_VERB_NAMES = new Set(["close"]);
8046
- const INTERVAL_RELEASE_VERB_NAMES = new Set(["clearInterval"]);
8047
- const getReleaseVerbName = (node) => {
8048
- if (!isReleaseLikeCall(node, EMPTY_NAME_SET$1, EMPTY_NAME_SET$1)) return null;
8049
- const callNode = isNodeOfType(node, "ChainExpression") ? node.expression : node;
8050
- if (!isNodeOfType(callNode, "CallExpression")) return null;
8051
- const callee = isNodeOfType(callNode.callee, "ChainExpression") ? callNode.callee.expression : callNode.callee;
8052
- if (isNodeOfType(callee, "Identifier")) return callee.name;
8053
- if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) return callee.property.name;
8054
- return null;
8055
- };
8056
- const matchesPairedReleaseVerb = (releaseVerbName, pairedVerbNames) => pairedVerbNames.has(releaseVerbName) || UNIVERSAL_RELEASE_VERB_NAMES.has(releaseVerbName);
8057
- const bodyContainsPairedReleaseCall = (body, pairedVerbNames) => {
8058
- let didFindPairedRelease = false;
8059
- walkAst(body, (child) => {
8060
- if (didFindPairedRelease) return false;
8061
- if (child !== body && isFunctionLike$1(child)) return false;
8062
- const releaseVerbName = getReleaseVerbName(child);
8063
- if (releaseVerbName !== null && matchesPairedReleaseVerb(releaseVerbName, pairedVerbNames)) {
8064
- didFindPairedRelease = true;
8065
- return false;
8066
- }
8067
- });
8068
- return didFindPairedRelease;
8069
- };
8070
- const fileReleaseVerbNamesCache = /* @__PURE__ */ new WeakMap();
8071
- const collectFileReleaseVerbNames = (anyNode) => {
8072
- let programNode = anyNode;
8073
- while (programNode.parent) programNode = programNode.parent;
8074
- const cached = fileReleaseVerbNamesCache.get(programNode);
8075
- if (cached) return cached;
8076
- const releaseVerbNames = /* @__PURE__ */ new Set();
8077
- walkAst(programNode, (child) => {
8078
- const releaseVerbName = getReleaseVerbName(child);
8079
- if (releaseVerbName !== null) releaseVerbNames.add(releaseVerbName);
8080
- });
8081
- fileReleaseVerbNamesCache.set(programNode, releaseVerbNames);
8082
- return releaseVerbNames;
8083
- };
8084
- const fileContainsPairedReleaseCall = (registrationCall, registrationVerbName) => {
8085
- const fileReleaseVerbNames = collectFileReleaseVerbNames(registrationCall);
8086
- const pairedVerbNames = PAIRED_RELEASE_VERB_NAMES_BY_REGISTRATION_VERB.get(registrationVerbName);
8087
- if (!pairedVerbNames) return fileReleaseVerbNames.size > 0;
8088
- for (const releaseVerbName of fileReleaseVerbNames) if (matchesPairedReleaseVerb(releaseVerbName, pairedVerbNames)) return true;
8089
- return false;
8090
- };
8091
- const findRetainedFunctionLeak = (retainedFunction) => {
8092
- if (!isFunctionLike$1(retainedFunction)) return null;
8093
- const body = retainedFunction.body;
8094
- if (!body) return null;
8095
- let leak = null;
8096
- walkAst(body, (child) => {
8097
- if (leak !== null) return false;
8098
- if (isFunctionLike$1(child)) return false;
8099
- if (isSocketConstruction(child) && isResultDiscardedCall(child) && !bodyContainsPairedReleaseCall(body, SOCKET_RELEASE_VERB_NAMES)) {
8100
- leak = {
8101
- kind: "socket",
8102
- node: child,
8103
- resourceName: isNodeOfType(child.callee, "Identifier") ? child.callee.name : "WebSocket"
8104
- };
8105
- return false;
8106
- }
8107
- if (!isNodeOfType(child, "CallExpression")) return;
8108
- if (isNodeOfType(child.callee, "Identifier") && child.callee.name === "setInterval" && isResultDiscardedCall(child) && !bodyContainsPairedReleaseCall(body, INTERVAL_RELEASE_VERB_NAMES)) {
8109
- leak = {
8110
- kind: "timer",
8111
- node: child,
8112
- resourceName: "setInterval"
8113
- };
8114
- return false;
8115
- }
8116
- if (isSubscribeOrObserveCall(child) && isResultDiscardedCall(child)) {
8117
- const registrationVerbName = isNodeOfType(child.callee, "MemberExpression") && isNodeOfType(child.callee.property, "Identifier") ? child.callee.property.name : "subscribe";
8118
- if (!hasSelfReleasingListenerOptions(child) && !fileContainsPairedReleaseCall(child, registrationVerbName)) leak = {
8119
- kind: "subscribe",
8120
- node: child,
8121
- resourceName: registrationVerbName
8122
- };
8123
- return false;
8124
- }
8125
- });
8126
- return leak;
8127
- };
8128
- const isRetainedComponentScopeFunction = (functionNode) => {
8129
- if (isNodeOfType(functionNode, "FunctionDeclaration")) return enclosingComponentOrHookName(functionNode) !== null;
8130
- if (!isNodeOfType(functionNode, "ArrowFunctionExpression") && !isNodeOfType(functionNode, "FunctionExpression")) return false;
8131
- if (!isNodeOfType(functionNode.parent, "VariableDeclarator")) return false;
8132
- return enclosingComponentOrHookName(functionNode) !== null;
8133
- };
8134
7897
  const effectNeedsCleanup = defineRule({
8135
7898
  id: "effect-needs-cleanup",
8136
7899
  title: "Effect subscription or timer never cleaned up",
8137
7900
  severity: "error",
8138
7901
  tags: ["test-noise"],
8139
- recommendation: "Return a cleanup function that stops the subscription or timer: `return () => target.removeEventListener(name, handler)` for listeners, `return () => clearInterval(id)` or `clearTimeout(id)` for timers, `return () => observer.disconnect()` for observers, `return () => socket.close()` for connections, or `return unsubscribe` if the subscribe call already gave you one.",
8140
- create: (context) => {
8141
- const reportRetainedLeak = (retainedFunction) => {
8142
- const leak = findRetainedFunctionLeak(retainedFunction);
8143
- if (!leak) return;
8144
- const resourceNoun = RESOURCE_NOUN_BY_KIND[leak.kind];
8145
- context.report({
8146
- node: leak.node,
8147
- message: `\`${leak.resourceName}\` creates a ${resourceNoun} in a function that outlives the render, with no cleanup path. Store the handle and release it, or move this into a useEffect that returns cleanup, so it does not leak after unmount.`
8148
- });
8149
- };
8150
- return {
8151
- CallExpression(node) {
8152
- if (isHookCall$2(node, "useCallback")) {
8153
- const retainedCallback = getEffectCallback(node);
8154
- if (retainedCallback) reportRetainedLeak(retainedCallback);
8155
- return;
8156
- }
8157
- if (!isHookCall$2(node, EFFECT_HOOK_NAMES$1)) return;
8158
- const callback = getEffectCallback(node);
8159
- if (!callback) return;
8160
- const usages = findSubscribeLikeUsages(callback);
8161
- if (usages.length === 0) return;
8162
- if (effectHasCleanupReturn(callback, usages)) return;
8163
- const firstUsage = usages[0];
8164
- const resourceNoun = RESOURCE_NOUN_BY_KIND[firstUsage.kind];
8165
- context.report({
8166
- node,
8167
- message: `\`${firstUsage.resourceName}\` creates a ${resourceNoun} in useEffect without returning cleanup. Return a cleanup function so it does not leak after unmount.`
8168
- });
8169
- },
8170
- FunctionDeclaration(node) {
8171
- if (isRetainedComponentScopeFunction(node)) reportRetainedLeak(node);
8172
- },
8173
- ArrowFunctionExpression(node) {
8174
- if (isRetainedComponentScopeFunction(node)) reportRetainedLeak(node);
8175
- },
8176
- FunctionExpression(node) {
8177
- if (isRetainedComponentScopeFunction(node)) reportRetainedLeak(node);
8178
- }
8179
- };
8180
- }
7902
+ recommendation: "Return a cleanup function that stops the subscription or timer: `return () => target.removeEventListener(name, handler)` for listeners, `return () => clearInterval(id)` or `clearTimeout(id)` for timers, or `return unsubscribe` if the subscribe call already gave you one.",
7903
+ create: (context) => ({ CallExpression(node) {
7904
+ if (!isHookCall$2(node, EFFECT_HOOK_NAMES$1)) return;
7905
+ const callback = getEffectCallback(node);
7906
+ if (!callback) return;
7907
+ const usages = findSubscribeLikeUsages(callback);
7908
+ if (usages.length === 0) return;
7909
+ if (effectHasCleanupReturn(callback, usages)) return;
7910
+ const firstUsage = usages[0];
7911
+ const resourceKind = firstUsage.kind === "timer" ? "timer" : "subscription";
7912
+ context.report({
7913
+ node,
7914
+ message: `\`${firstUsage.resourceName}\` creates a ${resourceKind} in useEffect without returning cleanup. Return a cleanup function so it does not leak after unmount.`
7915
+ });
7916
+ } })
8181
7917
  });
8182
7918
  //#endregion
8183
7919
  //#region src/plugin/semantic/scope-analysis.ts
@@ -8736,6 +8472,17 @@ const closureCaptures = (functionNode, scopes) => {
8736
8472
  return computedCaptures;
8737
8473
  };
8738
8474
  //#endregion
8475
+ //#region src/plugin/utils/is-react-hook-name.ts
8476
+ const isReactHookName = (name) => {
8477
+ if (!name.startsWith("use")) return false;
8478
+ if (name.length === 3) return true;
8479
+ const fourthCharacter = name.charCodeAt(3);
8480
+ return fourthCharacter >= 65 && fourthCharacter <= 90 || fourthCharacter >= 48 && fourthCharacter <= 57;
8481
+ };
8482
+ //#endregion
8483
+ //#region src/plugin/utils/is-react-component-or-hook-name.ts
8484
+ const isReactComponentOrHookName = (name) => isReactComponentName(name) || isReactHookName(name);
8485
+ //#endregion
8739
8486
  //#region src/plugin/utils/is-react-hoc-callback-argument.ts
8740
8487
  const reactHocCalleeName = (callee) => {
8741
8488
  if (isNodeOfType(callee, "Identifier")) return callee.name;
@@ -11550,6 +11297,16 @@ const collectHandlerReferencedNames = (root) => {
11550
11297
  return names;
11551
11298
  };
11552
11299
  //#endregion
11300
+ //#region src/plugin/utils/find-enclosing-function.ts
11301
+ const findEnclosingFunction = (node) => {
11302
+ let cursor = node.parent;
11303
+ while (cursor) {
11304
+ if (isFunctionLike$1(cursor)) return cursor;
11305
+ cursor = cursor.parent ?? null;
11306
+ }
11307
+ return null;
11308
+ };
11309
+ //#endregion
11553
11310
  //#region src/plugin/utils/get-function-binding-name.ts
11554
11311
  const getFunctionBindingIdentifier = (functionNode) => {
11555
11312
  if (isNodeOfType(functionNode, "FunctionDeclaration") && isNodeOfType(functionNode.id, "Identifier")) return functionNode.id;
@@ -21354,7 +21111,7 @@ const isObjectUrlLifecycleEffect = (effectFn) => {
21354
21111
  const noAdjustStateOnPropChange = defineRule({
21355
21112
  id: "no-adjust-state-on-prop-change",
21356
21113
  title: "State synced to a prop inside an effect",
21357
- severity: "warn",
21114
+ severity: "error",
21358
21115
  tags: ["test-noise"],
21359
21116
  recommendation: "Adjust the state inline during render with a `prev`-prop comparison (`if (prop !== prevProp) { setPrevProp(prop); setX(...); }`), or refactor to remove the duplicated state. Routing the adjustment through a useEffect forces an extra render with a stale UI between the two commits. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes",
21360
21117
  create: (context) => ({ CallExpression(node) {
@@ -23722,6 +23479,40 @@ const noCloneElement = defineRule({
23722
23479
  } })
23723
23480
  });
23724
23481
  //#endregion
23482
+ //#region src/plugin/utils/component-or-hook-display-name.ts
23483
+ const hocWrapperCalleeName = (callee) => {
23484
+ if (isNodeOfType(callee, "Identifier")) return callee.name;
23485
+ if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) return callee.property.name;
23486
+ return null;
23487
+ };
23488
+ const displayNameFromFunctionBinding = (functionNode) => {
23489
+ let current = functionNode;
23490
+ for (;;) {
23491
+ const parent = current.parent;
23492
+ if (parent && isNodeOfType(parent, "CallExpression") && parent.arguments?.[0] === current) {
23493
+ const calleeName = hocWrapperCalleeName(parent.callee);
23494
+ if (calleeName && COMPONENT_HOC_WRAPPER_NAMES.has(calleeName)) {
23495
+ current = parent;
23496
+ continue;
23497
+ }
23498
+ }
23499
+ break;
23500
+ }
23501
+ const binding = current.parent;
23502
+ if (binding && isNodeOfType(binding, "VariableDeclarator") && isNodeOfType(binding.id, "Identifier") && binding.init === current) return isReactComponentOrHookName(binding.id.name) ? binding.id.name : null;
23503
+ return null;
23504
+ };
23505
+ const componentOrHookDisplayNameForFunction = (functionNode) => {
23506
+ if ((isNodeOfType(functionNode, "FunctionDeclaration") || isNodeOfType(functionNode, "FunctionExpression")) && functionNode.id) return isReactComponentOrHookName(functionNode.id.name) ? functionNode.id.name : null;
23507
+ return displayNameFromFunctionBinding(functionNode);
23508
+ };
23509
+ //#endregion
23510
+ //#region src/plugin/utils/enclosing-component-or-hook-name.ts
23511
+ const enclosingComponentOrHookName = (node) => {
23512
+ const functionNode = findEnclosingFunction(node);
23513
+ return functionNode ? componentOrHookDisplayNameForFunction(functionNode) : null;
23514
+ };
23515
+ //#endregion
23725
23516
  //#region src/plugin/rules/state-and-effects/no-create-context-in-render.ts
23726
23517
  const MESSAGE$30 = "createContext() builds a new context every render, so every consumer gets cut off & resets.";
23727
23518
  const CONTEXT_MODULES = [
@@ -24735,26 +24526,6 @@ const collectValueIdentifierNames = (node, into, localBindingNames = /* @__PURE_
24735
24526
  } else if (child && typeof child === "object" && "type" in child) collectValueIdentifierNames(child, into, localBindingNames);
24736
24527
  }
24737
24528
  };
24738
- const flattenGuardedStatements = (statements) => {
24739
- const flattened = [];
24740
- for (const statement of statements) {
24741
- if (isNodeOfType(statement, "ExpressionStatement")) {
24742
- flattened.push(statement);
24743
- continue;
24744
- }
24745
- if (isNodeOfType(statement, "IfStatement")) {
24746
- for (const branch of [statement.consequent, statement.alternate]) {
24747
- if (!branch) continue;
24748
- const flattenedBranch = flattenGuardedStatements(isNodeOfType(branch, "BlockStatement") ? branch.body ?? [] : [branch]);
24749
- if (flattenedBranch === null) return null;
24750
- flattened.push(...flattenedBranch);
24751
- }
24752
- continue;
24753
- }
24754
- return null;
24755
- }
24756
- return flattened;
24757
- };
24758
24529
  const noDerivedStateEffect = defineRule({
24759
24530
  id: "no-derived-state-effect",
24760
24531
  title: "Derived state stored in an effect",
@@ -24786,8 +24557,8 @@ const noDerivedStateEffect = defineRule({
24786
24557
  }
24787
24558
  }
24788
24559
  if (sawAnyDep && allDepsAreInitialOnly) return;
24789
- const statements = flattenGuardedStatements(getCallbackStatements(callback));
24790
- if (statements === null || statements.length === 0) return;
24560
+ const statements = getCallbackStatements(callback);
24561
+ if (statements.length === 0) return;
24791
24562
  if (!statements.every((statement) => {
24792
24563
  if (!isNodeOfType(statement, "ExpressionStatement")) return false;
24793
24564
  const expression = statement.expression;
@@ -29266,7 +29037,7 @@ const extractReturnTypeAnnotation = (returnType) => {
29266
29037
  const noJsxElementType = defineRule({
29267
29038
  id: "no-jsx-element-type",
29268
29039
  title: "No JSX.Element",
29269
- severity: "warn",
29040
+ severity: "error",
29270
29041
  recommendation: "Replace `JSX.Element` with `React.ReactNode`. `JSX.Element` is too narrow: it excludes `null`, strings, numbers, and fragments that components commonly return.",
29271
29042
  create: (context) => {
29272
29043
  let isJsxImported = false;
@@ -30308,6 +30079,40 @@ const noMutableInDeps = defineRule({
30308
30079
  }
30309
30080
  });
30310
30081
  //#endregion
30082
+ //#region src/plugin/utils/is-result-discarded-call.ts
30083
+ const isResultDiscardedCall = (callExpression) => {
30084
+ let node = callExpression;
30085
+ let parent = node.parent;
30086
+ while (parent) {
30087
+ if (isNodeOfType(parent, "ExpressionStatement")) return true;
30088
+ if (isNodeOfType(parent, "ArrowFunctionExpression") && parent.body === node) return true;
30089
+ if (isNodeOfType(parent, "ChainExpression")) {
30090
+ node = parent;
30091
+ parent = node.parent;
30092
+ continue;
30093
+ }
30094
+ if (isNodeOfType(parent, "LogicalExpression") && parent.right === node) {
30095
+ node = parent;
30096
+ parent = node.parent;
30097
+ continue;
30098
+ }
30099
+ if (isNodeOfType(parent, "ConditionalExpression") && (parent.consequent === node || parent.alternate === node)) {
30100
+ node = parent;
30101
+ parent = node.parent;
30102
+ continue;
30103
+ }
30104
+ if (isNodeOfType(parent, "SequenceExpression")) {
30105
+ const expressions = parent.expressions ?? [];
30106
+ if (expressions[expressions.length - 1] !== node) return true;
30107
+ node = parent;
30108
+ parent = node.parent;
30109
+ continue;
30110
+ }
30111
+ return false;
30112
+ }
30113
+ return false;
30114
+ };
30115
+ //#endregion
30311
30116
  //#region src/plugin/rules/state-and-effects/utils/lodash-mutator-call.ts
30312
30117
  const LODASH_MUTATOR_NAMES = new Set([
30313
30118
  "set",
@@ -30749,7 +30554,7 @@ const noNestedComponentDefinition = defineRule({
30749
30554
  id: "no-nested-component-definition",
30750
30555
  title: "Component defined inside another component",
30751
30556
  tags: ["test-noise", "react-jsx-only"],
30752
- severity: "warn",
30557
+ severity: "error",
30753
30558
  category: "Correctness",
30754
30559
  recommendation: "Move it to module scope or a separate file so React does not recreate the component and erase its state on every parent render.",
30755
30560
  create: (context) => {
@@ -32641,7 +32446,7 @@ const noReactDomDeprecatedApis = defineRule({
32641
32446
  });
32642
32447
  //#endregion
32643
32448
  //#region src/plugin/rules/architecture/no-react19-deprecated-apis.ts
32644
- const REACT_19_DEPRECATED_MESSAGES = new Map([["forwardRef", "forwardRef is dead weight in React 19, since ref is a normal prop now, so drop it & pass ref straight through."]]);
32449
+ const REACT_19_DEPRECATED_MESSAGES = new Map([["forwardRef", "forwardRef is dead weight in React 19, since ref is a normal prop now, so drop it & pass ref straight through."], ["useContext", "useContext is replaced by `use()` in React 19, which reads context inside ifs & loops too, so switch to `import { use } from 'react'`."]]);
32645
32450
  const isVendoredShadcnUiFilename = (rawFilename) => {
32646
32451
  if (!rawFilename) return false;
32647
32452
  const filename = rawFilename.replaceAll("\\", "/");
@@ -32679,7 +32484,7 @@ const noReact19DeprecatedApis = defineRule({
32679
32484
  requires: ["react:19"],
32680
32485
  tags: ["test-noise", "migration-hint"],
32681
32486
  severity: "warn",
32682
- recommendation: "Pass `ref` as a normal prop on function components, since `forwardRef` isn't needed in React 19. Only runs on React 19+ projects.",
32487
+ recommendation: "Pass `ref` as a normal prop on function components, since `forwardRef` isn't needed in React 19. Replace `useContext(X)` with `use(X)`. Only runs on React 19+ projects.",
32683
32488
  create: (context) => {
32684
32489
  if (isVendoredShadcnUiFilename(context.filename)) return {};
32685
32490
  return deprecatedReactImportRule.create(buildOncePerApiContext(context));
@@ -33003,11 +32808,34 @@ const noRedundantShouldComponentUpdate = defineRule({
33003
32808
  });
33004
32809
  //#endregion
33005
32810
  //#region src/plugin/rules/architecture/no-render-in-render.ts
32811
+ const tracesToPropOrParameter = (symbol, scopes, visitedSymbols = /* @__PURE__ */ new Set()) => {
32812
+ if (!symbol || visitedSymbols.has(symbol)) return false;
32813
+ visitedSymbols.add(symbol);
32814
+ if (isComponentParameterSymbol(symbol)) return true;
32815
+ if (!isNodeOfType(symbol.declarationNode, "VariableDeclarator")) return false;
32816
+ const source = symbol.initializer;
32817
+ if (!source) return false;
32818
+ return initializerRootsInProps(source, scopes, visitedSymbols);
32819
+ };
32820
+ const initializerRootsInProps = (node, scopes, visitedSymbols = /* @__PURE__ */ new Set()) => {
32821
+ if (isNodeOfType(node, "LogicalExpression")) return initializerRootsInProps(node.left, scopes, visitedSymbols) || initializerRootsInProps(node.right, scopes, visitedSymbols);
32822
+ if (isNodeOfType(node, "ConditionalExpression")) return initializerRootsInProps(node.consequent, scopes, visitedSymbols) || initializerRootsInProps(node.alternate, scopes, visitedSymbols);
32823
+ return rootsInProps(node, scopes, visitedSymbols);
32824
+ };
32825
+ const rootsInProps = (node, scopes, visitedSymbols = /* @__PURE__ */ new Set()) => {
32826
+ let current = node;
32827
+ while (isNodeOfType(current, "MemberExpression")) {
32828
+ if (isNodeOfType(current.object, "ThisExpression") && isNodeOfType(current.property, "Identifier") && current.property.name === "props") return true;
32829
+ current = current.object;
32830
+ }
32831
+ if (isNodeOfType(current, "Identifier")) return tracesToPropOrParameter(scopes.symbolFor(current), scopes, visitedSymbols);
32832
+ return false;
32833
+ };
33006
32834
  const isInsideComponentContext = (node) => {
33007
32835
  let cursor = node.parent;
33008
32836
  while (cursor) {
32837
+ if (isNodeOfType(cursor, "ClassDeclaration") || isNodeOfType(cursor, "ClassExpression")) return true;
33009
32838
  if (isFunctionLike$1(cursor) && isComponentFunction$1(cursor)) return true;
33010
- if (isEs5Component(cursor) || isEs6Component(cursor)) return true;
33011
32839
  cursor = cursor.parent ?? null;
33012
32840
  }
33013
32841
  return false;
@@ -33020,21 +32848,21 @@ const functionBodyOf = (node) => {
33020
32848
  const containsHookCall = (body) => {
33021
32849
  let found = false;
33022
32850
  walkAst(body, (child) => {
33023
- if (found) return false;
33024
- if (child !== body && isFunctionLike$1(child) && isComponentFunction$1(child)) return false;
32851
+ if (found) return;
33025
32852
  if (!isNodeOfType(child, "CallExpression")) return;
33026
32853
  const name = getCalleeName$2(child);
33027
32854
  if (name && isReactHookName(name)) found = true;
33028
32855
  });
33029
32856
  return found;
33030
32857
  };
33031
- const isHookCallingRenderHelper = (symbol) => {
32858
+ const isModuleScopeHookFreeHelper = (symbol) => {
33032
32859
  if (!symbol) return false;
33033
32860
  const declaration = symbol.declarationNode;
33034
32861
  if (!isNodeOfType(declaration, "FunctionDeclaration") && !isNodeOfType(declaration, "VariableDeclarator")) return false;
33035
32862
  const body = functionBodyOf(declaration);
33036
32863
  if (!body) return false;
33037
- return containsHookCall(body);
32864
+ if (findEnclosingFunction(declaration) !== null) return false;
32865
+ return !containsHookCall(body);
33038
32866
  };
33039
32867
  const noRenderInRender = defineRule({
33040
32868
  id: "no-render-in-render",
@@ -33045,11 +32873,18 @@ const noRenderInRender = defineRule({
33045
32873
  create: (context) => ({ JSXExpressionContainer(node) {
33046
32874
  const expression = node.expression;
33047
32875
  if (!isNodeOfType(expression, "CallExpression")) return;
33048
- if (!isNodeOfType(expression.callee, "Identifier")) return;
33049
- const calleeName = expression.callee.name;
33050
- if (!RENDER_FUNCTION_PATTERN.test(calleeName)) return;
32876
+ let calleeName = null;
32877
+ if (isNodeOfType(expression.callee, "Identifier")) calleeName = expression.callee.name;
32878
+ else if (isNodeOfType(expression.callee, "MemberExpression") && isNodeOfType(expression.callee.property, "Identifier")) calleeName = expression.callee.property.name;
32879
+ if (!calleeName || !RENDER_FUNCTION_PATTERN.test(calleeName)) return;
33051
32880
  if (!isInsideComponentContext(node)) return;
33052
- if (!isHookCallingRenderHelper(context.scopes.symbolFor(expression.callee))) return;
32881
+ if (isNodeOfType(expression.callee, "Identifier")) {
32882
+ const calleeSymbol = context.scopes.symbolFor(expression.callee);
32883
+ if (tracesToPropOrParameter(calleeSymbol, context.scopes)) return;
32884
+ if (isModuleScopeHookFreeHelper(calleeSymbol)) return;
32885
+ } else if (isNodeOfType(expression.callee, "MemberExpression")) {
32886
+ if (rootsInProps(expression.callee.object, context.scopes)) return;
32887
+ }
33053
32888
  context.report({
33054
32889
  node: expression,
33055
32890
  message: `"${calleeName}()" hides a component behind an inline call, so pull it into its own component and render it as JSX so React can track it.`
@@ -36398,28 +36233,6 @@ const isTriviallyCheapExpression = (node) => {
36398
36233
  if (isNodeOfType(innerExpression, "MemberExpression")) return false;
36399
36234
  return true;
36400
36235
  };
36401
- const isTrivialContainerLiteral = (node) => {
36402
- if (!node) return false;
36403
- const innerExpression = stripParenExpression(node);
36404
- if (isNodeOfType(innerExpression, "ArrayExpression")) return (innerExpression.elements ?? []).every((element) => element !== null && isSimpleExpression(element));
36405
- if (isNodeOfType(innerExpression, "ObjectExpression")) return (innerExpression.properties ?? []).every((property) => isNodeOfType(property, "Property") && !property.computed && isSimpleExpression(property.value));
36406
- return false;
36407
- };
36408
- const isNonEscapingRead = (identifier) => {
36409
- const parentNode = identifier.parent;
36410
- return isNodeOfType(parentNode, "MemberExpression") && parentNode.object === identifier;
36411
- };
36412
- const isMemoIdentityUnused = (memoCallNode, scopes) => {
36413
- const parentNode = memoCallNode.parent;
36414
- if (isNodeOfType(parentNode, "ExpressionStatement")) return true;
36415
- if (!isNodeOfType(parentNode, "VariableDeclarator") || parentNode.init !== memoCallNode) return false;
36416
- const bindingTarget = parentNode.id;
36417
- if (isNodeOfType(bindingTarget, "ArrayPattern") || isNodeOfType(bindingTarget, "ObjectPattern")) return true;
36418
- if (!isNodeOfType(bindingTarget, "Identifier")) return false;
36419
- const symbol = scopes.symbolFor(bindingTarget);
36420
- if (!symbol) return false;
36421
- return symbol.references.every((reference) => reference.flag === "read" && isNonEscapingRead(reference.identifier));
36422
- };
36423
36236
  const noUsememoSimpleExpression = defineRule({
36424
36237
  id: "no-usememo-simple-expression",
36425
36238
  title: "useMemo on a cheap value",
@@ -36441,17 +36254,9 @@ const noUsememoSimpleExpression = defineRule({
36441
36254
  let returnExpression = null;
36442
36255
  if (!isNodeOfType(callback.body, "BlockStatement")) returnExpression = callback.body;
36443
36256
  else if (callback.body.body?.length === 1 && isNodeOfType(callback.body.body[0], "ReturnStatement")) returnExpression = callback.body.body[0].argument;
36444
- if (!returnExpression) return;
36445
- if (isTriviallyCheapExpression(returnExpression)) {
36446
- context.report({
36447
- node,
36448
- message: "This costs more than it saves because useMemo is wrapping a value that's already cheap, so remove the useMemo"
36449
- });
36450
- return;
36451
- }
36452
- if (isTrivialContainerLiteral(returnExpression) && isMemoIdentityUnused(node, context.scopes)) context.report({
36257
+ if (returnExpression && isTriviallyCheapExpression(returnExpression)) context.report({
36453
36258
  node,
36454
- message: "This useMemo rebuilds a tiny literal whose reference is never relied on, so remove the useMemo and build the value inline"
36259
+ message: "This costs more than it saves because useMemo is wrapping a value that's already cheap, so remove the useMemo"
36455
36260
  });
36456
36261
  } })
36457
36262
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.7.2-dev.08b768b",
3
+ "version": "0.7.2-dev.11e9c87",
4
4
  "description": "React Doctor rules for oxlint.",
5
5
  "keywords": [
6
6
  "accessibility",