Compare commits

...

6 Commits

Author SHA1 Message Date
Evan Rusackas
ecbe96c006 address review: widen reactify return type to ComponentType
Preserves backward compatibility for TypeScript consumers that
previously saw ComponentClass<Props & ReactifyProps>. The new
ReactifiedComponent<Props> type is now exported so callers that want
the ref-aware surface can narrow to it explicitly.
2026-04-22 13:59:35 -07:00
Evan Rusackas
3c06394c0a address review: clear container before componentWillUnmount 2026-04-22 12:37:02 -07:00
Evan Rusackas
780342e7fa address review: drop stray async from willUnmount test 2026-04-22 12:35:31 -07:00
Evan Rusackas
12136b3881 fix(reactify): bypass forwardRef static field type mismatch
forwardRef's inherent propTypes/defaultProps types include RefAttributes,
which the renderFn's validator types don't satisfy. Cast to any for the
static field copy (matches the pre-existing pattern from the class
version of reactify).
2026-04-18 15:49:57 -07:00
Evan Rusackas
b7b40f09bd fix(reactify): preserve props + drop any cast
- Pass `props` into the componentWillUnmount callback context via a
  propsRef, so consumers that read `this.props` (e.g. ReactNVD3's
  unmount handler reading `this.props.id`) continue to work.
- Replace the `any` cast around propTypes/defaultProps assignment with
  the concrete ReactifiedComponent<Props> type.
2026-04-17 17:22:34 -07:00
Evan Rusackas
c5b4ab5822 chore(lint): convert reactify.tsx to function component
Converts the reactify HOC in superset-ui-core from a class-based
wrapper to a function component. Updates the associated unit test.

⚠️ reactify wraps legacy D3/NVD3 render functions — class→function
conversion changes componentDidMount/Update to useEffect, which has
different timing semantics. This PR isolates that change for focused
review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:35:16 -07:00
2 changed files with 109 additions and 95 deletions

View File

@@ -17,8 +17,14 @@
* under the License. * under the License.
*/ */
// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing. import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { Component, ComponentClass, WeakValidationMap } from 'react'; import type {
ComponentType,
WeakValidationMap,
ForwardRefExoticComponent,
PropsWithoutRef,
RefAttributes,
} from 'react';
// TODO: Note that id and className can collide between Props and ReactifyProps // TODO: Note that id and className can collide between Props and ReactifyProps
// leading to (likely) unexpected behaviors. We should either require Props to not // leading to (likely) unexpected behaviors. We should either require Props to not
@@ -49,66 +55,90 @@ export interface RenderFuncType<Props> {
propTypes?: WeakValidationMap<Props & ReactifyProps>; propTypes?: WeakValidationMap<Props & ReactifyProps>;
} }
export interface ReactifiedComponentRef {
container?: HTMLDivElement;
}
export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
>;
// Return the widest public type that covers "use it as a React component" so
// TypeScript consumers who previously relied on `ComponentClass<...>` still
// compile. Callers that want the new ref-aware surface can narrow to
// `ReactifiedComponent<Props>` explicitly.
export default function reactify<Props extends object>( export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>, renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks, callbacks?: LifeCycleCallbacks,
): ComponentClass<Props & ReactifyProps> { ): ComponentType<Props & ReactifyProps> {
class ReactifiedComponent extends Component<Props & ReactifyProps> { const ReactifiedComponent = forwardRef<
container?: HTMLDivElement; ReactifiedComponentRef,
Props & ReactifyProps
>(function ReactifiedComponent(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
// Keep the latest props available to the unmount callback — legacy
// consumers read values off `this.props` (e.g. ReactNVD3 uses id).
const propsRef = useRef(props);
propsRef.current = props;
constructor(props: Props & ReactifyProps) { // Expose container via ref for external access
super(props); useImperativeHandle(
this.setContainerRef = this.setContainerRef.bind(this); ref,
} () => ({
get container() {
return containerRef.current ?? undefined;
},
}),
[],
);
componentDidMount() { // Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
this.execute(); useEffect(() => {
} if (containerRef.current) {
renderFn(containerRef.current, props);
componentDidUpdate() {
this.execute();
}
componentWillUnmount() {
this.container = undefined;
if (callbacks?.componentWillUnmount) {
callbacks.componentWillUnmount.bind(this)();
} }
} });
setContainerRef(ref: HTMLDivElement) { // Cleanup on unmount
this.container = ref; useEffect(
} () => () => {
if (callbacks?.componentWillUnmount) {
// Preserve legacy behavior where `this` was a component instance
// exposing `props`. The class version cleared `this.container`
// before invoking componentWillUnmount, so mirror that here to
// prevent callbacks from touching a DOM node that's being torn
// down.
callbacks.componentWillUnmount.call({
container: undefined,
props: propsRef.current,
});
}
},
[],
);
execute() { const { id, className } = props;
if (this.container) {
renderFn(this.container, this.props);
}
}
render() { return <div ref={containerRef} id={id} className={className} />;
const { id, className } = this.props; });
return <div ref={this.setContainerRef} id={id} className={className} />;
}
}
const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
ReactifiedComponent;
if (renderFn.displayName) { if (renderFn.displayName) {
ReactifiedClass.displayName = renderFn.displayName; ReactifiedComponent.displayName = renderFn.displayName;
} }
// eslint-disable-next-line react/forbid-foreign-prop-types
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- forwardRef static field types don't line up with renderFn's validator types
const result = ReactifiedComponent as any;
if (renderFn.propTypes) { if (renderFn.propTypes) {
ReactifiedClass.propTypes = { result.propTypes = {
...ReactifiedClass.propTypes, ...result.propTypes,
...renderFn.propTypes, ...renderFn.propTypes,
}; };
} }
if (renderFn.defaultProps) { if (renderFn.defaultProps) {
ReactifiedClass.defaultProps = renderFn.defaultProps; result.defaultProps = renderFn.defaultProps;
} }
return ReactifiedComponent; return result as unknown as ComponentType<Props & ReactifyProps>;
} }

