Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/tidy-readonly-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'react-docgen': patch
---

Extract component prop documentation from the builtin TypeScript
`Readonly<Props>` wrapper without unwrapping locally declared types named
`Readonly`.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ import { describe, expect, test } from 'vitest';
import type { NodePath } from '@babel/traverse';

describe('unwrapBuiltinTSPropTypes', () => {
test.each(['Readonly<Props>', 'Readonly<Readonly<Props>>'])(
'unwraps the builtin %s',
(type) => {
const path = parseTypescript
.statementLast<VariableDeclaration>(`var foo: ${type}`)
.get('declarations.0.id.typeAnnotation.typeAnnotation');
const result = unwrapBuiltinTSPropTypes(path);

expect(result.isTSTypeReference()).toBe(true);
if (result.isTSTypeReference()) {
expect(result.get('typeName').isIdentifier({ name: 'Props' })).toBe(
true,
);
}
},
);

test.each([
'type Readonly<T> = { local: T };',
'interface Readonly<T> { local: T }',
"import { Readonly } from './types';",
])('preserves a locally declared Readonly: %s', (declaration) => {
const path = parseTypescript
.statementLast<VariableDeclaration>(
`${declaration} var foo: Readonly<Props>`,
)
.get('declarations.0.id.typeAnnotation.typeAnnotation');

expect(unwrapBuiltinTSPropTypes(path)).toBe(path);
});

test('preserves Readonly without type parameters', () => {
const path = parseTypescript
.statementLast<VariableDeclaration>('var foo: Readonly')
.get('declarations.0.id.typeAnnotation.typeAnnotation');

expect(unwrapBuiltinTSPropTypes(path)).toBe(path);
});

test('React.PropsWithChildren', () => {
const path = parseTypescript
.statementLast<VariableDeclaration>(
Expand Down
5 changes: 4 additions & 1 deletion packages/react-docgen/src/utils/unwrapBuiltinTSPropTypes.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { NodePath } from '@babel/traverse';
import isReactBuiltinReference from './isReactBuiltinReference.js';
import resolveToValue from './resolveToValue.js';

/**
* Unwraps NodePaths from the builtin TS types `PropsWithoutRef`,
* `PropsWithRef` and `PropsWithChildren` and returns the inner type param.
* `PropsWithRef`, `PropsWithChildren` and the global `Readonly` utility.
* If none of the builtin types is detected the path is returned as-is
*/
export default function unwrapBuiltinTSPropTypes(typePath: NodePath): NodePath {
if (typePath.isTSTypeReference()) {
const typeName = typePath.get('typeName');

if (
(typeName.isIdentifier({ name: 'Readonly' }) &&
resolveToValue(typeName) === typeName) ||
isReactBuiltinReference(typeName, 'PropsWithoutRef') ||
isReactBuiltinReference(typeName, 'PropsWithRef') ||
isReactBuiltinReference(typeName, 'PropsWithChildren')
Expand Down
23 changes: 23 additions & 0 deletions packages/react-docgen/tests/integration/integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ import { parse, makeFsImporter } from '../../src/main';
import { fileURLToPath } from 'url';

describe('integration', () => {
test('extracts props from the builtin Readonly wrapper', () => {
const result = parse(
`interface Props {
/** The displayed label. */
label: string;
disabled?: boolean;
}
export default function Example(props: Readonly<Props>) {
return <div>{props.label}</div>;
}`,
{ babelOptions: { filename: 'readonly.tsx', babelrc: false } },
);

expect(result[0]?.props).toMatchObject({
label: {
required: true,
tsType: { name: 'string' },
description: 'The displayed label.',
},
disabled: { required: false, tsType: { name: 'boolean' } },
});
});

describe('fixtures', () => {
const fixturePath = join(
dirname(fileURLToPath(import.meta.url)),
Expand Down