1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, unwrapExpression } from "../utils.ts";
5
6const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect", "useInsertionEffect"]);
7const EFFECT_HOOK_TEXT = /use(?:Layout|Insertion)?Effect/;
8const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|[\\/](?:tests?|__tests__)[\\/]/;
9
10const isEffectHookCall = (node: ESTree.CallExpression): boolean => {
11 const callee = unwrapExpression(node.callee);
12 if (Option.isNone(callee)) return false;
13 const expression = callee.value;
14 if (expression.type === "Identifier") return EFFECT_HOOKS.has(expression.name);
15 if (expression.type === "MemberExpression" && !expression.computed) {
16 return Option.exists(getPropertyName(expression.property), (name) => EFFECT_HOOKS.has(name));
17 }
18 return false;
19};
20
21// Agents read this message and act on it, so it walks the alternatives before it shows the way out.
22const MESSAGE =
23 "Stop and reconsider before keeping this effect. " +
24 "Is it computable from props or state? Compute it during render; the compiler memoizes it. " +
25 "Does it respond to a user action? Do it in that handler. " +
26 "Does it reset state when a prop changes? Key the component on that prop. " +
27 "Does it tell a parent or store that a value changed? Do it where the change happens. " +
28 "Does it load app data? Read it from an atom. " +
29 "Does it read a value from outside React? Use an atom or useSyncExternalStore. " +
30 "An effect that sets state another effect reads is one handler or one derivation. " +
31 "An effect is right only when it keeps something outside React in step with this component while mounted: " +
32 "a DOM node, listener, timer, native module, connection, router, imperative toast, " +
33 "or a registry entry or resource held until unmount. " +
34 "Moving the same logic into a ref or store to dodge this rule is not a fix. " +
35 "If the effect does that, name what it keeps in step on the line above: " +
36 "`// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- <what it syncs with and why>`.";
37
38// Every kept effect carries its reason in the suppression above it; t3code/require-suppression-reason
39// rejects a suppression that does not say why.
40export default defineRule({
41 meta: {
42 type: "suggestion",
43 docs: {
44 description:
45 "Report every React effect hook, the usual way state gets synchronized; one that stays says why.",
46 },
47 },
48 create(context) {
49 if (TEST_FILE.test(context.filename) || !EFFECT_HOOK_TEXT.test(context.sourceCode.text)) {
50 return {};
51 }
52 return {
53 CallExpression(node) {
54 if (!isEffectHookCall(node)) return;
55 context.report({
56 node,
57 message: MESSAGE,
58 });
59 },
60 };
61 },
62});