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
5 changes: 5 additions & 0 deletions .changeset/fix-react-runtime-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiny-design/react": patch
---

Replace runtime displayName checks with component markers for React component composition and keep displayName for debugging only.
31 changes: 31 additions & 0 deletions packages/react/src/_utils/component-markers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const INPUT_GROUP_CONTROL_MARK = Symbol('tiny-design.input-group-control');
export const SELECT_MARK = Symbol('tiny-design.select');
export const SELECT_OPTION_MARK = Symbol('tiny-design.select-option');
export const SELECT_OPT_GROUP_MARK = Symbol('tiny-design.select-opt-group');
export const MENU_MARK = Symbol('tiny-design.menu');
export const MENU_ITEM_MARK = Symbol('tiny-design.menu-item');
export const SUB_MENU_MARK = Symbol('tiny-design.sub-menu');
export const MENU_ITEM_GROUP_MARK = Symbol('tiny-design.menu-item-group');
export const MENU_DIVIDER_MARK = Symbol('tiny-design.menu-divider');
export const ANCHOR_LINK_MARK = Symbol('tiny-design.anchor-link');
export const STEPS_ITEM_MARK = Symbol('tiny-design.steps-item');
export const TIMELINE_ITEM_MARK = Symbol('tiny-design.timeline-item');
export const CARD_CONTENT_MARK = Symbol('tiny-design.card-content');
export const FLIP_ITEM_MARK = Symbol('tiny-design.flip-item');
export const AVATAR_MARK = Symbol('tiny-design.avatar');

type Marker = symbol;
type Markable = Record<PropertyKey, unknown>;

export function markComponent<T extends object>(component: T, marker: Marker): T {
(component as T & Markable)[marker] = true;
return component;
}

export function hasMarker(type: unknown, marker: Marker): boolean {
if (!type || (typeof type !== 'function' && typeof type !== 'object')) {
return false;
}

return Boolean((type as Markable)[marker]);
}
16 changes: 16 additions & 0 deletions packages/react/src/anchor/__tests__/anchor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ANCHOR_LINK_MARK, markComponent } from '../../_utils/component-markers';
import Anchor from '../index';

