Skip to content

Commit 1215e57

Browse files
authored
T1106899 - Toolbar - An item's content is not updated according to the React state if it is nested in a DataGrid component instance in React 18 (#751) (#752)
* Fix T1106899 In new react 18 guards call early then a switch is rendered. Move schedule guard timeout after the switch is rendered.
1 parent 56516c8 commit 1215e57

File tree

6 files changed

+152
-46
lines changed

6 files changed

+152
-46
lines changed

.eslintrc

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": [
3-
"devextreme/typescript"
3+
"devextreme/typescript",
4+
"devextreme/spell-check"
45
],
56
"env": {
67
"browser": true,
@@ -15,14 +16,30 @@
1516
"import/no-cycle": "warn",
1617
"no-param-reassign": "warn",
1718
"no-underscore-dangle": "warn",
18-
"@typescript-eslint/no-explicit-any": "warn"
19+
"@typescript-eslint/no-explicit-any": "warn",
20+
"spellcheck/spell-checker": [1, {
21+
"lang": "en_US",
22+
"comments": false,
23+
"strings": false,
24+
"identifiers": true,
25+
"templates": false,
26+
"skipIfMatch": [ "^\\$?..$" ],
27+
"skipWords": [
28+
"unschedule",
29+
"subscribable",
30+
"renderer",
31+
"rerender"
32+
]
33+
}
34+
]
1935
},
2036
"overrides": [
2137
{
22-
"files": [ "./packages/sandbox/*.ts", "./packages/sandbox/*.tsx" ],
38+
"files": [ "./packages/sandbox/*.ts", "./packages/sandbox/*.tsx", "packages/devextreme-react/src/*.ts" ],
2339
"rules": {
24-
"import/extensions": "warn"
40+
"import/extensions": "warn",
2541
}
2642
}
2743
]
2844
}
45+

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

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
/* eslint-disable max-classes-per-file */
22
import { cleanup, render } from '@testing-library/react';
33
import * as React from 'react';
4+
import * as CommonModule from 'devextreme/core/utils/common';
45
import ConfigurationComponent from '../nested-option';
5-
import { OptionsManager } from '../options-manager';
6+
import * as OptionsManagerModule from '../options-manager';
67
import {
78
eventHandlers,
89
fireOptionChange,
910
TestComponent,
1011
Widget,
1112
WidgetClass,
1213
} from './test-component';
14+
import TemplatesManager from '../templates-manager';
15+
import { TemplatesStore } from '../templates-store';
1316

1417
jest.useFakeTimers();
1518

@@ -592,6 +595,69 @@ describe('cfg-component option control', () => {
592595
expect(Widget.option.mock.calls[1]).toEqual(['nestedOption.b', 'const']);
593596
});
594597

