Skip to content

Commit 408757c

Browse files
authored
React - useImperativeHandle dependencies fix and useCallbacks' cleanup (T1308983) (#31870)
1 parent 495c567 commit 408757c

File tree

5 files changed

+145
-71
lines changed

5 files changed

+145
-71
lines changed

packages/devextreme-react/src/core/__tests__/props-updating.test.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable max-classes-per-file */
2-
import { cleanup, render } from '@testing-library/react';
2+
import { cleanup, render, fireEvent } from '@testing-library/react';
33
import * as React from 'react';
44
import { memo } from 'react';
55
import { act } from 'react-dom/test-utils';
@@ -188,6 +188,80 @@ describe('option update', () => {
188188
expect(Widget.option.mock.calls[1][1]).toEqual(ref.current?.instance().element());
189189
});
190190

191+
it('keeps component ref defined when controlled selection triggers optionChanged', () => {
192+
const ref = React.createRef<TestComponentRef>();
193+
const clickInstances: Array<ReturnType<TestComponentRef['instance']> | undefined> = [];
194+
const optionChangedInstances: Array<ReturnType<TestComponentRef['instance']> | undefined> = [];
195+
196+
const SelectionScenario = () => {
197+
const [selectedRowKeys, setSelectedRowKeys] = React.useState<number[]>([]);
198+
199+
const handleClick = React.useCallback(() => {
200+
clickInstances.push(ref.current?.instance());
201+
setSelectedRowKeys((prev) => [...prev, prev.length + 1]);
202+
}, []);
203+
204+
const handleOptionChanged = React.useCallback((e: { fullName?: string }) => {
205+
if (e.fullName === 'selectedRowKeys') {
206+
optionChangedInstances.push(ref.current?.instance());
207+
}
208+
}, []);
209+
210+
return (
211+
<>
212+
<button type="button" onClick={handleClick}>Test</button>
213+
<TestComponent
214+
ref={ref}
215+
selectedRowKeys={selectedRowKeys}
216+
selection={{ mode: 'multiple' }}
217+
onOptionChanged={handleOptionChanged}
218+
independentEvents={['onOptionChanged']}
219+
/>
220+
</>
221+
);
222+
};
223+
224+
const { getByText } = render(<SelectionScenario />);
225+
226+
let currentOnOptionChanged = WidgetClass.mock.calls[0][1].onOptionChanged;
227+
228+
expect(typeof currentOnOptionChanged).toBe('function');
229+
230+
Widget.option.mockImplementation((name: string, value: unknown) => {
231+
if (name === 'integrationOptions.useDeferUpdateForTemplates') {
232+
return false;
233+
}
234+
235+
if (name === 'onOptionChanged') {
236+
currentOnOptionChanged = value as typeof currentOnOptionChanged;
237+
return undefined;
238+
}
239+
240+
if (name === 'selectedRowKeys' && typeof currentOnOptionChanged === 'function') {
241+
currentOnOptionChanged({
242+
component: Widget,
243+
element: undefined,
244+
fullName: name,
245+
model: undefined,
246+
name,
247+
previousValue: undefined,
248+
value,
249+
});
250+
}
251+
252+
return undefined;
253+
});
254+
255+
act(() => {
256+
fireEvent.click(getByText('Test'));
257+
});
258+
259+
expect(clickInstances).toHaveLength(1);
260+
expect(clickInstances[0]).toBeTruthy();
261+
expect(optionChangedInstances).toHaveLength(1);
262+
expect(optionChangedInstances[0]).toBeTruthy();
263+
});
264+
191265
it('updates nested collection item', () => {
192266
const TestContainer = (props: any) => {
193267
const { value } = props;

packages/devextreme-react/src/core/__tests__/test-component.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
useCallback,
99
useContext,
1010
useRef,
11+
useLayoutEffect,
1112
forwardRef,
1213
ReactElement,
1314
} from 'react';
@@ -65,20 +66,26 @@ const TestComponent = memo(forwardRef<TestComponentRef, any>(function TestCompon
6566
Widget.resetOption.mockReset();
6667
}, []);
6768

69+
const propsRef = useRef(props);
70+
71+
useLayoutEffect(() => {
72+
propsRef.current = props;
73+
}, [props]);
74+
6875
useImperativeHandle(ref, () => {
6976
return {
7077
instance() {
7178
return {
7279
element() {
7380
return getElement();
7481
}
75-
}
82+
};
7683
},
7784
getProps() {
78-
return props;
85+
return propsRef.current;
7986
},
8087
};
81-
}, [componentRef.current, getElement, props]);
88+
}, []);
8289

