diff --git a/packages/react-doctor/src/oxlint-config.ts b/packages/react-doctor/src/oxlint-config.ts index af6a47d2e..2178b5f19 100644 --- a/packages/react-doctor/src/oxlint-config.ts +++ b/packages/react-doctor/src/oxlint-config.ts @@ -231,6 +231,7 @@ export const GLOBAL_REACT_DOCTOR_RULES: Record = { "react-doctor/no-derived-state-effect": "warn", "react-doctor/no-fetch-in-effect": "warn", "react-doctor/no-cascading-set-state": "warn", + "react-doctor/no-effect-chain": "warn", "react-doctor/no-effect-event-handler": "warn", "react-doctor/no-effect-event-in-deps": "error", "react-doctor/no-event-trigger-state": "warn", diff --git a/packages/react-doctor/src/plugin/constants.ts b/packages/react-doctor/src/plugin/constants.ts index fd43e544a..1a8b4ba55 100644 --- a/packages/react-doctor/src/plugin/constants.ts +++ b/packages/react-doctor/src/plugin/constants.ts @@ -390,6 +390,86 @@ export const MUTATING_ROUTE_SEGMENTS = new Set([ export const EFFECT_HOOK_NAMES = new Set(["useEffect", "useLayoutEffect"]); export const HOOKS_WITH_DEPS = new Set(["useEffect", "useLayoutEffect", "useMemo", "useCallback"]); +// Used by `no-effect-chain` to decide whether an effect is doing +// "real" external-system synchronization (in which case effects on +// either side of the chain are exempt, per the article's own caveat +// about cascading network fetches) versus pure internal reactivity +// (which is the anti-pattern). A cleanup return is the strongest +// signal; the curated method list covers the rest. +// Member-method names that, on their own, mark a call as external +// sync regardless of receiver. These are unambiguous in real React +// codebases — they don't clash with built-in JS APIs. +export const EXTERNAL_SYNC_MEMBER_METHOD_NAMES = new Set([ + // Subscriptions / event listeners + "subscribe", + "addEventListener", + "addListener", + "on", + "watch", + "listen", + "sub", + // Imperative widget lifecycle (createConnection().connect()/.disconnect()) + "connect", + "disconnect", + "open", + "close", + // Mutating HTTP verbs — `*.post(url, body)` is essentially always + // a network call. (`delete` is moved to the ambiguous set below + // because Map / Set / URLSearchParams / Headers / FormData / + // WeakMap all expose `.delete(...)` as a built-in method.) + "fetch", + "post", + "put", + "patch", +]); + +// HACK: `get`, `head`, `options` are HTTP verbs but ALSO names of +// universal data-structure methods (`Map.get`, `URLSearchParams.get`, +// `FormData.get`, `Headers.get`, `WeakMap.get`, `Set.has`, etc.). We +// only treat them as external-sync calls when the receiver is a +// recognized HTTP-client-shaped name. Lets the `axios.get(...)` +// cascade case work without false-classifying `params.get('id')` as +// external sync. +export const EXTERNAL_SYNC_HTTP_CLIENT_RECEIVERS = new Set([ + "axios", + "ky", + "got", + "wretch", + "ofetch", + "api", + "client", + "http", + "request", + "fetcher", +]); + +export const EXTERNAL_SYNC_AMBIGUOUS_HTTP_METHOD_NAMES = new Set([ + "get", + "head", + "options", + "delete", +]); + +export const EXTERNAL_SYNC_DIRECT_CALLEE_NAMES = new Set([ + "fetch", + "ky", + "got", + "wretch", + "ofetch", + "setInterval", + "setTimeout", + "requestAnimationFrame", + "requestIdleCallback", + "queueMicrotask", +]); + +export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([ + "IntersectionObserver", + "MutationObserver", + "ResizeObserver", + "PerformanceObserver", +]); + // Subscription-shaped method names recognized by `prefer-use-sync-external-store`. // Covers the canonical `store.subscribe`, the browser `addEventListener` / // `addListener`, the EventEmitter `on` / `watch` / `listen`, and shorter diff --git a/packages/react-doctor/src/plugin/index.ts b/packages/react-doctor/src/plugin/index.ts index aed403965..2a5341698 100644 --- a/packages/react-doctor/src/plugin/index.ts +++ b/packages/react-doctor/src/plugin/index.ts @@ -180,6 +180,7 @@ import { noDerivedStateEffect, noDerivedUseState, noDirectStateMutation, + noEffectChain, noEffectEventHandler, noEffectEventInDeps, noEventTriggerState, @@ -203,6 +204,7 @@ const plugin: RulePlugin = { "no-derived-state-effect": noDerivedStateEffect, "no-fetch-in-effect": noFetchInEffect, "no-cascading-set-state": noCascadingSetState, + "no-effect-chain": noEffectChain, "no-effect-event-handler": noEffectEventHandler, "no-effect-event-in-deps": noEffectEventInDeps, "no-event-trigger-state": noEventTriggerState, diff --git a/packages/react-doctor/src/plugin/rules/state-and-effects.ts b/packages/react-doctor/src/plugin/rules/state-and-effects.ts index 7a02e479a..9945a3de4 100644 --- a/packages/react-doctor/src/plugin/rules/state-and-effects.ts +++ b/packages/react-doctor/src/plugin/rules/state-and-effects.ts @@ -4,6 +4,11 @@ import { EFFECT_HOOK_NAMES, EVENT_TRIGGERED_SIDE_EFFECT_CALLEES, EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS, + EXTERNAL_SYNC_AMBIGUOUS_HTTP_METHOD_NAMES, + EXTERNAL_SYNC_DIRECT_CALLEE_NAMES, + EXTERNAL_SYNC_HTTP_CLIENT_RECEIVERS, + EXTERNAL_SYNC_MEMBER_METHOD_NAMES, + EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS, HOOKS_WITH_DEPS, MUTATING_ARRAY_METHODS, RELATED_USE_STATE_THRESHOLD, @@ -1796,3 +1801,221 @@ export const noEventTriggerState: Rule = { }; }, }; + +// HACK: §7 of "You Might Not Need an Effect" — chains of computations: +// +// useEffect(() => { if (card.gold) setGoldCardCount(c => c + 1); }, [card]); +// useEffect(() => { if (goldCardCount > 3) setRound(r => r + 1); }, [goldCardCount]); +// useEffect(() => { if (round > 5) setIsGameOver(true); }, [round]); +// +// Each link adds one extra render to the tree below the component. +// More importantly, the chain is rigid: setting `card` to a value from +// the past re-fires every downstream effect. +// +// `noCascadingSetState` (already shipped) catches multi-setter calls +// inside ONE effect; it does NOT see across effects. This rule +// complements it by detecting the cross-effect dependence. +// +// Detector (per component body): +// 1. Collect every top-level useEffect call and, for each: +// - depNames: Identifier names in the dep array +// - writtenStateNames: state names whose setter is called in the body +// - isExternalSync: body returns cleanup OR contains a recognized +// external-system call (subscribe / addEventListener / fetch / +// setInterval / new MutationObserver / etc.) OR mutates a ref +// 2. For every ordered pair (A, B) of distinct effects: +// edge iff (writes(A) ∩ deps(B)) ≠ ∅ AND ¬isExternalSync(A) +// AND ¬isExternalSync(B) +// 3. Report on every effect B that is the target of any edge, +// naming the chained state and the upstream effect's writer. +// +// The article calls out one legitimate "chain" — a multi-step network +// cascade where each effect re-fetches based on the previous step's +// result. Those effects all have `isExternalSync = true` because they +// contain `fetch`, so the rule won't fire. +const findTopLevelEffectCalls = (componentBody: EsTreeNode): EsTreeNode[] => { + const effectCalls: EsTreeNode[] = []; + if (componentBody?.type !== "BlockStatement") return effectCalls; + for (const statement of componentBody.body ?? []) { + if (statement.type !== "ExpressionStatement") continue; + const expression = statement.expression; + if (expression?.type !== "CallExpression") continue; + if (!isHookCall(expression, EFFECT_HOOK_NAMES)) continue; + effectCalls.push(expression); + } + return effectCalls; +}; + +const collectDepIdentifierNames = (effectNode: EsTreeNode): Set => { + const depNames = new Set(); + const depsNode = effectNode.arguments?.[1]; + if (depsNode?.type !== "ArrayExpression") return depNames; + for (const element of depsNode.elements ?? []) { + if (element?.type === "Identifier") depNames.add(element.name); + } + return depNames; +}; + +const collectWrittenStateNamesInEffect = ( + effectCallback: EsTreeNode, + setterToStateName: Map, +): Set => { + const writtenStateNames = new Set(); + walkAst(effectCallback, (child: EsTreeNode) => { + if (child.type !== "CallExpression") return; + if (child.callee?.type !== "Identifier") return; + const stateName = setterToStateName.get(child.callee.name); + if (stateName) writtenStateNames.add(stateName); + }); + return writtenStateNames; +}; + +const isExternalSyncEffect = (effectCallback: EsTreeNode): boolean => { + if (effectCallback.body?.type === "BlockStatement") { + const statements = effectCallback.body.body ?? []; + for (const statement of statements) { + if (statement.type === "ReturnStatement" && statement.argument) return true; + } + } else if (effectCallback.body?.type !== "BlockStatement") { + // Concise arrow body — `useEffect(() => something())`. If the + // expression itself is an external sync call, the effect is + // single-statement-external; otherwise it can't be a chain link + // anyway because chains require setter calls in the body. + } + + let didFindExternalCall = false; + walkAst(effectCallback, (child: EsTreeNode) => { + if (didFindExternalCall) return false; + + if (child.type === "NewExpression") { + const constructor = child.callee; + if ( + constructor?.type === "Identifier" && + EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS.has(constructor.name) + ) { + didFindExternalCall = true; + } + return; + } + + if (child.type === "AssignmentExpression") { + if ( + child.left?.type === "MemberExpression" && + child.left.property?.type === "Identifier" && + child.left.property.name === "current" + ) { + didFindExternalCall = true; + } + return; + } + + if (child.type !== "CallExpression") return; + + if ( + child.callee?.type === "Identifier" && + EXTERNAL_SYNC_DIRECT_CALLEE_NAMES.has(child.callee.name) + ) { + didFindExternalCall = true; + return; + } + + if (child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier") { + const propertyName = child.callee.property.name; + if (EXTERNAL_SYNC_MEMBER_METHOD_NAMES.has(propertyName)) { + didFindExternalCall = true; + return; + } + // HACK: `get` / `head` / `options` are HTTP verbs but also names + // of universal data-structure methods (Map.get, URLSearchParams.get, + // etc.). Only count them when the receiver looks like an HTTP + // client. + if (EXTERNAL_SYNC_AMBIGUOUS_HTTP_METHOD_NAMES.has(propertyName)) { + let receiverCursor: EsTreeNode | undefined = child.callee.object; + while (receiverCursor?.type === "MemberExpression") { + receiverCursor = receiverCursor.object; + } + if ( + receiverCursor?.type === "Identifier" && + EXTERNAL_SYNC_HTTP_CLIENT_RECEIVERS.has(receiverCursor.name) + ) { + didFindExternalCall = true; + } + } + } + }); + + return didFindExternalCall; +}; + +interface EffectInfo { + node: EsTreeNode; + depNames: Set; + writtenStateNames: Set; + isExternalSync: boolean; +} + +export const noEffectChain: Rule = { + create: (context: RuleContext) => { + const checkComponent = (componentBody: EsTreeNode | null | undefined): void => { + if (!componentBody || componentBody.type !== "BlockStatement") return; + + const useStateBindings = collectUseStateBindings(componentBody); + if (useStateBindings.length === 0) return; + const setterToStateName = new Map(); + for (const binding of useStateBindings) { + setterToStateName.set(binding.setterName, binding.valueName); + } + + const effectInfos: EffectInfo[] = []; + for (const effectCall of findTopLevelEffectCalls(componentBody)) { + const callback = getEffectCallback(effectCall); + if (!callback) continue; + effectInfos.push({ + node: effectCall, + depNames: collectDepIdentifierNames(effectCall), + writtenStateNames: collectWrittenStateNamesInEffect(callback, setterToStateName), + isExternalSync: isExternalSyncEffect(callback), + }); + } + if (effectInfos.length < 2) return; + + const reportedNodes = new Set(); + for (const writerEffect of effectInfos) { + if (writerEffect.isExternalSync) continue; + if (writerEffect.writtenStateNames.size === 0) continue; + for (const readerEffect of effectInfos) { + if (readerEffect === writerEffect) continue; + if (readerEffect.isExternalSync) continue; + if (readerEffect.depNames.size === 0) continue; + + let chainedStateName: string | null = null; + for (const writtenName of writerEffect.writtenStateNames) { + if (readerEffect.depNames.has(writtenName)) { + chainedStateName = writtenName; + break; + } + } + if (!chainedStateName) continue; + if (reportedNodes.has(readerEffect.node)) continue; + reportedNodes.add(readerEffect.node); + + context.report({ + node: readerEffect.node, + message: `useEffect reacts to "${chainedStateName}" which is set by another useEffect — chains of effects add an extra render per link and become rigid as code evolves. Compute what you can during render and write all related state inside the event handler that originally fires the chain`, + }); + } + } + }; + + return { + FunctionDeclaration(node: EsTreeNode) { + if (!node.id?.name || !isUppercaseName(node.id.name)) return; + checkComponent(node.body); + }, + VariableDeclarator(node: EsTreeNode) { + if (!isComponentAssignment(node)) return; + checkComponent(node.init?.body); + }, + }; + }, +}; diff --git a/packages/react-doctor/src/utils/run-oxlint.ts b/packages/react-doctor/src/utils/run-oxlint.ts index b297b1974..3bcf12a81 100644 --- a/packages/react-doctor/src/utils/run-oxlint.ts +++ b/packages/react-doctor/src/utils/run-oxlint.ts @@ -46,6 +46,7 @@ const RULE_CATEGORY_MAP: Record = { "react-doctor/no-derived-state-effect": "State & Effects", "react-doctor/no-fetch-in-effect": "State & Effects", "react-doctor/no-cascading-set-state": "State & Effects", + "react-doctor/no-effect-chain": "State & Effects", "react-doctor/no-effect-event-handler": "State & Effects", "react-doctor/no-effect-event-in-deps": "State & Effects", "react-doctor/no-event-trigger-state": "State & Effects", @@ -242,6 +243,8 @@ const RULE_HELP_MAP: Record = { "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead", "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`", + "no-effect-chain": + "Compute as much as possible during render (e.g. `const isGameOver = round > 5`) and write all related state inside the event handler that originally fires the chain. Each effect link adds an extra render and makes the code rigid as requirements evolve", "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly", "no-event-trigger-state": diff --git a/packages/react-doctor/tests/fixtures/basic-react/src/state-issues.tsx b/packages/react-doctor/tests/fixtures/basic-react/src/state-issues.tsx index 621b99015..e157c66a8 100644 --- a/packages/react-doctor/tests/fixtures/basic-react/src/state-issues.tsx +++ b/packages/react-doctor/tests/fixtures/basic-react/src/state-issues.tsx @@ -173,6 +173,30 @@ const EventTriggerStateComponent = () => { ); }; +interface Card { + gold: boolean; +} + +const EffectChainComponent = ({ card }: { card: Card | null }) => { + const [goldCount, setGoldCount] = useState(0); + const [round, setRound] = useState(1); + useEffect(() => { + if (card !== null && card.gold) { + setGoldCount((c) => c + 1); + } + }, [card]); + useEffect(() => { + if (goldCount > 3) { + setRound((r) => r + 1); + } + }, [goldCount]); + return ( +
+ {goldCount} {round} +
+ ); +}; + const UncontrolledInputComponent = () => { // HACK: explicit `` keeps TypeScript happy while the // RUNTIME initializer stays undefined — that's what trips the @@ -209,5 +233,6 @@ export { ConditionalSetStateInRenderComponent, SubscribeStorePatternComponent, EventTriggerStateComponent, + EffectChainComponent, UncontrolledInputComponent, }; diff --git a/packages/react-doctor/tests/regressions/state-rules.test.ts b/packages/react-doctor/tests/regressions/state-rules.test.ts index 65528cf2b..f4efe772e 100644 --- a/packages/react-doctor/tests/regressions/state-rules.test.ts +++ b/packages/react-doctor/tests/regressions/state-rules.test.ts @@ -959,6 +959,273 @@ export const Notify = ({ message }: { message: string | null }) => { }); }); +describe("no-effect-chain", () => { + it("flags the article §7 Game-style cross-effect chain", async () => { + // https://react.dev/learn/you-might-not-need-an-effect#chains-of-computations + const projectDir = setupReactProject(tempRoot, "no-effect-chain-game", { + files: { + "src/Game.tsx": `import { useEffect, useState } from "react"; + +interface Card { gold: boolean } + +export const Game = ({ card }: { card: Card | null }) => { + const [goldCount, setGoldCount] = useState(0); + const [round, setRound] = useState(1); + const [isGameOver, setIsGameOver] = useState(false); + + useEffect(() => { + if (card !== null && card.gold) { + setGoldCount((c) => c + 1); + } + }, [card]); + + useEffect(() => { + if (goldCount > 3) { + setRound((r) => r + 1); + setGoldCount(0); + } + }, [goldCount]); + + useEffect(() => { + if (round > 5) { + setIsGameOver(true); + } + }, [round]); + + return
{isGameOver ? "over" : round}
; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits.length).toBeGreaterThanOrEqual(2); + expect(hits.some((hit) => hit.message.includes("goldCount"))).toBe(true); + expect(hits.some((hit) => hit.message.includes("round"))).toBe(true); + }); + + it("does NOT flag a single effect with multiple setters (covered by no-cascading-set-state)", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-single-effect", { + files: { + "src/Settings.tsx": `import { useEffect, useState } from "react"; + +export const Settings = () => { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + useEffect(() => { + setName("default"); + setEmail("default@example.com"); + }, []); + return
{name} {email}
; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits).toHaveLength(0); + }); + + it("does NOT flag the article's GOOD network-cascade exception with a real write→dep chain", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-network-real-chain", { + files: { + "src/ShippingForm.tsx": `import { useEffect, useState } from "react"; + +export const ShippingForm = ({ country }: { country: string }) => { + const [cities, setCities] = useState(null); + const [areas, setAreas] = useState(null); + + useEffect(() => { + let ignore = false; + fetch(\`/api/cities?country=\${country}\`) + .then((response) => response.json()) + .then((json) => { + if (!ignore) setCities(json); + }); + return () => { + ignore = true; + }; + }, [country]); + + useEffect(() => { + if (cities === null) return; + let ignore = false; + fetch(\`/api/areas?cities=\${cities.join(",")}\`) + .then((response) => response.json()) + .then((json) => { + if (!ignore) setAreas(json); + }); + return () => { + ignore = true; + }; + }, [cities]); + + return {areas?.length}; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits).toHaveLength(0); + }); + + it("does NOT flag a chat-connection chain when both effects do real external sync", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-chat-real-chain", { + files: { + "src/Chat.tsx": `import { useEffect, useState } from "react"; + +declare const createConnection: (url: string) => { + connect: () => Promise; + disconnect: () => void; +}; +declare const window: { addEventListener: (name: string, handler: () => void) => void; removeEventListener: (name: string, handler: () => void) => void }; + +export const Chat = ({ roomId }: { roomId: string }) => { + const [status, setStatus] = useState("connecting"); + + useEffect(() => { + const connection = createConnection(roomId); + connection.connect().then(setStatus); + return () => connection.disconnect(); + }, [roomId]); + + useEffect(() => { + const onFocus = () => setStatus("connecting"); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [status]); + + return {status}; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits).toHaveLength(0); + }); + + it("DOES still flag chains where effects only call `set.delete()` (Bugbot #156 round 3)", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-set-delete-not-external", { + files: { + "src/Pruner.tsx": `import { useEffect, useState } from "react"; + +export const Pruner = ({ stale }: { stale: ReadonlySet }) => { + const [pruned, setPruned] = useState>(new Set()); + const [count, setCount] = useState(0); + useEffect(() => { + const next = new Set(); + for (const item of stale) next.add(item); + next.delete("ignore-me"); + setPruned(next); + }, [stale]); + useEffect(() => { + setCount(pruned.size); + }, [pruned]); + return {count}; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits.length).toBeGreaterThanOrEqual(1); + }); + + it("DOES still flag chains where effects only call `params.get()` (Bugbot #156 round 2)", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-params-get-not-external", { + files: { + "src/Settings.tsx": `import { useEffect, useState } from "react"; + +declare const params: URLSearchParams; + +export const Settings = () => { + const [theme, setTheme] = useState(""); + const [highlight, setHighlight] = useState(""); + useEffect(() => { + setTheme(params.get("theme") ?? "light"); + }, []); + useEffect(() => { + setHighlight(theme === "dark" ? "white" : "black"); + }, [theme]); + return {theme}; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits.length).toBeGreaterThanOrEqual(1); + }); + + it("does NOT flag a real write→dep cascade where both effects use `axios.get` (Bugbot #156, real chain)", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-axios-real-cascade", { + files: { + "src/Cascade.tsx": `import { useEffect, useState } from "react"; + +declare const axios: { get: (url: string) => Promise<{ data: unknown }> }; + +export const Cascade = ({ country }: { country: string }) => { + const [cities, setCities] = useState | null>(null); + const [enriched, setEnriched] = useState | null>(null); + + useEffect(() => { + let ignore = false; + axios.get(\`/api/cities?country=\${country}\`).then((response) => { + if (!ignore) setCities(response.data as Array); + }); + return () => { + ignore = true; + }; + }, [country]); + + useEffect(() => { + if (cities === null) return; + let ignore = false; + axios.get("/api/enrich").then((response) => { + if (!ignore) setEnriched(response.data as Array); + }); + return () => { + ignore = true; + }; + }, [cities]); + + return {enriched?.length}; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits).toHaveLength(0); + }); + + it("does NOT flag two effects whose written/read state sets are disjoint", async () => { + const projectDir = setupReactProject(tempRoot, "no-effect-chain-disjoint", { + files: { + "src/Profile.tsx": `import { useEffect, useState } from "react"; + +export const Profile = ({ userId, theme }: { userId: string; theme: string }) => { + const [name, setName] = useState(""); + const [highlight, setHighlight] = useState(""); + useEffect(() => { + setName(userId.toUpperCase()); + }, [userId]); + useEffect(() => { + setHighlight(theme === "dark" ? "white" : "black"); + }, [theme]); + return {name}; +}; +`, + }, + }); + + const hits = await collectRuleHits(projectDir, "no-effect-chain"); + expect(hits).toHaveLength(0); + }); +}); + describe("no-uncontrolled-input", () => { it("flags `value` without onChange / readOnly", async () => { const projectDir = setupReactProject(tempRoot, "no-uncontrolled-input-no-onchange", { diff --git a/packages/react-doctor/tests/run-oxlint.test.ts b/packages/react-doctor/tests/run-oxlint.test.ts index f4f0538ce..012f4bed5 100644 --- a/packages/react-doctor/tests/run-oxlint.test.ts +++ b/packages/react-doctor/tests/run-oxlint.test.ts @@ -128,6 +128,12 @@ describe("runOxlint", () => { ruleSource: "rules/state-and-effects.ts", severity: "warning", }, + "no-effect-chain": { + fixture: "state-issues.tsx", + ruleSource: "rules/state-and-effects.ts", + severity: "warning", + category: "State & Effects", + }, "no-effect-event-handler": { fixture: "state-issues.tsx", ruleSource: "rules/state-and-effects.ts",