diff --git a/packages/react-docgen/src/handlers/__tests__/__snapshots__/codeTypeHandler-test.ts.snap b/packages/react-docgen/src/handlers/__tests__/__snapshots__/codeTypeHandler-test.ts.snap index 58dfd645a8c..feb9fe95148 100644 --- a/packages/react-docgen/src/handlers/__tests__/__snapshots__/codeTypeHandler-test.ts.snap +++ b/packages/react-docgen/src/handlers/__tests__/__snapshots__/codeTypeHandler-test.ts.snap @@ -1655,7 +1655,7 @@ exports[`codeTypeHandler > stateless TS component and variable type takes preced "description": "", "required": true, "tsType": { - "name": "string", + "name": "number | string", }, }, } diff --git a/packages/react-docgen/src/handlers/__tests__/codeTypeHandler-test.ts b/packages/react-docgen/src/handlers/__tests__/codeTypeHandler-test.ts index 3bdfb497269..2f910688709 100644 --- a/packages/react-docgen/src/handlers/__tests__/codeTypeHandler-test.ts +++ b/packages/react-docgen/src/handlers/__tests__/codeTypeHandler-test.ts @@ -262,7 +262,7 @@ describe('codeTypeHandler', () => { expect(documentation.descriptors).toMatchSnapshot(); }); - test('does not support union proptypes', () => { + test('does support union proptypes', () => { const definition = parse .statement( `(props: Props) =>
; @@ -274,7 +274,24 @@ describe('codeTypeHandler', () => { .get('expression') as NodePath; expect(() => codeTypeHandler(documentation, definition)).not.toThrow(); - expect(documentation.descriptors).toEqual({}); + expect(documentation.descriptors).toEqual({ + bar: { + required: true, + flowType: { + name: 'literal', + value: "'barValue'", + }, + description: '', + }, + foo: { + required: true, + flowType: { + name: 'literal', + value: "'fooValue'", + }, + description: '', + }, + }); }); describe('imported prop types', () => { diff --git a/packages/react-docgen/src/handlers/codeTypeHandler.ts b/packages/react-docgen/src/handlers/codeTypeHandler.ts index 75310b4b3d8..f008a9ba8e7 100644 --- a/packages/react-docgen/src/handlers/codeTypeHandler.ts +++ b/packages/react-docgen/src/handlers/codeTypeHandler.ts @@ -13,6 +13,7 @@ import type { NodePath } from '@babel/traverse'; import type { FlowType } from '@babel/types'; import type { ComponentNode } from '../resolver/index.js'; import type { Handler } from './index.js'; +import mergeTSIntersectionTypes from '../utils/mergeTSIntersectionTypes.js'; function setPropDescriptor( documentation: Documentation, @@ -80,15 +81,30 @@ function setPropDescriptor( return; } const type = getTSType(typeAnnotation, typeParams); - const propName = getPropertyName(path); if (!propName) return; const propDescriptor = documentation.getPropDescriptor(propName); - propDescriptor.required = !path.node.optional; - propDescriptor.tsType = type; + if (propDescriptor.tsType) { + const mergedType = mergeTSIntersectionTypes( + { + name: propDescriptor.tsType.name, + required: propDescriptor.required, + }, + { + name: type.name, + required: !path.node.optional, + }, + ); + + propDescriptor.tsType.name = mergedType.name; + propDescriptor.required = mergedType.required; + } else { + propDescriptor.tsType = type; + propDescriptor.required = !path.node.optional; + } // We are doing this here instead of in a different handler // to not need to duplicate the logic for checking for diff --git a/packages/react-docgen/src/utils/__tests__/__snapshots__/getTSType-test.ts.snap b/packages/react-docgen/src/utils/__tests__/__snapshots__/getTSType-test.ts.snap index 22e588f60e3..1479e58e7ab 100644 --- a/packages/react-docgen/src/utils/__tests__/__snapshots__/getTSType-test.ts.snap +++ b/packages/react-docgen/src/utils/__tests__/__snapshots__/getTSType-test.ts.snap @@ -125,6 +125,36 @@ exports[`getTSType > can resolve indexed access to imported type 1`] = ` } `; +exports[`getTSType > deep resolve intersection types 1`] = ` +{ + "elements": [ + { + "key": "name", + "value": { + "name": "string", + "required": true, + }, + }, + { + "key": "a", + "value": { + "name": "number", + "required": true, + }, + }, + { + "key": "b", + "value": { + "name": "string", + "required": false, + }, + }, + ], + "name": "intersection", + "raw": "{ name: string } & (MyType | MySecondType)", +} +`; + exports[`getTSType > detects array type 1`] = ` { "elements": [ diff --git a/packages/react-docgen/src/utils/__tests__/getTSType-test.ts b/packages/react-docgen/src/utils/__tests__/getTSType-test.ts index 0413d2b8503..9e4659d3146 100644 --- a/packages/react-docgen/src/utils/__tests__/getTSType-test.ts +++ b/packages/react-docgen/src/utils/__tests__/getTSType-test.ts @@ -70,6 +70,12 @@ const mockImporter = makeMockImporter({ true, ).get('declaration') as NodePath, + MySecondType: (stmtLast) => + stmtLast( + `export type MySecondType = { a: number, b?: never };`, + true, + ).get('declaration') as NodePath, + MyGenericType: (stmtLast) => stmtLast( `export type MyGenericType = { a: T, b: Array };`, @@ -501,6 +507,20 @@ describe('getTSType', () => { expect(getTSType(typePath)).toMatchSnapshot(); }); + test('deep resolve intersection types', () => { + const typePath = typeAlias( + ` + const x: SuperType = {}; + import { MyType } from 'MyType'; + import { MySecondType } from 'MySecondType'; + type SuperType = { name: string } & (MyType | MySecondType); + `, + mockImporter, + ); + + expect(getTSType(typePath)).toMatchSnapshot(); + }); + test('resolves typeof of import type', () => { const typePath = typeAlias( "var x: typeof import('MyType') = {};", diff --git a/packages/react-docgen/src/utils/__tests__/mergeTSIntersectionTypes-test.ts b/packages/react-docgen/src/utils/__tests__/mergeTSIntersectionTypes-test.ts new file mode 100644 index 00000000000..1430aa12b92 --- /dev/null +++ b/packages/react-docgen/src/utils/__tests__/mergeTSIntersectionTypes-test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; +import mergeTSIntersectionTypes from '../mergeTSIntersectionTypes.js'; + +describe('mergeTSIntersectionTypes', () => { + test('it merges two types correctly', () => { + const mergedType = mergeTSIntersectionTypes( + { + name: 'string', + required: true, + }, + { + name: 'number', + required: true, + }, + ); + + expect(mergedType).toEqual({ + name: 'string | number', + required: true, + }); + }); + + test('it ignores types of "never"', () => { + const mergedType = mergeTSIntersectionTypes( + { + name: 'string', + required: true, + }, + { + name: 'never', + required: true, + }, + ); + + expect(mergedType).toEqual({ + name: 'string', + required: true, + }); + }); + + test('if one of the types is "unknown", it overrides all other types', () => { + const mergedType = mergeTSIntersectionTypes( + { + name: 'string', + required: true, + }, + { + name: 'unknown', + required: true, + }, + ); + + expect(mergedType).toEqual({ + name: 'unknown', + required: true, + }); + }); + + test('if one of the types is NOT required, the merged one is NOT required too', () => { + const mergedType = mergeTSIntersectionTypes( + { + name: 'string', + required: true, + }, + { + name: 'number', + required: false, + }, + ); + + expect(mergedType).toEqual({ + name: 'string | number', + required: false, + }); + }); +}); diff --git a/packages/react-docgen/src/utils/getTSType.ts b/packages/react-docgen/src/utils/getTSType.ts index c938bf9470b..d1f2fd80fbf 100644 --- a/packages/react-docgen/src/utils/getTSType.ts +++ b/packages/react-docgen/src/utils/getTSType.ts @@ -36,8 +36,10 @@ import type { TypeScript, TSQualifiedName, TSLiteralType, + TSParenthesizedType, } from '@babel/types'; import { getDocblock } from './docblock.js'; +import mergeTSIntersectionTypes from './mergeTSIntersectionTypes.js'; const tsTypes: Record = { TSAnyKeyword: 'any', @@ -69,6 +71,7 @@ const namedTypes: Record< TSUnionType: handleTSUnionType, TSFunctionType: handleTSFunctionType, TSIntersectionType: handleTSIntersectionType, + TSParenthesizedType: handleTSParenthesizedType, TSMappedType: handleTSMappedType, TSTupleType: handleTSTupleType, TSTypeQuery: handleTSTypeQuery, @@ -127,8 +130,7 @@ function handleTSTypeReference( } const resolvedPath = - (typeParams && typeParams[type.name]) || - resolveToValue(path.get('typeName')); + (typeParams && typeParams[type.name]) || resolveToValue(typeName); const typeParameters = path.get('typeParameters'); const resolvedTypeParameters = resolvedPath.get('typeParameters') as NodePath< @@ -267,19 +269,109 @@ function handleTSUnionType( }; } +function handleTSParenthesizedType( + path: NodePath, + typeParams: TypeParameters | null, +): ElementsType { + const innerTypePath = path.get('typeAnnotation'); + const resolvedType = getTSTypeWithResolvedTypes(innerTypePath, typeParams); + + return { + name: 'parenthesized', + raw: printValue(path), + elements: Array.isArray(resolvedType) ? resolvedType : [resolvedType], + }; +} + +interface PropertyWithKey { + key: TypeDescriptor | string; + value: TypeDescriptor; + description?: string | undefined; +} + function handleTSIntersectionType( path: NodePath, typeParams: TypeParameters | null, ): ElementsType { + const resolvedTypes = path + .get('types') + .map((subTypePath) => getTSTypeWithResolvedTypes(subTypePath, typeParams)); + + let elements: Array> = []; + + resolvedTypes.forEach((resolvedType) => { + switch (resolvedType.name) { + default: + case 'signature': + elements.push(resolvedType); + break; + case 'parenthesized': { + if ('elements' in resolvedType && resolvedType.elements[0]) { + const firstElement = resolvedType.elements[0]; + + if (firstElement && 'elements' in firstElement) { + elements = [...elements, ...firstElement.elements]; + } + } + break; + } + } + }); + + const elementsDedup: PropertyWithKey[] = []; + + // dedup elements + elements.forEach((element) => { + if (hasSignature(element)) { + const { signature } = element; + + if (hasProperties(signature)) { + signature.properties.forEach((property) => { + const existingIndex = elementsDedup.findIndex( + ({ key }) => key === property.key, + ); + + if (existingIndex === -1) { + elementsDedup.push(property); + } else { + const existingProperty = elementsDedup[existingIndex]; + + if (existingProperty) { + elementsDedup[existingIndex] = { + key: property.key, + value: mergeTSIntersectionTypes( + existingProperty.value, + property.value, + ), + }; + } + } + }); + } + } else { + elementsDedup.push(element as unknown as PropertyWithKey); + } + }); + return { name: 'intersection', raw: printValue(path), - elements: path - .get('types') - .map((subType) => getTSTypeWithResolvedTypes(subType, typeParams)), + elements: elementsDedup as unknown as Array< + TypeDescriptor + >, }; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function hasSignature(element: any): element is { signature: unknown } { + return 'signature' in element; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function hasProperties(element: any): element is { properties: unknown } { + return 'properties' in element; +} + // type OptionsFlags = { [Property in keyof Type]; }; function handleTSMappedType( path: NodePath, diff --git a/packages/react-docgen/src/utils/getTypeFromReactComponent.ts b/packages/react-docgen/src/utils/getTypeFromReactComponent.ts index b0241c121a0..b1c7f8e87ca 100644 --- a/packages/react-docgen/src/utils/getTypeFromReactComponent.ts +++ b/packages/react-docgen/src/utils/getTypeFromReactComponent.ts @@ -162,9 +162,28 @@ export function applyToTypeProperties( (typesPath) => applyToTypeProperties(documentation, typesPath, callback, typeParams), ); - } else if (!path.isUnionTypeAnnotation()) { - // The react-docgen output format does not currently allow - // for the expression of union types + } else if (path.isParenthesizedExpression() || path.isTSParenthesizedType()) { + const typeAnnotation = path.get('typeAnnotation'); + const typeAnnotationPath = Array.isArray(typeAnnotation) + ? typeAnnotation[0] + : typeAnnotation; + + if (typeAnnotationPath) { + applyToTypeProperties( + documentation, + typeAnnotationPath, + callback, + typeParams, + ); + } + } else if (path.isUnionTypeAnnotation() || path.isTSUnionType()) { + const typeNodes = path.get('types'); + const types = Array.isArray(typeNodes) ? typeNodes : [typeNodes]; + + types.forEach((typesPath) => + applyToTypeProperties(documentation, typesPath, callback, typeParams), + ); + } else { const typePath = resolveGenericTypeAnnotation(path); if (typePath) { diff --git a/packages/react-docgen/src/utils/mergeTSIntersectionTypes.ts b/packages/react-docgen/src/utils/mergeTSIntersectionTypes.ts new file mode 100644 index 00000000000..41a88ab64b3 --- /dev/null +++ b/packages/react-docgen/src/utils/mergeTSIntersectionTypes.ts @@ -0,0 +1,60 @@ +import type { + TSFunctionSignatureType, + TypeDescriptor, +} from '../Documentation.js'; + +/** + * Merges two TSFunctionSignatureType types into one. + * + * @example + * const existingType = { + * "key": "children", + * "value": { + * "name": "ReactNode", + * "required": true, + * }, + * }; + * const newType = { + * "key": "children", + * "value": { + * "name": "never", + * "required": false, + * }, + * }; + * + * return { + * "key": "children", + * "value": { + * "name": "ReactNode", + * "required": false, + * }, + * }; + */ +export default ( + existingType: TypeDescriptor, + newType: TypeDescriptor, +): TypeDescriptor => { + const required = + newType.required === false || existingType.required === false + ? false + : existingType.required; + + const existingTypesArray = existingType.name.split('|').map((t) => t.trim()); + const existingTypes = new Set(existingTypesArray); + + if (!['never'].includes(newType.name)) { + existingTypes.add(newType.name); + } + + if (existingType.name === 'unknown' || newType.name === 'unknown') { + return { + name: 'unknown', + required, + }; + } + + return { + name: Array.from(existingTypes).join(' | '), + required, + }; +};