Skip to content

Commit 5215bab

Browse files
authored
Add new resolver for finding annotated components (#743)
1 parent 826299d commit 5215bab

9 files changed

+656
-44
lines changed

.changeset/stupid-files-buy.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'react-docgen': minor
3+
---
4+
5+
Added a new resolver that finds annotated components. This resolver is also
6+
enabled by default.
7+
8+
To use this feature simply annotated a component with `@component`.
9+
10+
```ts
11+
// @component
12+
class MyComponent {}
13+
```

.changeset/thin-foxes-talk.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'react-docgen': major
3+
---
4+
5+
Removed support for the `@extends React.Component` annotation on react class
6+
components.
7+
8+
Instead you can use the new `@component` annotation.

packages/react-docgen/src/config.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
import type { Importer } from './importer/index.js';
1717
import { fsImporter } from './importer/index.js';
1818
import type { Resolver } from './resolver/index.js';
19-
import { FindExportedDefinitionsResolver } from './resolver/index.js';
19+
import {
20+
ChainResolver,
21+
FindAnnotatedDefinitionsResolver,
22+
FindExportedDefinitionsResolver,
23+
} from './resolver/index.js';
2024

2125
export interface Config {
2226
handlers?: Handler[];
@@ -32,8 +36,14 @@ export interface Config {
3236
}
3337
export type InternalConfig = Omit<Required<Config>, 'filename'>;
3438

35-
const defaultResolver: Resolver = new FindExportedDefinitionsResolver({
36-
limit: 1,
39+
const defaultResolvers: Resolver[] = [
40+
new FindExportedDefinitionsResolver({
41+
limit: 1,
42+
}),
43+
new FindAnnotatedDefinitionsResolver(),
44+
];
45+
const defaultResolver: Resolver = new ChainResolver(defaultResolvers, {
46+
chainingLogic: ChainResolver.Logic.ALL,
3747
});
3848
const defaultImporter: Importer = fsImporter;
3949

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import normalizeClassDefinition from '../utils/normalizeClassDefinition.js';
2+
import type { NodePath } from '@babel/traverse';
3+
import { visitors } from '@babel/traverse';
4+
import type FileState from '../FileState.js';
5+
import type { ComponentNodePath, ResolverClass } from './index.js';
6+
import type {
7+
ArrowFunctionExpression,
8+
ClassDeclaration,
9+
ClassExpression,
10+
FunctionDeclaration,
11+
FunctionExpression,
12+
ObjectMethod,
13+
} from '@babel/types';
14+
15+
interface TraverseState {
16+
foundDefinitions: Set<ComponentNodePath>;
17+
annotation: string;
18+
}
19+
20+
function isAnnotated(path: NodePath, annotation: string): boolean {
21+
let inspectPath: NodePath | null = path;
22+
23+
do {
24+
const leadingComments = inspectPath.node.leadingComments;
25+
26+
// If an export doesn't have leading comments, we can simply continue
27+
if (leadingComments && leadingComments.length > 0) {
28+
// Search for the annotation in any comment.
29+
const hasAnnotation = leadingComments.some(({ value }) =>
30+
value.includes(annotation),
31+
);
32+
33+
// if we found an annotation return true
34+
if (hasAnnotation) {
35+
return true;
36+
}
37+
}
38+
39+
// return false if the container of the current path is an array
40+
// as we do not want to traverse up through this kind of nodes, like ArrayExpressions for example
41+
// The only exception is variable declarations
42+
if (
43+
Array.isArray(inspectPath.container) &&
44+
!inspectPath.isVariableDeclarator() &&
45+
!inspectPath.parentPath?.isCallExpression()
46+
) {
47+
return false;
48+
}
49+
} while ((inspectPath = inspectPath.parentPath));
50+
51+
return false;
52+
}
53+
54+
function classVisitor(
55+
path: NodePath<ClassDeclaration | ClassExpression>,
56+
state: TraverseState,
57+
): void {
58+
if (isAnnotated(path, state.annotation)) {
59+
normalizeClassDefinition(path);
60+
state.foundDefinitions.add(path);
61+
}
62+
}
63+
64+
function statelessVisitor(
65+
path: NodePath<
66+
| ArrowFunctionExpression
67+
| FunctionDeclaration
68+
| FunctionExpression
69+
| ObjectMethod
70+
>,
71+
state: TraverseState,
72+
): void {
73+
if (isAnnotated(path, state.annotation)) {
74+
state.foundDefinitions.add(path);
75+
}
76+
}
77+
78+
const explodedVisitors = visitors.explode<TraverseState>({
79+
ArrowFunctionExpression: { enter: statelessVisitor },
80+
FunctionDeclaration: { enter: statelessVisitor },
81+
FunctionExpression: { enter: statelessVisitor },
82+
ObjectMethod: { enter: statelessVisitor },
83+
84+
ClassDeclaration: { enter: classVisitor },
85+
ClassExpression: { enter: classVisitor },
86+
});
87+
88+
interface FindAnnotatedDefinitionsResolverOptions {
89+
annotation?: string;
90+
}
91+
92+
/**
93+
* Given an AST, this function tries to find all react components which
94+
* are annotated with an annotation
95+
*/
96+
export default class FindAnnotatedDefinitionsResolver implements ResolverClass {
97+
annotation: string;
98+
99+
constructor({
100+
annotation = '@component',
101+
}: FindAnnotatedDefinitionsResolverOptions = {}) {
102+
this.annotation = annotation;
103+
}
104+
105+
resolve(file: FileState): ComponentNodePath[] {
106+
const state: TraverseState = {
107+
foundDefinitions: new Set<ComponentNodePath>(),
108+
annotation: this.annotation,
109+
};
110+
111+
file.traverse(explodedVisitors, state);
112+
113+
return Array.from(state.foundDefinitions);
114+
}
115+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import type { NodePath } from '@babel/traverse';
2+
import { parse, noopImporter } from '../../../tests/utils';
3+
import FindAnnotatedDefinitionsResolver from '../FindAnnotatedDefinitionsResolver.js';
4+
import { describe, expect, test } from 'vitest';
5+
6+
describe('FindAnnotatedDefinitionsResolver', () => {
7+
const resolver = new FindAnnotatedDefinitionsResolver();
8+
9+
function findComponentsInSource(
10+
source: string,
11+
importer = noopImporter,
12+
): NodePath[] {
13+
return resolver.resolve(parse(source, {}, importer, true));
14+
}
15+
16+
describe('class definitions', () => {
17+
test('finds ClassDeclaration with line comment', () => {
18+
const source = `
19+
// @component
20+
class Component {}
21+
`;
22+
23+
expect(findComponentsInSource(source)).toMatchSnapshot();
24+
});
25+
26+
test('finds ClassDeclaration with line comment and empty line', () => {
27+
const source = `
28+
// @component
29+
30+
class Component {}
31+
`;
32+
33+
expect(findComponentsInSource(source)).toMatchSnapshot();
34+
});
35+
36+
test('finds ClassDeclaration with block comment', () => {
37+
const source = `
38+
/* @component */
39+
class Component {}
40+
`;
41+
42+
expect(findComponentsInSource(source)).toMatchSnapshot();
43+
});
44+
45+
test('finds ClassDeclaration with block comment and empty line', () => {
46+
const source = `
47+
/* @component */
48+
49+
class Component {}
50+
`;
51+
52+
expect(findComponentsInSource(source)).toMatchSnapshot();
53+
});
54+
55+
test('finds ClassExpression', () => {
56+
const source = `
57+
// @component
58+
const Component = class {}
59+
`;
60+
61+
expect(findComponentsInSource(source)).toMatchSnapshot();
62+
});
63+
64+
test('finds ClassExpression with assignment', () => {
65+
const source = `
66+
let Component;
67+
// @component
68+
Component = class {}
69+
`;
70+
71+
expect(findComponentsInSource(source)).toMatchSnapshot();
72+
});
73+
74+
test('finds nothing when not annotated', () => {
75+
const source = `
76+
class ComponentA {}
77+
const ComponentB = class {}
78+
`;
79+
80+
const result = findComponentsInSource(source);
81+
82+
expect(Array.isArray(result)).toBe(true);
83+
expect(result.length).toBe(0);
84+
});
85+
});
86+
87+
describe('stateless components', () => {
88+
test('finds ArrowFunctionExpression', () => {
89+
const source = `
90+
// @component
91+
const Component = () => {};
92+
`;
93+
94+
expect(findComponentsInSource(source)).toMatchSnapshot();
95+
});
96+
97+
test('finds FunctionDeclaration', () => {
98+
const source = `
99+
// @component
100+
function Component() {}
101+
`;
102+
103+
expect(findComponentsInSource(source)).toMatchSnapshot();
104+
});
105+
106+
test('finds FunctionExpression', () => {
107+
const source = `
108+
// @component
109+
const Component = function() {};
110+
`;
111+
112+
expect(findComponentsInSource(source)).toMatchSnapshot();
113+
});
114+
115+
test('finds ObjectMethod', () => {
116+
const source = `
117+
const obj = {
118+
// @component
119+
component() {}
120+
};
121+
`;
122+
123+
expect(findComponentsInSource(source)).toMatchSnapshot();
124+
});
125+
126+
test('finds ObjectProperty', () => {
127+
const source = `
128+
const obj = {
129+
// @component
130+
component: function() {}
131+
};
132+
`;
133+
134+
expect(findComponentsInSource(source)).toMatchSnapshot();
135+
});
136+
137+
test('finds nothing when not annotated', () => {
138+
const source = `
139+
const ComponentA = () => {};
140+
function ComponentB() {}
141+
const ComponentC = function() {};
142+
const obj = { component() {} };
143+
const obj2 = { component: function() {} };
144+
`;
145+
146+
const result = findComponentsInSource(source);
147+
148+
expect(Array.isArray(result)).toBe(true);
149+
expect(result.length).toBe(0);
150+
});
151+
});
152+
153+
test('finds component wrapped in HOC', () => {
154+
const source = `
155+
// @component
156+
const Component = React.memo(() => {});
157+
`;
158+
159+
expect(findComponentsInSource(source)).toMatchSnapshot();
160+
});
161+
162+
test('finds component wrapped in two HOCs', () => {
163+
const source = `
164+
// @component
165+
const Component = React.memo(otherHOC(() => {}));
166+
`;
167+
168+
expect(findComponentsInSource(source)).toMatchSnapshot();
169+
});
170+
171+
test('finds component wrapped in function', () => {
172+
const source = `
173+
function X () {
174+
// @component
175+
const subcomponent = class {}
176+
}
177+
`;
178+
179+
expect(findComponentsInSource(source)).toMatchSnapshot();
180+
});
181+
182+
test('does not traverse up ArrayExpressions', () => {
183+
const source = `
184+
// @component
185+
const arr = [
186+
function() {},
187+
function() {}
188+
]
189+
`;
190+
191+
const result = findComponentsInSource(source);
192+
193+
expect(Array.isArray(result)).toBe(true);
194+
expect(result.length).toBe(0);
195+
});
196+
197+
test('does not traverse up function parameters', () => {
198+
const source = `
199+
// @component
200+
function x(component = () => {}) {}
201+
`;
202+
203+
const result = findComponentsInSource(source);
204+
205+
expect(Array.isArray(result)).toBe(true);
206+
expect(result.length).toBe(1);
207+
expect(result[0].type).not.toBe('ArrowFunctionExpression');
208+
});
209+
210+
test('finds function parameter with annotation', () => {
211+
const source = `
212+
function x(component = /* @component */() => {}) {}
213+
`;
214+
215+
expect(findComponentsInSource(source)).toMatchSnapshot();
216+
});
217+
});

0 commit comments

Comments
 (0)