From 95ebcdf6528c73a5a92f69a83fa0e39ca2cc0231 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Fri, 21 Nov 2025 16:12:18 +0400 Subject: [PATCH 1/6] fix undefined ref on option change --- .../core/__tests__/props-updating.test.tsx | 54 +++++++++++++++++++ .../src/core/__tests__/test-component.tsx | 2 +- .../src/core/component-base.tsx | 2 +- .../devextreme-react/src/core/component.tsx | 2 +- .../src/core/extension-component.tsx | 2 +- 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx index f9495df421b7..7bbf966a4853 100644 --- a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx @@ -188,6 +188,60 @@ describe('option update', () => { expect(Widget.option.mock.calls[1][1]).toEqual(ref.current?.instance().element()); }); + it('provides component ref instance inside onOptionChanged during option sync', () => { + const ref = React.createRef(); + const optionChangedInstances: Array | undefined> = []; + + const handleOptionChanged = () => { + optionChangedInstances.push(ref.current?.instance()); + }; + + const renderComponent = (text: string) => ( + + ); + + const { rerender } = render(renderComponent('value-1')); + + let currentOnOptionChanged = WidgetClass.mock.calls[0][1].onOptionChanged; + + expect(typeof currentOnOptionChanged).toBe('function'); + + Widget.option.mockImplementation((name: string, value: unknown) => { + if (name === 'integrationOptions.useDeferUpdateForTemplates') { + return false; + } + + if (name === 'onOptionChanged') { + currentOnOptionChanged = value as typeof currentOnOptionChanged; + return undefined; + } + + if (name === 'text' && typeof currentOnOptionChanged === 'function') { + currentOnOptionChanged({ + component: Widget, + element: undefined, + fullName: name, + model: undefined, + name, + previousValue: undefined, + value, + }); + } + + return undefined; + }); + + rerender(renderComponent('value-2')); + + expect(optionChangedInstances).toHaveLength(1); + expect(optionChangedInstances[0]).toBeTruthy(); + }); + it('updates nested collection item', () => { const TestContainer = (props: any) => { const { value } = props; diff --git a/packages/devextreme-react/src/core/__tests__/test-component.tsx b/packages/devextreme-react/src/core/__tests__/test-component.tsx index 8fecfc3c250d..72939371edad 100644 --- a/packages/devextreme-react/src/core/__tests__/test-component.tsx +++ b/packages/devextreme-react/src/core/__tests__/test-component.tsx @@ -78,7 +78,7 @@ const TestComponent = memo(forwardRef(function TestCompon return props; }, }; - }, [componentRef.current, getElement, props]); + }, [props]); return ( diff --git a/packages/devextreme-react/src/core/component-base.tsx b/packages/devextreme-react/src/core/component-base.tsx index 67a702fe0a0e..1e94a504ae36 100644 --- a/packages/devextreme-react/src/core/component-base.tsx +++ b/packages/devextreme-react/src/core/component-base.tsx @@ -392,7 +392,7 @@ const ComponentBase = forwardRef( createWidget(el); }, } - ), [instance.current, element.current, createWidget]); + ), []); const _renderChildren = useCallback(() => { if (renderChildren) { diff --git a/packages/devextreme-react/src/core/component.tsx b/packages/devextreme-react/src/core/component.tsx index 192df9d54d8e..bc866608a054 100644 --- a/packages/devextreme-react/src/core/component.tsx +++ b/packages/devextreme-react/src/core/component.tsx @@ -100,7 +100,7 @@ const Component = forwardRef( clearExtensions(); }, } - ), [componentBaseRef.current, createWidget, clearExtensions]); + ), []); return ( diff --git a/packages/devextreme-react/src/core/extension-component.tsx b/packages/devextreme-react/src/core/extension-component.tsx index f317ce8d5e25..b849b4c411b9 100644 --- a/packages/devextreme-react/src/core/extension-component.tsx +++ b/packages/devextreme-react/src/core/extension-component.tsx @@ -48,7 +48,7 @@ const ExtensionComponent = forwardRef( createWidget(el); }, } - ), [componentBaseRef.current, createWidget]); + ), []); return ( From 7a3b448c7b8570aa8821ae9a43965ee68e430737 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Fri, 21 Nov 2025 17:00:24 +0400 Subject: [PATCH 2/6] fix props issue --- .../src/core/__tests__/test-component.tsx | 13 ++++++++++--- .../devextreme-react/src/core/component-base.tsx | 8 +++++++- packages/devextreme-react/src/core/component.tsx | 15 +++++++++++++-- .../src/core/extension-component.tsx | 8 +++++++- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/devextreme-react/src/core/__tests__/test-component.tsx b/packages/devextreme-react/src/core/__tests__/test-component.tsx index 72939371edad..ff89233f2c26 100644 --- a/packages/devextreme-react/src/core/__tests__/test-component.tsx +++ b/packages/devextreme-react/src/core/__tests__/test-component.tsx @@ -8,6 +8,7 @@ import { useCallback, useContext, useRef, + useLayoutEffect, forwardRef, ReactElement, } from 'react'; @@ -65,6 +66,12 @@ const TestComponent = memo(forwardRef(function TestCompon Widget.resetOption.mockReset(); }, []); + const propsRef = useRef(props); + + useLayoutEffect(() => { + propsRef.current = props; + }, [props]); + useImperativeHandle(ref, () => { return { instance() { @@ -72,13 +79,13 @@ const TestComponent = memo(forwardRef(function TestCompon element() { return getElement(); } - } + }; }, getProps() { - return props; + return propsRef.current; }, }; - }, [props]); + }, []); return ( diff --git a/packages/devextreme-react/src/core/component-base.tsx b/packages/devextreme-react/src/core/component-base.tsx index 1e94a504ae36..9b86a73d4c8a 100644 --- a/packages/devextreme-react/src/core/component-base.tsx +++ b/packages/devextreme-react/src/core/component-base.tsx @@ -380,6 +380,12 @@ const ComponentBase = forwardRef( onComponentUpdated(); }); + const imperativeCreateWidget = useRef(createWidget); + + useLayoutEffect(() => { + imperativeCreateWidget.current = createWidget; + }, [createWidget]); + useImperativeHandle(ref, () => ( { getInstance() { @@ -389,7 +395,7 @@ const ComponentBase = forwardRef( return element.current; }, createWidget(el) { - createWidget(el); + imperativeCreateWidget.current?.(el); }, } ), []); diff --git a/packages/devextreme-react/src/core/component.tsx b/packages/devextreme-react/src/core/component.tsx index bc866608a054..d0f4325a9063 100644 --- a/packages/devextreme-react/src/core/component.tsx +++ b/packages/devextreme-react/src/core/component.tsx @@ -85,6 +85,17 @@ const Component = forwardRef( }; }, []); + const imperativeCreateWidget = useRef(createWidget); + const imperativeClearExtensions = useRef(clearExtensions); + + useLayoutEffect(() => { + imperativeCreateWidget.current = createWidget; + }, [createWidget]); + + useLayoutEffect(() => { + imperativeClearExtensions.current = clearExtensions; + }, [clearExtensions]); + useImperativeHandle(ref, () => ( { getInstance() { @@ -94,10 +105,10 @@ const Component = forwardRef( return componentBaseRef.current?.getElement(); }, createWidget(el) { - createWidget(el); + imperativeCreateWidget.current?.(el); }, clearExtensions() { - clearExtensions(); + imperativeClearExtensions.current?.(); }, } ), []); diff --git a/packages/devextreme-react/src/core/extension-component.tsx b/packages/devextreme-react/src/core/extension-component.tsx index b849b4c411b9..aaf165c61bc7 100644 --- a/packages/devextreme-react/src/core/extension-component.tsx +++ b/packages/devextreme-react/src/core/extension-component.tsx @@ -36,6 +36,12 @@ const ExtensionComponent = forwardRef( } }, []); + const imperativeCreateWidget = useRef(createWidget); + + useLayoutEffect(() => { + imperativeCreateWidget.current = createWidget; + }, [createWidget]); + useImperativeHandle(ref, () => ( { getInstance() { @@ -45,7 +51,7 @@ const ExtensionComponent = forwardRef( return componentBaseRef.current?.getElement(); }, createWidget(el) { - createWidget(el); + imperativeCreateWidget.current?.(el); }, } ), []); From 7b3abf883430a0bbd352653fc6024482b3885637 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Mon, 24 Nov 2025 19:33:41 +0400 Subject: [PATCH 3/6] useCallbacks refactor in core --- .../src/core/__tests__/test-component.tsx | 9 ++- .../src/core/component-base.tsx | 61 +++++-------------- .../devextreme-react/src/core/component.tsx | 16 ++--- .../src/core/extension-component.tsx | 2 +- 4 files changed, 29 insertions(+), 59 deletions(-) diff --git a/packages/devextreme-react/src/core/__tests__/test-component.tsx b/packages/devextreme-react/src/core/__tests__/test-component.tsx index ff89233f2c26..491818148a94 100644 --- a/packages/devextreme-react/src/core/__tests__/test-component.tsx +++ b/packages/devextreme-react/src/core/__tests__/test-component.tsx @@ -111,12 +111,17 @@ const TestPortalComponent = memo(forwardRef(function Test const TestRestoreTreeComponent = forwardRef((_, ref: React.ForwardedRef<{ restoreTree?: () => void }>) => { const restoreParentLink = useContext(RestoreTreeContext); + const restoreParentLinkRef = useRef<() => void>(() => {}); + + useLayoutEffect(() => { + restoreParentLinkRef.current = restoreParentLink ?? (() => {}); + }, [restoreParentLink]); useImperativeHandle(ref, () => { return { - restoreTree: restoreParentLink + restoreTree: () => restoreParentLinkRef.current(), }; - }, [restoreParentLink]); + }, []); return
Context Component
; }); diff --git a/packages/devextreme-react/src/core/component-base.tsx b/packages/devextreme-react/src/core/component-base.tsx index 9b86a73d4c8a..c7178218d224 100644 --- a/packages/devextreme-react/src/core/component-base.tsx +++ b/packages/devextreme-react/src/core/component-base.tsx @@ -80,6 +80,7 @@ const ComponentBase = forwardRef( const [, setForceUpdateToken] = useState(Symbol('initial force update token')); const removalLocker = useContext(RemovalLockerContext); const restoreParentLink = useContext(RestoreTreeContext); + const restoreParentLinkRef = useRef(restoreParentLink); const instance = useRef(); const element = useRef(); const portalContainer = useRef(); @@ -127,15 +128,10 @@ const ComponentBase = forwardRef( childElementsDetached.current = false; } - if (restoreParentLink && element.current && !element.current.isConnected) { - restoreParentLink(); + if (restoreParentLinkRef.current && element.current && !element.current.isConnected) { + restoreParentLinkRef.current(); } - }, [ - childNodes.current, - element.current, - childElementsDetached.current, - restoreParentLink, - ]); + }, []); const updateCssClasses = useCallback((prevProps: (P & ComponentBaseProps) | undefined, newProps: P & ComponentBaseProps) => { const prevClassName = prevProps ? getClassName(prevProps) : undefined; @@ -156,7 +152,7 @@ const ComponentBase = forwardRef( element.current?.classList.add(...classNames); } } - }, [element.current]); + }, []); const setInlineStyles = useCallback((styles) => { if (element.current) { @@ -172,7 +168,7 @@ const ComponentBase = forwardRef( }, ); } - }, [element.current]); + }, []); const setTemplateManagerHooks = useCallback(({ createDXTemplates: createDXTemplatesFn, @@ -182,11 +178,7 @@ const ComponentBase = forwardRef( createDXTemplates.current = createDXTemplatesFn; clearInstantiationModels.current = clearInstantiationModelsFn; updateTemplates.current = updateTemplatesFn; - }, [ - createDXTemplates.current, - clearInstantiationModels.current, - updateTemplates.current, - ]); + }, []); const getElementProps = useCallback(() => { const elementProps: Record = { @@ -203,7 +195,7 @@ const ComponentBase = forwardRef( } }); return elementProps; - }, [element.current, props]); + }, [props]); const scheduleTemplatesUpdate = useCallback(() => { if (guardsUpdateScheduled.current) { @@ -221,11 +213,7 @@ const ComponentBase = forwardRef( }); unscheduleGuards(); - }, [ - guardsUpdateScheduled.current, - useDeferUpdateForTemplates.current, - updateTemplates.current, - ]); + }, []); const createWidget = useCallback((el?: Element) => { beforeCreateWidget(); @@ -266,14 +254,8 @@ const ComponentBase = forwardRef( }, [ beforeCreateWidget, afterCreateWidget, - element.current, - optionsManager.current, - createDXTemplates.current, - clearInstantiationModels.current, WidgetClass, useRequestAnimationFrameFlag, - useDeferUpdateForTemplates.current, - instance.current, subscribableOptions, independentEvents, widgetConfig, @@ -284,7 +266,7 @@ const ComponentBase = forwardRef( instance.current.focus(); shouldRestoreFocus.current = false; } - }, [shouldRestoreFocus.current, instance.current]); + }, []); const onComponentUpdated = useCallback(() => { if (!optionsManager.current?.isInstanceSet) { @@ -301,9 +283,6 @@ const ComponentBase = forwardRef( prevPropsRef.current = props; }, [ - optionsManager.current, - prevPropsRef.current, - createDXTemplates.current, scheduleTemplatesUpdate, updateCssClasses, props, @@ -326,9 +305,6 @@ const ComponentBase = forwardRef( prevPropsRef.current = props; }, [ - childNodes.current, - element.current, - childElementsDetached.current, updateCssClasses, setInlineStyles, props, @@ -358,15 +334,7 @@ const ComponentBase = forwardRef( optionsManager.current.dispose(); removalLocker?.unlock(); - }, [ - removalLocker, - instance.current, - childNodes.current, - element.current, - optionsManager.current, - childElementsDetached.current, - shouldRestoreFocus.current, - ]); + }, [removalLocker]); useLayoutEffect(() => { onComponentMounted(); @@ -376,6 +344,10 @@ const ComponentBase = forwardRef( }; }, []); + useLayoutEffect(() => { + restoreParentLinkRef.current = restoreParentLink; + }, [restoreParentLink]); + useLayoutEffect(() => { onComponentUpdated(); }); @@ -412,7 +384,7 @@ const ComponentBase = forwardRef( const renderPortal = useCallback(() => portalContainer.current && createPortal( _renderChildren(), portalContainer.current, - ), [portalContainer.current, _renderChildren]); + ), [_renderChildren]); const renderContent = useCallback(() => { const { children } = props; @@ -431,7 +403,6 @@ const ComponentBase = forwardRef( }, [ props, isPortalComponent, - portalContainer.current, _renderChildren, ]); diff --git a/packages/devextreme-react/src/core/component.tsx b/packages/devextreme-react/src/core/component.tsx index d0f4325a9063..37737efa3e20 100644 --- a/packages/devextreme-react/src/core/component.tsx +++ b/packages/devextreme-react/src/core/component.tsx @@ -41,11 +41,11 @@ const Component = forwardRef( const registerExtension = useCallback((creator: any) => { extensionCreators.current.push(creator); - }, [extensionCreators.current]); + }, []); const createExtensions = useCallback(() => { extensionCreators.current.forEach((creator) => creator(componentBaseRef.current?.getElement() as HTMLDivElement)); - }, [extensionCreators.current, componentBaseRef.current]); + }, []); const renderChildren = useCallback(() => React.Children.map( props.children, @@ -63,18 +63,12 @@ const Component = forwardRef( const createWidget = useCallback((el?: Element) => { componentBaseRef.current?.createWidget(el); - }, [componentBaseRef.current]); + }, []); const clearExtensions = useCallback(() => { - if (props.clearExtensions) { - props.clearExtensions(); - } - + props.clearExtensions?.(); extensionCreators.current = []; - }, [ - extensionCreators.current, - props.clearExtensions, - ]); + }, [props.clearExtensions]); useLayoutEffect(() => { createWidget(); diff --git a/packages/devextreme-react/src/core/extension-component.tsx b/packages/devextreme-react/src/core/extension-component.tsx index aaf165c61bc7..b9fa7d0dda54 100644 --- a/packages/devextreme-react/src/core/extension-component.tsx +++ b/packages/devextreme-react/src/core/extension-component.tsx @@ -25,7 +25,7 @@ const ExtensionComponent = forwardRef( const createWidget = useCallback((el?: Element) => { componentBaseRef.current?.createWidget(el); - }, [componentBaseRef.current]); + }, []); useLayoutEffect(() => { const { onMounted } = props as any; From 470f0a4066b5b8adb6cc1c6a3532a8d9388fc899 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Tue, 25 Nov 2025 12:07:04 +0400 Subject: [PATCH 4/6] improve props updating test --- .../core/__tests__/props-updating.test.tsx | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx index 7bbf966a4853..8c2b2e2b4526 100644 --- a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-classes-per-file */ -import { cleanup, render } from '@testing-library/react'; +import { cleanup, render, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { memo } from 'react'; import { act } from 'react-dom/test-utils'; @@ -188,24 +188,40 @@ describe('option update', () => { expect(Widget.option.mock.calls[1][1]).toEqual(ref.current?.instance().element()); }); - it('provides component ref instance inside onOptionChanged during option sync', () => { + it('keeps component ref defined when controlled selection triggers optionChanged', () => { const ref = React.createRef(); + const clickInstances: Array | undefined> = []; const optionChangedInstances: Array | undefined> = []; - const handleOptionChanged = () => { - optionChangedInstances.push(ref.current?.instance()); - }; + const SelectionScenario = () => { + const [selectedRowKeys, setSelectedRowKeys] = React.useState([]); - const renderComponent = (text: string) => ( - - ); + const handleClick = React.useCallback(() => { + clickInstances.push(ref.current?.instance()); + setSelectedRowKeys((prev) => [...prev, prev.length + 1]); + }, []); + + const handleOptionChanged = React.useCallback((e: { fullName?: string }) => { + if (e.fullName === 'selectedRowKeys') { + optionChangedInstances.push(ref.current?.instance()); + } + }, []); + + return ( + <> + + + + ); + }; - const { rerender } = render(renderComponent('value-1')); + const { getByText } = render(); let currentOnOptionChanged = WidgetClass.mock.calls[0][1].onOptionChanged; @@ -221,7 +237,7 @@ describe('option update', () => { return undefined; } - if (name === 'text' && typeof currentOnOptionChanged === 'function') { + if (name === 'selectedRowKeys' && typeof currentOnOptionChanged === 'function') { currentOnOptionChanged({ component: Widget, element: undefined, @@ -236,8 +252,12 @@ describe('option update', () => { return undefined; }); - rerender(renderComponent('value-2')); + act(() => { + fireEvent.click(getByText('Test')); + }); + expect(clickInstances).toHaveLength(1); + expect(clickInstances[0]).toBeTruthy(); expect(optionChangedInstances).toHaveLength(1); expect(optionChangedInstances[0]).toBeTruthy(); }); From 9eadb4a8bde8de629ea9de2b621d2436fe1f3858 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Tue, 25 Nov 2025 18:14:40 +0400 Subject: [PATCH 5/6] rename ref entities --- .../devextreme-react/src/core/component-base.tsx | 6 +++--- packages/devextreme-react/src/core/component.tsx | 12 ++++++------ .../src/core/extension-component.tsx | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/devextreme-react/src/core/component-base.tsx b/packages/devextreme-react/src/core/component-base.tsx index c7178218d224..0101be60eb09 100644 --- a/packages/devextreme-react/src/core/component-base.tsx +++ b/packages/devextreme-react/src/core/component-base.tsx @@ -352,10 +352,10 @@ const ComponentBase = forwardRef( onComponentUpdated(); }); - const imperativeCreateWidget = useRef(createWidget); + const createWidgetRef = useRef(createWidget); useLayoutEffect(() => { - imperativeCreateWidget.current = createWidget; + createWidgetRef.current = createWidget; }, [createWidget]); useImperativeHandle(ref, () => ( @@ -367,7 +367,7 @@ const ComponentBase = forwardRef( return element.current; }, createWidget(el) { - imperativeCreateWidget.current?.(el); + createWidgetRef.current?.(el); }, } ), []); diff --git a/packages/devextreme-react/src/core/component.tsx b/packages/devextreme-react/src/core/component.tsx index 37737efa3e20..0eeb20e688a3 100644 --- a/packages/devextreme-react/src/core/component.tsx +++ b/packages/devextreme-react/src/core/component.tsx @@ -79,15 +79,15 @@ const Component = forwardRef( }; }, []); - const imperativeCreateWidget = useRef(createWidget); - const imperativeClearExtensions = useRef(clearExtensions); + const createWidgetRef = useRef(createWidget); + const clearExtensionsRef = useRef(clearExtensions); useLayoutEffect(() => { - imperativeCreateWidget.current = createWidget; + createWidgetRef.current = createWidget; }, [createWidget]); useLayoutEffect(() => { - imperativeClearExtensions.current = clearExtensions; + clearExtensionsRef.current = clearExtensions; }, [clearExtensions]); useImperativeHandle(ref, () => ( @@ -99,10 +99,10 @@ const Component = forwardRef( return componentBaseRef.current?.getElement(); }, createWidget(el) { - imperativeCreateWidget.current?.(el); + createWidgetRef.current?.(el); }, clearExtensions() { - imperativeClearExtensions.current?.(); + clearExtensionsRef.current?.(); }, } ), []); diff --git a/packages/devextreme-react/src/core/extension-component.tsx b/packages/devextreme-react/src/core/extension-component.tsx index b9fa7d0dda54..b2a212f4844d 100644 --- a/packages/devextreme-react/src/core/extension-component.tsx +++ b/packages/devextreme-react/src/core/extension-component.tsx @@ -36,10 +36,10 @@ const ExtensionComponent = forwardRef( } }, []); - const imperativeCreateWidget = useRef(createWidget); + const createWidgetRef = useRef(createWidget); useLayoutEffect(() => { - imperativeCreateWidget.current = createWidget; + createWidgetRef.current = createWidget; }, [createWidget]); useImperativeHandle(ref, () => ( @@ -51,7 +51,7 @@ const ExtensionComponent = forwardRef( return componentBaseRef.current?.getElement(); }, createWidget(el) { - imperativeCreateWidget.current?.(el); + createWidgetRef.current?.(el); }, } ), []); From d9c6e3490690289156f594c29c75ce7bb88db894 Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Mon, 1 Dec 2025 10:08:22 +0400 Subject: [PATCH 6/6] fix cleanup --- packages/devextreme-react/src/core/component.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devextreme-react/src/core/component.tsx b/packages/devextreme-react/src/core/component.tsx index 0eeb20e688a3..9ec5af7b4cc7 100644 --- a/packages/devextreme-react/src/core/component.tsx +++ b/packages/devextreme-react/src/core/component.tsx @@ -70,18 +70,18 @@ const Component = forwardRef( extensionCreators.current = []; }, [props.clearExtensions]); + const createWidgetRef = useRef(createWidget); + const clearExtensionsRef = useRef(clearExtensions); + useLayoutEffect(() => { createWidget(); createExtensions(); return () => { - clearExtensions(); + clearExtensionsRef.current?.(); }; }, []); - const createWidgetRef = useRef(createWidget); - const clearExtensionsRef = useRef(clearExtensions); - useLayoutEffect(() => { createWidgetRef.current = createWidget; }, [createWidget]);