From 4b06f645dce2e211f14a2ce46d5120b03533f2f8 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Fri, 24 Apr 2026 07:53:59 +1000 Subject: [PATCH] fix(react): migrate runtime component checks to markers --- .changeset/fix-react-runtime-markers.md | 5 ++ .../react/src/_utils/component-markers.ts | 31 ++++++++++ .../src/anchor/__tests__/anchor.test.tsx | 16 ++++++ packages/react/src/anchor/anchor-link.tsx | 2 + packages/react/src/anchor/anchor.tsx | 3 +- .../src/avatar/__tests__/avatar.test.tsx | 19 +++++++ packages/react/src/avatar/avatar-group.tsx | 3 +- packages/react/src/avatar/avatar.tsx | 2 + packages/react/src/button/button.tsx | 4 +- .../react/src/card/__tests__/card.test.tsx | 16 ++++++ packages/react/src/card/card-content.tsx | 2 + packages/react/src/card/card.tsx | 3 +- .../src/dropdown/__tests__/dropdown.test.tsx | 28 +++++++++ packages/react/src/dropdown/dropdown.tsx | 3 +- .../react/src/flip/__tests__/flip.test.tsx | 19 +++++++ packages/react/src/flip/flip-item.tsx | 2 + packages/react/src/flip/flip.tsx | 3 +- .../react/src/input-number/input-number.tsx | 2 + .../__tests__/input-group-addon.test.tsx | 57 +++++++++++++++++++ .../react/src/input/input-group-addon.tsx | 14 ++--- packages/react/src/input/input.tsx | 2 + packages/react/src/input/types.ts | 5 ++ .../react/src/menu/__tests__/menu.test.tsx | 40 +++++++++++++ packages/react/src/menu/menu-divider.tsx | 2 + packages/react/src/menu/menu-item-group.tsx | 4 +- packages/react/src/menu/menu-item.tsx | 2 + packages/react/src/menu/menu.tsx | 42 ++++++++------ packages/react/src/menu/sub-menu.tsx | 18 ++++-- .../src/select/__tests__/select.test.tsx | 24 ++++++++ packages/react/src/select/opt-group.tsx | 4 +- packages/react/src/select/option.tsx | 2 + packages/react/src/select/select.tsx | 18 ++++-- .../react/src/steps/__tests__/steps.test.tsx | 16 ++++++ packages/react/src/steps/steps-item.tsx | 2 + packages/react/src/steps/steps.tsx | 3 +- .../src/timeline/__tests__/timeline.test.tsx | 16 ++++++ packages/react/src/timeline/timeline-item.tsx | 2 + packages/react/src/timeline/timeline.tsx | 3 +- 38 files changed, 394 insertions(+), 45 deletions(-) create mode 100644 .changeset/fix-react-runtime-markers.md create mode 100644 packages/react/src/_utils/component-markers.ts create mode 100644 packages/react/src/input/__tests__/input-group-addon.test.tsx diff --git a/.changeset/fix-react-runtime-markers.md b/.changeset/fix-react-runtime-markers.md new file mode 100644 index 000000000..52d432667 --- /dev/null +++ b/.changeset/fix-react-runtime-markers.md @@ -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. diff --git a/packages/react/src/_utils/component-markers.ts b/packages/react/src/_utils/component-markers.ts new file mode 100644 index 000000000..af2a79688 --- /dev/null +++ b/packages/react/src/_utils/component-markers.ts @@ -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; + +export function markComponent(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]); +} diff --git a/packages/react/src/anchor/__tests__/anchor.test.tsx b/packages/react/src/anchor/__tests__/anchor.test.tsx index 9e51cbd7c..8afac7856 100644 --- a/packages/react/src/anchor/__tests__/anchor.test.tsx +++ b/packages/react/src/anchor/__tests__/anchor.test.tsx @@ -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('', () => { @@ -33,6 +34,21 @@ describe('', () => { expect(getByText('Link 2')).toBeInTheDocument(); }); + it('should recognize marker-based link wrappers', () => { + const WrappedLink = markComponent( + (props: React.ComponentProps) => , + ANCHOR_LINK_MARK + ); + + const { getByText } = render( + + + + ); + + expect(getByText('Wrapped Link')).toBeInTheDocument(); + }); + it('should forward ref to root list element', () => { const ref = React.createRef(); diff --git a/packages/react/src/anchor/anchor-link.tsx b/packages/react/src/anchor/anchor-link.tsx index 024d8b7cc..c4a49ae2f 100755 --- a/packages/react/src/anchor/anchor-link.tsx +++ b/packages/react/src/anchor/anchor-link.tsx @@ -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'; @@ -50,5 +51,6 @@ const AnchorLink = React.forwardRef( ); AnchorLink.displayName = 'AnchorLink'; +markComponent(AnchorLink, ANCHOR_LINK_MARK); export default AnchorLink; diff --git a/packages/react/src/anchor/anchor.tsx b/packages/react/src/anchor/anchor.tsx index a1d2623a1..c065bbf78 100755 --- a/packages/react/src/anchor/anchor.tsx +++ b/packages/react/src/anchor/anchor.tsx @@ -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'; @@ -228,7 +229,7 @@ const Anchor = React.forwardRef((props, ref): JSX {React.Children.map(children, (child) => { const childElement = child as React.FunctionComponentElement; - if (childElement.type.displayName === 'AnchorLink') { + if (hasMarker(childElement.type, ANCHOR_LINK_MARK)) { const childProps: Partial = { prefixCls, }; diff --git a/packages/react/src/avatar/__tests__/avatar.test.tsx b/packages/react/src/avatar/__tests__/avatar.test.tsx index 3fba3058c..27b1c714c 100644 --- a/packages/react/src/avatar/__tests__/avatar.test.tsx +++ b/packages/react/src/avatar/__tests__/avatar.test.tsx @@ -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('', () => { @@ -37,4 +38,22 @@ describe('', () => { const { container } = render(A); expect(container.firstChild).toHaveStyle({ width: '50px', height: '50px' }); }); + + it('should recognize marker-based avatar wrappers in Avatar.Group', () => { + const WrappedAvatar = markComponent( + (props: React.ComponentProps) => , + AVATAR_MARK + ); + + const { container } = render( + + A + B + + ); + + const avatars = container.querySelectorAll('.ty-avatar'); + expect(avatars).toHaveLength(2); + expect(avatars[1]).toHaveStyle({ marginLeft: '-10px' }); + }); }); diff --git a/packages/react/src/avatar/avatar-group.tsx b/packages/react/src/avatar/avatar-group.tsx index b33660600..cdf09462e 100755 --- a/packages/react/src/avatar/avatar-group.tsx +++ b/packages/react/src/avatar/avatar-group.tsx @@ -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'; @@ -14,7 +15,7 @@ const AvatarGroup = (props: AvatarGroupProps): JSX.Element => { {React.Children.map(children, (child, idx) => { const childElement = child as React.FunctionComponentElement; - if (childElement.type.displayName === 'Avatar') { + if (hasMarker(childElement.type, AVATAR_MARK)) { const childProps: Partial = { style: { ...childElement.props.style, diff --git a/packages/react/src/avatar/avatar.tsx b/packages/react/src/avatar/avatar.tsx index 096877a62..aa4eed437 100644 --- a/packages/react/src/avatar/avatar.tsx +++ b/packages/react/src/avatar/avatar.tsx @@ -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'; @@ -98,5 +99,6 @@ const Avatar = React.forwardRef((props, ref) => { }); Avatar.displayName = 'Avatar'; +markComponent(Avatar, AVATAR_MARK); export default Avatar; diff --git a/packages/react/src/button/button.tsx b/packages/react/src/button/button.tsx index ecdd57bad..80ab91291 100644 --- a/packages/react/src/button/button.tsx +++ b/packages/react/src/button/button.tsx @@ -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'; @@ -93,6 +94,7 @@ const Button = React.forwardRef((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; diff --git a/packages/react/src/card/__tests__/card.test.tsx b/packages/react/src/card/__tests__/card.test.tsx index 03a359f41..3a023036d 100644 --- a/packages/react/src/card/__tests__/card.test.tsx +++ b/packages/react/src/card/__tests__/card.test.tsx @@ -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('', () => { @@ -65,4 +66,19 @@ describe('', () => { const { getByText } = render(Footer}>Content); expect(getByText('Footer')).toBeInTheDocument(); }); + + it('should recognize marker-based card content wrappers', () => { + const WrappedContent = markComponent( + (props: React.ComponentProps) => , + CARD_CONTENT_MARK + ); + + const { container } = render( + + Wrapped body + + ); + + expect(container.querySelector('.ty-card__body')).toHaveTextContent('Wrapped body'); + }); }); diff --git a/packages/react/src/card/card-content.tsx b/packages/react/src/card/card-content.tsx index cdafde3ea..3d8708560 100644 --- a/packages/react/src/card/card-content.tsx +++ b/packages/react/src/card/card-content.tsx @@ -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 => { @@ -11,5 +12,6 @@ const CardContent = (props: CardContentProps): React.ReactElement => { }; CardContent.displayName = 'CardContent'; +markComponent(CardContent, CARD_CONTENT_MARK); export default CardContent; diff --git a/packages/react/src/card/card.tsx b/packages/react/src/card/card.tsx index d07be8f1b..d72e24eb6 100644 --- a/packages/react/src/card/card.tsx +++ b/packages/react/src/card/card.tsx @@ -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'; @@ -72,7 +73,7 @@ const Card = React.forwardRef((props, ref) => { // Pass prefixCls attribute to child if it is a CardContent instance const childElement = child as React.FunctionComponentElement; - if (childElement.type.displayName === 'CardContent') { + if (hasMarker(childElement.type, CARD_CONTENT_MARK)) { const childProps: Partial = { prefixCls, }; diff --git a/packages/react/src/dropdown/__tests__/dropdown.test.tsx b/packages/react/src/dropdown/__tests__/dropdown.test.tsx index f9469d944..920989a29 100644 --- a/packages/react/src/dropdown/__tests__/dropdown.test.tsx +++ b/packages/react/src/dropdown/__tests__/dropdown.test.tsx @@ -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'; @@ -88,6 +89,33 @@ describe('', () => { }); }); + it('should treat marker-based menu overlays as menus', async () => { + const WrappedMenu = markComponent( + (props: React.ComponentProps) => , + MENU_MARK + ); + + render( + + Menu Item + + } + > + + + ); + + 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( { 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; diff --git a/packages/react/src/flip/__tests__/flip.test.tsx b/packages/react/src/flip/__tests__/flip.test.tsx index 157bdd7e9..82ab522af 100644 --- a/packages/react/src/flip/__tests__/flip.test.tsx +++ b/packages/react/src/flip/__tests__/flip.test.tsx @@ -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('', () => { @@ -32,4 +34,21 @@ describe('', () => { expect(getByText('Front Side')).toBeInTheDocument(); expect(getByText('Back Side')).toBeInTheDocument(); }); + + it('should recognize marker-based flip item wrappers', () => { + const WrappedItem = markComponent( + (props: React.ComponentProps) => , + FLIP_ITEM_MARK + ); + + const { getByText } = render( + +
Front Wrapped
+
Back Wrapped
+
+ ); + + expect(getByText('Front Wrapped')).toBeInTheDocument(); + expect(getByText('Back Wrapped')).toBeInTheDocument(); + }); }); diff --git a/packages/react/src/flip/flip-item.tsx b/packages/react/src/flip/flip-item.tsx index 56060daa3..dea4260d5 100755 --- a/packages/react/src/flip/flip-item.tsx +++ b/packages/react/src/flip/flip-item.tsx @@ -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; @@ -10,5 +11,6 @@ const FlipItem = (props: FlipItemProps): JSX.Element => { }; FlipItem.displayName = 'FlipItem'; +markComponent(FlipItem, FLIP_ITEM_MARK); export default FlipItem; diff --git a/packages/react/src/flip/flip.tsx b/packages/react/src/flip/flip.tsx index b533f6bed..76b5a40b1 100644 --- a/packages/react/src/flip/flip.tsx +++ b/packages/react/src/flip/flip.tsx @@ -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'; @@ -39,7 +40,7 @@ const Flip = (props: FlipProps): React.ReactElement => {
{React.Children.map(children, (child, index: number) => { const childElement = child as React.FunctionComponentElement; - if (childElement.type.displayName === 'FlipItem') { + if (hasMarker(childElement.type, FLIP_ITEM_MARK)) { const childProps: Partial = { className: classNames( { diff --git a/packages/react/src/input-number/input-number.tsx b/packages/react/src/input-number/input-number.tsx index 2bde16c89..923dd012f 100755 --- a/packages/react/src/input-number/input-number.tsx +++ b/packages/react/src/input-number/input-number.tsx @@ -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'; @@ -123,5 +124,6 @@ const InputNumber = React.forwardRef((props, r }); InputNumber.displayName = 'InputNumber'; +markComponent(InputNumber, INPUT_GROUP_CONTROL_MARK); export default InputNumber; diff --git a/packages/react/src/input/__tests__/input-group-addon.test.tsx b/packages/react/src/input/__tests__/input-group-addon.test.tsx new file mode 100644 index 000000000..4068c2994 --- /dev/null +++ b/packages/react/src/input/__tests__/input-group-addon.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Input from '../index'; +import Button from '../../button'; +import Select from '../../select'; +import InputNumber from '../../input-number'; + +describe('', () => { + it('adds control styling and injects props into supported controls', () => { + const { container, rerender } = render( + + + + ); + + expect(container.firstChild).toHaveClass('ty-input-group-addon_control', 'ty-input-group-addon_lg'); + expect(container.querySelector('.ty-input')).toHaveClass('ty-input_lg', 'ty-input_disabled'); + expect(container.querySelector('input')).toBeDisabled(); + + rerender( + + + + ); + expect(container.querySelector('.ty-btn')).toHaveClass('ty-btn_lg', 'ty-btn_disabled'); + expect(container.querySelector('button')).toBeDisabled(); + + rerender( + + ', () => { expect(container.firstChild).toHaveClass('ty-select'); }); + it('should recognize marker-based option wrappers', () => { + const WrappedOption = markComponent( + (props: React.ComponentProps) =>