View File

@@ -19,9 +19,9 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useEffect, useState } from 'react';
import { reactify } from '@superset-ui/core'; import { reactify } from '@superset-ui/core';
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { RenderFuncType } from '../../../src/chart/components/reactify'; import { RenderFuncType } from '../../../src/chart/components/reactify';
describe('reactify(renderFn)', () => { describe('reactify(renderFn)', () => {
@@ -52,48 +52,36 @@ describe('reactify(renderFn)', () => {
componentWillUnmount: willUnmountCb, componentWillUnmount: willUnmountCb,
}); });
class TestComponent extends PureComponent<{}, { content: string }> { function TestComponent() {
constructor(props = {}) { const [content, setContent] = useState('abc');
super(props);
this.state = { content: 'abc' };
}
componentDidMount() { useEffect(() => {
setTimeout(() => { const timer = setTimeout(() => {
this.setState({ content: 'def' }); setContent('def');
}, 10); }, 10);
} return () => clearTimeout(timer);
}, []);
render() { return <TheChart id="test" content={content} />;
const { content } = this.state;
return <TheChart id="test" content={content} />;
}
} }
class AnotherTestComponent extends PureComponent<{}, {}> { function AnotherTestComponent() {
render() { return <TheChartWithWillUnmountHook id="another_test" />;
return <TheChartWithWillUnmountHook id="another_test" />;
}
} }
test('returns a React component class', () => test('returns a React component and re-renders on prop changes', async () => {
new Promise(done => { render(<TestComponent />);
render(<TestComponent />);
expect(renderFn).toHaveBeenCalledTimes(1); expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument(); expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test'); expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
setTimeout(() => {
expect(renderFn).toHaveBeenCalledTimes(2); await waitFor(() => {
expect(screen.getByText('def')).toBeInTheDocument(); expect(screen.getByText('def')).toBeInTheDocument();
expect(screen.getByText('def').parentNode).toHaveAttribute( });
'id', expect(screen.getByText('def').parentNode).toHaveAttribute('id', 'test');
'test', expect(renderFn).toHaveBeenCalledTimes(2);
); });
done(undefined);
}, 20);
}));
describe('displayName', () => { describe('displayName', () => {
test('has displayName if renderFn.displayName is defined', () => { test('has displayName if renderFn.displayName is defined', () => {
expect(TheChart.displayName).toEqual('BoldText'); expect(TheChart.displayName).toEqual('BoldText');
@@ -126,20 +114,16 @@ describe('reactify(renderFn)', () => {
expect(AnotherChart.defaultProps).toBeUndefined(); expect(AnotherChart.defaultProps).toBeUndefined();
}); });
}); });
test('does not try to render if not mounted', () => { test('calls renderFn when container is set', () => {
const anotherRenderFn = jest.fn(); const anotherRenderFn = jest.fn();
const AnotherChart = reactify(anotherRenderFn); // enables valid new AnotherChart() call const AnotherChart = reactify(anotherRenderFn);
// @ts-expect-error const { unmount } = render(<AnotherChart id="test" />);
new AnotherChart({ id: 'test' }).execute(); expect(anotherRenderFn).toHaveBeenCalled();
expect(anotherRenderFn).not.toHaveBeenCalled(); unmount();
});
test('calls willUnmount hook when it is provided', () => {
const { unmount } = render(<AnotherTestComponent />);
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
}); });
test('calls willUnmount hook when it is provided', () =>
new Promise(done => {
const { unmount } = render(<AnotherTestComponent />);
setTimeout(() => {
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
done(undefined);
}, 20);
}));
}); });