Skip to content

fix: detect useCallback methods in imperative handles - #1139

Open
askalf wants to merge 10 commits into
reactjs:mainfrom
askalf:fix/imperative-handle-callback-methods
Open

askalf wants to merge 10 commits into
reactjs:mainfrom
askalf:fix/imperative-handle-callback-methods

Conversation

@askalf

@askalf askalf commented Sep 20, 2026

Copy link
Copy Markdown

Fixes #856

Summary

  • useImperativeHandle methods that are wrapped in useCallback are now documented, with the signature, docblock and modifiers of the function the callback wraps.
  • The unwrapping is scoped to the imperative-handle path only. Class fields, statics objects and object-literal components keep their previous behaviour (plain function values only), because hooks are not valid React usage there.
  • The reported component is parsed verbatim through the public parse(), so the wrapper-resolution path (memo(forwardRef(...)) + shorthand property) is pinned end to end, not just the handler.

Current head 1d45d98. Review feedback from the 2026-09-21 review is addressed in three commits (one per comment) plus one follow-up control; see Review follow-ups below.

$ npx vitest run src/handlers/__tests__/componentMethodsHandler-useCallback-test.ts src/__tests__/imperativeHandleCallbacks-test.ts src/handlers/__tests__/componentMethodsHandler-test.ts src/utils/__tests__/getMethodDocumentation-test.ts
 ✓  lib  src/handlers/__tests__/componentMethodsHandler-useCallback-test.ts (22 tests)
 ✓  lib  src/handlers/__tests__/componentMethodsHandler-test.ts (25 tests)
 ✓  lib  src/__tests__/imperativeHandleCallbacks-test.ts (3 tests)
 ✓  lib  src/utils/__tests__/getMethodDocumentation-test.ts (25 tests)

 Test Files  4 passed (4)
      Tests  75 passed (75)

$ npx vitest run                       # whole react-docgen package
 Test Files  67 passed (67)
      Tests  1468 passed (1468)
   Duration  18.18s

Against main (4abfaaf2) with the same test files in place, exactly the 12 behavioural tests fail and all 13 controls pass:

$ git checkout 4abfaaf2 -- packages/react-docgen/src/utils/getMethodDocumentation.ts \
                           packages/react-docgen/src/handlers/componentMethodsHandler.ts
$ npx vitest run src/handlers/__tests__/componentMethodsHandler-useCallback-test.ts src/__tests__/imperativeHandleCallbacks-test.ts
 × extracts a method wrapped with a directly-declared callback
 × extracts a method wrapped with a callback identifier
 × extracts a method wrapped with a React namespace callback
 × extracts a method wrapped with a renamed useCallback import
 × extracts a callback written inline in the handle object
 × extracts a callback declared after the imperative handle
 × carries the docblock of the handle property
 × records the async modifier of the wrapped function
 × records the generator modifier of the wrapped function
 × extracts a zero-parameter callback with no return annotation
 × documents a useCallback method of a memo(forwardRef) component
 × documents a useCallback method of a forwardRef component

 Test Files  2 failed (2)
      Tests  12 failed | 13 passed (25)

The reported component itself, before and after:

# on main
parse(<the component from #856>).methods  ->  []
# with this change
parse(<the component from #856>).methods  ->  [{ name: '_myMethod',
    docblock: 'myMethod description',
    params: [{ name: 'argument', optional: false, type: { name: 'string' } }],
    returns: { type: { name: 'number' } } }]

Review follow-ups