598+
// T1106899
599+
it('apply cfg-component option value if value has changes', () => {
600+
const optionsManager = new OptionsManagerModule.OptionsManager(
601+
new TemplatesManager(new TemplatesStore(() => {})),
602+
);
603+
const config = {
604+
fullName: '',
605+
predefinedOptions: {},
606+
initialOptions: {},
607+
options: { value: 1 },
608+
templates: [],
609+
configs: {},
610+
configCollections: {},
611+
};
612+
optionsManager.setInstance({
613+
skipOptionsRollBack: false,
614+
option: jest.fn(),
615+
on: jest.fn(),
616+
off: jest.fn(),
617+
beginUpdate: jest.fn(),
618+
endUpdate: jest.fn(),
619+
}, config, [], []);
620+
jest.spyOn(optionsManager as any, 'addGuard');
621+
jest.spyOn(optionsManager as any, 'setValue');
622+
jest.spyOn(OptionsManagerModule, 'scheduleGuards');
623+
jest.spyOn(OptionsManagerModule, 'unscheduleGuards');
624+
let renderTemplate;
625+
jest.spyOn(CommonModule, 'deferUpdate').mockImplementation((cb) => { renderTemplate = cb; });
626+
const TestContainer = (props: any) => {
627+
const { value } = props;
628+
return (
629+
<ControlledComponent>
630+
<NestedComponent a={value} b="const" />
631+
</ControlledComponent>
632+
);
633+
};
634+
635+
const { rerender } = render(<TestContainer value={2} />);
636+
jest.runAllTimers();
637+
// simulate option changing in jQuery control
638+
optionsManager.onOptionChanged({ name: 'value', value: 2, fullName: 'value' });
639+
// add guards for restore value
640+
expect((optionsManager as any).addGuard).toBeCalled();
641+
// but no call it
642+
expect((optionsManager as any).setValue).not.toBeCalled();
643+
// re-render container. Unschedule guards and wait template render for schedule it back
644+
rerender(<TestContainer value={2} />);
645+
expect(OptionsManagerModule.scheduleGuards).not.toBeCalled();
646+
expect(OptionsManagerModule.unscheduleGuards).toBeCalled();
647+
expect((optionsManager as any).setValue).not.toBeCalled();
648+
jest.runAllTimers();
649+
// simulate Request Animation Frame for template re-render
650+
renderTemplate();
651+
// guards are scheduled
652+
expect(OptionsManagerModule.scheduleGuards).toBeCalled();
653+
const updatedConfig = { ...config, options: { value: 2 } };
654+
// value changed and options manager set value and remove scheduled guard
655+
optionsManager.update(updatedConfig);
656+
expect((optionsManager as any).setValue).toBeCalled();
657+
jest.runAllTimers();
658+
expect((optionsManager as any).setValue).toHaveBeenCalledTimes(1);
659+
});
660+
595661
it('apply cfg-component option change if value really change', () => {
596662
const TestContainer = (props: any) => {
597663
const { value } = props;
@@ -805,7 +871,8 @@ describe('onXXXChange', () => {
805871

806872
beforeAll(() => {
807873
jest.spyOn(
808-
OptionsManager.prototype as OptionsManager & { isOptionSubscribable: () => boolean; },
874+
OptionsManagerModule.OptionsManager.prototype as
875+
OptionsManagerModule.OptionsManager & { isOptionSubscribable: () => boolean; },
809876
'isOptionSubscribable',
810877
)
811878
.mockImplementation(() => true);
@@ -1040,7 +1107,8 @@ describe('onXXXChange', () => {
10401107
describe('non-subscribable options', () => {
10411108
beforeAll(() => {
10421109
jest.spyOn(
1043-
OptionsManager.prototype as OptionsManager & { isOptionSubscribable: () => boolean; },
1110+
OptionsManagerModule.OptionsManager.prototype as
1111+
OptionsManagerModule.OptionsManager & { isOptionSubscribable: () => boolean; },
10441112
'isOptionSubscribable',
10451113
)
10461114
.mockImplementation(() => false);
@@ -1080,7 +1148,8 @@ describe('onXXXChange', () => {
10801148
describe('independent events', () => {
10811149
beforeAll(() => {
10821150
jest.spyOn(
1083-
OptionsManager.prototype as OptionsManager & { isIndependentEvent: () => boolean; },
1151+
OptionsManagerModule.OptionsManager.prototype as
1152+
OptionsManagerModule.OptionsManager & { isIndependentEvent: () => boolean; },
10841153
'isIndependentEvent',
10851154
)
10861155
.mockImplementation(() => true);
@@ -1136,7 +1205,8 @@ describe('onXXXChange', () => {
11361205
describe('dependent events', () => {
11371206
beforeAll(() => {
11381207
jest.spyOn(
1139-
OptionsManager.prototype as OptionsManager & { isIndependentEvent: () => boolean; },
1208+
OptionsManagerModule.OptionsManager.prototype as
1209+
OptionsManagerModule.OptionsManager & { isIndependentEvent: () => boolean; },
11401210
'isIndependentEvent',
11411211
)
11421212
.mockImplementation(() => false);

packages/devextreme-react/src/core/__tests__/templates-renderer.test.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jest.mock('devextreme/core/utils/common', () => ({
9090
});
9191
});
9292

93-
fdescribe('option update', () => {
93+
describe('option update', () => {
9494
it('should call forceUpdateCallback and reset "updateScheduled" after forceUpdateCallback', async () => {
9595
const ref = React.createRef<TemplatesRenderer>();
9696
const templatesStore = new TemplatesStore(() => { });
@@ -100,19 +100,17 @@ fdescribe('option update', () => {
100100
});
101101

102102
render(<TemplatesRenderer templatesStore={templatesStore} ref={ref} />);
103-
104103
const current = ref.current!;
105-
const actualForceUpdateCallback = current.forceUpdateCallback;
106-
const spyForceUpdateCallback = jest.spyOn(current, 'forceUpdateCallback').mockImplementation(() => {
104+
const onRendered = jest.fn();
105+
const spyForceUpdateCallback = jest.spyOn(current, 'forceUpdate').mockImplementation((cb) => {
107106
expect((current as any).updateScheduled).toEqual(true);
108-
109-
actualForceUpdateCallback();
107+
expect(onRendered).not.toHaveBeenCalled();
108+
cb();
109+
expect(onRendered).toHaveBeenCalled();
110110

111111
expect((current as any).updateScheduled).toEqual(false);
112112
});
113-
114-
current.scheduleUpdate(true);
115-
113+
current.scheduleUpdate(true, onRendered);
116114
expect(spyForceUpdateCallback).toHaveBeenCalledTimes(1);
117115
});
118116
});

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import * as events from 'devextreme/events';
22
import * as React from 'react';
33
import { createPortal } from 'react-dom';
44

5-
import { OptionsManager } from './options-manager';
6-
import { ITemplateMeta } from './template';
75
import TemplatesManager from './templates-manager';
86
import { TemplatesRenderer } from './templates-renderer';
97
import { TemplatesStore } from './templates-store';
8+
9+
import { OptionsManager, scheduleGuards, unscheduleGuards } from './options-manager';
10+
import { ITemplateMeta } from './template';
1011
import { elementPropNames, getClassName } from './widget-config';
1112

1213
import { IConfigNode } from './configuration/config-node';
@@ -94,12 +95,12 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
9495

9596
public componentDidUpdate(prevProps: P): void {
9697
this._updateCssClasses(prevProps, this.props);
97-
9898
const config = this._getConfig();
9999
this._optionsManager.update(config);
100100
if (this._templatesRendererRef) {
101-
this._templatesRendererRef.scheduleUpdate(this.useDeferUpdateForTemplates);
101+
this._templatesRendererRef.scheduleUpdate(this.useDeferUpdateForTemplates, scheduleGuards);
102102
}
103+
unscheduleGuards();
103104
}
104105

105106
public componentWillUnmount(): void {

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

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
1-
import TemplatesManager from './templates-manager';
2-
1+
/* eslint-disable no-restricted-globals */
32
import { getChanges } from './configuration/comparer';
4-
import { IConfigNode } from './configuration/config-node';
53
import { buildConfig, findValue, ValueType } from './configuration/tree';
64
import { mergeNameParts, shallowEquals } from './configuration/utils';
75
import { capitalizeFirstLetter } from './helpers';
86

7+
import type TemplatesManager from './templates-manager';
8+
import type { IConfigNode } from './configuration/config-node';
9+
10+
const optionsManagers = new Set<OptionsManager>();
11+
let guardTimeoutHandler = -1;
12+
13+
export function unscheduleGuards(): void {
14+
clearTimeout(guardTimeoutHandler);
15+
}
16+
export function scheduleGuards(): void {
17+
unscheduleGuards();
18+
guardTimeoutHandler = window.setTimeout(() => {
19+
optionsManagers.forEach((optionManager) => optionManager.execGuards());
20+
});
21+
}
22+
923
class OptionsManager {
10-
private readonly guards: Record<string, number> = {};
24+
private readonly guards: Record<string, ()=> void> = {};
1125

1226
private templatesManager: TemplatesManager;
1327

@@ -23,6 +37,7 @@ class OptionsManager {
2337

2438
constructor(templatesManager: TemplatesManager) {
2539
this.templatesManager = templatesManager;
40+
optionsManagers.add(this);
2641

2742
this.onOptionChanged = this.onOptionChanged.bind(this);
2843
this.wrapOptionValue = this.wrapOptionValue.bind(this);
@@ -62,7 +77,7 @@ class OptionsManager {
6277
}
6378

6479
public update(config: IConfigNode): void {
65-
const changedOptions:Array<[string, any]> = [];
80+
const changedOptions:Array<[string, unknown]> = [];
6681
const optionChangedHandler = ({ value, fullName }) => {
6782
changedOptions.push([fullName, value]);
6883
};
@@ -103,7 +118,8 @@ class OptionsManager {
103118

104119
changedOptions.forEach(([name, value]) => {
105120
const currentPropValue = config.options[name];
106-
if (config.options.hasOwnProperty(name) && currentPropValue !== value) {
121+
if (Object.prototype.hasOwnProperty.call(config.options, name)
122+
&& currentPropValue !== value) {
107123
this.setValue(name, currentPropValue);
108124
}
109125
});
@@ -129,7 +145,7 @@ class OptionsManager {
129145
if (value instanceof Array && type === ValueType.Array) {
130146
for (let i = 0; i < value.length; i += 1) {
131147
if (value[i] !== (e.value as Array<unknown>)?.[i]) {
132-
this.setGuard(e.fullName, value);
148+
this.addGuard(e.fullName, value);
133149
return;
134150
}
135151
}
@@ -138,7 +154,7 @@ class OptionsManager {
138154
if (value[key] === (e.value as Record<string, unknown>)?.[key]) {
139155
return;
140156
}
141-
this.setGuard(mergeNameParts(e.fullName, key), value[key]);
157+
this.addGuard(mergeNameParts(e.fullName, key), value[key]);
142158
});
143159
} else {
144160
const valuesAreEqual = value === e.value;
@@ -151,13 +167,13 @@ class OptionsManager {
151167
return;
152168
}
153169

154-
this.setGuard(e.fullName, value);
170+
this.addGuard(e.fullName, value);
155171
}
156172
}
157173

158174
public dispose(): void {
175+
optionsManagers.delete(this);
159176
Object.keys(this.guards).forEach((optionName) => {
160-
window.clearTimeout(this.guards[optionName]);
161177
delete this.guards[optionName];
162178
});
163179
}
@@ -211,18 +227,21 @@ class OptionsManager {
211227
return value;
212228
}
213229

214-
private setGuard(optionName: string, optionValue: unknown): void {
230+
private addGuard(optionName: string, optionValue: unknown): void {
215231
if (this.guards[optionName] !== undefined) {
216232
return;
217233
}
218-
219-
const guardId = window.setTimeout(() => {
234+
const handler = () => {
220235
this.setValue(optionName, optionValue);
221-
window.clearTimeout(guardId);
222236
delete this.guards[optionName];
223-
});
237+
};
238+
this.guards[optionName] = handler;
239+
scheduleGuards();
240+
}
224241

225-
this.guards[optionName] = guardId;
242+
public execGuards(): void {
243+
Object.values(this.guards)
244+
.forEach((handler) => handler());
226245
}
227246

228247
private resetOption(name: string) {
@@ -231,7 +250,6 @@ class OptionsManager {
231250

232251
private setValue(name: string, value: unknown) {
233252
if (this.guards[name]) {
234-
window.clearTimeout(this.guards[name]);
235253
delete this.guards[name];
236254
}
237255

0 commit comments

Comments
 (0)