diff --git a/packages-internal/scripts/typescript-to-proptypes/src/getPropTypesFromFile.ts b/packages-internal/scripts/typescript-to-proptypes/src/getPropTypesFromFile.ts index 53fcef445773fb..492c3fd9cad2d5 100644 --- a/packages-internal/scripts/typescript-to-proptypes/src/getPropTypesFromFile.ts +++ b/packages-internal/scripts/typescript-to-proptypes/src/getPropTypesFromFile.ts @@ -31,6 +31,7 @@ function getSymbolFileNames(symbol: ts.Symbol): Set { function getSymbolDocumentation({ symbol, project, + parentType, }: { symbol: ts.Symbol | undefined; project: TypeScriptProject; @@ -42,23 +43,9 @@ function getSymbolDocumentation({ const decl = symbol.getDeclarations(); if (decl && decl.length > 0) { - // This behavior tries to replicate how TypeScript itself merges JSDoc comments - // It is a complex logic that changes based on the kind of declarations - // There is an open issue for it in: https://github.com/microsoft/TypeScript/issues/30901 - // - // For intersection types (A & B), the symbol may have multiple declarations. - // We need to handle three cases: - // 1. Intersection (type C = A & B): merge JSDoc from all declarations (deduplicated) - // 2. Interface extends (interface Z extends X, Y): use the (only) declaration's JSDoc - // 3. Interface override (interface W extends X { prop }): use the override's JSDoc (which is the only declaration) - // - // Note: TypeScript gives us: - // - Multiple declarations for intersection types (one from each constituent type) - // - Single declaration for interface extends (from the original interface) - // - Single declaration for interface override (from the overriding interface) - - // Get JSDoc comments paired with their declarations - const declarationsWithComments = decl + // Replicates how TypeScript merges JSDoc comments across declarations. + // See https://github.com/microsoft/TypeScript/issues/30901 + const commentedDeclarations = decl .map((d) => { const jsDocNodes = ts.getJSDocCommentsAndTags(d).filter((node) => ts.isJSDoc(node)); const comment = @@ -67,19 +54,24 @@ function getSymbolDocumentation({ : undefined; return { declaration: d, comment }; }) - .filter((item) => item.comment !== undefined); + .filter( + (item): item is { declaration: ts.Declaration; comment: string } => + item.comment !== undefined, + ); + + if (commentedDeclarations.length > 0) { + if (commentedDeclarations.length === 1) { + return commentedDeclarations[0].comment; + } - if (declarationsWithComments.length > 0) { - // If there's only one declaration with a comment, use it - // This handles both interface extends and interface override cases - if (declarationsWithComments.length === 1) { - return declarationsWithComments[0].comment; + // Intersection types: merge unique JSDoc (matches TS hover behavior) + if (parentType && parentType.isIntersection()) { + const uniqueComments = [...new Set(commentedDeclarations.map((d) => d.comment))]; + return uniqueComments.join('\n'); } - // Multiple declarations with comments - this is the intersection case (type C = A & B) - // Merge JSDoc comments, deduplicating identical ones - const uniqueComments = [...new Set(declarationsWithComments.map((d) => d.comment))]; - return uniqueComments.join('\n'); + // Declaration merging / module augmentation: last declaration wins + return commentedDeclarations[commentedDeclarations.length - 1].comment; } } diff --git a/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/input.tsx b/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/input.tsx new file mode 100644 index 00000000000000..85b149d3a15780 --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/input.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import type { ComponentProps } from './types'; + +export default function Component(props: ComponentProps) { + const { name, onItemClick } = props; + + return ; +} diff --git a/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/output.js b/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/output.js new file mode 100644 index 00000000000000..f0d3bd83b5f4dc --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/output.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +function Component(props) { + const { name, onItemClick } = props; + return ; +} + +Component.propTypes = { + /** + * A normal prop. + */ + name: PropTypes.string.isRequired, + /** + * Augmented description of the callback. + * @param {MouseEvent | React.MouseEvent} event The event source (augmented). + */ + onItemClick: PropTypes.func, +}; + +export default Component; diff --git a/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/types.ts b/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/types.ts new file mode 100644 index 00000000000000..460c2e1808b435 --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/declaration-merging/types.ts @@ -0,0 +1,20 @@ +export interface ComponentProps { + /** + * Original description of the callback. + * @param {MouseEvent} event The event source. + */ + onItemClick?(event: MouseEvent): void; + /** + * A normal prop. + */ + name: string; +} + +// Module augmentation / declaration merging +export interface ComponentProps { + /** + * Augmented description of the callback. + * @param {MouseEvent | React.MouseEvent} event The event source (augmented). + */ + onItemClick?(event: MouseEvent | React.MouseEvent): void; +} diff --git a/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-extends/input.tsx b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-extends/input.tsx new file mode 100644 index 00000000000000..e797e575bf8747 --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-extends/input.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +interface BaseProps { + /** + * The label from base. + * @default 'base' + */ + label: string; + /** + * If true, the component is disabled. + */ + disabled?: boolean; +} + +interface ExtraProps { + /** + * The label from extra. + * @default 'extra' + */ + label: string; + /** + * The size of the component. + */ + size?: 'small' | 'medium' | 'large'; +} + +interface CombinedProps extends BaseProps, ExtraProps {} + +export default function Component(props: CombinedProps) { + const { label, disabled, size } = props; + return ( + + ); +} diff --git a/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-extends/output.js b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-extends/output.js new file mode 100644 index 00000000000000..6b24fa65f20a6b --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-extends/output.js @@ -0,0 +1,28 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +function Component(props) { + const { label, disabled, size } = props; + return ( + + ); +} + +Component.propTypes = { + /** + * If true, the component is disabled. + */ + disabled: PropTypes.bool, + /** + * The label from base. + * @default 'base' + */ + label: PropTypes.string.isRequired, + /** + * The size of the component. + */ + size: PropTypes.oneOf(['large', 'medium', 'small']), +}; + +export default Component; diff --git a/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-override/input.tsx b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-override/input.tsx new file mode 100644 index 00000000000000..ef9c3ca1918fa0 --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-override/input.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +interface BaseProps { + /** + * The label from base. + * @default 'base' + */ + label: string; + /** + * If true, the component is disabled. + */ + disabled?: boolean; +} + +interface OverrideProps extends BaseProps { + /** + * The overridden label description. + * @default 'override' + */ + label: string; +} + +export default function Component(props: OverrideProps) { + const { label, disabled } = props; + return ; +} diff --git a/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-override/output.js b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-override/output.js new file mode 100644 index 00000000000000..5eb5b3fdee3cdb --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-interface-override/output.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +function Component(props) { + const { label, disabled } = props; + return ; +} + +Component.propTypes = { + /** + * If true, the component is disabled. + */ + disabled: PropTypes.bool, + /** + * The overridden label description. + * @default 'override' + */ + label: PropTypes.string.isRequired, +}; + +export default Component; diff --git a/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-intersection/input.tsx b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-intersection/input.tsx new file mode 100644 index 00000000000000..8f972a30bca3b1 --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-intersection/input.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +type BaseProps = { + /** + * The label of the component. + * @default 'base' + */ + label: string; + /** + * If true, the component is disabled. + */ + disabled?: boolean; +}; + +type ExtraProps = { + /** + * The label from extra props. + * @default 'extra' + */ + label: string; + /** + * The size of the component. + */ + size?: 'small' | 'medium' | 'large'; +}; + +type CombinedProps = BaseProps & ExtraProps; + +export default function Component(props: CombinedProps) { + const { label, disabled, size } = props; + return ( + + ); +} diff --git a/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-intersection/output.js b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-intersection/output.js new file mode 100644 index 00000000000000..030c8112caef38 --- /dev/null +++ b/packages-internal/scripts/typescript-to-proptypes/test/jsdoc-intersection/output.js @@ -0,0 +1,30 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +function Component(props) { + const { label, disabled, size } = props; + return ( + + ); +} + +Component.propTypes = { + /** + * If true, the component is disabled. + */ + disabled: PropTypes.bool, + /** + * The label of the component. + * @default 'base' + * The label from extra props. + * @default 'extra' + */ + label: PropTypes.string.isRequired, + /** + * The size of the component. + */ + size: PropTypes.oneOf(['large', 'medium', 'small']), +}; + +export default Component;