Comment Commit What changed
Cover the exact #856 reproducer through the normal parser/resolver c97359c New src/__tests__/imperativeHandleCallbacks-test.ts parses the issue's component verbatim via the public parse()memo(forwardRef(...)), top-level useCallback, shorthand exposure — plus a forwardRef-only case and a plain-function memo(forwardRef) control.
Keep the unwrapping local to the imperative-handle path f5d7939 resolveToMethodFunction became resolveToUseCallbackFunction (only the useCallback branch). getMethodFunctionExpression keeps its original plain-function resolution and consults useCallback only when options.isImperativeHandle is set, which only findImperativeHandleMethods sets. isMethod is restored to its original body; the handler's imperative-handle visitor uses a new isImperativeHandleMethod. Class fields, statics and object components behave exactly as on main.
Remove the out-of-scope Component.foo = useCallback(...) case e9a51c5 src/utils/__tests__/getMethodDocumentation-useCallback-test.ts deleted. findAssignedMethods still filters on resolveToValue(right).isFunction(), so that assignment never reaches getMethodDocumentation, and nothing in the change claims it does.
— (follow-up) 1d45d98 Control pinning that the React lifecycle filter still applies to an unwrapped callback.

Tests on the branch

Behavioural tests fail on main (4abfaaf2) and pass here. Controls pass on both arms; each states what it controls for.

