Skip to content

Commit ea25b16

Browse files
authored
Handle cycle in PropTypes shapeish validators (#748)
1 parent bca73e9 commit ea25b16

File tree

5 files changed

+191
-68
lines changed

5 files changed

+191
-68
lines changed

.changeset/seven-camels-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-docgen': patch
3+
---
4+
5+
Handle cyclic references in PropTypes `shape()` and `exact()` methods.

packages/react-docgen/src/utils/__tests__/__snapshots__/getPropType-test.ts.snap

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,31 @@ exports[`getPropType > resolve identifier to their values > resolves variables t
369369
},
370370
}
371371
`;
372+
373+
exports[`getPropType > works with cyclic references in shape 1`] = `
374+
{
375+
"name": "shape",
376+
"value": "Component.propTypes",
377+
}
378+
`;
379+
380+
exports[`getPropType > works with cyclic references in shape and required 1`] = `
381+
{
382+
"name": "shape",
383+
"value": "Component.propTypes",
384+
}
385+
`;
386+
387+
exports[`getPropType > works with missing argument 1`] = `
388+
{
389+
"name": "shape",
390+
"value": {
391+
"foo": {
392+
"computed": true,
393+
"name": "shape",
394+
"required": false,
395+
"value": "",
396+
},
397+
},
398+
}
399+
`;

packages/react-docgen/src/utils/__tests__/getPropType-test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ExpressionStatement } from '@babel/types';
22
import { parse, makeMockImporter } from '../../../tests/utils';
33
import getPropType from '../getPropType.js';
44
import { describe, expect, test } from 'vitest';
5+
import type { NodePath } from '@babel/traverse';
56

67
describe('getPropType', () => {
78
test('detects simple prop types', () => {
@@ -532,4 +533,50 @@ describe('getPropType', () => {
532533
),
533534
).toMatchSnapshot();
534535
});
536+
537+
test('works with cyclic references in shape', () => {
538+
expect(
539+
getPropType(
540+
parse
541+
.statementLast<ExpressionStatement>(
542+
`const Component = () => {}
543+
Component.propTypes = {
544+
foo: shape(Component.propTypes)
545+
}`,
546+
)
547+
.get('expression.right.properties.0.value') as NodePath,
548+
),
549+
).toMatchSnapshot();
550+
});
551+
552+
test('works with cyclic references in shape and required', () => {
553+
expect(
554+
getPropType(
555+
parse
556+
.statementLast<ExpressionStatement>(
557+
`const Component = () => {}
558+
Component.propTypes = {
559+
foo: shape(Component.propTypes).isRequired
560+
}`,
561+
)
562+
.get('expression.right.properties.0.value') as NodePath,
563+
),
564+
).toMatchSnapshot();
565+
});
566+
567+
test('works with missing argument', () => {
568+
expect(
569+
getPropType(
570+
parse
571+
.statementLast<ExpressionStatement>(
572+
`const Component = () => {}
573+
const MyShape = { foo: shape() }
574+
Component.propTypes = {
575+
foo: shape(MyShape)
576+
}`,
577+
)
578+
.get('expression.right.properties.0.value') as NodePath,
579+
),
580+
).toMatchSnapshot();
581+
});
535582
});

packages/react-docgen/src/utils/getPropType.ts

Lines changed: 110 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/*eslint no-use-before-define: 0*/
21
import type { NodePath } from '@babel/traverse';
32
import { getDocblock } from '../utils/docblock.js';
43
import getMembers from './getMembers.js';
@@ -8,13 +7,8 @@ import printValue from './printValue.js';
87
import resolveToValue from './resolveToValue.js';
98
import resolveObjectKeysToArray from './resolveObjectKeysToArray.js';
109
import resolveObjectValuesToArray from './resolveObjectValuesToArray.js';
11-
import type { PropTypeDescriptor, PropDescriptor } from '../Documentation.js';
12-
import type {
13-
ArrayExpression,
14-
Expression,
15-
ObjectProperty,
16-
SpreadElement,
17-
} from '@babel/types';
10+
import type { PropTypeDescriptor } from '../Documentation.js';
11+
import type { ArrayExpression, Expression, SpreadElement } from '@babel/types';
1812

1913
function getEnumValuesFromArrayExpression(
2014
path: NodePath<ArrayExpression>,
@@ -51,11 +45,15 @@ function getEnumValuesFromArrayExpression(
5145
return values;
5246
}
5347

54-
function getPropTypeOneOf(argumentPath: NodePath): PropTypeDescriptor {
55-
const type: PropTypeDescriptor = { name: 'enum' };
56-
const value: NodePath = resolveToValue(argumentPath);
48+
function getPropTypeOneOf(
49+
type: PropTypeDescriptor,
50+
argumentPath: NodePath,
51+
): PropTypeDescriptor {
52+
const value = resolveToValue(argumentPath);
5753

58-
if (!value.isArrayExpression()) {
54+
if (value.isArrayExpression()) {
55+
type.value = getEnumValuesFromArrayExpression(value);
56+
} else {
5957
const objectValues =
6058
resolveObjectKeysToArray(value) || resolveObjectValuesToArray(value);
6159

@@ -69,20 +67,16 @@ function getPropTypeOneOf(argumentPath: NodePath): PropTypeDescriptor {
6967
type.computed = true;
7068
type.value = printValue(argumentPath);
7169
}
72-
} else {
73-
type.value = getEnumValuesFromArrayExpression(value);
7470
}
7571

7672
return type;
7773
}
7874

79-
function getPropTypeOneOfType(argumentPath: NodePath): PropTypeDescriptor {
80-
const type: PropTypeDescriptor = { name: 'union' };
81-
82-
if (!argumentPath.isArrayExpression()) {
83-
type.computed = true;
84-
type.value = printValue(argumentPath);
85-
} else {
75+
function getPropTypeOneOfType(
76+
type: PropTypeDescriptor,
77+
argumentPath: NodePath,
78+
): PropTypeDescriptor {
79+
if (argumentPath.isArrayExpression()) {
8680
type.value = argumentPath.get('elements').map((elementPath) => {
8781
if (!elementPath.hasNode()) return;
8882
const descriptor: PropTypeDescriptor = getPropType(elementPath);
@@ -101,9 +95,7 @@ function getPropTypeOneOfType(argumentPath: NodePath): PropTypeDescriptor {
10195
return type;
10296
}
10397

104-
function getPropTypeArrayOf(argumentPath: NodePath) {
105-
const type: PropTypeDescriptor = { name: 'arrayOf' };
106-
98+
function getPropTypeArrayOf(type: PropTypeDescriptor, argumentPath: NodePath) {
10799
const docs = getDocblock(argumentPath);
108100

109101
if (docs) {
@@ -113,19 +105,14 @@ function getPropTypeArrayOf(argumentPath: NodePath) {
113105
const subType = getPropType(argumentPath);
114106

115107
// @ts-ignore
116-
if (subType.name === 'unknown') {
117-
type.value = printValue(argumentPath);
118-
type.computed = true;
119-
} else {
108+
if (subType.name !== 'unknown') {
120109
type.value = subType;
121110
}
122111

123112
return type;
124113
}
125114

126-
function getPropTypeObjectOf(argumentPath: NodePath) {
127-
const type: PropTypeDescriptor = { name: 'objectOf' };
128-
115+
function getPropTypeObjectOf(type: PropTypeDescriptor, argumentPath: NodePath) {
129116
const docs = getDocblock(argumentPath);
130117

131118
if (docs) {
@@ -135,63 +122,87 @@ function getPropTypeObjectOf(argumentPath: NodePath) {
135122
const subType = getPropType(argumentPath);
136123

137124
// @ts-ignore
138-
if (subType.name === 'unknown') {
139-
type.value = printValue(argumentPath);
140-
type.computed = true;
141-
} else {
125+
if (subType.name !== 'unknown') {
142126
type.value = subType;
143127
}
144128

145129
return type;
146130
}
147131

132+
function getFirstArgument(path: NodePath): NodePath | undefined {
133+
let argument: NodePath | undefined;
134+
135+
if (path.isCallExpression()) {
136+
argument = path.get('arguments')[0];
137+
} else {
138+
const members = getMembers(path, true);
139+
140+
if (members[0] && members[0].argumentPaths[0]) {
141+
argument = members[0].argumentPaths[0];
142+
}
143+
}
144+
145+
return argument;
146+
}
147+
148+
function isCyclicReference(
149+
argument: NodePath,
150+
argumentPath: NodePath,
151+
): boolean {
152+
return Boolean(argument && resolveToValue(argument) === argumentPath);
153+
}
154+
148155
/**
149156
* Handles shape and exact prop types
150157
*/
151-
function getPropTypeShapish(name: 'exact' | 'shape', argumentPath: NodePath) {
152-
const type: PropTypeDescriptor = { name };
153-
158+
function getPropTypeShapish(type: PropTypeDescriptor, argumentPath: NodePath) {
154159
if (!argumentPath.isObjectExpression()) {
155160
argumentPath = resolveToValue(argumentPath);
156161
}
157162

158163
if (argumentPath.isObjectExpression()) {
159-
const value = {};
164+
let value: Record<string, PropTypeDescriptor> | string = {};
160165

161166
argumentPath.get('properties').forEach((propertyPath) => {
162-
if (propertyPath.isSpreadElement() || propertyPath.isObjectMethod()) {
163-
// It is impossible to resolve a name for a spread element
164-
return;
165-
}
167+
// We only handle ObjectProperty as there is nothing to handle for
168+
// SpreadElements and ObjectMethods
169+
if (propertyPath.isObjectProperty()) {
170+
const propertyName = getPropertyName(propertyPath);
166171

167-
const propertyName = getPropertyName(propertyPath);
172+
if (!propertyName) return;
168173

169-
if (!propertyName) return;
174+
const valuePath = propertyPath.get('value');
175+
const argument = getFirstArgument(valuePath);
170176

171-
const valuePath = (propertyPath as NodePath<ObjectProperty>).get('value');
177+
// This indicates we have a cyclic reference in the shape
178+
// In this case we simply print the argument to shape and bail
179+
if (argument && isCyclicReference(argument, argumentPath)) {
180+
value = printValue(argument);
172181

173-
const descriptor: PropDescriptor | PropTypeDescriptor =
174-
getPropType(valuePath);
175-
const docs = getDocblock(propertyPath);
182+
return;
183+
}
176184

177-
if (docs) {
178-
descriptor.description = docs;
185+
const descriptor = getPropType(valuePath);
186+
const docs = getDocblock(propertyPath);
187+
188+
if (docs) {
189+
descriptor.description = docs;
190+
}
191+
descriptor.required = isRequiredPropType(valuePath);
192+
value[propertyName] = descriptor;
179193
}
180-
descriptor.required = isRequiredPropType(valuePath);
181-
value[propertyName] = descriptor;
182194
});
183-
type.value = value;
184-
}
185195

186-
if (!type.value) {
187-
type.value = printValue(argumentPath);
188-
type.computed = true;
196+
type.value = value;
189197
}
190198

191199
return type;
192200
}
193201

194-
function getPropTypeInstanceOf(argumentPath: NodePath): PropTypeDescriptor {
202+
function getPropTypeInstanceOf(
203+
_type: PropTypeDescriptor,
204+
argumentPath: NodePath,
205+
): PropTypeDescriptor {
195206
return {
196207
name: 'instanceOf',
197208
value: printValue(argumentPath),
@@ -218,16 +229,47 @@ function isSimplePropType(
218229
return simplePropTypes.includes(name as (typeof simplePropTypes)[number]);
219230
}
220231

221-
const propTypes = new Map<string, (path: NodePath) => PropTypeDescriptor>([
222-
['oneOf', getPropTypeOneOf],
223-
['oneOfType', getPropTypeOneOfType],
224-
['instanceOf', getPropTypeInstanceOf],
225-
['arrayOf', getPropTypeArrayOf],
226-
['objectOf', getPropTypeObjectOf],
227-
['shape', getPropTypeShapish.bind(null, 'shape')],
228-
['exact', getPropTypeShapish.bind(null, 'exact')],
232+
type PropTypeHandler = (
233+
type: PropTypeDescriptor,
234+
argumentPath: NodePath,
235+
) => PropTypeDescriptor;
236+
237+
const propTypes = new Map<
238+
string,
239+
(argumentPath: NodePath | undefined) => PropTypeDescriptor
240+
>([
241+
['oneOf', callPropTypeHandler.bind(null, 'enum', getPropTypeOneOf)],
242+
['oneOfType', callPropTypeHandler.bind(null, 'union', getPropTypeOneOfType)],
243+
[
244+
'instanceOf',
245+
callPropTypeHandler.bind(null, 'instanceOf', getPropTypeInstanceOf),
246+
],
247+
['arrayOf', callPropTypeHandler.bind(null, 'arrayOf', getPropTypeArrayOf)],
248+
['objectOf', callPropTypeHandler.bind(null, 'objectOf', getPropTypeObjectOf)],
249+
['shape', callPropTypeHandler.bind(null, 'shape', getPropTypeShapish)],
250+
['exact', callPropTypeHandler.bind(null, 'exact', getPropTypeShapish)],
229251
]);
230252

253+
function callPropTypeHandler(
254+
name: PropTypeDescriptor['name'],
255+
handler: PropTypeHandler,
256+
argumentPath: NodePath | undefined,
257+
) {
258+
let type: PropTypeDescriptor = { name };
259+
260+
if (argumentPath) {
261+
type = handler(type, argumentPath);
262+
}
263+
264+
if (!type.value) {
265+
// If there is no argument then leave the value an empty string
266+
type.value = argumentPath ? printValue(argumentPath) : '';
267+
type.computed = true;
268+
}
269+
270+
return type;
271+
}
272+
231273
/**
232274
* Tries to identify the prop type by inspecting the path for known
233275
* prop type names. This method doesn't check whether the found type is actually
@@ -256,7 +298,7 @@ export default function getPropType(path: NodePath): PropTypeDescriptor {
256298
}
257299
const propTypeHandler = propTypes.get(name);
258300

259-
if (propTypeHandler && member.argumentPaths.length) {
301+
if (propTypeHandler) {
260302
descriptor = propTypeHandler(member.argumentPaths[0]);
261303

262304
return true;

tsconfig.base.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"strict": true,
99
"noImplicitAny": false,
1010
"noImplicitReturns": true,
11+
"noUncheckedIndexedAccess": false,
1112
"noUnusedLocals": true,
1213
"noUnusedParameters": true,
1314
"moduleResolution": "node16",

0 commit comments

Comments
 (0)