Skip to content
Open
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
26 changes: 26 additions & 0 deletions packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ export const REACT_HANDLER_PROP_PATTERN = /^on[A-Z]/;
export const EFFECT_HOOK_NAMES = new Set(["useEffect", "useLayoutEffect"]);
export const HOOKS_WITH_DEPS = new Set(["useEffect", "useLayoutEffect", "useMemo", "useCallback"]);

// Every stateful/effectful primitive hook React itself ships. A function
// that calls one of these is a genuine React hook implementation — used by
// `prefer-standard-hook` to distinguish a real hook reimplementation from an
// unrelated helper that merely happens to share a library hook's name.
export const REACT_BUILTIN_HOOK_NAMES = new Set([
"useState",
"useReducer",
"useRef",
"useEffect",
"useLayoutEffect",
"useInsertionEffect",
"useMemo",
"useCallback",
"useContext",
"useImperativeHandle",
"useDebugValue",
"useId",
"useSyncExternalStore",
"useTransition",
"useDeferredValue",
"useOptimistic",
"useActionState",
"useFormStatus",
"useEffectEvent",
]);

// React's two component-wrapping HOCs that the rule visitor needs to
// "see through" — `memo(Comp)` and `forwardRef(Comp)`. Both forms
// (`memo` from a named import + `React.memo` via the namespace) are
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// The complete set of hook names shipped by `react-standard-hooks`, which
// re-exports the union of `react-use` (107 hooks) and `usehooks-ts` (33 hooks)
// — 127 distinct names. `prefer-standard-hook` matches a locally-defined hook
// against this set to recommend importing the battle-tested library version
// instead of hand-rolling it. Generated from the published packages:
// node -e "console.log([...new Set([...Object.keys(require('react-use')),
// ...Object.keys(require('usehooks-ts'))].filter(name => /^use[A-Z]/.test(name)))].sort())"
export const STANDARD_LIBRARY_HOOK_NAMES = new Set([
"useAsync",
"useAsyncFn",
"useAsyncRetry",
"useAudio",
"useBattery",
"useBeforeUnload",
"useBoolean",
"useClickAnyWhere",
"useClickAway",
"useCookie",
"useCopyToClipboard",
"useCountdown",
"useCounter",
"useCss",
"useCustomCompareEffect",
"useDarkMode",
"useDebounce",
"useDebounceCallback",
"useDebounceValue",
"useDeepCompareEffect",
"useDefault",
"useDocumentTitle",
"useDrop",
"useDropArea",
"useEffectOnce",
"useEnsuredForwardedRef",
"useError",
"useEvent",
"useEventCallback",
"useEventListener",
"useFavicon",
"useFirstMountState",
"useFullscreen",
"useGeolocation",
"useGetSet",
"useGetSetState",
"useHarmonicIntervalFn",
"useHash",
"useHover",
"useHoverDirty",
"useIdle",
"useIntersection",
"useIntersectionObserver",
"useInterval",
"useIsClient",
"useIsMounted",
"useIsomorphicLayoutEffect",
"useKey",
"useKeyPress",
"useKeyPressEvent",
"useLatest",
"useLifecycles",
"useList",
"useLocalStorage",
"useLocation",
"useLockBodyScroll",
"useLogger",
"useLongPress",
"useMap",
"useMeasure",
"useMedia",
"useMediaDevices",
"useMediaQuery",
"useMediatedState",
"useMethods",
"useMotion",
"useMount",
"useMountedState",
"useMouse",
"useMouseHovered",
"useMouseWheel",
"useMultiStateValidator",
"useNetworkState",
"useNumber",
"useObservable",
"useOnClickOutside",
"useOrientation",
"usePageLeave",
"usePermission",
"usePinchZoom",
"usePrevious",
"usePreviousDistinct",
"usePromise",
"useQueue",
"useRaf",
"useRafLoop",
"useRafState",
"useReadLocalStorage",
"useRendersCount",
"useResizeObserver",
"useScratch",
"useScreen",
"useScript",
"useScroll",
"useScrollLock",
"useScrollbarWidth",
"useScrolling",
"useSearchParam",
"useSessionStorage",
"useSet",
"useSetState",
"useShallowCompareEffect",
"useSize",
"useSlider",
"useSpeech",
"useStartTyping",
"useStateList",
"useStateValidator",
"useStateWithHistory",
"useStep",
"useTernaryDarkMode",
"useThrottle",
"useThrottleFn",
"useTimeout",
"useTimeoutFn",
"useTitle",
"useToggle",
"useTween",
"useUnmount",
"useUnmountPromise",
"useUpdate",
"useUpdateEffect",
"useUpsert",
"useVibrate",
"useVideo",
"useWindowScroll",
"useWindowSize",
]);

