diff --git a/.changeset/forty-wolves-care.md b/.changeset/forty-wolves-care.md new file mode 100644 index 0000000..1cc2024 --- /dev/null +++ b/.changeset/forty-wolves-care.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-effector": minor +--- + +New rule `no-units-spawn-in-render` diff --git a/package.json b/package.json index 484b986..4ea07c7 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@typescript-eslint/rule-tester": "^8.52.0", "@vitest/coverage-v8": "^4.0.16", "effector": "^23.4.4", + "effector-factorio": "^1.3.0", "effector-react": "^23.3.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feb2a2f..92eeef4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: effector: specifier: ^23.4.4 version: 23.4.4 + effector-factorio: + specifier: ^1.3.0 + version: 1.3.0(effector@23.4.4)(react@19.2.3) effector-react: specifier: ^23.3.0 version: 23.3.0(effector@23.4.4)(react@19.2.3) @@ -1385,6 +1388,17 @@ packages: oxc-resolver: optional: true + effector-factorio@1.3.0: + resolution: {integrity: sha512-KYz4AoHjyEZlzGmoNJaf1qzZn8zbYs5OQB78HjX8zdGxCwvNTRasA7ETcgmebbu+2l4je6nnf6fnczTB4o45mg==} + engines: {node: '>=10'} + peerDependencies: + effector: ^22 || ^23 + react: '>=16.14.0' + solid-js: ~1.4.8 + peerDependenciesMeta: + solid-js: + optional: true + effector-react@23.3.0: resolution: {integrity: sha512-QR0+x1EnbiWhO80Yc0GVF+I9xCYoxBm3t+QLB5Wg+1uY1Q1BrSWDmKvJaJJZ/+9BU4RAr25yS5J2EkdWnicu8g==} engines: {node: '>=11.0.0'} @@ -3692,6 +3706,11 @@ snapshots: dts-resolver@2.1.3: {} + effector-factorio@1.3.0(effector@23.4.4)(react@19.2.3): + dependencies: + effector: 23.4.4 + react: 19.2.3 + effector-react@23.3.0(effector@23.4.4)(react@19.2.3): dependencies: effector: 23.4.4 diff --git a/src/index.ts b/src/index.ts index bac8f24..cd37dbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import noForward from "./rules/no-forward/no-forward" import noGetState from "./rules/no-getState/no-getState" import noGuard from "./rules/no-guard/no-guard" import noPatronumDebug from "./rules/no-patronum-debug/no-patronum-debug" +import noUnitsSpawnInRender from "./rules/no-units-spawn-in-render/no-units-spawn-in-render" import noUnnecessaryCombination from "./rules/no-unnecessary-combination/no-unnecessary-combination" import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unnecessary-duplication" import noUselessMethods from "./rules/no-useless-methods/no-useless-methods" @@ -40,6 +41,7 @@ const base = { "no-getState": noGetState, "no-guard": noGuard, "no-patronum-debug": noPatronumDebug, + "no-units-spawn-in-render": noUnitsSpawnInRender, "no-unnecessary-combination": noUnnecessaryCombination, "no-unnecessary-duplication": noUnnecessaryDuplication, "no-useless-methods": noUselessMethods, diff --git a/src/rules/no-units-spawn-in-render/fixtures/context.tsx b/src/rules/no-units-spawn-in-render/fixtures/context.tsx new file mode 100644 index 0000000..11da7c1 --- /dev/null +++ b/src/rules/no-units-spawn-in-render/fixtures/context.tsx @@ -0,0 +1,6 @@ +import { createStore } from "effector" +import { createContext } from "react" + +const $store = createStore(0) + +export const ModelContext = createContext({ $store }) diff --git a/src/rules/no-units-spawn-in-render/fixtures/factorio.ts b/src/rules/no-units-spawn-in-render/fixtures/factorio.ts new file mode 100644 index 0000000..cdff836 --- /dev/null +++ b/src/rules/no-units-spawn-in-render/fixtures/factorio.ts @@ -0,0 +1,12 @@ +import { createEvent, createStore } from "effector" +import { modelFactory } from "effector-factorio" + +export const counterFactory = modelFactory(() => { + const $count = createStore(0) + const inc = createEvent() + const dec = createEvent() + + $count.on(inc, (n) => n + 1).on(dec, (n) => n - 1) + + return { $count, inc, dec } +}) diff --git a/src/rules/no-units-spawn-in-render/fixtures/factory.ts b/src/rules/no-units-spawn-in-render/fixtures/factory.ts new file mode 100644 index 0000000..5fe9fa6 --- /dev/null +++ b/src/rules/no-units-spawn-in-render/fixtures/factory.ts @@ -0,0 +1,30 @@ +import { createEffect, createEvent, createStore, sample } from "effector" + +export function createModel() { + const $store = createStore(0) + const increment = createEvent() + const fetchFx = createEffect(() => {}) + + sample({ clock: increment, target: fetchFx }) + + return { $store, increment, fetchFx } +} + +export function createCounter(initial: number) { + const $count = createStore(initial) + const inc = createEvent() + const dec = createEvent() + + $count.on(inc, (n) => n + 1).on(dec, (n) => n - 1) + + return { $count, inc, dec } +} + +export function createDeepModel() { + const $store = createStore(0) + return { level1: { level2: { $store } } } +} + +export function regularFunction() { + return { foo: "bar" } +} diff --git a/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md new file mode 100644 index 0000000..3e2a214 --- /dev/null +++ b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.md @@ -0,0 +1,138 @@ +--- +description: Forbid creating Effector units inside React components or hooks +--- + +# effector/no-units-spawn-in-render + +Forbids creating Effector units or calling operators inside React components or hooks. + +Creating units in render leads to: +- New units created on every render, causing memory leaks +- Subscriptions and relationships being recreated unnecessarily +- Unpredictable application state + +## Examples + +### Invalid + +```tsx +// 👎 Units created inside component will be recreated on every render +function Component() { + const $store = createStore(0) + const clicked = createEvent() + + sample({ clock: clicked, target: $store }) + + return +} + +// 👎 Units inside hooks (useMemo, useEffect, useCallback) are still problematic +function Component() { + const $store = useMemo(() => createStore(0), []) + + useEffect(() => { + sample({ clock: clicked, target: $store }) + }, []) + + return
hello
+} + +// 👎 Custom factory functions that return units are also forbidden +function createModel() { + const $store = createStore(0) + return { $store } +} + +function Component() { + const model = createModel() + return
hello
+} + +// 👎 Same rules apply to custom hooks +function useMyStore() { + return useMemo(() => createStore(0), []) +} +``` + +### Valid + +```tsx +// 👍 Units created at module level (outside components) +const $store = createStore(0) +const clicked = createEvent() + +sample({ clock: clicked, target: $store }) + +function Component() { + const value = useUnit($store) + const click = useUnit(clicked) + + return +} + +// 👍 Units accessed via useContext are OK +const ModelContext = createContext({ $store }) + +function Component() { + const { $store } = useContext(ModelContext) + const value = useUnit($store) + + return
{value}
+} + +// 👍 effector-factorio's useModel() retrieves units from context, not creating new ones +import { counterFactory } from "./model" + +function Counter() { + const { $count, inc } = counterFactory.useModel() + const count = useUnit($count) + + return +} +``` + +## Rule Details + +This rule detects: + +1. **Direct Effector API calls**: `createStore`, `createEvent`, `createEffect`, `createDomain`, `createApi`, `restore` +2. **Effector operators**: `sample`, `guard`, `forward`, `merge`, `split`, `combine`, `attach` +3. **Custom factory functions**: Functions that return objects containing Effector units (requires TypeScript) + +The rule uses TypeScript type information to detect custom factories that return Effector units. + +## Configuration + +### `detectCustomFactories` + +Controls whether the rule detects custom factory functions (functions that return objects containing Effector units). Defaults to `true`. + +**Disable custom factory detection entirely:** + +```jsonc +// Only flag direct Effector API calls and operators — ignore custom factories +"effector/no-units-spawn-in-render": ["error", { "detectCustomFactories": false }] +``` + +**Allow specific functions (allowlist):** + +```jsonc +// Detect custom factories, but skip these specific function names +"effector/no-units-spawn-in-render": ["error", { + "detectCustomFactories": { "allowlist": ["useModel", "getViewModel"] } +}] +``` + +The allowlist matches against the resolved callee name — `"getModel"` will skip both `getModel()` and `obj.getModel()`. + +## Known Exceptions + +### `effector-factorio` + +The [`effector-factorio`](https://github.com/Kelin2025/effector-factorio) library provides a `factory.useModel()` hook that retrieves pre-created units from React context (similar to `useContext`). This rule has a built-in exception for `useModel` — it will not be flagged. + +Note that `factory.createModel()` **will** still be flagged if called inside a component, since it creates new unit instances. + +## When Not To Use It + +If you have a specific use case where creating units dynamically is intentional and properly managed (e.g., in a factory pattern with proper cleanup), you may disable this rule for that specific case. diff --git a/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.test.ts b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.test.ts new file mode 100644 index 0000000..16302db --- /dev/null +++ b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.test.ts @@ -0,0 +1,611 @@ +import path from "path" + +import { RuleTester } from "@typescript-eslint/rule-tester" +import { parser } from "typescript-eslint" + +import { tsx } from "@/shared/tag" + +import rule from "./no-units-spawn-in-render" + +const ruleTester = new RuleTester({ + languageOptions: { + parser, + parserOptions: { + projectService: { allowDefaultProject: ["*.tsx"], defaultProject: "tsconfig.fixture.json" }, + ecmaFeatures: { jsx: true }, + }, + }, +}) + +const fixture = (file: string) => path.resolve(__dirname, "fixtures", file) + +ruleTester.run("no-units-spawn-in-render", rule, { + valid: [ + { + name: "units created at module level", + code: tsx` + import React from "react" + import { createStore, createEvent, sample } from "effector" + + const $store = createStore(0) + const clicked = createEvent() + + sample({ clock: clicked, target: $store }) + + const Component: React.FC = () => { + return + } + `, + }, + { + name: "units accessed via useContext", + code: tsx` + import React, { useContext } from "react" + import { useUnit } from "effector-react" + + import { ModelContext } from "${fixture("context")}" + + const Component: React.FC = () => { + const { $store } = useContext(ModelContext) + const value = useUnit($store) + + return
{value}
+ } + `, + }, + { + name: "units used via useUnit", + code: tsx` + import React from "react" + import { createStore, createEvent } from "effector" + import { useUnit } from "effector-react" + + const $store = createStore(0) + const clicked = createEvent() + + const Component: React.FC = () => { + const [value, click] = useUnit([$store, clicked]) + + return + } + `, + }, + { + name: "non-React function returning non-JSX", + code: tsx` + import { createStore, createEvent } from "effector" + + function createModel() { + const $store = createStore(0) + const clicked = createEvent() + return { $store, clicked } + } + + const model = createModel() + `, + }, + { + name: "class component does not trigger", + code: tsx` + import React from "react" + import { createStore } from "effector" + + class Component extends React.Component { + store = createStore(0) + + render() { + return
hello
+ } + } + `, + }, + { + name: "regular function call in component", + code: tsx` + import React from "react" + + import { regularFunction } from "${fixture("factory")}" + + const Component: React.FC = () => { + const data = regularFunction() + + return
{data.foo}
+ } + `, + }, + { + name: "effector-factorio useModel in component (context-based)", + code: tsx` + import React from "react" + import { useUnit } from "effector-react" + + import { counterFactory } from "${fixture("factorio")}" + + const Component: React.FC = () => { + const { $count, inc, dec } = counterFactory.useModel() + const count = useUnit($count) + + return
{count}
+ } + `, + }, + { + name: "custom factory with detectCustomFactories disabled", + options: [{ detectCustomFactories: false }], + code: tsx` + import React from "react" + + import { createModel } from "${fixture("factory")}" + + const Component: React.FC = () => { + const model = createModel() + + return
hello
+ } + `, + }, + { + name: "allowlisted custom factory in component", + options: [{ detectCustomFactories: { allowlist: ["createModel"] } }], + code: tsx` + import React from "react" + + import { createModel } from "${fixture("factory")}" + + const Component: React.FC = () => { + const model = createModel() + + return
hello
+ } + `, + }, + { + name: "fork and allSettled in non-render context", + code: tsx` + import { createStore, fork, allSettled, createEvent } from "effector" + + const $store = createStore(0) + const event = createEvent() + + async function runTest() { + const scope = fork() + await allSettled(event, { scope }) + } + `, + }, + ], + invalid: [ + { + name: "createStore directly in component body", + code: tsx` + import React from "react" + import { createStore } from "effector" + + const Component: React.FC = () => { + const $store = createStore(0) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }], + }, + { + name: "createEvent directly in component body", + code: tsx` + import React from "react" + import { createEvent } from "effector" + + function Component() { + const clicked = createEvent() + + return + } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 19, data: { name: "createEvent" } }], + }, + { + name: "createEffect directly in component body", + code: tsx` + import React from "react" + import { createEffect } from "effector" + + const Component = () => { + const fetchFx = createEffect(() => {}) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 19, data: { name: "createEffect" } }], + }, + { + name: "sample operator in component body", + code: tsx` + import React from "react" + import { createStore, createEvent, sample } from "effector" + + const $store = createStore(0) + const clicked = createEvent() + + const Component: React.FC = () => { + sample({ clock: clicked, target: $store }) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 8, column: 3, data: { name: "sample" } }], + }, + { + name: "combine operator in component body", + code: tsx` + import React from "react" + import { createStore, combine } from "effector" + + const $a = createStore(1) + const $b = createStore(2) + + const Component: React.FC = () => { + const $sum = combine($a, $b, (a, b) => a + b) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 8, column: 16, data: { name: "combine" } }], + }, + { + name: "createStore inside useMemo", + code: tsx` + import React, { useMemo } from "react" + import { createStore } from "effector" + + const Component: React.FC = () => { + const $store = useMemo(() => createStore(0), []) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 32, data: { name: "createStore" } }], + }, + { + name: "sample inside useEffect", + code: tsx` + import React, { useEffect } from "react" + import { createStore, createEvent, sample } from "effector" + + const $store = createStore(0) + const clicked = createEvent() + + const Component: React.FC = () => { + useEffect(() => { + sample({ clock: clicked, target: $store }) + }, []) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 9, column: 5, data: { name: "sample" } }], + }, + { + name: "createStore inside useCallback", + code: tsx` + import React, { useCallback } from "react" + import { createStore } from "effector" + + const Component: React.FC = () => { + const init = useCallback(() => { + const $store = createStore(0) + return $store + }, []) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 6, column: 20, data: { name: "createStore" } }], + }, + { + name: "custom factory in component body", + code: tsx` + import React from "react" + + import { createModel } from "${fixture("factory")}" + + const Component: React.FC = () => { + const model = createModel() + + return
hello
+ } + `, + errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 17, data: { name: "createModel" } }], + }, + { + name: "custom factory inside useMemo", + code: tsx` + import React, { useMemo } from "react" + + import { createCounter } from "${fixture("factory")}" + + const Component: React.FC = () => { + const counter = useMemo(() => createCounter(0), []) + + return
hello
+ } + `, + errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 33, data: { name: "createCounter" } }], + }, + { + name: "effector factory in custom hook", + code: tsx` + import { createStore } from "effector" + + function useMyStore() { + const $store = createStore(0) + return $store + } + `, + errors: [{ messageId: "noFactoryInRender", line: 4, column: 18, data: { name: "createStore" } }], + }, + { + name: "effector factory in custom hook with useMemo", + code: tsx` + import { useMemo } from "react" + import { createStore } from "effector" + + function useMyStore() { + return useMemo(() => createStore(0), []) + } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 24, data: { name: "createStore" } }], + }, + { + name: "custom factory in custom hook", + code: tsx` + import { useMemo } from "react" + + import { createModel } from "${fixture("factory")}" + + function useModel() { + return useMemo(() => createModel(), []) + } + `, + errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 24, data: { name: "createModel" } }], + }, + { + name: "multiple violations in component", + code: tsx` + import React from "react" + import { createStore, createEvent, sample } from "effector" + + const Component: React.FC = () => { + const $store = createStore(0) + const clicked = createEvent() + sample({ clock: clicked, target: $store }) + + return + } + `, + errors: [ + { messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }, + { messageId: "noFactoryInRender", line: 6, column: 19, data: { name: "createEvent" } }, + { messageId: "noOperatorInRender", line: 7, column: 3, data: { name: "sample" } }, + ], + }, + { + name: "attach operator in component", + code: tsx` + import React from "react" + import { createEffect, attach } from "effector" + + const baseFx = createEffect(() => {}) + + const Component: React.FC = () => { + const boundFx = attach({ effect: baseFx, mapParams: () => undefined }) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 7, column: 19, data: { name: "attach" } }], + }, + { + name: "merge operator in component", + code: tsx` + import React from "react" + import { createEvent, merge } from "effector" + + const a = createEvent() + const b = createEvent() + + const Component: React.FC = () => { + const merged = merge([a, b]) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 8, column: 18, data: { name: "merge" } }], + }, + { + name: "split operator in component", + code: tsx` + import React from "react" + import { createEvent, split } from "effector" + + const event = createEvent() + + const Component: React.FC = () => { + const { positive, negative } = split(event, { + positive: (n) => n > 0, + negative: (n) => n < 0, + }) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 7, column: 34, data: { name: "split" } }], + }, + { + name: "restore in component", + code: tsx` + import React from "react" + import { createEvent, restore } from "effector" + + const setValue = createEvent() + + const Component: React.FC = () => { + const $value = restore(setValue, 0) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 7, column: 18, data: { name: "restore" } }], + }, + { + name: "createDomain in component", + code: tsx` + import React from "react" + import { createDomain } from "effector" + + const Component: React.FC = () => { + const domain = createDomain() + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createDomain" } }], + }, + { + name: "createApi in component", + code: tsx` + import React from "react" + import { createStore, createApi } from "effector" + + const $store = createStore(0) + + const Component: React.FC = () => { + const api = createApi($store, { + increment: (n) => n + 1, + }) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 7, column: 15, data: { name: "createApi" } }], + }, + { + name: "inferred component (arrow function returning JSX)", + code: tsx` + import React from "react" + import { createStore } from "effector" + + const Component = () => { + const $store = createStore(0) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }], + }, + { + name: "inferred component via forwardRef", + code: tsx` + import React from "react" + import { createStore } from "effector" + + const Component = React.forwardRef((props, ref) => { + const $store = createStore(0) + + return
hello
+ }) + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }], + }, + { + name: "inferred component via memo", + code: tsx` + import { memo } from "react" + import { createStore } from "effector" + + const Component = memo(() => { + const $store = createStore(0) + + return
hello
+ }) + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }], + }, + { + name: "renamed import in component", + code: tsx` + import React from "react" + import { createStore as cs } from "effector" + + const Component: React.FC = () => { + const $store = cs(0) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "cs" } }], + }, + { + name: "deep nested factory (unit 3 levels deep)", + code: tsx` + import React from "react" + + import { createDeepModel } from "${fixture("factory")}" + + const Component: React.FC = () => { + const model = createDeepModel() + + return
hello
+ } + `, + errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 17, data: { name: "createDeepModel" } }], + }, + { + name: "forward (deprecated) operator in component", + code: tsx` + import React from "react" + import { createStore, createEvent, forward } from "effector" + + const $store = createStore(0) + const clicked = createEvent() + + const Component: React.FC = () => { + forward({ from: clicked, to: $store }) + + return
hello
+ } + `, + errors: [{ messageId: "noOperatorInRender", line: 8, column: 3, data: { name: "forward" } }], + }, + { + name: "direct effector import still flagged with detectCustomFactories disabled", + options: [{ detectCustomFactories: false }], + code: tsx` + import React from "react" + import { createStore } from "effector" + + const Component: React.FC = () => { + const $store = createStore(0) + + return
hello
+ } + `, + errors: [{ messageId: "noFactoryInRender", line: 5, column: 18, data: { name: "createStore" } }], + }, + { + name: "non-allowlisted custom factory still flagged", + options: [{ detectCustomFactories: { allowlist: ["createModel"] } }], + code: tsx` + import React from "react" + + import { createCounter } from "${fixture("factory")}" + + const Component: React.FC = () => { + const counter = createCounter(0) + + return
hello
+ } + `, + errors: [{ messageId: "noCustomFactoryInRender", line: 6, column: 19, data: { name: "createCounter" } }], + }, + ], +}) diff --git a/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts new file mode 100644 index 0000000..8d8925d --- /dev/null +++ b/src/rules/no-units-spawn-in-render/no-units-spawn-in-render.ts @@ -0,0 +1,282 @@ +import { getContextualType, typeMatchesSpecifier } from "@typescript-eslint/type-utils" +import { AST_NODE_TYPES, ESLintUtils, type TSESTree as ESNode } from "@typescript-eslint/utils" +import { type Program, type Node as TSNode, type Type, type TypeChecker, isExpression } from "typescript" + +import { createRule } from "@/shared/create" +import { isType } from "@/shared/is" +import { nameOf } from "@/shared/name" +import { PACKAGE_NAME } from "@/shared/package" + +const EFFECTOR_FACTORIES = new Set([ + "createStore", + "createEvent", + "createEffect", + "createDomain", + "createApi", + "restore", +]) + +const EFFECTOR_OPERATORS = new Set(["sample", "guard", "forward", "merge", "split", "combine", "attach"]) + +const REACT_HOOKS_SPEC = { + from: "package" as const, + package: "react", + name: [ + "useState", + "useEffect", + "useLayoutEffect", + "useCallback", + "useMemo", + "useRef", + "useReducer", + "useImperativeHandle", + "useDebugValue", + "useDeferredValue", + "useTransition", + "useId", + "useSyncExternalStore", + "useInsertionEffect", + "useContext", + ], +} + +const EFFECTOR_FACTORY_SPEC = { from: "package" as const, package: "effector", name: [...EFFECTOR_FACTORIES] } +const EFFECTOR_OPERATOR_SPEC = { from: "package" as const, package: "effector", name: [...EFFECTOR_OPERATORS] } + +// effector-factorio's `factory.useModel()` is a context-based hook (like useContext) that retrieves +// pre-created units — not a factory that spawns new ones. Exclude it from false-positive reports. +// We identify a factorio factory by the structural shape of the receiver object's type. +const EFFECTOR_FACTORIO_SHAPE = ["useModel", "createModel", "Provider", "@@unitShape"] as const + +type Options = { + detectCustomFactories: true | false | { allowlist: string[] } +} + +export default createRule<[Options], "noFactoryInRender" | "noOperatorInRender" | "noCustomFactoryInRender">({ + name: "no-units-spawn-in-render", + meta: { + type: "problem", + docs: { + description: "Forbid creating Effector units or calling operators inside React components or hooks.", + }, + messages: { + noFactoryInRender: + 'Creating Effector units with "{{ name }}" inside React component or hook is forbidden, since it may cause memory leaks and other bugs.', + noOperatorInRender: + 'Using Effector operator "{{ name }}" inside React component or hook is forbidden, since it may cause memory leaks and other bugs.', + noCustomFactoryInRender: + 'Creating Effector units with "{{ name }}" inside React component or hook is forbidden, since it may cause memory leaks and other bugs. If this is a false positive, add "{{ name }}" to the allowlist in the detectCustomFactories option.', + }, + schema: [ + { + type: "object", + properties: { + detectCustomFactories: { + oneOf: [ + { type: "boolean" }, + { + type: "object", + properties: { + allowlist: { type: "array", items: { type: "string" }, uniqueItems: true }, + }, + required: ["allowlist"], + additionalProperties: false, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{ detectCustomFactories: true }], + create: (context, [options]) => { + const services = ESLintUtils.getParserServices(context) + const checker = services.program.getTypeChecker() + + const { detectCustomFactories } = options + const allowlist = typeof detectCustomFactories === "object" ? new Set(detectCustomFactories.allowlist) : undefined + + // Tracks whether each nested function scope is a render context (component/hook). + // On function enter we push true/false, on exit we pop. Nested functions inherit: if the + // parent is a render context, all children are too (e.g. callbacks inside a component). + const stack = { render: [] as boolean[] } + + // Maps local names of effector imports to their kind, so we can report them without type analysis. + // Populated from `import { createStore, sample } from "effector"` (handles renames too). + const effectorImports = new Map() + + type ComponentFunction = ESNode.FunctionDeclaration | ESNode.FunctionExpression | ESNode.ArrowFunctionExpression + + const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME.core}]` + + return { + // ── Phase 1: Collect effector imports ────────────────────────────────── + + [`${importSelector} > ImportSpecifier[imported.type="Identifier"]`]: ( + node: ESNode.ImportSpecifier & { imported: ESNode.Identifier }, + ) => { + const imported = node.imported.name + const local = node.local.name + + if (EFFECTOR_FACTORIES.has(imported)) { + effectorImports.set(local, "factory") + } else if (EFFECTOR_OPERATORS.has(imported)) { + effectorImports.set(local, "operator") + } + }, + + // ── Phase 2: Track render scope via function enter/exit ──────────────── + // + // Determines if a function is a render context using a series of heuristics, + // ordered from cheapest to most expensive: + // 1. Inherit from parent — if already inside render, every nested scope is too + // 2. Name check — `useXxx` convention means a custom hook + // 3. Return type — if the function returns JSX, it's a component + // 4. Contextual type — if the function is used where a component type is expected + // (e.g. React.memo, forwardRef), it's a component even without explicit annotation + + [`FunctionDeclaration, FunctionExpression, ArrowFunctionExpression`]: (node: ComponentFunction) => { + const current = stack.render.at(-1) ?? false + if (current) return void stack.render.push(true) + + const name = nameOf.function(node) + if (name && UseRegex.test(name.name)) return void stack.render.push(true) + + const tsnode = services.esTreeNodeToTSNodeMap.get(node) + + const signature = checker.getSignatureFromDeclaration(tsnode) + // Void is a safe fallback: if TS can't resolve a signature, it won't match JSX types, + // so the function won't be misclassified as a component + const returnType = signature ? checker.getReturnTypeOfSignature(signature) : checker.getVoidType() + + const isJSX = returnType.isUnion() + ? returnType.types.some((type) => isType.jsx(type, services.program)) + : isType.jsx(returnType, services.program) + + if (isJSX) return void stack.render.push(true) + + const inferred = (isExpression(tsnode) && getContextualType(checker, tsnode)) || checker.getUnknownType() + + const isComponent = inferred.isUnion() + ? inferred.types.some((type) => isType.component(type, services.program)) + : isType.component(inferred, services.program) + + if (isComponent) return void stack.render.push(true) + + return void stack.render.push(false) + }, + + [`:matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression):exit`]: () => + void stack.render.pop(), + + // Class bodies are never render contexts themselves — class methods (like render()) + // will get their own stack entry and be evaluated independently. + "ClassDeclaration": () => void stack.render.push(false), + "ClassDeclaration:exit": () => void stack.render.pop(), + + // ── Phase 3: Flag violating calls inside render ──────────────────────── + // + // Detection is done in two tiers, ordered so we can avoid expensive type + // analysis when possible and catch operators whose return type is not a unit: + // + // Tier 1 — Import-based (no type analysis): + // If the callee was imported from effector (tracked in Phase 1), we know + // its kind immediately. This is essential for operators like `forward` + // and `guard` that return Subscription instead of a unit. + // + // Tier 2 — Type-based (requires type checker): + // For everything else, check if the call's return type contains effector + // units. If it does, classify the callee via typeMatchesSpecifier: + // - Known React hooks are excluded to avoid double-reporting: e.g. + // `useMemo(() => createStore(0), [])` returns Store, but the inner + // `createStore` and the like is already flagged — reporting `useMemo` too would be noise. + // `useContext` is excluded because it legitimately retrieves pre-created units. + // - effector-factorio's `factory.useModel()` is excluded — it retrieves + // pre-created units from React context, similar to useContext + // - Namespaced effector calls (e.g. `effector.createStore`) are matched + // by callee type against the effector package + // - Anything remaining is treated as a custom factory + + "CallExpression": (node: ESNode.CallExpression) => { + const isWithinRender = stack.render.at(-1) ?? false + if (!isWithinRender) return + + const calleeName = getCalleeName(node.callee) + + // Tier 1: known effector import — report immediately, skip type analysis + const importType = calleeName ? effectorImports.get(calleeName) : undefined + switch (importType) { + case "factory": + return context.report({ node, messageId: "noFactoryInRender", data: { name: calleeName } }) + case "operator": + return context.report({ node, messageId: "noOperatorInRender", data: { name: calleeName } }) + } + + // Tier 2: type-based detection — skip entirely if custom factories are disabled + if (detectCustomFactories === false) return + + const returnType = services.getTypeAtLocation(node) + const ctx: TraverseCtx = { node: services.esTreeNodeToTSNodeMap.get(node), checker, program: services.program } + + if (!hasEffectorUnitInType(ctx, returnType)) return + + const calleeType = services.getTypeAtLocation(node.callee) + const displayName = calleeName ?? "" + + if (typeMatchesSpecifier(calleeType, REACT_HOOKS_SPEC, services.program)) return + if (isEffectorFactorioHook(node.callee, services.getTypeAtLocation)) return + + if (typeMatchesSpecifier(calleeType, EFFECTOR_FACTORY_SPEC, services.program)) + return context.report({ node, messageId: "noFactoryInRender", data: { name: displayName } }) + + if (typeMatchesSpecifier(calleeType, EFFECTOR_OPERATOR_SPEC, services.program)) + return context.report({ node, messageId: "noOperatorInRender", data: { name: displayName } }) + + if (allowlist && calleeName && allowlist.has(calleeName)) return + + context.report({ node, messageId: "noCustomFactoryInRender", data: { name: displayName } }) + }, + } + }, +}) + +const UseRegex = /^use[A-Z0-9].*$/ + +function getCalleeName(callee: ESNode.Expression): string | null { + if (callee.type === AST_NODE_TYPES.Identifier) return callee.name + if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) + return callee.property.name + else return null +} + +type TraverseCtx = { node: TSNode; checker: TypeChecker; program: Program } + +// Walks the type structure up to `depth` levels of object nesting to find effector units. +// Unions don't consume depth — they are alternative shapes at the same level. +function hasEffectorUnitInType(ctx: TraverseCtx, type: Type, depth = 3): boolean { + if (isType.unit(type, ctx.program)) return true + if (depth <= 0) return false + + // For unions, getProperties() only returns common properties across all members. + // We must recurse into each member to check their individual properties for userland factories. + if (type.isUnion()) return type.types.some((type) => hasEffectorUnitInType(ctx, type, depth)) + + for (const property of type.getProperties()) { + const type = ctx.checker.getTypeOfSymbolAtLocation(property, ctx.node) + if (hasEffectorUnitInType(ctx, type, depth - 1)) return true + } + + return false +} + +// Checks if the callee is a method call on an effector-factorio factory object (e.g. `factory.useModel()`). +// Matches by structural shape of the receiver: must have useModel, createModel, Provider, and @@unitShape. +function isEffectorFactorioHook(callee: ESNode.Expression, getTypeAtLocation: (node: ESNode.Node) => Type): boolean { + if (callee.type !== AST_NODE_TYPES.MemberExpression) return false + + const objectType = getTypeAtLocation(callee.object) + const propertyNames = new Set(objectType.getProperties().map((p) => p.getName())) + + return EFFECTOR_FACTORIO_SHAPE.every((name) => propertyNames.has(name)) +} diff --git a/src/ruleset.ts b/src/ruleset.ts index b11fbd2..25fc617 100644 --- a/src/ruleset.ts +++ b/src/ruleset.ts @@ -27,6 +27,7 @@ const scope = { const react = { "effector/enforce-gate-naming-convention": "error", "effector/mandatory-scope-binding": "error", + "effector/no-units-spawn-in-render": "error", "effector/prefer-useUnit": "error", } satisfies TSESLint.Linter.RulesRecord diff --git a/src/shared/is.ts b/src/shared/is.ts index 409dc6d..f20c781 100644 --- a/src/shared/is.ts +++ b/src/shared/is.ts @@ -24,7 +24,7 @@ export const isType = { typeMatchesSpecifier(type, { from: "package", package: "effector", name: "Effect" }, program), unit: (type: Type, program: Program) => { - const name = ["Store", "StoreWritable", "Event", "EventCallable", "Effect"] + const name = ["Store", "StoreWritable", "Event", "EventCallable", "Effect", "Domain"] return typeMatchesSpecifier(type, { from: "package", package: "effector", name }, program) },