Skip to content

fix: rules-of-hooks FP on forwardRef/memo callbacks under non-PascalCase bindings#794

Merged
aidenybai merged 2 commits into
mainfrom
devin/1781244134-rules-of-hooks-hoc-underscore-fp
Jun 12, 2026
Merged

fix: rules-of-hooks FP on forwardRef/memo callbacks under non-PascalCase bindings#794
aidenybai merged 2 commits into
mainfrom
devin/1781244134-rules-of-hooks-hoc-underscore-fp

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

rules-of-hooks flagged hooks inside React HoC render callbacks whenever the resulting binding wasn't PascalCase — the exact shape compiled/generated mobile code uses:

const _Wrapped = forwardRef((props, ref) => {
  useHook(); // FP: "`useHook` runs inside `_Wrapped`, which is not a component or Hook"
  ...
});

The component-or-hook decision keyed only on the inferred binding name (inferFunctionName sees through forwardRef/memo to _Wrapped, and isReactComponentName rejects _-prefixed names). But the callback passed directly to memo(...) / forwardRef(...) (incl. React.* forms, and nested memo(forwardRef(...))) is a component by construction, regardless of the binding name — same stance as upstream eslint-plugin-react-hooks' isForwardRefCallback/isMemoCallback.

 findEnclosingFunctionInfo:
+  isHocCallback = isReactHocCallbackArgument(fn)  // direct arg of REACT_HOC_NAMES call
-  isComponentOrHook: isReactComponentOrHookName(resolvedName)
+  isComponentOrHook: isHocCallback || isReactComponentOrHookName(resolvedName)

findEnclosingComponentOrHookFunction (useEffectEvent same-scope check) gets the same treatment. Arbitrary non-React wrappers (trackEvents(fn)) are NOT promoted, so a genuinely non-component _helper() calling hooks still fires.

Eval results

Full oxlint-plugin-react-doctor suite: 39 failures on this branch — byte-identical to the 39 preexisting failures on main (no new misses on the adversarial suite). New regression tests in rules-of-hooks.regressions.test.ts cover the forwardRef/memo/React.forwardRef/nested shapes plus both still-fires cases.

Test plan

  • vp test run src/plugin/rules/react-builtins/rules-of-hooks*.test.ts (167 passed)
  • pnpm --filter oxlint-plugin-react-doctor typecheck
  • pnpm lint

Link to Devin session: https://app.devin.ai/sessions/9f543a0266b74767b52eb6b246c652d4
Requested by: @aidenybai


Note

Low Risk
Lint-rule heuristic change with targeted regression tests; no runtime or auth/data impact, and real violations (memo comparator, non-React HoCs) remain covered.

Overview
Fixes false positives in rules-of-hooks and exhaustive-deps when hooks run inside forwardRef / memo render callbacks assigned to non-PascalCase names (e.g. const _Wrapped = forwardRef(...)).

Adds isReactHocCallbackArgument, which treats the first argument to known React HoCs (memo, forwardRef, including React.*) as a component by construction. rules-of-hooks uses it in findEnclosingFunctionInfo and findEnclosingComponentOrHookFunction, and tightens the non-component branch so HoC callbacks are not misclassified via inferred binding names. exhaustive-deps uses the same helper so factory-scope captures (e.g. logger) are not reported as missing deps when the component boundary was wrong.

memo’s second-argument comparator, arbitrary wrappers, and plain _helper functions are unchanged and still report. New regression tests cover the fixed and still-firing cases.

Reviewed by Cursor Bugbot for commit 480f578. Bugbot is set up for automated code reviews on this repo. Configure here.

…non-PascalCase bindings

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@794
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@794
npm i https://pkg.pr.new/react-doctor@794

commit: 480f578

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

No React Doctor issues found. 🎉

Reviewed by React Doctor for commit 480f578.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f9849a5. Configure here.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Test results — built the react-doctor CLI from this branch and from clean main (d48e7f1) and diffed every rules-of-hooks diagnostic.

Escalation: I could not locate the RDE harness (OSS recording-repo manifest from .agents/skills/rule-validate/SKILL.md) on this machine, so the literal "29 known FPs → 0" RDE run was substituted with a deterministic E2E CLI run on the exact reported shape plus main-vs-branch CLI scans of 3 OSS RN repos. Happy to run the official flow if pointed at the harness.

  • Exact reported shape E2E (fixture project, full CLI --json run): passed
  • OSS no-regression scan (paper, bottom-sheet, gesture-handler): passed — diagnostics byte-identical main vs branch
  • Regression: full plugin unit suite parity: passed — same 39 preexisting failures as main, +6 new passing regression tests
