Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ const NamespaceLazyInit = () => {

const NamespaceFunctionalSetState = () => {
const [count, setCount] = React.useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
// Deferred (setTimeout) read of `count` — a real stale-closure trap.
// A synchronous `onClick={() => setCount(count + 1)}` would NOT fire
// (fresh state per render), so the deferred form keeps coverage of the
// namespaced-setter arithmetic path.
return <button onClick={() => setTimeout(() => setCount(count + 1), 0)}>{count}</button>;
};

const NamespaceDependencyLiteral = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ const PreferUseReducerComponent = () => {

const FunctionalSetStateComponent = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
// Deferred (setTimeout) read of `count` — a real stale-closure trap. A
// synchronous `onClick={() => setCount(count + 1)}` would NOT fire (fresh
// state per render), so the deferred form keeps coverage of the setter
// arithmetic path.
return <button onClick={() => setTimeout(() => setCount(count + 1), 0)}>{count}</button>;
};

const DependencyLiteralComponent = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
/**
* Pure string / comparison reads — `props.text.startsWith(prev)`,
* `props.path.includes(sep)`, `props.label.indexOf(x)` read FROM the
* prop and return a primitive; they never hand the child's data back
* to a parent callback. Split out of DATA_SINK_METHOD_NAMES because
* these names CAN collide with a real parent callback when the method
* is called directly on the props object (`props.search(results)`) —
* `no-pass-data-to-parent` un-exempts exactly that shape.
*/
export const STRING_READ_METHOD_NAMES: ReadonlySet<string> = new Set([
"startsWith",
"endsWith",
"includes",
"indexOf",
"lastIndexOf",
"match",
"matchAll",
"search",
"localeCompare",
"test",
]);

/**
* Method names that conventionally "consume" or "sink" the value
* passed to them rather than handing it BACK to a parent — used by
Expand Down Expand Up @@ -48,6 +70,7 @@ export const DATA_SINK_METHOD_NAMES: ReadonlySet<string> = new Set([
"fire",
"broadcast",
"send",
...STRING_READ_METHOD_NAMES,
// Promise
"then",
"catch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ const hasJsxSpreadAttribute = (attributes: EsTreeNode[]): boolean =>
// `register()`, Headless UI, Radix, etc. routinely supply `onChange` /
// `defaultValue` via spread, and we can't see through it without scope
// analysis. False-negative > false-positive on a heavily used pattern.
//
// Tagged `test-noise` so `defineRule` skips test-like files entirely:
// jest/vitest suites routinely render deliberately static
// `<input value={x} />` presentational stubs, where the missing handler
// is intentional, never user-facing (ant-design's form __tests__ was a
// mined bench FP).
export const noUncontrolledInput = defineRule({
id: "no-uncontrolled-input",
title: "Uncontrolled input value",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { advancedEventHandlerRefs } from "./advanced-event-handler-refs.js";

describe("advanced-event-handler-refs — regressions", () => {
it("stays silent when the handler has a stable useCallback identity", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C() {
const onResize = useCallback(() => {}, []);
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [onResize]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent when another dep is itself the subscription target", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C({ onMessage, socket }) {
useEffect(() => {
socket.on('message', onMessage);
return () => socket.off('message', onMessage);
}, [onMessage, socket]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("still flags a fresh unstable handler with no other deps", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C({ onResize }) {
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [onResize]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("stays silent for useEvent-wrapped handlers (mined ant-design useLocalStorage FP)", () => {
const result = runRule(
advancedEventHandlerRefs,
`function useLocalStorage(key) {
const onNativeStorage = useEvent((event) => {
syncState();
});
const onCustomStorage = useEvent((event) => {
syncState();
});
useEffect(() => {
window?.addEventListener('storage', onNativeStorage);
window?.addEventListener('ant-sync', onCustomStorage);
return () => {
window?.removeEventListener('storage', onNativeStorage);
window?.removeEventListener('ant-sync', onCustomStorage);
};
}, [onNativeStorage, onCustomStorage]);
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent for the other common stable-callback hook names", () => {
for (const stableHookName of ["useEventCallback", "useMemoizedFn", "useStableCallback"]) {
const result = runRule(
advancedEventHandlerRefs,
`function C() {
const onScroll = ${stableHookName}(() => syncState());
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
}
});

it("still flags a handler built by a plain (non-stable-hook) call each render", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C({ delay }) {
const onScroll = throttle(() => syncState(), delay);
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("stays silent for React.useMemo(fn, []) handlers (mined ant-design AffixTabs FP)", () => {
const result = runRule(
advancedEventHandlerRefs,
`function AffixTabs() {
const onSyncAffix = React.useMemo(() => {
function doSync() {}
return throttle(doSync);
}, []);
React.useEffect(() => {
window.addEventListener('scroll', onSyncAffix);
return () => window.removeEventListener('scroll', onSyncAffix);
}, [onSyncAffix]);
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("still flags a useMemo handler whose non-empty deps churn its identity", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C({ delay }) {
const onScroll = useMemo(() => throttle(() => syncState(), delay), [delay]);
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("still flags an unstable prop param that shadows an outer stable binding", () => {
const result = runRule(
advancedEventHandlerRefs,
`const onResize = useCallback(() => {}, []);
function C({ onResize }) {
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [onResize]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("stays silent when the outer stable binding is NOT shadowed by a param", () => {
const result = runRule(
advancedEventHandlerRefs,
`const onResize = stableCallback;
function C() {
const onResizeStable = useCallback(onResize, []);
useEffect(() => {
window.addEventListener('resize', onResizeStable);
return () => window.removeEventListener('resize', onResizeStable);
}, [onResizeStable]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("still flags a churning handler when the receiver dep is a stable useRef", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C({ onScroll }) {
const ref = useRef(null);
useEffect(() => {
ref.current.addEventListener('scroll', onScroll);
return () => ref.current.removeEventListener('scroll', onScroll);
}, [onScroll, ref]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("stays silent when the receiver dep is an unstable local, not a ref", () => {
const result = runRule(
advancedEventHandlerRefs,
`function C({ onMessage, channelId }) {
const channel = getChannel(channelId);
useEffect(() => {
channel.addEventListener('message', onMessage);
return () => channel.removeEventListener('message', onMessage);
}, [onMessage, channel]);
return null;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});
});
Loading
Loading