From bcea2c2668b2e9ccb889acb5e704d634438d9b94 Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Thu, 14 May 2026 15:48:24 +0300 Subject: [PATCH] fix(react18): forwardRef on superset-ui-core wrappers used as tooltip/dropdown triggers React 18.3 promotes findDOMNode to a deprecation warning. rc-resize-observer (inside antd Tooltip/Dropdown) falls back to findDOMNode when its child does not support refs. Without forwardRef on Button, Label, Tooltip, BaseIconComponent, AsyncIcon, the antd-generated Icons (antdEnhancedIcons + iconOverrides), and the AsyncIcon jest mock, every Tooltip/Dropdown wrapping these would emit the warning and fail jest-fail-on-console. EditableTitle wraps antd Input.TextArea in a Tooltip. TextArea's imperative ref returns { resizableTextArea, focus, blur } (not a DOM node), so the resize observer's findDOMNode-fallback fires anyway; wrap in a span to attach a real DOM ref. Also drop the empty IconWithoutRef helper in RefreshLabel that was wrapping an icon in forwardRef without forwarding the ref. --- .../src/components/Button/index.tsx | 7 +- .../src/components/EditableTitle/index.tsx | 5 +- .../src/components/Icons/AntdEnhanced.tsx | 24 ++-- .../src/components/Icons/AsyncIcon.tsx | 7 +- .../src/components/Icons/BaseIcon.tsx | 115 +++++++++--------- .../src/components/Icons/index.tsx | 16 ++- .../src/components/Label/index.tsx | 6 +- .../src/components/RefreshLabel/index.tsx | 37 +++--- .../src/components/Tooltip/index.tsx | 22 ++-- superset-frontend/spec/helpers/shim.tsx | 60 +++++---- 10 files changed, 163 insertions(+), 136 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx index 78f525e5a7c..8f7a3d9aa33 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Children, ReactElement, Fragment } from 'react'; +import { Children, ReactElement, Fragment, forwardRef, Ref } from 'react'; import cx from 'classnames'; import { Button as AntdButton } from 'antd'; import { useTheme } from '@apache-superset/core/theme'; @@ -100,7 +100,7 @@ const BUTTON_STYLE_MAP: Record< link: { type: 'link' }, }; -export function Button(props: ButtonProps) { +function ButtonInner(props: ButtonProps, ref: Ref) { const { tooltip, placement, @@ -160,6 +160,7 @@ export function Button(props: ButtonProps) { const button = ( } href={disabled ? undefined : href} disabled={disabled} type={antdType} @@ -235,4 +236,6 @@ export function Button(props: ButtonProps) { return button; } +export const Button = forwardRef(ButtonInner); + export type { ButtonProps, OnClickHandler }; diff --git a/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx index 17d8c51ef6d..470256fddc6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx @@ -240,7 +240,10 @@ export function EditableTitle({ t("You don't have the rights to alter this title.") } > - {titleComponent} + {/* Wrap in span so the Tooltip can attach a ref to a DOM element. + antd's Input.TextArea forwards a non-DOM imperative handle, which + triggers a React 18 findDOMNode deprecation warning. */} + {titleComponent} ); } diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 818a4ed5e23..2597cdbea64 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -165,7 +165,7 @@ import { SlackOutlined, ApiOutlined, } from '@ant-design/icons'; -import { FC } from 'react'; +import { ForwardRefExoticComponent, RefAttributes, forwardRef } from 'react'; import { IconType } from './types'; import { BaseIconComponent } from './BaseIcon'; @@ -323,19 +323,25 @@ type AntdIconNames = keyof typeof AntdIcons; export const antdEnhancedIcons: Record< AntdIconNames, - FC + ForwardRefExoticComponent> > = Object.keys(AntdIcons) .filter(key => !EXCLUDED_ICONS.some(excluded => key.includes(excluded))) .reduce( (acc, key) => { - acc[key as AntdIconNames] = (props: IconType) => ( - + acc[key as AntdIconNames] = forwardRef( + (props, ref) => ( + + ), ); return acc; }, - {} as Record>, + {} as Record< + AntdIconNames, + ForwardRefExoticComponent> + >, ); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AsyncIcon.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AsyncIcon.tsx index 68f0af35bbb..317cee02029 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AsyncIcon.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AsyncIcon.tsx @@ -17,12 +17,12 @@ * under the License. */ -import { FC, SVGProps, useEffect, useRef, useState } from 'react'; +import { FC, SVGProps, forwardRef, useEffect, useRef, useState } from 'react'; import TransparentIcon from './svgs/transparent.svg'; import { IconType } from './types'; import { BaseIconComponent } from './BaseIcon'; -const AsyncIcon = (props: IconType) => { +const AsyncIcon = forwardRef((props, ref) => { const [, setLoaded] = useState(false); const ImportedSVG = useRef>>(); const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } = @@ -46,6 +46,7 @@ const AsyncIcon = (props: IconType) => { return ( { {...restProps} /> ); -}; +}); export default AsyncIcon; diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/BaseIcon.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/BaseIcon.tsx index e87e6a04adf..55025cebfa6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/BaseIcon.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/BaseIcon.tsx @@ -17,6 +17,7 @@ * under the License. */ +import { forwardRef } from 'react'; import { css, useTheme, getFontSize } from '@apache-superset/core/theme'; import { AntdIconType, BaseIconProps, CustomIconType, IconType } from './types'; @@ -35,65 +36,65 @@ const genAriaLabel = (fileName: string) => { return name.toLowerCase(); }; -export const BaseIconComponent: React.FC< +export const BaseIconComponent = forwardRef< + HTMLSpanElement, BaseIconProps & Omit -> = ({ - component: Component, - iconColor, - iconSize, - viewBox, - customIcons, - fileName, - ...rest -}) => { - const theme = useTheme(); - const whatRole = rest?.onClick ? 'button' : 'img'; - const ariaLabel = genAriaLabel(fileName || ''); - const style = { - color: iconColor, - fontSize: iconSize - ? `${getFontSize(theme, iconSize)}px` - : `${theme.fontSize}px`, - cursor: rest?.onClick ? 'pointer' : undefined, - }; +>( + ( + { component: Component, iconColor, iconSize, viewBox, customIcons, fileName, ...rest }, + ref, + ) => { + const theme = useTheme(); + const whatRole = rest?.onClick ? 'button' : 'img'; + const ariaLabel = genAriaLabel(fileName || ''); + const style = { + color: iconColor, + fontSize: iconSize + ? `${getFontSize(theme, iconSize)}px` + : `${theme.fontSize}px`, + cursor: rest?.onClick ? 'pointer' : undefined, + }; - return customIcons ? ( - + return customIcons ? ( + + + + ) : ( - - ) : ( - - ); -}; + ); + }, +); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx index fa246edae02..8750d8299ad 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx @@ -17,12 +17,16 @@ * under the License. */ -import { FC } from 'react'; +import { ForwardRefExoticComponent, RefAttributes, forwardRef } from 'react'; import { antdEnhancedIcons } from './AntdEnhanced'; import AsyncIcon from './AsyncIcon'; import type { IconType } from './types'; +type IconComponent = ForwardRefExoticComponent< + IconType & RefAttributes +>; + /** * Filename is going to be inferred from the icon name. * i.e. BigNumberChartTile => assets/images/icons/big_number_chart_tile @@ -58,15 +62,17 @@ const customIcons = [ 'Undo', ] as const; -type CustomIconType = Record<(typeof customIcons)[number], FC>; +type CustomIconType = Record<(typeof customIcons)[number], IconComponent>; const iconOverrides: CustomIconType = {} as CustomIconType; customIcons.forEach(customIcon => { const fileName = customIcon .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .toLowerCase(); - iconOverrides[customIcon] = (props: IconType) => ( - + iconOverrides[customIcon] = forwardRef( + (props, ref) => ( + + ), ); }); @@ -74,7 +80,7 @@ export type IconNameType = | keyof typeof antdEnhancedIcons | keyof typeof iconOverrides; -type IconComponentType = Record>; +type IconComponentType = Record; export const Icons: IconComponentType = { ...antdEnhancedIcons, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx index 4d1de1fe125..c78673b091b 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Label/index.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { forwardRef } from 'react'; import { Tag } from '@superset-ui/core/components/Tag'; import { css } from '@emotion/react'; import { useTheme, getColorVariants } from '@apache-superset/core/theme'; @@ -23,7 +24,7 @@ import { DatasetTypeLabel } from './reusable/DatasetTypeLabel'; import { PublishedLabel } from './reusable/PublishedLabel'; import type { LabelProps } from './types'; -export function Label(props: LabelProps) { +export const Label = forwardRef((props, ref) => { const theme = useTheme(); // Use Ant Design's motion duration instead of deprecated transitionTiming const { @@ -71,6 +72,7 @@ export function Label(props: LabelProps) { return ( ); -} +}); export { DatasetTypeLabel, PublishedLabel }; export type { LabelType } from './types'; diff --git a/superset-frontend/packages/superset-ui-core/src/components/RefreshLabel/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/RefreshLabel/index.tsx index 08f5b1c88f9..1c01ca63acd 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/RefreshLabel/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/RefreshLabel/index.tsx @@ -16,10 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { MouseEventHandler, forwardRef } from 'react'; +import { MouseEventHandler } from 'react'; import { SupersetTheme } from '@apache-superset/core/theme'; import { Icons } from '@superset-ui/core/components/Icons'; -import type { IconType } from '@superset-ui/core/components/Icons/types'; import { Tooltip } from '../Tooltip'; export interface RefreshLabelProps { @@ -32,25 +31,19 @@ const RefreshLabel = ({ onClick, tooltipContent, disabled, -}: RefreshLabelProps) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const IconWithoutRef = forwardRef((props: IconType, ref: any) => ( - - )); - - return ( - - ({ - cursor: 'pointer', - color: theme.colorIcon, - '&:hover': { color: theme.colorPrimary }, - })} - /> - - ); -}; +}: RefreshLabelProps) => ( + + ({ + cursor: 'pointer', + color: theme.colorIcon, + '&:hover': { color: theme.colorPrimary }, + })} + /> + +); export default RefreshLabel; diff --git a/superset-frontend/packages/superset-ui-core/src/components/Tooltip/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Tooltip/index.tsx index ee8844a540e..acf16c2b60c 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Tooltip/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Tooltip/index.tsx @@ -16,17 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -import { Tooltip as AntdTooltip } from 'antd'; +import { forwardRef } from 'react'; +import { Tooltip as AntdTooltip, type TooltipRef } from 'antd'; import type { TooltipProps, TooltipPlacement } from './types'; -export const Tooltip = ({ overlayStyle, ...props }: TooltipProps) => ( - +export const Tooltip = forwardRef( + ({ overlayStyle, ...props }, ref) => ( + + ), ); export type { TooltipProps, TooltipPlacement }; diff --git a/superset-frontend/spec/helpers/shim.tsx b/superset-frontend/spec/helpers/shim.tsx index 8dd24a70e8e..0bff723e4ca 100644 --- a/superset-frontend/spec/helpers/shim.tsx +++ b/superset-frontend/spec/helpers/shim.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { AriaAttributes } from 'react'; +import { AriaAttributes, Ref } from 'react'; import 'core-js/stable'; import 'regenerator-runtime/runtime'; import jQuery from 'jquery'; @@ -98,31 +98,39 @@ jest.mock('rehype-raw', () => () => jest.fn()); // Tests should override this when needed jest.mock('@superset-ui/core/components/Icons/AsyncIcon', () => ({ __esModule: true, - default: ({ - fileName, - role, - 'aria-label': ariaLabel, - onClick, - ...rest - }: { - fileName: string; - role?: string; - 'aria-label'?: AriaAttributes['aria-label']; - onClick?: () => void; - }) => { - // Simple mock that provides the essential attributes for testing - const label = ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || ''; - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - - ); - }, + // eslint-disable-next-line global-require + default: require('react').forwardRef( + ( + { + fileName, + role, + 'aria-label': ariaLabel, + onClick, + ...rest + }: { + fileName: string; + role?: string; + 'aria-label'?: AriaAttributes['aria-label']; + onClick?: () => void; + }, + ref: Ref, + ) => { + // Simple mock that provides the essential attributes for testing + const label = + ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || ''; + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + + ); + }, + ), StyledIcon: ({ component: Component, role,