Skip to content

Commit 65a87d2

Browse files
fix: double event fire in React wrappers (T1293563) (DevExpress#30138)
1 parent f279cae commit 65a87d2

File tree

4 files changed

+129
-58
lines changed

4 files changed

+129
-58
lines changed

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

Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -250,35 +250,37 @@ describe('option update', () => {
250250
expect(Widget.option.mock.calls.length).toBe(1);
251251
expect(Widget.option.mock.calls[0]).toEqual(['items[0].subItems[0].a', 234]);
252252
});
253-
});
254253

255-
it('updates sub-nested collection item within a custom component', () => {
256-
const MySetting = (props: any) => {
257-
const { value } = props;
254+
it('updates sub-nested collection item within a custom component', () => {
255+
const MySetting = (props: any) => {
256+
const { value } = props;
258257

259-
return (
260-
<CollectionNestedComponent>
261-
<CollectionSubNestedComponent a={value} />
262-
</CollectionNestedComponent>
263-
);
264-
};
258+
return (
259+
<CollectionNestedComponent>
260+
<CollectionSubNestedComponent a={value} />
261+
</CollectionNestedComponent>
262+
);
263+
};
265264

266-
const TestContainer = (props: any) => {
267-
const { value } = props;
268-
return (
269-
<TestComponentWithExpectation>
270-
<MySetting value={value} />
271-
</TestComponentWithExpectation>
272-
);
273-
};
274-
const { rerender } = render(<TestContainer value={123} />);
275-
rerender(<TestContainer value={234} />);
265+
const TestContainer = (props: any) => {
266+
const { value } = props;
267+
return (
268+
<TestComponentWithExpectation>
269+
<MySetting value={value} />
270+
</TestComponentWithExpectation>
271+
);
272+
};
273+
const { rerender } = render(<TestContainer value={123} />);
274+
rerender(<TestContainer value={234} />);
276275

277-
jest.runAllTimers();
278-
expect(Widget.option.mock.calls.length).toBe(1);
279-
expect(Widget.option.mock.calls[0]).toEqual(['items[0].subItems[0].a', 234]);
276+
jest.runAllTimers();
277+
expect(Widget.option.mock.calls.length).toBe(1);
278+
expect(Widget.option.mock.calls[0]).toEqual(['items[0].subItems[0].a', 234]);
279+
});
280280
});
281281