describe('<Anchor />', () => {
Expand Down Expand Up @@ -33,6 +34,21 @@ describe('<Anchor />', () => {
expect(getByText('Link 2')).toBeInTheDocument();
});

it('should recognize marker-based link wrappers', () => {
const WrappedLink = markComponent(
(props: React.ComponentProps<typeof Anchor.Link>) => <Anchor.Link {...props} />,
ANCHOR_LINK_MARK
);

const { getByText } = render(
<Anchor>
<WrappedLink href="#s1" title="Wrapped Link" />
</Anchor>
);

expect(getByText('Wrapped Link')).toBeInTheDocument();
});

it('should forward ref to root list element', () => {
const ref = React.createRef<HTMLUListElement>();

Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/anchor/anchor-link.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext, useEffect } from 'react';
import classNames from 'classnames';
import { ANCHOR_LINK_MARK, markComponent } from '../_utils/component-markers';
import { AnchorLinkProps } from './types';
import { AnchorContext } from './anchor-context';

Expand Down Expand Up @@ -50,5 +51,6 @@ const AnchorLink = React.forwardRef<HTMLAnchorElement, AnchorLinkProps>(
);

AnchorLink.displayName = 'AnchorLink';
markComponent(AnchorLink, ANCHOR_LINK_MARK);

export default AnchorLink;
3 changes: 2 additions & 1 deletion packages/react/src/anchor/anchor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext, useState, useCallback, useEffect, useRef, useMemo } from 'react';
import classNames from 'classnames';
import { ANCHOR_LINK_MARK, hasMarker } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { resolveTargetContainer } from '../config-provider/container-utils';
import { getPrefixCls } from '../_utils/general';
Expand Down Expand Up @@ -228,7 +229,7 @@ const Anchor = React.forwardRef<HTMLUListElement, AnchorProps>((props, ref): JSX
</div>
{React.Children.map(children, (child) => {
const childElement = child as React.FunctionComponentElement<AnchorLinkProps>;
if (childElement.type.displayName === 'AnchorLink') {
if (hasMarker(childElement.type, ANCHOR_LINK_MARK)) {
const childProps: Partial<AnchorLinkProps> = {
prefixCls,
};
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/avatar/__tests__/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import { AVATAR_MARK, markComponent } from '../../_utils/component-markers';
import Avatar from '../index';

describe('<Avatar />', () => {
Expand Down Expand Up @@ -37,4 +38,22 @@ describe('<Avatar />', () => {
const { container } = render(<Avatar size={50}>A</Avatar>);
expect(container.firstChild).toHaveStyle({ width: '50px', height: '50px' });
});

it('should recognize marker-based avatar wrappers in Avatar.Group', () => {
const WrappedAvatar = markComponent(
(props: React.ComponentProps<typeof Avatar>) => <Avatar {...props} />,
AVATAR_MARK
);

const { container } = render(
<Avatar.Group gap={-10}>
<WrappedAvatar>A</WrappedAvatar>
<WrappedAvatar>B</WrappedAvatar>
</Avatar.Group>
);

const avatars = container.querySelectorAll('.ty-avatar');
expect(avatars).toHaveLength(2);
expect(avatars[1]).toHaveStyle({ marginLeft: '-10px' });
});
});
3 changes: 2 additions & 1 deletion packages/react/src/avatar/avatar-group.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext } from 'react';
import classNames from 'classnames';
import { AVATAR_MARK, hasMarker } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { AvatarProps, AvatarGroupProps } from './types';
Expand All @@ -14,7 +15,7 @@ const AvatarGroup = (props: AvatarGroupProps): JSX.Element => {
<span {...otherProps} className={cls} style={style}>
{React.Children.map(children, (child, idx) => {
const childElement = child as React.FunctionComponentElement<AvatarProps>;
if (childElement.type.displayName === 'Avatar') {
if (hasMarker(childElement.type, AVATAR_MARK)) {
const childProps: Partial<AvatarProps> = {
style: {
...childElement.props.style,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/avatar/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState, useContext } from 'react';
import classNames from 'classnames';
import { AVATAR_MARK, markComponent } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { AvatarProps } from './types';
Expand Down Expand Up @@ -72,7 +73,7 @@
...style,
};

useEffect(() => {

Check warning on line 76 in packages/react/src/avatar/avatar.tsx

View workflow job for this annotation

GitHub Actions / build (22)

React Hook useEffect contains a call to 'setScale'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [prefixCls] as a second argument to the useEffect Hook
if (outerEl.current && textEl.current && textEl.current!.className === `${prefixCls}__text`) {
const textElWidth = textEl.current!.offsetWidth;
const outerElWidth = outerEl.current!.offsetWidth;
Expand All @@ -98,5 +99,6 @@
});

Avatar.displayName = 'Avatar';
markComponent(Avatar, AVATAR_MARK);

export default Avatar;
4 changes: 3 additions & 1 deletion packages/react/src/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext } from 'react';
import classNames from 'classnames';
import { INPUT_GROUP_CONTROL_MARK, markComponent } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { ButtonProps } from './types';
Expand Down Expand Up @@ -93,6 +94,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props: ButtonPr
});

Button.displayName = 'Button';
(Button as typeof Button & { [BUTTON_MARK]?: boolean })[BUTTON_MARK] = true;
markComponent(Button, BUTTON_MARK);
markComponent(Button, INPUT_GROUP_CONTROL_MARK);

export default Button;
16 changes: 16 additions & 0 deletions packages/react/src/card/__tests__/card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import { CARD_CONTENT_MARK, markComponent } from '../../_utils/component-markers';
import Card from '../index';

describe('<Card />', () => {
Expand Down Expand Up @@ -65,4 +66,19 @@ describe('<Card />', () => {
const { getByText } = render(<Card footer={<div>Footer</div>}>Content</Card>);
expect(getByText('Footer')).toBeInTheDocument();
});

it('should recognize marker-based card content wrappers', () => {
const WrappedContent = markComponent(
(props: React.ComponentProps<typeof Card.Content>) => <Card.Content {...props} />,
CARD_CONTENT_MARK
);

const { container } = render(
<Card>
<WrappedContent>Wrapped body</WrappedContent>
</Card>
);

expect(container.querySelector('.ty-card__body')).toHaveTextContent('Wrapped body');
});
});
2 changes: 2 additions & 0 deletions packages/react/src/card/card-content.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { CARD_CONTENT_MARK, markComponent } from '../_utils/component-markers';
import { CardContentProps } from './types';

const CardContent = (props: CardContentProps): React.ReactElement => {
Expand All @@ -11,5 +12,6 @@ const CardContent = (props: CardContentProps): React.ReactElement => {
};

CardContent.displayName = 'CardContent';
markComponent(CardContent, CARD_CONTENT_MARK);

export default CardContent;
3 changes: 2 additions & 1 deletion packages/react/src/card/card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { ReactNode, useContext } from 'react';
import classNames from 'classnames';
import { CARD_CONTENT_MARK, hasMarker } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { CardContentProps, CardProps, CardVariant } from './types';
Expand Down Expand Up @@ -72,7 +73,7 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>((props, ref) => {

// Pass prefixCls attribute to child if it is a CardContent instance
const childElement = child as React.FunctionComponentElement<CardContentProps>;
if (childElement.type.displayName === 'CardContent') {
if (hasMarker(childElement.type, CARD_CONTENT_MARK)) {
const childProps: Partial<CardContentProps> = {
prefixCls,
};
Expand Down
28 changes: 28 additions & 0 deletions packages/react/src/dropdown/__tests__/dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { markComponent, MENU_MARK } from '../../_utils/component-markers';
import Dropdown from '../index';
import Menu from '../../menu';

Expand Down Expand Up @@ -88,6 +89,33 @@ describe('<Dropdown />', () => {
});
});

it('should treat marker-based menu overlays as menus', async () => {
const WrappedMenu = markComponent(
(props: React.ComponentProps<typeof Menu>) => <Menu {...props} />,
MENU_MARK
);

render(
<Dropdown
trigger="click"
overlay={
<WrappedMenu>
<Menu.Item>Menu Item</Menu.Item>
</WrappedMenu>
}
>
<button>Trigger</button>
</Dropdown>
);

fireEvent.click(screen.getByText('Trigger'));

await waitFor(() => {
expect(screen.getByRole('menu')).toHaveClass('ty-menu_vertical');
expect(screen.getByRole('menu')).toHaveClass('ty-menu_appearance-dropdown');
});
});

it('should keep cascade submenu popup attached to the submenu branch', async () => {
render(
<Dropdown
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useCallback, useContext, useState } from 'react';
import classNames from 'classnames';
import { hasMarker, MENU_MARK } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { DropdownProps } from './types';
Expand Down Expand Up @@ -43,7 +44,7 @@ const Dropdown = (props: DropdownProps): JSX.Element => {

const originalOnSelect = overlay.props.onSelect;
const isMenuOverlay =
(overlay.type as React.ElementType & { displayName?: string }).displayName === 'Menu';
hasMarker(overlay.type, MENU_MARK);

if (!isMenuOverlay) {
return overlay;
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/flip/__tests__/flip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import { FLIP_ITEM_MARK, markComponent } from '../../_utils/component-markers';
import Flip from '../index';

describe('<Flip />', () => {
Expand Down Expand Up @@ -32,4 +34,21 @@ describe('<Flip />', () => {
expect(getByText('Front Side')).toBeInTheDocument();
expect(getByText('Back Side')).toBeInTheDocument();
});

it('should recognize marker-based flip item wrappers', () => {
const WrappedItem = markComponent(
(props: React.ComponentProps<typeof Flip.Item>) => <Flip.Item {...props} />,
FLIP_ITEM_MARK
);

const { getByText } = render(
<Flip width={200} height={200}>
<WrappedItem><div>Front Wrapped</div></WrappedItem>
<WrappedItem><div>Back Wrapped</div></WrappedItem>
</Flip>
);

expect(getByText('Front Wrapped')).toBeInTheDocument();
expect(getByText('Back Wrapped')).toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions packages/react/src/flip/flip-item.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FlipItemProps } from './types';
import { FLIP_ITEM_MARK, markComponent } from '../_utils/component-markers';

const FlipItem = (props: FlipItemProps): JSX.Element => {
const { className, children, ...otherProps } = props;
Expand All @@ -10,5 +11,6 @@ const FlipItem = (props: FlipItemProps): JSX.Element => {
};

FlipItem.displayName = 'FlipItem';
markComponent(FlipItem, FLIP_ITEM_MARK);

export default FlipItem;
3 changes: 2 additions & 1 deletion packages/react/src/flip/flip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext } from 'react';
import classNames from 'classnames';
import { FLIP_ITEM_MARK, hasMarker } from '../_utils/component-markers';
import warning from '../_utils/warning';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
Expand Down Expand Up @@ -39,7 +40,7 @@ const Flip = (props: FlipProps): React.ReactElement => {
<div className={innerCls}>
{React.Children.map(children, (child, index: number) => {
const childElement = child as React.FunctionComponentElement<FlipItemProps>;
if (childElement.type.displayName === 'FlipItem') {
if (hasMarker(childElement.type, FLIP_ITEM_MARK)) {
const childProps: Partial<FlipItemProps> = {
className: classNames(
{
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/input-number/input-number.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useState, MouseEvent, useContext } from 'react';
import classNames from 'classnames';
import { INPUT_GROUP_CONTROL_MARK, markComponent } from '../_utils/component-markers';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { InputNumberProps } from './types';
Expand Down Expand Up @@ -123,5 +124,6 @@ const InputNumber = React.forwardRef<HTMLDivElement, InputNumberProps>((props, r
});

InputNumber.displayName = 'InputNumber';
markComponent(InputNumber, INPUT_GROUP_CONTROL_MARK);

export default InputNumber;
Loading
Loading