diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/dom.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/dom.ts index d1257026d..a6f09deef 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/dom.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/dom.ts @@ -65,3 +65,5 @@ export const EXTERNAL_SYNC_OBSERVER_CONSTRUCTORS = new Set([ ]); export const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]); + +export const KEYBOARD_EVENT_NAMES = new Set(["keydown", "keyup", "keypress"]); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts index 3c304487e..1af30aabb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts @@ -27,6 +27,7 @@ import { checkedRequiresOnchangeOrReadonly } from "./rules/react-builtins/checke import { clickEventsHaveKeyEvents } from "./rules/a11y/click-events-have-key-events.js"; import { clientLocalstorageNoVersion } from "./rules/client/client-localstorage-no-version.js"; import { clientPassiveEventListeners } from "./rules/client/client-passive-event-listeners.js"; +import { clientPreferKeybindLibrary } from "./rules/client/client-prefer-keybind-library.js"; import { controlHasAssociatedLabel } from "./rules/a11y/control-has-associated-label.js"; import { noBoldHeading } from "./rules/react-ui/no-bold-heading.js"; import { noEmDashInJsxText } from "./rules/react-ui/no-em-dash-in-jsx-text.js"; @@ -562,6 +563,20 @@ export const reactDoctorRules = [ category: "Performance", }, }, + { + key: "react-doctor/client-prefer-keybind-library", + id: "client-prefer-keybind-library", + source: "react-doctor", + originallyExternal: false, + framework: "global", + category: "Architecture", + severity: "warn", + rule: { + ...clientPreferKeybindLibrary, + framework: "global", + category: "Architecture", + }, + }, { key: "react-doctor/control-has-associated-label", id: "control-has-associated-label", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/client/client-prefer-keybind-library.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/client/client-prefer-keybind-library.test.ts new file mode 100644 index 000000000..dbad8b30d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/client/client-prefer-keybind-library.test.ts @@ -0,0 +1,468 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { clientPreferKeybindLibrary } from "./client-prefer-keybind-library.js"; + +describe("client-prefer-keybind-library", () => { + describe("flags manual keyboard event listeners", () => { + it("flags window.addEventListener('keydown', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + const handler = (e) => { + if (e.key === 'k' && e.metaKey) openSearch(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("react-hotkeys-hook"); + expect(result.diagnostics[0].message).toContain("keydown"); + }); + + it("flags document.addEventListener('keydown', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("document"); + }); + + it("flags window.addEventListener('keyup', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('keyup', handler); + return () => window.removeEventListener('keyup', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("keyup"); + }); + + it("flags window.addEventListener('keypress', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('keypress', handler); + return () => window.removeEventListener('keypress', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("keypress"); + }); + + it("flags global keydown listener outside useEffect", () => { + const code = ` + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }); + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags document keydown listener outside useEffect", () => { + const code = ` + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }); + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags keydown in useLayoutEffect", () => { + const code = ` + const App = () => { + useLayoutEffect(() => { + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags multiple keyboard listeners in the same effect", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(2); + }); + }); + + describe("does not flag non-keyboard event listeners", () => { + it("ignores window.addEventListener('click', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('click', handler); + return () => window.removeEventListener('click', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores window.addEventListener('scroll', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('scroll', handler); + return () => window.removeEventListener('scroll', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores window.addEventListener('resize', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores addEventListener('mousedown', handler)", () => { + const code = ` + const App = () => { + useEffect(() => { + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("still flags even when a keybind library is imported", () => { + it("flags even when react-hotkeys-hook is imported", () => { + const code = ` + import { useHotkeys } from 'react-hotkeys-hook'; + const App = () => { + useEffect(() => { + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags even when tinykeys is imported", () => { + const code = ` + import tinykeys from 'tinykeys'; + const App = () => { + useEffect(() => { + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + }); + + describe("does not flag element-scoped listeners (non-global, non-effect)", () => { + it("ignores ref.current.addEventListener('keydown', handler) outside useEffect", () => { + const code = ` + const handler = (e) => { console.log(e.key); }; + inputRef.current.addEventListener('keydown', handler); + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores element.addEventListener('keydown', handler) in non-effect function", () => { + const code = ` + const setupKeybinds = (element) => { + element.addEventListener('keydown', handler); + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("flags element-scoped listeners inside useEffect", () => { + it("flags ref.current.addEventListener('keydown', handler) inside useEffect", () => { + const code = ` + const App = () => { + const inputRef = useRef(null); + useEffect(() => { + inputRef.current.addEventListener('keydown', handler); + return () => inputRef.current.removeEventListener('keydown', handler); + }, []); + return ; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + }); + + describe("does not flag unrelated patterns", () => { + it("ignores addEventListener with dynamic event name", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener(eventName, handler); + return () => window.removeEventListener(eventName, handler); + }, [eventName]); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores addEventListener with template literal event name", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener(\`key\${type}\`, handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores non-addEventListener member calls with 'keydown'", () => { + const code = ` + const App = () => { + useEffect(() => { + emitter.on('keydown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + + it("ignores addEventListener with fewer than 2 arguments", () => { + const code = ` + window.addEventListener('keydown'); + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(0); + }); + }); + + describe("open-source repo patterns", () => { + it("flags VS Code-style Ctrl+Shift+P command palette shortcut", () => { + const code = ` + const CommandPalette = () => { + const [isOpen, setIsOpen] = useState(false); + useEffect(() => { + const handleKeyDown = (event) => { + if (event.ctrlKey && event.shiftKey && event.key === 'p') { + event.preventDefault(); + setIsOpen(true); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + return isOpen ? : null; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags Cmd+K search shortcut pattern", () => { + const code = ` + const SearchBar = () => { + useEffect(() => { + const onKeyDown = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + openSearch(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + return ; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags Escape-to-close modal pattern", () => { + const code = ` + const Modal = ({ onClose }) => { + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [onClose]); + return
Content
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags arrow-key navigation pattern", () => { + const code = ` + const List = ({ items }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + useEffect(() => { + const handleArrowKeys = (e) => { + if (e.key === 'ArrowDown') setSelectedIndex(i => Math.min(i + 1, items.length - 1)); + if (e.key === 'ArrowUp') setSelectedIndex(i => Math.max(i - 1, 0)); + }; + window.addEventListener('keydown', handleArrowKeys); + return () => window.removeEventListener('keydown', handleArrowKeys); + }, [items.length]); + return
    {items.map((item, i) =>
  • {item}
  • )}
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags multi-key shortcut table pattern", () => { + const code = ` + const App = () => { + useEffect(() => { + const shortcuts = { + 'ctrl+s': save, + 'ctrl+z': undo, + 'ctrl+y': redo, + }; + const handler = (e) => { + const key = [e.ctrlKey && 'ctrl', e.key].filter(Boolean).join('+'); + if (shortcuts[key]) { + e.preventDefault(); + shortcuts[key](); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return ; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + + it("flags focus-trap keyboard handler pattern", () => { + const code = ` + const Dialog = ({ children }) => { + const dialogRef = useRef(null); + useEffect(() => { + const trapFocus = (e) => { + if (e.key === 'Tab') { + const focusable = dialogRef.current.querySelectorAll('button, input'); + if (e.shiftKey && document.activeElement === focusable[0]) { + e.preventDefault(); + focusable[focusable.length - 1].focus(); + } + } + }; + document.addEventListener('keydown', trapFocus); + return () => document.removeEventListener('keydown', trapFocus); + }, []); + return
{children}
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code); + expect(result.diagnostics).toHaveLength(1); + }); + }); + + describe("test file skipping (test-noise tag)", () => { + it("does not flag in test files", () => { + const code = ` + const App = () => { + useEffect(() => { + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return
; + }; + `; + const result = runRule(clientPreferKeybindLibrary, code, { + filename: "App.test.tsx", + }); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag in spec files", () => { + const code = ` + window.addEventListener('keydown', handler); + `; + const result = runRule(clientPreferKeybindLibrary, code, { + filename: "keyboard.spec.ts", + }); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag in story files", () => { + const code = ` + window.addEventListener('keydown', handler); + `; + const result = runRule(clientPreferKeybindLibrary, code, { + filename: "Modal.stories.tsx", + }); + expect(result.diagnostics).toHaveLength(0); + }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/client/client-prefer-keybind-library.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/client/client-prefer-keybind-library.ts new file mode 100644 index 000000000..2e93b3b58 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/client/client-prefer-keybind-library.ts @@ -0,0 +1,71 @@ +import { KEYBOARD_EVENT_NAMES } from "../../constants/dom.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { isMemberProperty } from "../../utils/is-member-property.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +const GLOBAL_LISTENER_OBJECTS = new Set(["window", "document"]); + +const isInsideUseEffectCallback = (node: EsTreeNode): boolean => { + let current: EsTreeNode | null | undefined = node.parent; + while (current) { + if ( + isNodeOfType(current, "CallExpression") && + isNodeOfType(current.callee, "Identifier") && + (current.callee.name === "useEffect" || current.callee.name === "useLayoutEffect") + ) { + return true; + } + current = current.parent; + } + return false; +}; + +const buildRecommendationMessage = (eventName: string, receiverName: string | null): string => { + const receiverPrefix = receiverName ? `${receiverName}.` : ""; + return `${receiverPrefix}addEventListener("${eventName}", …) registers a manual keyboard shortcut — use a keybind library like react-hotkeys-hook instead for consistent, declarative, and accessible keyboard shortcut management`; +}; + +export const clientPreferKeybindLibrary = defineRule({ + id: "client-prefer-keybind-library", + tags: ["test-noise"], + severity: "warn", + category: "Architecture", + recommendation: + 'Use a keybind library like react-hotkeys-hook (`useHotkeys("mod+k", handler)`) instead of manual addEventListener("keydown", …) — it handles focus scoping, modifier normalization, and cleanup automatically', + create: (context: RuleContext) => ({ + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (!isMemberProperty(node.callee, "addEventListener")) return; + if ((node.arguments?.length ?? 0) < 2) return; + + const eventNameNode = node.arguments[0]; + if ( + !isNodeOfType(eventNameNode, "Literal") || + typeof eventNameNode.value !== "string" || + !KEYBOARD_EVENT_NAMES.has(eventNameNode.value) + ) { + return; + } + + const callee = node.callee; + if (!isNodeOfType(callee, "MemberExpression")) return; + + const isGlobalReceiver = + isNodeOfType(callee.object, "Identifier") && + GLOBAL_LISTENER_OBJECTS.has(callee.object.name); + const isEffectBound = isInsideUseEffectCallback(node); + + if (!isGlobalReceiver && !isEffectBound) return; + + const receiverName = isNodeOfType(callee.object, "Identifier") ? callee.object.name : null; + + context.report({ + node, + message: buildRecommendationMessage(eventNameNode.value, receiverName), + }); + }, + }), +});