8390
return (
8491
<Component<P & IHtmlOptions>
@@ -104,12 +111,17 @@ const TestPortalComponent = memo(forwardRef<TestComponentRef, any>(function Test
104111

105112
const TestRestoreTreeComponent = forwardRef((_, ref: React.ForwardedRef<{ restoreTree?: () => void }>) => {
106113
const restoreParentLink = useContext(RestoreTreeContext);
114+
const restoreParentLinkRef = useRef<() => void>(() => {});
115+
116+
useLayoutEffect(() => {
117+
restoreParentLinkRef.current = restoreParentLink ?? (() => {});
118+
}, [restoreParentLink]);
107119

108120
useImperativeHandle(ref, () => {
109121
return {
110-
restoreTree: restoreParentLink
122+
restoreTree: () => restoreParentLinkRef.current(),
111123
};
112-
}, [restoreParentLink]);
124+
}, []);
113125

114126
return <div>Context Component</div>;
115127
});

packages/devextreme-react/src/core/component-base.tsx

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
8080
const [, setForceUpdateToken] = useState(Symbol('initial force update token'));
8181
const removalLocker = useContext(RemovalLockerContext);
8282
const restoreParentLink = useContext(RestoreTreeContext);
83+
const restoreParentLinkRef = useRef(restoreParentLink);
8384
const instance = useRef<any>();
8485
const element = useRef<HTMLDivElement>();
8586
const portalContainer = useRef<HTMLElement | null>();
@@ -127,15 +128,10 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
127128
childElementsDetached.current = false;
128129
}
129130

130-
if (restoreParentLink && element.current && !element.current.isConnected) {
131-
restoreParentLink();
131+
if (restoreParentLinkRef.current && element.current && !element.current.isConnected) {
132+
restoreParentLinkRef.current();
132133
}
133-
}, [
134-
childNodes.current,
135-
element.current,
136-
childElementsDetached.current,
137-
restoreParentLink,
138-
]);
134+
}, []);
139135

140136
const updateCssClasses = useCallback((prevProps: (P & ComponentBaseProps) | undefined, newProps: P & ComponentBaseProps) => {
141137
const prevClassName = prevProps ? getClassName(prevProps) : undefined;
@@ -156,7 +152,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
156152
element.current?.classList.add(...classNames);
157153
}
158154
}
159-
}, [element.current]);
155+
}, []);
160156

161157
const setInlineStyles = useCallback((styles) => {
162158
if (element.current) {
@@ -172,7 +168,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
172168
},
173169
);
174170
}
175-
}, [element.current]);
171+
}, []);
176172

177173
const setTemplateManagerHooks = useCallback(({
178174
createDXTemplates: createDXTemplatesFn,
@@ -182,11 +178,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
182178
createDXTemplates.current = createDXTemplatesFn;
183179
clearInstantiationModels.current = clearInstantiationModelsFn;
184180
updateTemplates.current = updateTemplatesFn;
185-
}, [
186-
createDXTemplates.current,
187-
clearInstantiationModels.current,
188-
updateTemplates.current,
189-
]);
181+
}, []);
190182

191183
const getElementProps = useCallback(() => {
192184
const elementProps: Record<string, any> = {
@@ -203,7 +195,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
203195
}
204196
});
205197
return elementProps;
206-
}, [element.current, props]);
198+
}, [props]);
207199

