Skip to content

Commit e928d14

Browse files
authored
fix(react): restore intrinsic HTML props under React 19 types (#121)
* fix(react): restore intrinsic HTML props under React 19 types `React.PropsWithRef<JSX.IntrinsicElements['x']>` relied on the global `JSX` namespace, which @types/react@19 no longer augments. Under React 19 the type collapsed to `any`, silently dropping `onClick`, `type`, `disabled`, `aria-*`, `children`, etc. from every affected component. Replaced with `React.ComponentProps<'x'>` / `React.ComponentPropsWithoutRef<'x'>` across ~60 type files. Works on React 18 and 19. Fixes #120. * refactor(react): normalize forwardRef props to ComponentPropsWithoutRef The codebase mixed `ComponentProps` (with ref) and `ComponentPropsWithoutRef` across prop interfaces, all consumed by `React.forwardRef`. Normalize to `ComponentPropsWithoutRef` throughout — the idiomatic pattern React docs recommend for forwardRef, since forwardRef re-adds ref via `RefAttributes<T>` to the external component type. Side benefit: for the handful of non-forwardRef components (Link, Form, FormItem, Descriptions), this removes `ref` from the prop type so consumers can no longer pass a non-functional `ref` prop. Zero external API change for forwardRef components — `<Button ref={...}/>` still type-checks via `RefAttributes<HTMLButtonElement>`. * fix(react): normalize ref exposure policy
1 parent e2e8b60 commit e928d14

77 files changed

Lines changed: 387 additions & 298 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiny-design/react': patch
3+
---
4+
5+
Fix TypeScript compatibility with React 19. Component prop interfaces used the pattern `React.PropsWithRef<JSX.IntrinsicElements['x']>`, which relied on a globally-augmented `JSX` namespace that `@types/react@19` no longer provides. The resolved prop types collapsed to `any`, silently dropping every intrinsic HTML attribute (`onClick`, `type`, `disabled`, `aria-*`, `children`, etc.) for consumers on React 19. Replaced the pattern with `React.ComponentProps<'x'>` / `React.ComponentPropsWithoutRef<'x'>` across ~60 component type files. Works on React 18 and 19.

packages/react/REFS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Ref Conventions
2+
3+
This package treats `ref` as an explicit component capability, not an accidental side effect of inheriting DOM props.
4+
5+
## Rules
6+
7+
- Use `React.ComponentPropsWithoutRef<'tag'>` for DOM-derived prop interfaces by default.
8+
- Only expose `ref` when the component intentionally supports DOM access through `React.forwardRef`.
9+
- Do not rely on `React.ComponentProps<'tag'>` to leak `ref` into props interfaces.
10+
- If a component is primarily compositional or layout-only, do not expose `ref` unless there is a concrete DOM integration need.
11+
12+
## Components That Should Expose `ref`
13+
14+
- Interactive controls and focus targets such as `Button`, `Input`, `NativeSelect`, `Link`, `Switch`, `Checkbox`, `Radio`.
15+
- Components frequently used for measurement, scrolling, positioning, or animation integration such as `Tree`, `Slider`, `Grid`, `Space`, `Flex`, `Layout`, `Anchor`, `Waterfall`, `Transfer`, `Steps`, and typography primitives.
16+
17+
## Components That Usually Should Not Expose `ref`
18+
19+
- Grouping or compositional wrappers such as `Button.Group`, `Checkbox.Group`, `Radio.Group`, `Input.Group`, `Input.Addon`, `Form.Item`, `Descriptions`, `Descriptions.Item`.
20+
21+
## Practical Guidance
22+
23+
- If consumers need the outer wrapper and an inner native control, expose both intentionally, for example `Checkbox` plus `checkboxRef`, or `Radio` plus `radioRef`.
24+
- If a component renders different DOM elements by state, make the forwarded ref type reflect that reality instead of pretending it always points to one tag.
25+
- When adding a new forwarded ref, add a focused test that proves the ref resolves to the expected DOM node.

packages/react/src/alert/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type AlertType = 'success' | 'info' | 'warning' | 'error';
55

66
export interface AlertProps
77
extends BaseProps,
8-
Omit<React.PropsWithoutRef<JSX.IntrinsicElements['div']>, 'title'> {
8+
Omit<React.ComponentPropsWithoutRef<'div'>, 'title'> {
99
/** alert title */
1010
title?: string | ReactNode;
1111

packages/react/src/anchor/__tests__/anchor.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,16 @@ describe('<Anchor />', () => {
3232
expect(getByText('Link 1')).toBeInTheDocument();
3333
expect(getByText('Link 2')).toBeInTheDocument();
3434
});
35+
36+
it('should forward ref to root list element', () => {
37+
const ref = React.createRef<HTMLUListElement>();
38+
39+
render(
40+
<Anchor ref={ref}>
41+
<Anchor.Link href="#section1" title="Section 1" />
42+
</Anchor>
43+
);
44+
45+
expect(ref.current).toBeInstanceOf(HTMLUListElement);
46+
});
3547
});

packages/react/src/anchor/anchor.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@ import { AnchorLinkProps, AnchorProps } from './types';
77
import { AnchorContext } from './anchor-context';
88
import Sticky from '../sticky';
99

10-
const Anchor = (props: AnchorProps): JSX.Element => {
10+
function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {
11+
if (!ref) {
12+
return;
13+
}
14+
15+
if (typeof ref === 'function') {
16+
ref(value);
17+
return;
18+
}
19+
20+
(ref as React.MutableRefObject<T | null>).current = value;
21+
}
22+
23+
const Anchor = React.forwardRef<HTMLUListElement, AnchorProps>((props, ref): JSX.Element => {
1124
const {
1225
affix = false,
1326
offsetTop = 0,
@@ -20,6 +33,7 @@ const Anchor = (props: AnchorProps): JSX.Element => {
2033
style,
2134
children,
2235
prefixCls: customisedCls,
36+
...otherProps
2337
} = props;
2438
const configContext = useContext(ConfigContext);
2539
const prefixCls = getPrefixCls('anchor', configContext.prefixCls, customisedCls);
@@ -37,6 +51,14 @@ const Anchor = (props: AnchorProps): JSX.Element => {
3751
linksRef.current.delete(href);
3852
}, []);
3953

54+
const setAnchorNode = useCallback(
55+
(node: HTMLUListElement | null) => {
56+
anchorRef.current = node;
57+
assignRef(ref, node);
58+
},
59+
[ref]
60+
);
61+
4062
const updateInk = useCallback(() => {
4163
const anchorEl = anchorRef.current;
4264
if (!anchorEl) return;
@@ -200,7 +222,7 @@ const Anchor = (props: AnchorProps): JSX.Element => {
200222
);
201223

202224
const anchorContent = (
203-
<ul className={cls} style={style} ref={anchorRef}>
225+
<ul {...otherProps} className={cls} style={style} ref={setAnchorNode}>
204226
<div className={`${prefixCls}__ink`}>
205227
<div className={`${prefixCls}__ink-ball`} ref={inkBallRef} />
206228
</div>
@@ -228,7 +250,7 @@ const Anchor = (props: AnchorProps): JSX.Element => {
228250
)}
229251
</AnchorContext.Provider>
230252
);
231-
};
253+
});
232254

233255
Anchor.displayName = 'Anchor';
234256

packages/react/src/anchor/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import React from 'react';
22
import { BaseProps } from '../_utils/props';
33

4-
export interface AnchorProps extends BaseProps {
4+
export interface AnchorProps
5+
extends BaseProps,
6+
Omit<React.ComponentPropsWithoutRef<'ul'>, 'children' | 'onChange' | 'onClick'> {
57
affix?: boolean;
68
type?: 'dot' | 'line';
79
offsetBottom?: number;
@@ -12,7 +14,7 @@ export interface AnchorProps extends BaseProps {
1214
children?: React.ReactNode;
1315
}
1416

15-
export interface AnchorLinkProps extends BaseProps, React.PropsWithRef<JSX.IntrinsicElements['a']> {
17+
export interface AnchorLinkProps extends BaseProps, React.ComponentPropsWithoutRef<'a'> {
1618
href: string;
1719
title: string;
1820
children?: React.ReactElement<AnchorLinkProps>[];

packages/react/src/aspect-ratio/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BaseProps } from '../_utils/props';
33

44
export interface AspectRatioProps
55
extends BaseProps,
6-
React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
6+
React.ComponentPropsWithoutRef<'div'> {
77
/** the width of the content */
88
width?: number | string;
99

packages/react/src/avatar/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type AvatarPresence = 'online' | 'busy' | 'away' | 'offline';
66

77
export interface AvatarProps
88
extends BaseProps,
9-
React.PropsWithoutRef<JSX.IntrinsicElements['span']> {
9+
React.ComponentPropsWithoutRef<'span'> {
1010
/** use an icon */
1111
icon?: React.ReactNode;
1212

@@ -28,7 +28,7 @@ export interface AvatarProps
2828

2929
export interface AvatarGroupProps
3030
extends BaseProps,
31-
React.PropsWithoutRef<JSX.IntrinsicElements['span']> {
31+
React.ComponentPropsWithoutRef<'span'> {
3232
/** the distance between two avatars */
3333
gap: number | string;
3434
}

packages/react/src/badge/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BaseProps } from '../_utils/props';
33

44
export interface BadgeProps
55
extends BaseProps,
6-
React.PropsWithoutRef<JSX.IntrinsicElements['span']> {
6+
React.ComponentPropsWithoutRef<'span'> {
77
/** the number to show in badge */
88
count?: React.ReactNode;
99

packages/react/src/breadcrumb/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { BaseProps } from '../_utils/props';
33

44
export interface BreadcrumbProps
55
extends BaseProps,
6-
React.PropsWithoutRef<JSX.IntrinsicElements['nav']> {
6+
React.ComponentPropsWithoutRef<'nav'> {
77
separator?: React.ReactNode;
88
children: ReactElement<BreadcrumbItemProps> | ReactElement<BreadcrumbItemProps>[];
99
}
1010

1111
export interface BreadcrumbItemProps
1212
extends BaseProps,
13-
React.PropsWithoutRef<JSX.IntrinsicElements['li']> {
13+
React.ComponentPropsWithoutRef<'li'> {
1414
separator?: React.ReactNode;
1515
children?: React.ReactNode;
1616
}

0 commit comments

Comments
 (0)