282+
283+
282284
describe('option control', () => {
283285
afterEach(() => {
284286
jest.clearAllMocks();
@@ -359,26 +361,36 @@ describe('option control', () => {
359361
});
360362

361363
it('should not rollback option if optionChanged is fired in endUpdate on props updating', () => {
362-
const { rerender } = render(
364+
const obj = { a: 'changed' };
365+
366+
let { rerender } = render(
363367
<ControlledComponent
364368
controlledOption="controlled"
369+
complexOption={{ a: "controlled" }}
365370
/>,
366371
);
367372

368-
Widget.endUpdate.mockImplementation(
369-
() => {
370-
fireOptionChange('controlledOption', 'changed');
371-
},
372-
);
373+
try {
374+
Widget.endUpdate.mockImplementation(
375+
() => {
376+
fireOptionChange('controlledOption', 'changed');
377+
fireOptionChange('complexOption', obj);
378+
},
379+
);
373380

374-
rerender(<ControlledComponent
375-
controlledOption="changed"
376-
/>);
381+
rerender(<ControlledComponent
382+
controlledOption="changed"
383+
complexOption={obj}
384+
/>);
377385

378-
jest.runAllTimers(); // it is necessary to test that setGuard is not called
386+
jest.runAllTimers(); // it is necessary to test that setGuard is not called
379387

380-
expect(Widget.option).toHaveBeenCalledTimes(1);
381-
expect(Widget.option).toHaveBeenCalledWith('controlledOption', 'changed');
388+
expect(Widget.option).toHaveBeenCalledTimes(2);
389+
expect(Widget.option).toHaveBeenCalledWith('controlledOption', 'changed');
390+
expect(Widget.option).toHaveBeenCalledWith('complexOption', obj);
391+
} finally {
392+
Widget.endUpdate.mockRestore();
393+
}
382394
});
383395

384396
it('is not updated on other prop updating', () => {
@@ -542,8 +554,42 @@ describe('option control', () => {
542554
);
543555

544556
jest.runAllTimers();
557+
expect(Widget.option.mock.calls.length).toBe(0);
558+
});
559+
560+
it('applies option changed twice', () => {
561+
const { rerender } = render(
562+
<ControlledComponent controlledOption='a' />,
563+
);
564+
565+
fireOptionChange('controlledOption', 'b');
566+
fireOptionChange('controlledOption', 'c');
567+
568+
rerender(
569+
<ControlledComponent controlledOption='c' />,
570+
);
571+
572+
jest.runAllTimers();
573+
574+
expect(Widget.option.mock.calls.length).toBe(0);
575+
});
576+
577+
it('rolls back an option changed one extra time', () => {
578+
const { rerender } = render(
579+
<ControlledComponent controlledOption='a' />,
580+
);
581+
582+
fireOptionChange('controlledOption', 'b');
583+
fireOptionChange('controlledOption', 'c');
584+
585+
rerender(
586+
<ControlledComponent controlledOption='b' />,
587+
);
588+
589+
jest.runAllTimers();
590+
545591
expect(Widget.option.mock.calls.length).toBe(1);
546-
expect(Widget.option.mock.calls[0]).toEqual(['everyOption', 234]);
592+
expect(Widget.option.mock.calls[0]).toEqual(['controlledOption', 'b']);
547593
});
548594

549595
it('applies option change with async React 18+ update', () => {
@@ -561,8 +607,7 @@ describe('option control', () => {
561607
);
562608

563609
jest.runAllTimers();
564-
expect(Widget.option.mock.calls.length).toBe(1);
565-
expect(Widget.option.mock.calls[0]).toEqual(['everyOption', 234]);
610+
expect(Widget.option.mock.calls.length).toBe(0);
566611
});
567612

568613
it('applies complex option change', () => {
@@ -939,8 +984,7 @@ describe('cfg-component option control', () => {
939984
rerender(<TestContainer value={234} />);
940985

941986
jest.runAllTimers();
942-
expect(Widget.option.mock.calls.length).toBe(1);
943-
expect(Widget.option.mock.calls[0]).toEqual(['nestedOption.a', 234]);
987+
expect(Widget.option.mock.calls.length).toBe(0);
944988
});
945989

946990
it('apply cfg-component option change if value really change (option in custom configuration component)', () => {
@@ -961,8 +1005,7 @@ describe('cfg-component option control', () => {
9611005
rerender(<TestContainer value={234} />);
9621006

9631007
jest.runAllTimers();
964-
expect(Widget.option.mock.calls.length).toBe(1);
965-
expect(Widget.option.mock.calls[0]).toEqual(['nestedOption.a', 234]);
1008+
expect(Widget.option.mock.calls.length).toBe(0);
9661009
});
9671010

9681011
it('does not control not specified cfg-component option', () => {

packages/devextreme-react/src/core/configuration/comparer.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ interface IConfigChanges {
1010
currentOptions: Record<string, any>, prevOptions: Record<string, any>, path: string) => void;
1111
}
1212

13+
function compareValues(
14+
value1: unknown,
15+
value2: unknown,
16+
): boolean {
17+
return value1 === value2;
18+
}
19+
1320
function compareTemplates(
1421
current: IConfigNode,
1522
currentFullName: string,
@@ -29,7 +36,7 @@ function compareTemplates(
2936
// appendRemovedValues(currentTemplates, prevTemplates, "", changesAccum.templates);
3037

3138
Object.keys(currentTemplatesOptions).forEach((key) => {
32-
if (currentTemplatesOptions[key] === prevTemplatesOptions[key]) {
39+
if (compareValues(currentTemplatesOptions[key], prevTemplatesOptions[key])) {
3340
return;
3441
}
3542

@@ -39,7 +46,7 @@ function compareTemplates(
3946
Object.keys(currentTemplates).forEach((key) => {
4047
const currentTemplate = currentTemplates[key];
4148
const prevTemplate = prevTemplates[key];
42-
if (prevTemplate && currentTemplate.content === prevTemplate.content) {
49+
if (prevTemplate && compareValues(currentTemplate.content, prevTemplate.content)) {
4350
return;
4451
}
4552

@@ -75,7 +82,7 @@ function compare(current: IConfigNode, prev: IConfigNode, changesAccum: IConfigC
7582
});
7683

7784
Object.keys(current.options).forEach((key) => {
78-
if (current.options[key] === prev.options[key]) {
85+
if (compareValues(current.options[key], prev.options[key])) {
7986
return;
8087
}
8188

@@ -142,4 +149,5 @@ function compareCollections(
142149

143150
export {
144151
getChanges,
152+
compareValues,
145153
};

packages/devextreme-react/src/core/options-manager.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/* eslint-disable no-restricted-globals */
2-
import { getChanges } from './configuration/comparer';
2+
import { compareValues, getChanges } from './configuration/comparer';
33
import { buildConfig, findValue, ValueType } from './configuration/tree';
44
import { mergeNameParts, shallowEquals } from './configuration/utils';
55
import { capitalizeFirstLetter } from './helpers';
66
import type { IConfigNode, ITemplate } from './configuration/config-node';
7-
import { DXTemplateCollection } from './types';
7+
import { DXTemplateCollection, GuardObject } from './types';
88

99
const optionsManagers = new Set<OptionsManager>();
1010
let guardTimeoutHandler = -1;
@@ -24,7 +24,7 @@ export function scheduleGuards(): void {
2424
}
2525

2626
class OptionsManager {
27-
private readonly guards: Record<string, () => void> = {};
27+
private readonly guards: Record<string, GuardObject> = {};
2828

2929
private instance: any;
3030

@@ -137,17 +137,22 @@ class OptionsManager {
137137
const { value, type } = valueDescriptor;
138138
if (value instanceof Array && type === ValueType.Array) {
139139
for (let i = 0; i < value.length; i += 1) {
140-
if (value[i] !== (e.value as unknown[])?.[i]) {
141-
this.addGuard(e.fullName, value);
140+
const newValue = (e.value as unknown[])?.[i];
141+
142+
if (value[i] !== newValue) {
143+
this.addGuard(e.fullName, value, newValue);
142144
return;
143145
}
144146
}
145147
} else if (type === ValueType.Complex && value instanceof Object) {
146148
Object.keys(value).forEach((key) => {
147-
if (value[key] === (e.value as Record<string, unknown>)?.[key]) {
149+
const newValue = (e.value as Record<string, unknown>)?.[key];
150+
151+
if (value[key] === newValue) {
148152
return;
149153
}
150-
this.addGuard(mergeNameParts(e.fullName, key), value[key]);
154+
155+
this.addGuard(mergeNameParts(e.fullName, key), value[key], newValue);
151156
});
152157
} else {
153158
const valuesAreEqual = value === e.value;
@@ -160,7 +165,7 @@ class OptionsManager {
160165
return;
161166
}
162167

163-
this.addGuard(e.fullName, value);
168+
this.addGuard(e.fullName, value, e.value);
164169
}
165170
}
166171

@@ -225,21 +230,25 @@ class OptionsManager {
225230
return value;
226231
}
227232

228-
private addGuard(optionName: string, optionValue: unknown): void {
233+
private addGuard(optionName: string, initialValue: unknown, latestValue: unknown): void {
229234
if (this.guards[optionName] !== undefined) {
235+
this.guards[optionName].latestValue = latestValue;
230236
return;
231237
}
232238
const handler = () => {
233-
this.setValue(optionName, optionValue);
239+
this.setValue(optionName, initialValue);
234240
delete this.guards[optionName];
235241
};
236-
this.guards[optionName] = handler;
242+
this.guards[optionName] = {
243+
handler,
244+
latestValue,
245+
};
237246
scheduleGuards();
238247
}
239248

240249
public execGuards(): void {
241250
Object.values(this.guards)
242-
.forEach((handler) => handler());
251+
.forEach((guard) => guard.handler());
243252
}
244253

245254
private resetOption(name: string) {
@@ -257,7 +266,13 @@ class OptionsManager {
257266

258267
private setValue(name: string, value: unknown) {
259268
if (this.guards[name]) {
260-
delete this.guards[name];
269+
try {
270+
if (compareValues(this.guards[name].latestValue, value)) {
271+
return;
272+
}
273+
} finally {
274+
delete this.guards[name];
275+
}
261276
}
262277

263278
this.instance.option(

packages/devextreme-react/src/core/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export interface RenderArgs {
2626

2727
export type DXTemplateCollection = Record<string, DXTemplate>;
2828

29+
export interface GuardObject {
30+
handler: () => void;
31+
latestValue: unknown;
32+
}
33+
2934
export interface TemplateWrapperProps {
3035
templateFactory: TemplateFunc;
3136
data: any;

0 commit comments

Comments
 (0)