Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
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
58 changes: 57 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,7 +368,63 @@ we are getting this output:
}
```

### Types
## React Hooks support

If you are using React Hooks, react-docgen will now also find component methods defined directly via the `useImperativeHandle()` hook.

> **Note**: react-docgen will not be able to grab the type definition if the type is imported or declared in a different file.

### Example

For the following component using `useImperativeHandle`:


```js
import React, { useImperativeHandle } from 'react';

/**
* General component description.
*/
const MyComponent = React.forwardRef((props, ref) => {

useImperativeHandle(ref, () => ({
/**
* This is my method
*/
myMethod: (arg1) => {},
}));

return /* ... */;
});

export default MyComponent;
```

we are getting this output:

```json
{
"description": "General component description.",
"displayName": "MyComponent",
"methods": [
{
"name": "myMethod",
"docblock": "This is my method",
"modifiers": [],
"params": [
{
"name": "arg1",
"optional": false
}
],
"returns": null,
"description": "This is my method"
}
]
}
```

## Types

Here is a list of all the available types and its result structure.

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,10 @@ import type {
ClassDeclaration,
ExportDefaultDeclaration,
FunctionDeclaration,
VariableDeclaration,
} from '@babel/types';
import type { NodePath } from '@babel/traverse';
import type { ComponentNode } from '../../resolver';

jest.mock('../../Documentation');

Expand DownExpand Up@@ -413,4 +415,115 @@ describe('componentMethodsHandler', () => {
expect(documentation.methods).toMatchSnapshot();
});
});

describe('useImperativeHandle() methods', () => {
// We're not worried about doc-blocks here, simply about finding method(s)
// defined via the useImperativeHandle() hook.
// To simplify the variations, each one ends up with the following in the
// parsed body:
//
// [0] : the initial definition/declaration
// [1] : a React.forwardRef wrapper (or nothing)
// [last]: the react import
//
// Note that in the cases where the React.forwardRef is used "inline" with
// the definition/declaration, there is no [1], and it will be skipped.
function testImperative(src, paths: Array<string | null> = [null]) {
const srcWithImport = `
${src}
import React, { useImperativeHandle } from "react";
`;

paths.forEach((path, index) => {
const parsed = parse.statement<VariableDeclaration>(
srcWithImport,
index,
);
const componentDefinition =
path != null
? (parsed.get(path) as NodePath<ComponentNode>)
: (parsed as unknown as NodePath<ComponentNode>);

// reset the documentation, since we may test more than once!
documentation = new Documentation() as Documentation & DocumentationMock;
componentMethodsHandler(documentation, componentDefinition);
expect(documentation.methods).toEqual([
{
docblock: null,
modifiers: [],
name: 'doFoo',
params: [],
returns: null,
},
]);
});
}

it('finds inside a component in a variable declaration', () => {
testImperative(
`
const Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
React.forwardRef(Test);
`,
['declarations.0.init', null],
);
});

it.only('finds inside a component in an assignment', () => {
testImperative(
`
Test = (props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
};
`,
['expression.right'],
);
});

it('finds inside a function declaration', () => {
testImperative(
`
function Test(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
}
React.forwardRef(Test);
`,
[null, null],
);
});

it('finds inside an inlined React.forwardRef call with arrow function', () => {
testImperative(
`
React.forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});

it('finds inside an inlined React.forwardRef call with plain function', () => {
testImperative(
`
React.forwardRef(function(props, ref) {
useImperativeHandle(ref, () => ({
doFoo: ()=>{},
}));
});
`,
[null],
);
});
});
});
133 changes: 131 additions & 2 deletions packages/react-docgen/src/handlers/componentMethodsHandler.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
import getMemberValuePath from '../utils/getMemberValuePath';
import type { MethodNodePath } from '../utils/getMethodDocumentation';
import getMethodDocumentation from '../utils/getMethodDocumentation';
import isReactBuiltinCall from '../utils/isReactBuiltinCall';
import isReactComponentClass from '../utils/isReactComponentClass';
import isReactComponentMethod from '../utils/isReactComponentMethod';
import isReactForwardRefCall from '../utils/isReactForwardRefCall';
import type Documentation from '../Documentation';
import { shallowIgnoreVisitors } from '../utils/traverse';
import resolveToValue from '../utils/resolveToValue';
import type { NodePath, Scope } from '@babel/traverse';
import { visitors } from '@babel/traverse';
import type { AssignmentExpression, Identifier } from '@babel/types';
import type { AssignmentExpression, Identifier, Property } from '@babel/types';
import type { ComponentNode } from '../resolver';
import type { Handler } from '.';

