Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function getSymbolFileNames(symbol: ts.Symbol): Set<string> {
function getSymbolDocumentation({
symbol,
project,
parentType,
}: {
symbol: ts.Symbol | undefined;
project: TypeScriptProject;
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment not valid anymore?

// 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 =
Expand All @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <button onClick={(e) => onItemClick?.(e.nativeEvent)}>{name}</button>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { name, onItemClick } = props;
return <button onClick={(e) => onItemClick?.(e.nativeEvent)}>{name}</button>;
}

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,
Comment on lines +13 to +17

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this is "somewhat correct"?

Like, the real ts implementation resolves to different overloads (because it is an interface with functions), for natives objects it still merges the docs.

I've added more examples to the ts playground

But since proptypes will likely only care for the most complete, I guess in this case it makes sense to pick last?

@brijeshb42 brijeshb42 Apr 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most complete will need further refinement. 1st definition could also technically be more complete. But in this case (and in the relevant example in Charts), there is an augmented type that is more complete that comes later in the definition. So I went with that way of just picking the last. Devs would have to make sure that later definitions are more complete.

@brijeshb42 brijeshb42 Apr 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can refine this further as need arises. But this change doesnt affect anything in core and in X only 3-4 files are affected.

};

export default Component;
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 (
<button disabled={disabled} data-size={size}>
{label}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { label, disabled, size } = props;
return (
<button disabled={disabled} data-size={size}>
{label}
</button>
);
}

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;
Original file line number Diff line number Diff line change
@@ -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 <button disabled={disabled}>{label}</button>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { label, disabled } = props;
return <button disabled={disabled}>{label}</button>;
}

Component.propTypes = {
/**
* If true, the component is disabled.
*/
disabled: PropTypes.bool,
/**
* The overridden label description.
* @default 'override'
*/
label: PropTypes.string.isRequired,
};

export default Component;
Original file line number Diff line number Diff line change
@@ -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 (
<button disabled={disabled} data-size={size}>
{label}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { label, disabled, size } = props;
return (
<button disabled={disabled} data-size={size}>
{label}
</button>
);
}

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;
Loading