208200
const scheduleTemplatesUpdate = useCallback(() => {
209201
if (guardsUpdateScheduled.current) {
@@ -221,11 +213,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
221213
});
222214

223215
unscheduleGuards();
224-
}, [
225-
guardsUpdateScheduled.current,
226-
useDeferUpdateForTemplates.current,
227-
updateTemplates.current,
228-
]);
216+
}, []);
229217

230218
const createWidget = useCallback((el?: Element) => {
231219
beforeCreateWidget();
@@ -266,14 +254,8 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
266254
}, [
267255
beforeCreateWidget,
268256
afterCreateWidget,
269-
element.current,
270-
optionsManager.current,
271-
createDXTemplates.current,
272-
clearInstantiationModels.current,
273257
WidgetClass,
274258
useRequestAnimationFrameFlag,
275-
useDeferUpdateForTemplates.current,
276-
instance.current,
277259
subscribableOptions,
278260
independentEvents,
279261
widgetConfig,
@@ -284,7 +266,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
284266
instance.current.focus();
285267
shouldRestoreFocus.current = false;
286268
}
287-
}, [shouldRestoreFocus.current, instance.current]);
269+
}, []);
288270

289271
const onComponentUpdated = useCallback(() => {
290272
if (!optionsManager.current?.isInstanceSet) {
@@ -301,9 +283,6 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
301283

302284
prevPropsRef.current = props;
303285
}, [
304-
optionsManager.current,
305-
prevPropsRef.current,
306-
createDXTemplates.current,
307286
scheduleTemplatesUpdate,
308287
updateCssClasses,
309288
props,
@@ -326,9 +305,6 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
326305

327306
prevPropsRef.current = props;
328307
}, [
329-
childNodes.current,
330-
element.current,
331-
childElementsDetached.current,
332308
updateCssClasses,
333309
setInlineStyles,
334310
props,
@@ -358,15 +334,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
358334
optionsManager.current.dispose();
359335

360336
removalLocker?.unlock();
361-
}, [
362-
removalLocker,
363-
instance.current,
364-
childNodes.current,
365-
element.current,
366-
optionsManager.current,
367-
childElementsDetached.current,
368-
shouldRestoreFocus.current,
369-
]);
337+
}, [removalLocker]);
370338

371339
useLayoutEffect(() => {
372340
onComponentMounted();
@@ -376,10 +344,20 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
376344
};
377345
}, []);
378346

347+
useLayoutEffect(() => {
348+
restoreParentLinkRef.current = restoreParentLink;
349+
}, [restoreParentLink]);
350+
379351
useLayoutEffect(() => {
380352
onComponentUpdated();
381353
});
382354

355+
const createWidgetRef = useRef(createWidget);
356+
357+
useLayoutEffect(() => {
358+
createWidgetRef.current = createWidget;
359+
}, [createWidget]);
360+
383361
useImperativeHandle(ref, () => (
384362
{
385363
getInstance() {
@@ -389,10 +367,10 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
389367
return element.current;
390368
},
391369
createWidget(el) {
392-
createWidget(el);
370+
createWidgetRef.current?.(el);
393371
},
394372
}
395-
), [instance.current, element.current, createWidget]);
373+
), []);
396374

397375
const _renderChildren = useCallback(() => {
398376
if (renderChildren) {
@@ -406,7 +384,7 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
406384
const renderPortal = useCallback(() => portalContainer.current && createPortal(
407385
_renderChildren(),
408386
portalContainer.current,
409-
), [portalContainer.current, _renderChildren]);
387+
), [_renderChildren]);
410388

411389
const renderContent = useCallback(() => {
412390
const { children } = props;
@@ -425,7 +403,6 @@ const ComponentBase = forwardRef<ComponentBaseRef, any>(
425403
}, [
426404
props,
427405
isPortalComponent,
428-
portalContainer.current,
429406
_renderChildren,
430407
]);
431408

0 commit comments

Comments
 (0)