Expand DownExpand Up@@ -94,6 +96,121 @@ function findAssignedMethods(
return state.methods;
}

// Finding the component itself depends heavily on how it's exported.
// Conversely, finding any 'useImperativeHandle()' methods requires digging
// through intervening assignments, declarations, and optionally a
// React.forwardRef() call.
function findUnderlyingComponentDefinition(
componentDefinition: NodePath<ComponentNode>,
) {
let path: NodePath | null = componentDefinition;
let keepDigging = true;
let sawForwardRef = false;

// We can't use 'visit', because we're not necessarily climbing "down" the
// AST, we're following the logic flow *backwards* to the component
// definition. Once we do find what looks like the underlying functional
// component definition, *then* we can 'visit' downwards to find the call to
// useImperativeHandle, if it exists.
while (keepDigging && path) {
// Using resolveToValue automatically gets the "value" from things like
// assignments or identifier references. Putting this here removes the need
// to call it in a bunch of places on a per-type basis.
const value = resolveToValue(path);

if (value.isVariableDeclaration()) {
const decls: NodePath[] = value.get('declarations');

if (decls.length == 1) {
path = decls[0];
} else {
path = null;
}
} else if (value.isExpressionStatement()) {
path = value.get('expression');
} else if (value.isCallExpression()) {
if (isReactForwardRefCall(value) && !sawForwardRef) {
sawForwardRef = true;
path = value.get('arguments')[0];
} else {
path = null;
}
} else if (
value.isArrowFunctionExpression() ||
value.isFunctionDeclaration() ||
value.isFunctionExpression()
) {
if (value.isArrowFunctionExpression()) {
path = value.get('body');
} else if (value.isFunctionDeclaration()) {
path = value.get('body');
} else if (value.isFunctionExpression()) {
path = value.get('body');
}

keepDigging = false;
} else {
// Any other type causes us to bail.
path = null;
}
}

return path;
}

function findImperativeHandleMethods(
componentDefinition: NodePath<ComponentNode>,
): Array<NodePath<Property>> {
const path = findUnderlyingComponentDefinition(componentDefinition);

if (!path) {
return [];
}

const results: Array<NodePath<Property>> = [];

path.traverse({
CallExpression: function (callPath) {
// console.log('* call expression...');
// We're trying to handle calls to React's useImperativeHandle. If this
// isn't, we can stop visiting this node path immediately.
if (!isReactBuiltinCall(callPath, 'useImperativeHandle')) {
return false;
}

// The standard use (and documented example) is:
//
// useImperativeHandle(ref, () => ({ name: () => {}, ...}))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//
// ... so we only handle a second argument (index 1) that is an
// ArrowFunctionExpression and whose body is an ObjectExpression.
const arg = callPath.get('arguments')[1];

if (!arg.isArrowFunctionExpression()) {
return false;
}

const body = arg.get('body');

if (!body.isObjectExpression()) {
return false;
}

// We found the object body, now add all of the properties as methods.
body.get('properties').forEach(p => {
if (p.isObjectProperty()) {
results.push(p);
}
});

return false;
},
});

return results;
}

/**
* Extract all flow types for the methods of a react component. Doesn't
* return any react specific lifecycle methods.
Expand All@@ -103,7 +220,10 @@ const componentMethodsHandler: Handler = function (
componentDefinition: NodePath<ComponentNode>,
): void {
// Extract all methods from the class or object.
let methodPaths: Array<{ path: MethodNodePath; isStatic?: boolean }> = [];
let methodPaths: Array<{
path: MethodNodePath;
isStatic?: boolean;
}> = [];

if (isReactComponentClass(componentDefinition)) {
methodPaths = (
Expand DownExpand Up@@ -159,6 +279,15 @@ const componentMethodsHandler: Handler = function (
).map(p => ({ path: p }));
}

// Also look for any methods that come from useImperativeHandle() calls.
const impMethodPaths = findImperativeHandleMethods(componentDefinition);

if (impMethodPaths && impMethodPaths.length > 0) {
methodPaths = methodPaths.concat(
impMethodPaths.map(p => ({ path: p as MethodNodePath })),
);
}

documentation.set(
'methods',
methodPaths
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

type Props = {
align?: "left" | "center" | "right" | "justify"
};

/**
* This is a TypeScript function component
*/
function FancyInput(props: Props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
/** this is a method on a component */
focus: () => {
inputRef.current.focus()
}
}));
return <input ref={inputRef} />;
}

export default forwardRef(FancyInput);
Loading