Skip to content

Commit 590ace1

Browse files
committed
feat: add react-xno-misused-capture-owner-stack rule with tests and documentation
1 parent 23c15e9 commit 590ace1

File tree

13 files changed

+343
-2
lines changed

13 files changed

+343
-2
lines changed

apps/website/content/docs/rules/meta.json

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"no-missing-component-display-name",
3333
"no-missing-context-display-name",
3434
"no-missing-key",
35+
"no-misused-capture-owner-stack",
3536
"no-nested-component-definitions",
3637
"no-prop-types",
3738
"no-redundant-should-component-update",

apps/website/content/docs/rules/overview.mdx

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Linter rules can have false positives, false negatives, and some rules are depen
5555
| [`no-missing-component-display-name`](./no-missing-component-display-name) | 0️⃣ | | Enforces that all components have a `displayName` which can be used in devtools | |
5656
| [`no-missing-context-display-name`](./no-missing-context-display-name) | 0️⃣ | | Enforces that all contexts have a `displayName` which can be used in devtools | |
5757
| [`no-missing-key`](./no-missing-key) | 2️⃣ | | Disallow missing `key` on items in list rendering | |
58+
| [`no-misused-capture-owner-stack`](./no-misused-capture-owner-stack) | 2️⃣ | | Prevents incorrect usage of `captureOwnerStack` | |
5859
| [`no-nested-component-definitions`](./no-nested-component-definitions) | 2️⃣ | | Disallow nesting component definitions inside other components | |
5960
| [`no-prop-types`](./no-prop-types) | 2️⃣ | | Disallow `propTypes` in favor of TypeScript or another type-checking solution | |
6061
| [`no-redundant-should-component-update`](./no-redundant-should-component-update) | 2️⃣ | | Disallow `shouldComponentUpdate` when extending `React.PureComponent` | |

apps/website/source.config.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ export default defineConfig({
2626
],
2727
},
2828
remarkPlugins: [
29-
remarkMermaid,
30-
remarkInstall,
3129
[remarkDocGen, { generators: [] }],
30+
remarkInstall,
31+
remarkMermaid,
3232
],
3333
},
3434
});

packages/core/src/utils/is-react-api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function isReactAPICall(arg0: string, arg1?: string) {
2020
: isCallFromReactObject(arg0, arg1);
2121
}
2222

23+
export const isCaptureOwnerStack = isReactAPI("captureOwnerStack");
2324
export const isChildrenCount = isReactAPI("Children", "count");
2425
export const isChildrenForEach = isReactAPI("Children", "forEach");
2526
export const isChildrenMap = isReactAPI("Children", "map");
@@ -32,6 +33,7 @@ export const isCreateRef = isReactAPI("createRef");
3233
export const isForwardRef = isReactAPI("forwardRef");
3334
export const isMemo = isReactAPI("memo");
3435

36+
export const isCaptureOwnerStackCall = isReactAPICall("captureOwnerStack");
3537
export const isChildrenCountCall = isReactAPICall("Children", "count");
3638
export const isChildrenForEachCall = isReactAPICall("Children", "forEach");
3739
export const isChildrenMapCall = isReactAPICall("Children", "map");

packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const rules = {
2727
"react-x/no-forward-ref": "warn",
2828
"react-x/no-implicit-key": "warn",
2929
"react-x/no-missing-key": "error",
30+
"react-x/no-misused-capture-owner-stack": "error",
3031
"react-x/no-nested-component-definitions": "error",
3132
"react-x/no-prop-types": "error",
3233
"react-x/no-redundant-should-component-update": "error",

packages/plugins/eslint-plugin-react-x/src/plugin.ts

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import noLeakedConditionalRendering from "./rules/no-leaked-conditional-renderin
3131
import noMissingComponentDisplayName from "./rules/no-missing-component-display-name";
3232
import noMissingContextDisplayName from "./rules/no-missing-context-display-name";
3333
import noMissingKey from "./rules/no-missing-key";
34+
import noMisusedCaptureOwnerStack from "./rules/no-misused-capture-owner-stack";
3435
import noNestedComponentDefinitions from "./rules/no-nested-component-definitions";
3536
import noPropTypes from "./rules/no-prop-types";
3637
import noRedundantShouldComponentUpdate from "./rules/no-redundant-should-component-update";
@@ -88,6 +89,7 @@ export const plugin = {
8889
"no-missing-component-display-name": noMissingComponentDisplayName,
8990
"no-missing-context-display-name": noMissingContextDisplayName,
9091
"no-missing-key": noMissingKey,
92+
"no-misused-capture-owner-stack": noMisusedCaptureOwnerStack,
9193
"no-nested-component-definitions": noNestedComponentDefinitions,
9294
"no-prop-types": noPropTypes,
9395
"no-redundant-should-component-update": noRedundantShouldComponentUpdate,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
title: no-misused-capture-owner-stack
3+
---
4+
5+
**Full Name in `eslint-plugin-react-x@beta`**
6+
7+
```plain copy
8+
react-x/no-misused-capture-owner-stack
9+
```
10+
11+
**Full Name in `@eslint-react/eslint-plugin@beta`**
12+
13+
```plain copy
14+
@eslint-react/no-misused-capture-owner-stack
15+
```
16+
17+
**Features**
18+
19+
`🔧`
20+
21+
## Description
22+
23+
Prevents incorrect usage of `captureOwnerStack`.
24+
25+
The `captureOwnerStack` is only available in development builds of React and must be:
26+
27+
1. Imported via namespace to avoid direct named imports.
28+
2. Conditionally accessed within an `if (process.env.NODE_ENV !== 'production') {...}` block to prevent execution in production environments.
29+
3. The call of `captureOwnerStack` happened inside of a React controlled function (**not implemented yet**).
30+
31+
## Examples
32+
33+
### Failing
34+
35+
```tsx
36+
// Failing: Using named import directly
37+
import { captureOwnerStack } from "react";
38+
// ^^^^^^^^^^^^^^^^^
39+
// - Don't use named imports of `captureOwnerStack` in files that are bundled for development and production. Use a namespace import instead.
40+
41+
if (process.env.NODE_ENV !== "production") {
42+
const ownerStack = React.captureOwnerStack();
43+
console.log("Owner Stack", ownerStack);
44+
}
45+
```
46+
47+
```tsx
48+
// Failing: Missing environment check
49+
import * as React from "react";
50+
51+
const ownerStack = React.captureOwnerStack();
52+
// ^^^^^^^^^^^^^^^^^^^^^^^^^
53+
// - `captureOwnerStack` should only be used in development builds. Use an environment check to ensure it is not executed in production.
54+
console.log(ownerStack);
55+
```
56+
57+
### Passing
58+
59+
```tsx
60+
// Passing: Correct namespace import with environment check
61+
import * as React from "react";
62+
63+
if (process.env.NODE_ENV !== "production") {
64+
const ownerStack = React.captureOwnerStack();
65+
console.log("Owner Stack", ownerStack);
66+
}
67+
```
68+
69+
## Implementation
70+
71+
- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-misused-capture-owner-stack.ts)
72+
- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-misused-capture-owner-stack.spec.ts)
73+
74+
## Further Reading
75+
76+
- [React: APIs `captureOwnerStack`](https://react.dev/reference/react/captureOwnerStack)
77+
- [The Owner Stack is `null`](https://react.dev/reference/react/captureOwnerStack#the-owner-stack-is-null)
78+
- [`captureOwnerStack` is not available](https://react.dev/reference/react/captureOwnerStack#captureownerstack-is-not-available)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
2+
import tsx from "dedent";
3+
4+
import { ruleTester } from "../../../../../test";
5+
import rule, { RULE_NAME } from "./no-misused-capture-owner-stack";
6+
7+
ruleTester.run(RULE_NAME, rule, {
8+
invalid: [
9+
{
10+
code: `import { captureOwnerStack } from 'react';`,
11+
errors: [
12+
{
13+
type: T.ImportSpecifier,
14+
messageId: "useNamespaceImport",
15+
},
16+
],
17+
},
18+
{
19+
code: `import { captureOwnerStack } from "react";`,
20+
errors: [
21+
{
22+
type: T.ImportSpecifier,
23+
messageId: "useNamespaceImport",
24+
},
25+
],
26+
},
27+
{
28+
code: tsx`
29+
// Failing: Using named import directly
30+
import { captureOwnerStack } from "react";
31+
32+
if (process.env.NODE_ENV !== "production") {
33+
const ownerStack = React.captureOwnerStack();
34+
console.log("Owner Stack", ownerStack);
35+
}
36+
`,
37+
errors: [
38+
{
39+
type: T.ImportSpecifier,
40+
messageId: "useNamespaceImport",
41+
},
42+
],
43+
},
44+
{
45+
code: tsx`
46+
// Failing: Missing environment check
47+
import * as React from "react";
48+
49+
const ownerStack = React.captureOwnerStack();
50+
51+
console.log("Owner Stack", ownerStack);
52+
`,
53+
errors: [
54+
{
55+
type: T.CallExpression,
56+
messageId: "missingDevelopmentOnlyCheck",
57+
},
58+
],
59+
},
60+
{
61+
code: tsx`
62+
// Failing: Using named import directly without environment check
63+
import { captureOwnerStack } from "react";
64+
65+
const ownerStack = React.captureOwnerStack();
66+
67+
console.log("Owner Stack", ownerStack);
68+
`,
69+
errors: [
70+
{
71+
type: T.ImportSpecifier,
72+
messageId: "useNamespaceImport",
73+
},
74+
{
75+
type: T.CallExpression,
76+
messageId: "missingDevelopmentOnlyCheck",
77+
},
78+
],
79+
},
80+
],
81+
valid: [
82+
{
83+
code: `import * as React from 'react';`,
84+
},
85+
{
86+
code: `import {useState} from 'react';`,
87+
},
88+
{
89+
code: `import {} from 'react';`,
90+
},
91+
{
92+
code: `import * as React from "react";`,
93+
},
94+
{
95+
code: `import {useState} from "react";`,
96+
},
97+
{
98+
code: `import {} from "react";`,
99+
},
100+
{
101+
code: tsx`
102+
// Passing: Correct namespace import with environment check
103+
import * as React from "react";
104+
105+
if (process.env.NODE_ENV !== "production") {
106+
const ownerStack = React.captureOwnerStack();
107+
console.log("Owner Stack", ownerStack);
108+
}
109+
`,
110+
},
111+
{
112+
code: `import * as React from "@pika/react";`,
113+
settings: {
114+
"react-x": {
115+
importSource: "@pika/react",
116+
},
117+
},
118+
},
119+
],
120+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
2+
import * as AST from "@eslint-react/ast";
3+
import * as ER from "@eslint-react/core";
4+
import { type RuleContext, type RuleFeature } from "@eslint-react/kit";
5+
import { getSettingsFromContext } from "@eslint-react/shared";
6+
import { AST_NODE_TYPES as T, type TSESTree } from "@typescript-eslint/types";
7+
8+
import { createRule } from "../utils";
9+
10+
export const RULE_NAME = "no-misused-capture-owner-stack";
11+
12+
export const RULE_FEATURES = [
13+
"FIX",
14+
] as const satisfies RuleFeature[];
15+
16+
export type MessageID =
17+
| "useNamespaceImport"
18+
| "missingDevelopmentOnlyCheck";
19+
20+
export default createRule<[], MessageID>({
21+
meta: {
22+
type: "problem",
23+
docs: {
24+
description: "Prevents incorrect usage of `captureOwnerStack`.",
25+
[Symbol.for("rule_features")]: RULE_FEATURES,
26+
},
27+
fixable: "code",
28+
messages: {
29+
useNamespaceImport:
30+
"Don't use named imports of 'captureOwnerStack' in files that are bundled for development and production. Use a namespace import instead.",
31+
// eslint-disable-next-line perfectionist/sort-objects
32+
missingDevelopmentOnlyCheck:
33+
`Don't call 'captureOwnerStack' directly. Use 'if (process.env.NODE_ENV !== "production") {...}' to conditionally access it.`,
34+
},
35+
schema: [],
36+
},
37+
name: RULE_NAME,
38+
create,
39+
defaultOptions: [],
40+
});
41+
42+
// TODO: Implement auto-fix for this rule.
43+
export function create(context: RuleContext<MessageID, []>): RuleListener {
44+
if (!context.sourceCode.text.includes("captureOwnerStack")) return {};
45+
const { importSource } = getSettingsFromContext(context);
46+
const reactNames = new Set<string>();
47+
48+
return {
49+
CallExpression(node) {
50+
if (!ER.isCaptureOwnerStackCall(context, node)) return;
51+
if (AST.findParentNode(node, isDevelopmentOnlyCheck) == null) {
52+
context.report({
53+
messageId: "missingDevelopmentOnlyCheck",
54+
node,
55+
});
56+
}
57+
},
58+
ImportDeclaration(node) {
59+
if (node.source.value !== importSource) return;
60+
for (const specifier of node.specifiers) {
61+
switch (specifier.type) {
62+
case T.ImportSpecifier:
63+
if (specifier.imported.type !== T.Identifier) continue;
64+
if (specifier.imported.name === "captureOwnerStack") {
65+
context.report({
66+
messageId: "useNamespaceImport",
67+
node: specifier,
68+
});
69+
}
70+
continue;
71+
case T.ImportDefaultSpecifier:
72+
case T.ImportNamespaceSpecifier:
73+
reactNames.add(specifier.local.name);
74+
continue;
75+
}
76+
}
77+
},
78+
};
79+
}
80+
81+
function isDevelopmentOnlyCheck(node: TSESTree.Node) {
82+
if (node.type !== T.IfStatement) return false;
83+
if (AST.isProcessEnvNodeEnvCompare(node.test, "!==", "production")) return true;
84+
// if (AST.isProcessEnvNodeEnvCompare(node.test, "===", "development")) return true;
85+
// if (AST.isProcessEnvNodeEnvCompare(node.test, "!=", "production")) return true;
86+
// if (AST.isProcessEnvNodeEnvCompare(node.test, "==", "development")) return true;
87+
return false;
88+
}

packages/plugins/eslint-plugin/src/configs/x.ts

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const rules = {
2828
"@eslint-react/no-forward-ref": "warn",
2929
"@eslint-react/no-implicit-key": "warn",
3030
"@eslint-react/no-missing-key": "error",
31+
"@eslint-react/no-misused-capture-owner-stack": "error",
3132
"@eslint-react/no-nested-component-definitions": "error",
3233
"@eslint-react/no-prop-types": "error",
3334
"@eslint-react/no-redundant-should-component-update": "error",

packages/utilities/ast/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export * from "./is-line-break";
1818
export * from "./is-literal";
1919
export * from "./is-multi-line";
2020
export * from "./is-node-equal";
21+
export * from "./is-process-env-node-env";
22+
export * from "./is-process-env-node-env-compare";
2123
export * from "./is-this-expression";
2224
export * from "./to-readable-node-name";
2325
export * from "./to-readable-node-type";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { _ } from "@eslint-react/eff";
2+
import type { TSESTree } from "@typescript-eslint/types";
3+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
4+
import { isProcessEnvNodeEnv } from "./is-process-env-node-env";
5+
6+
/**
7+
* Check if the given node is a binary expression that compares `process.env.NODE_ENV` with a string literal
8+
* @param node The AST node
9+
* @param operator The operator used in the comparison
10+
* @param value The string literal value to compare against
11+
* @returns True if the node is a binary expression that compares `process.env.NODE_ENV` with the specified value, false otherwise
12+
*/
13+
export function isProcessEnvNodeEnvCompare(
14+
node: TSESTree.Node | null | _,
15+
operator: "===" | "!==",
16+
value: "development" | "production",
17+
): node is TSESTree.BinaryExpression {
18+
return node != null
19+
&& node.type === T.BinaryExpression
20+
&& isProcessEnvNodeEnv(node.left)
21+
&& node.operator === operator
22+
&& node.right.type === T.Literal
23+
&& typeof node.right.value === "string"
24+
&& node.right.value === value;
25+
}

0 commit comments

Comments
 (0)