// Names that live in the library set above but are intentionally NOT
// reported. Each is a generic English word/concept that applications
// routinely use for an unrelated, app-specific hook, so a same-named local
// definition is far more often a coincidental name collision than a
// reimplementation of the library hook. Validated against ~50 OSS repos
// with react-doctor-evals, where the bare-word names below produced the
// rule's only false positives — e.g. `useScroll` reimplemented as a
// scrolled-past-threshold boolean (not react-use's `{x, y}` tracker),
// `useLocation` as a geo lat/lng editor (not the browser-location hook),
// `useSize`/`useScreen` as viewport-size hooks, `useVideo` as a context
// accessor, `useSpeech` as speech-to-text, `usePermission` as app
// authorization, `useCss` as a theme→CSS-string builder, and
// `useSearchParam`/`usePageLeave` built on the app's own router, and
// `useStep` as a wizard step-context accessor (not usehooks-ts's numeric
// step counter). Distinctive,
// purpose-specific names (useDebounce, useLocalStorage, useCopyToClipboard,
// useMediaQuery, usePrevious, useOnClickOutside, …) stay matched because a
// hand-rolled version of those is reliably the library hook.
//
// `useEvent` additionally collides with React's own experimental
// `useEvent` / `useEffectEvent` primitive, so a local one is usually that
// polyfill rather than react-use's DOM event-binding hook.
export const STANDARD_LIBRARY_HOOK_EXCLUSIONS = new Set([
"useAudio",
"useCss",
"useDefault",
"useDrop",
"useDropArea",
"useError",
"useEvent",
"useGetSet",
"useGetSetState",
"useHash",
"useKey",
"useLatest",
"useList",
"useLocation",
"useLogger",
"useMap",
"useMeasure",
"useMedia",
"useMediatedState",
"useMethods",
"useMotion",
"useMouse",
"useMultiStateValidator",
"useNumber",
"useObservable",
"usePageLeave",
"usePermission",
"useQueue",
"useRaf",
"useScratch",
"useScreen",
"useScroll",
"useSearchParam",
"useSet",
"useSetState",
"useSize",
"useSlider",
"useSpeech",
"useStateList",
"useStateValidator",
"useStep",
"useTitle",
"useUpdate",
"useUpsert",
"useVideo",
]);
12 changes: 12 additions & 0 deletions packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ import { preferHtmlDialog } from "./rules/a11y/prefer-html-dialog.js";
import { preferModuleScopePureFunction } from "./rules/architecture/prefer-module-scope-pure-function.js";
import { preferModuleScopeStaticValue } from "./rules/architecture/prefer-module-scope-static-value.js";
import { preferStableEmptyFallback } from "./rules/performance/prefer-stable-empty-fallback.js";
import { preferStandardHook } from "./rules/architecture/prefer-standard-hook.js";
import { preferTagOverRole } from "./rules/a11y/prefer-tag-over-role.js";
import { preferUseEffectEvent } from "./rules/state-and-effects/prefer-use-effect-event.js";
import { preferUseSyncExternalStore } from "./rules/state-and-effects/prefer-use-sync-external-store.js";
Expand Down Expand Up @@ -2792,6 +2793,17 @@ export const reactDoctorRules = [
category: "Performance",
},
},
{
key: "react-doctor/prefer-standard-hook",
id: "prefer-standard-hook",
source: "react-doctor",
originallyExternal: false,
rule: {
...preferStandardHook,
framework: "global",
category: "Architecture",
},
},
{
key: "react-doctor/prefer-tag-over-role",
id: "prefer-tag-over-role",
Expand Down
Loading
Loading