Test 1 — exact reported shape (CLI E2E)

Fixture src/wrapped.tsx: const _Wrapped = forwardRef((props, ref) => { useImperativeHandle(...); useState(...) }), const _Memoized = memo(...) with a hook, and non-component const _helper = () => { useState(...) }.

main CLI:

src/wrapped.tsx:4  useImperativeHandle inside `_Wrapped` … not a component or Hook   (FP)
src/wrapped.tsx:5  useState inside `_Wrapped` …                                      (FP)
src/wrapped.tsx:15 useState inside `_Memoized` …                                     (FP)
src/wrapped.tsx:25 useState inside `_helper` …                                       (true positive)

branch CLI:

src/wrapped.tsx:25 useState inside `_helper` …                                       (true positive kept)

Zero diagnostics on the HoC callbacks; _helper still fires.

Test 2 — OSS no-regression scan
Repo main roh diags branch roh diags diff
callstack/react-native-paper 1 1 identical
gorhom/react-native-bottom-sheet 2 2 identical
software-mansion/react-native-gesture-handler 10 10 identical

No branch-only diagnostics, no disappearances. None of these repos contain the non-PascalCase HoC-binding shape in source (it's typical of compiled/generated bundles), hence the fixture in Test 1 carries the FP→0 proof; facebook/react-native source was also grep-verified to have no occurrences.

Test 3 — Regression: unit suite parity

Full oxlint-plugin-react-doctor suite: branch 39 failed | 6820 passed vs clean main 39 failed | 6814 passed; failure lists byte-identical (timing-only diffs). The +6 passes are the new tests in rules-of-hooks.regressions.test.ts.

CI: 18 passed, 0 failed. Devin session

… it with exhaustive-deps

Review fixes for the forwardRef/memo non-PascalCase binding FP fix:

- Only the FIRST argument of memo/forwardRef is promoted to a
  component — memo's second argument is the props comparator, and
  promoting it silenced hooks-in-comparator violations that fired
  before this branch.
- Restore `hasResolvedName`'s documented meaning (true iff a name was
  inferred) by checking `isComponentOrHook` before the anonymous
  walk-out instead of overloading the flag for HoC callbacks.
- Extract `isReactHocCallbackArgument` into a shared util and apply it
  to exhaustive-deps' `findEnclosingComponentOrHookFunction`, which had
  the same FP: factory-scope captures inside a `const _Wrapped =
  forwardRef(...)` callback were reported as missing deps.
- Strengthen the still-flags regression tests to assert the exact
  diagnostic message and cover the memo-comparator case.
@aidenybai

Copy link
Copy Markdown
Member

Pushed review fixes in 480f578:

  • memo comparator regression (also flagged by Bugbot): isReactHocCallbackArgument matched any argument position, so hooks inside memo(Component, comparator) — flagged before this branch — went silent. Promotion is now restricted to the first argument (the render callback), with a regression test asserting the exact diagnostic.
  • hasResolvedName semantics: the field's doc comment ("true iff a name was actually inferred") no longer matched the || isHocCallback overload. The consumer now checks isComponentOrHook before the anonymous walk-out, which deletes the overload entirely. Behavior is unchanged for all combinations.
  • Shared util + exhaustive-deps: the same FP existed in exhaustive-deps' copy of this machinery — factory-scope captures inside const _Wrapped = forwardRef(...) were reported as missing deps (confirmed with a failing test before the fix). isReactHocCallbackArgument now lives in utils/is-react-hoc-callback-argument.ts and both rules use it.
  • Test strength: the two "still flags" tests now assert the exact diagnostic message instead of length > 0; the changeset covers the comparator restriction and the exhaustive-deps fix.

Verification: all 787 rules-of-hooks + exhaustive-deps tests pass; the full plugin suite's 39 failures are byte-identical to this branch's pre-fix baseline (same preexisting set); typecheck, lint, and format:check pass.

@aidenybai aidenybai merged commit 7c88165 into main Jun 12, 2026
20 checks passed
@aidenybai aidenybai deleted the devin/1781244134-rules-of-hooks-hoc-underscore-fp branch June 12, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant