From 4f9ab8deb414e4d236006a5984b14c1d5f70ee8a Mon Sep 17 00:00:00 2001 From: "yuxuan.weng" Date: Fri, 23 Oct 2026 13:47:06 +0800 Subject: [PATCH] fix: extract props from builtin Readonly wrappers ref:0 --- .changeset/tidy-readonly-props.md | 7 ++++ .../unwrapBuiltinTSPropTypes-test.ts | 39 +++++++++++++++++++ .../src/utils/unwrapBuiltinTSPropTypes.ts | 5 ++- .../tests/integration/integration-test.ts | 23 +++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-readonly-props.md diff --git a/.changeset/tidy-readonly-props.md b/.changeset/tidy-readonly-props.md new file mode 100644 index 00000000000..a80f32d5f85 --- /dev/null +++ b/.changeset/tidy-readonly-props.md @@ -0,0 +1,7 @@ +--- +'react-docgen': patch +--- + +Extract component prop documentation from the builtin TypeScript +`Readonly` wrapper without unwrapping locally declared types named +`Readonly`. diff --git a/packages/react-docgen/src/utils/__tests__/unwrapBuiltinTSPropTypes-test.ts b/packages/react-docgen/src/utils/__tests__/unwrapBuiltinTSPropTypes-test.ts index cf7e7f0aa32..8b3ead797d6 100644 --- a/packages/react-docgen/src/utils/__tests__/unwrapBuiltinTSPropTypes-test.ts +++ b/packages/react-docgen/src/utils/__tests__/unwrapBuiltinTSPropTypes-test.ts @@ -5,6 +5,45 @@ import { describe, expect, test } from 'vitest'; import type { NodePath } from '@babel/traverse'; describe('unwrapBuiltinTSPropTypes', () => { + test.each(['Readonly', 'Readonly>'])( + 'unwraps the builtin %s', + (type) => { + const path = parseTypescript + .statementLast(`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 = { local: T };', + 'interface Readonly { local: T }', + "import { Readonly } from './types';", + ])('preserves a locally declared Readonly: %s', (declaration) => { + const path = parseTypescript + .statementLast( + `${declaration} var foo: Readonly`, + ) + .get('declarations.0.id.typeAnnotation.typeAnnotation'); + + expect(unwrapBuiltinTSPropTypes(path)).toBe(path); + }); + + test('preserves Readonly without type parameters', () => { + const path = parseTypescript + .statementLast('var foo: Readonly') + .get('declarations.0.id.typeAnnotation.typeAnnotation'); + + expect(unwrapBuiltinTSPropTypes(path)).toBe(path); + }); + test('React.PropsWithChildren', () => { const path = parseTypescript .statementLast( diff --git a/packages/react-docgen/src/utils/unwrapBuiltinTSPropTypes.ts b/packages/react-docgen/src/utils/unwrapBuiltinTSPropTypes.ts index 06f3cee24d4..b5dc4233c9c 100644 --- a/packages/react-docgen/src/utils/unwrapBuiltinTSPropTypes.ts +++ b/packages/react-docgen/src/utils/unwrapBuiltinTSPropTypes.ts @@ -1,9 +1,10 @@ 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 { @@ -11,6 +12,8 @@ export default function unwrapBuiltinTSPropTypes(typePath: NodePath): NodePath { const typeName = typePath.get('typeName'); if ( + (typeName.isIdentifier({ name: 'Readonly' }) && + resolveToValue(typeName) === typeName) || isReactBuiltinReference(typeName, 'PropsWithoutRef') || isReactBuiltinReference(typeName, 'PropsWithRef') || isReactBuiltinReference(typeName, 'PropsWithChildren') diff --git a/packages/react-docgen/tests/integration/integration-test.ts b/packages/react-docgen/tests/integration/integration-test.ts index 48d0ba83ded..f4e812ac6ee 100644 --- a/packages/react-docgen/tests/integration/integration-test.ts +++ b/packages/react-docgen/tests/integration/integration-test.ts @@ -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) { + return
{props.label}
; + }`, + { 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)),