Test File Kind
extracts a method wrapped with a directly-declared callback componentMethodsHandler-useCallback fails on main
extracts a method wrapped with a callback identifier componentMethodsHandler-useCallback fails on main
extracts a method wrapped with a React namespace callback componentMethodsHandler-useCallback fails on main
extracts a method wrapped with a renamed useCallback import componentMethodsHandler-useCallback fails on main
extracts a callback written inline in the handle object componentMethodsHandler-useCallback fails on main
extracts a callback declared after the imperative handle componentMethodsHandler-useCallback fails on main
carries the docblock of the handle property componentMethodsHandler-useCallback fails on main
records the async modifier of the wrapped function componentMethodsHandler-useCallback fails on main
records the generator modifier of the wrapped function componentMethodsHandler-useCallback fails on main
extracts a zero-parameter callback with no return annotation componentMethodsHandler-useCallback fails on main
documents a useCallback method of a memo(forwardRef) component imperativeHandleCallbacks fails on main (the #856 reproducer)
documents a useCallback method of a forwardRef component imperativeHandleCallbacks fails on main
documents a plain function method of a memo(forwardRef) component (control) imperativeHandleCallbacks control: memo/forwardRef resolution, so the two above fail on the unwrapping
documents a plain function exposed through the handle (control) componentMethodsHandler-useCallback control: the handle surface itself
does not document a lifecycle name exposed as a callback (control) componentMethodsHandler-useCallback control: isReactComponentMethod still filters an unwrapped callback
does not document a callback method on an ObjectExpression component (control) componentMethodsHandler-useCallback control: the scoping — fails at the previous head a9605a4, passes on main and here
does not document a callback method in a statics object (control) componentMethodsHandler-useCallback control: the scoping — fails at a9605a4
does not document a callback class property (control) componentMethodsHandler-useCallback control: the scoping — fails at a9605a4
documents a plain function class property (control) componentMethodsHandler-useCallback control: the class-property surface still documents plain functions
does not document a local function named useCallback (control) componentMethodsHandler-useCallback control: binding, not identifier text
does not document a useCallback call with no arguments (control) componentMethodsHandler-useCallback control: arguments[0] tolerates an empty list
does not document a useCallback call whose first argument is not a function (control) componentMethodsHandler-useCallback control: isFunction on the resolved argument
does not document a useCallback call whose argument is spread (control) componentMethodsHandler-useCallback control: the !Array.isArray(callback) guard
does not document a nested useCallback call (control) componentMethodsHandler-useCallback control: one level, not recursive
does not document a useMemo call returning a function (control) componentMethodsHandler-useCallback control: scoped to useCallback, not every builtin

25 tests across the two files: 12 fail on main, 13 are controls. src/utils/__tests__/getMethodDocumentation-test.ts (25) and src/handlers/__tests__/componentMethodsHandler-test.ts (25) are the pre-existing suites for the touched files and stay green.

Boundaries

Every predicate and index expression the diff adds or changes:

Guard / expression Boundary inputs Behaviour Pinned by
options.isImperativeHandle in getMethodFunctionExpression set (handle) / unset (class field, statics, object literal, assignment) set → the useCallback fallback runs; unset → identical to main "extracts a method wrapped with a directly-declared callback"; the three scoping controls (each fails at a9605a4)
functionExpression.isFunction() (unchanged first branch) plain arrow, function, non-function value plain functions resolve as before, on every surface "documents a plain function exposed through the handle (control)", "documents a plain function class property (control)"
value.isCallExpression() call / identifier / literal / member expression non-calls return null "…first argument is not a function (control)" (42)
isReactBuiltinCall(value, 'useCallback') React useCallback, React.useCallback, renamed import, local binding of the same name, useMemo only a React useCallback binding unwraps namespace and renamed-import cases; "a local function named useCallback (control)"; "a useMemo call returning a function (control)"
value.get('arguments')[0] zero arguments, one, two, spread missing → null; spread → not a callback "…with no arguments (control)"; "…whose argument is spread (control)"
callback && !Array.isArray(callback) undefined, single path, array (spread) both falsy branches return null same two controls
wrapped.isFunction() after resolveToValue direct arrow, identifier to an arrow, number, nested useCallback call only a function is returned; a nested call is not unwrapped twice "a callback identifier"; "a nested useCallback call (control)"
path.isObjectProperty() in isImperativeHandleMethod ObjectProperty, ObjectMethod, SpreadElement in the handle object ObjectMethod is already admitted by isMethod; a spread is neither and is skipped isMethod branch covered by "documents a plain function exposed through the handle (control)"; a spread has no value and returns false — no separate test
!isReactComponentMethod(path) in isImperativeHandleMethod ordinary name, React lifecycle name a lifecycle name is not documented even when unwrapped "does not document a lifecycle name exposed as a callback (control)"
Empty signature of the wrapped function () => {} (no params, no return annotation) params: [], returns: null "extracts a zero-parameter callback with no return annotation"
Declaration order callback declared before / after the useImperativeHandle call both resolve "extracts a callback declared after the imperative handle"
Component wrapper bare function, forwardRef, memo(forwardRef(...)) all three reach the handle body the three imperativeHandleCallbacks tests

Decisions

The unwrapping goes through options.isImperativeHandle rather than a separate resolver call in the handler so that params, returns, docblock and the async/generator modifiers all come from one resolution of the same function; an earlier revision unwrapped inside isMethod only, which admitted the method but documented the useCallback call's own (empty) signature. One useCallback layer is unwrapped, not recursively, and no other React builtin is treated as a method wrapper.

Checks run locally: whole react-docgen package suite (67 files, 1468 tests), prettier --check, eslint --report-unused-disable-directives --max-warnings=0, and tsc --noEmit on the package tsconfig — all clean.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

askalf and others added 5 commits September 19, 2026 05:11
The handler recognised a useCallback-wrapped property as a method, but
the documentation was built from the call expression, so the method
came out with no parameters and no return type. resolveToMethodFunction
now resolves a value to the function it documents, unwrapping a React
useCallback call, and both the handler's method test and the
documentation builder use it. The tests assert the wrapped function's
signature and cover a local function named useCallback, a call with no
arguments and a non-function argument.
The handler's unwrapping now runs for every method node path, so add the
surfaces it reaches beyond the imperative handle identifier: a callback
written inline in the handle object, an ObjectExpression component, a
statics object, a class property and a Component.foo assignment, plus the
docblock and the async/generator modifiers of the wrapped function, a
renamed useCallback import and a handle that precedes the declaration.
The controls pin the guards the unwrapping keeps: a local useCallback, a
missing or non-function argument, a spread argument, a nested call and
useMemo.
Every positive callback fixture carried a typed parameter and a return
annotation, so the empty-signature path the boundary ledger claims was
never exercised. Admit useCallback(() => {}, []) through
useImperativeHandle and assert an empty params list and a null return.
@changeset-bot

changeset-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1d45d98

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
react-docgen Patch
@react-docgen/cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@netlify

netlify Bot commented Sep 20, 2026

Copy link
Copy Markdown

Deploy Preview for react-docgen ready!

Name Link
🔨 Latest commit 1d45d98
🔍 Latest deploy log https://app.netlify.com/projects/react-docgen/deploys/6ab173896fa08300086d3415
😎 Deploy Preview https://deploy-preview-1139--react-docgen.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@danez danez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR.

The core useImperativeHandle + useCallback support makes sense, but I think the tests and implementation should be tightened before merging. In particular, please cover the exact issue reproducer and either narrow or consistently implement the additional method surfaces.

])(
'extracts a method wrapped with $name',
({ imports, setup, value, imperativeHandle }) => {
const definition = parse.statementLast<FunctionDeclaration>(`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we add a regression test that runs the exact shape from #856 through the normal parser/resolver: memo(forwardRef(...)), a top-level useCallback, and shorthand exposure through useImperativeHandle? These tests invoke the handler directly with a plain function, so they do not pin the wrapper-resolution path from the reported bug.

AI disclosure: This comment was added by ChatGPT 5.6 Sol.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in c97359c: src/__tests__/imperativeHandleCallbacks-test.ts parses the component from #856 verbatim (memo(forwardRef(...)), a top-level useCallback, shorthand exposure through useImperativeHandle) through the public parse(), alongside a plain-function memo(forwardRef) case. Both fail on main without the fix.


isProbablyMethod = value.isFunction();
isProbablyMethod =
resolveToMethodFunction(path.get('value') as NodePath) !== null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes generic object and class-property method detection, although #856 only asks about methods exposed through useImperativeHandle. It also adds support for useCallback in class fields/statics, where Hooks are not valid React usage. Could we keep the unwrapping local to the imperative-handle path unless there is a separate use case for broadening every method surface?

AI disclosure: This comment was added by ChatGPT 5.6 Sol.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Narrowed in f5d7939. The useCallback unwrapping now lives only on the imperative-handle path: getMethodFunctionExpression keeps its old plain-function resolution and consults useCallback only when the caller passes isImperativeHandle, which the handler sets from the useImperativeHandle visitor alone. Class fields, statics and object methods are back to base behaviour, and there are tests pinning that they stay there.

.statementLast<ExpressionStatement>(
`import { useCallback } from 'react';
const Foo = () => {}
Foo.foo = useCallback((bar: number): number => bar, [])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This test suggests that Component.foo = useCallback(...) is supported, but findAssignedMethods still filters assignments with resolveToValue(right).isFunction(), so the public handler never passes this assignment to getMethodDocumentation. Please either update the handler consistently and test it through componentMethodsHandler, or remove this out-of-scope assignment case.

AI disclosure: This comment was added by ChatGPT 5.6 Sol.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed in e9a51c5. The Component.foo = useCallback(...) case was out of scope and the handler never reached it; the test file is gone rather than the handler widened.

The handler tests build a component definition and call the handler
directly, so none of them exercise the resolver path the issue reports:
a memo(forwardRef) component whose method is exposed as a shorthand
property. Parse that component verbatim through the public parse() and
assert the documented method, with the same component without
useCallback as a control for memo/forwardRef resolution.
Documenting the function a useCallback call wraps changed every method
surface, including class fields and statics objects where hooks are not
valid React usage. Pass the imperative handle down to
getMethodDocumentation and unwrap only there; the other surfaces keep
documenting plain function values only. The surface tests become
controls: they assert that an object, statics and class-property
useCallback stays undocumented, next to a plain function on the same
surface that still is.
findAssignedMethods only collects assignments whose right-hand side
resolves to a function, so Component.foo = useCallback(...) never
reaches getMethodDocumentation through the handler. Remove the unit test
that implied the assignment surface supported it.
The imperative handle admission keeps the React lifecycle check, so a
handle property named after a lifecycle method stays undocumented even
when its value is a useCallback call. Control: it passes with and
without the unwrapping.
@askalf

askalf commented Sep 22, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Pushed a9605a4..1d45d98, one commit per point: the exact #856 shape now runs through the parser (c97359c), the unwrapping is confined to the imperative-handle path (f5d7939), and the assignment case is dropped (e9a51c5); 1d45d98 pins the lifecycle filter on an unwrapped callback. Package suite: 1467 tests pass, prettier, eslint and tsc clean.

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.

useCallback + useImperativeHandle methods

2 participants