Repository: DevExpress/devextreme-react
Pull request #575 (2021-06-16 13:01:27)

Fix regression with object option changed
Cherry picked from https://github.com/DevExpress/devextreme-react/pull/572


Patch:
diff --git a/packages/devextreme-react-generator/src/generator.ts b/packages/devextreme-react-generator/src/generator.ts
index 25dc390..cfb0e39 100644
--- a/packages/devextreme-react-generator/src/generator.ts
+++ b/packages/devextreme-react-generator/src/generator.ts
@@ -44,7 +44,7 @@ enum BaseTypes {
   String = 'string',
   Number = 'number',
   Boolean = 'boolean',
-  Object = 'object'
+  Object = 'object',
 }
 
 export function mapSubscribableOption(prop: IProp): ISubscribableOption {
diff --git a/packages/devextreme-react/src/core/configuration/tree.ts b/packages/devextreme-react/src/core/configuration/tree.ts
index 16129a5..2661b21 100644
--- a/packages/devextreme-react/src/core/configuration/tree.ts
+++ b/packages/devextreme-react/src/core/configuration/tree.ts
@@ -86,7 +86,7 @@ function findValueInObject(obj: unknown, path: string[]): undefined | IValueDesc
   if (!key) {
     return {
       value: obj,
-      type: obj instanceof Object ? ValueType.Complex : ValueType.Simple,
+      type: ValueType.Simple,
     };
   }
 
diff --git a/packages/devextreme-react/src/core/configuration/utils.ts b/packages/devextreme-react/src/core/configuration/utils.ts
index 71d7b55..fffa3e0 100644
--- a/packages/devextreme-react/src/core/configuration/utils.ts
+++ b/packages/devextreme-react/src/core/configuration/utils.ts
@@ -37,3 +37,14 @@ export const isIE = (): boolean => {
 
   return (msie > 0 || trident > 0);
 };
+
+export const shallowEquals = (
+  first: Record<string, unknown>,
+  second: Record<string, unknown>,
+): boolean => {
+  if (Object.keys(first).length !== Object.keys(second).length) {
+    return false;
+  }
+
+  return Object.keys(first).every((key) => first[key] === second[key]);
+};
diff --git a/packages/devextreme-react/src/core/options-manager.ts b/packages/devextreme-react/src/core/options-manager.ts
index d2df91b..049fa7b 100644
--- a/packages/devextreme-react/src/core/options-manager.ts
+++ b/packages/devextreme-react/src/core/options-manager.ts
@@ -3,7 +3,7 @@ import TemplatesManager from './templates-manager';
 import { getChanges } from './configuration/comparer';
 import { IConfigNode } from './configuration/config-node';
 import { buildConfig, findValue, ValueType } from './configuration/tree';
-import { mergeNameParts } from './configuration/utils';
+import { mergeNameParts, shallowEquals } from './configuration/utils';
 import { capitalizeFirstLetter } from './helpers';
 
 class OptionsManager {
@@ -113,17 +113,31 @@ class OptionsManager {
     }
 
     const { value, type } = valueDescriptor;
-    if (type === ValueType.Complex && value instanceof Object) {
+    if (value instanceof Array && type === ValueType.Array) {
+      for (let i = 0; i < value.length; i += 1) {
+        if (value[i] !== (e.value as Array<unknown>)?.[i]) {
+          this.setGuard(e.fullName, value);
+          return;
+        }
+      }
+    } else if (type === ValueType.Complex && value instanceof Object) {
       Object.keys(value).forEach((key) => {
-        if (value[key] === (e.value as Record<string, unknown>)[key]) {
+        if (value[key] === (e.value as Record<string, unknown>)?.[key]) {
           return;
         }
         this.setGuard(mergeNameParts(e.fullName, key), value[key]);
       });
     } else {
-      if (value === e.value) {
+      const valuesAreEqual = value === e.value;
+      const valuesAreEqualObjects = !valuesAreEqual
+        && value instanceof Object
+        && e.value instanceof Object
+        && shallowEquals(value as Record<string, unknown>, e.value as Record<string, unknown>);
+
+      if (valuesAreEqual || valuesAreEqualObjects) {
         return;
       }
+
       this.setGuard(e.fullName, value);
     }
   }
diff --git a/packages/devextreme-react/src/core/template-wrapper.ts b/packages/devextreme-react/src/core/template-wrapper.ts
index f8b06b0..3ff1e86 100644
--- a/packages/devextreme-react/src/core/template-wrapper.ts
+++ b/packages/devextreme-react/src/core/template-wrapper.ts
@@ -24,6 +24,7 @@ class TemplateWrapper extends React.PureComponent<ITemplateWrapperProps, ITempla
   private readonly _removalListenerRef = React.createRef<HTMLElement>();
 
   private element: HTMLElement | undefined | null;
+
   private hiddenElement: HTMLElement | undefined | null;
 
   constructor(props: ITemplateWrapperProps) {
@@ -53,7 +54,7 @@ class TemplateWrapper extends React.PureComponent<ITemplateWrapperProps, ITempla
     if (node) {
       container.appendChild(node);
     }
-    if(hiddenNode) {
+    if (hiddenNode) {
       container.appendChild(hiddenNode);
     }
     if (this._listenerElement) {
diff --git a/packages/devextreme-react/tsconfig.json b/packages/devextreme-react/tsconfig.json
index d7bff72..c9650e3 100644
--- a/packages/devextreme-react/tsconfig.json
+++ b/packages/devextreme-react/tsconfig.json
@@ -9,4 +9,4 @@
     "src/**/*.ts",
     "src/**/*.tsx"
   ]
-}
\ No newline at end of file
+}
diff --git a/packages/sandbox/rerendered-chart-example.tsx b/packages/sandbox/rerendered-chart-example.tsx
index d843626..40b7a67 100644
--- a/packages/sandbox/rerendered-chart-example.tsx
+++ b/packages/sandbox/rerendered-chart-example.tsx
@@ -77,13 +77,11 @@ let handleClick = (): void => {
     const dataSource = chartDataSource;
 
     setTimeout(() => {
-      const dataFrame = data.map((item) => {
-        return {
-          date: new Date(item["date"]),
-          minTemp: item["minTemp"],
-          maxTemp: item["maxTemp"]
-        };
-      });
+      const dataFrame = data.map((item) => ({
+        date: new Date(item.date),
+        minTemp: item.minTemp,
+        maxTemp: item.maxTemp,
+      }));
 
       const componentStorage = dataSource.store();
       dataFrame.forEach((item) => componentStorage.insert(item));
@@ -105,7 +103,7 @@ class NewChartExample extends React.Component {
         <Chart id="chart" dataSource={chartDataSource}>
           <Series argumentField="date" valueField="minTemp" />
           <ArgumentAxis argumentType="datetime">
-            <Label render={LabelTemplate}></Label>
+            <Label render={LabelTemplate} />
           </ArgumentAxis>
         </Chart>
         <button onClick={handleClick}>Click Me</button>


Test patch:
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 cc7e056..8f22464 100644
--- a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx
+++ b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx
@@ -33,6 +33,7 @@ class NestedComponent extends ConfigurationComponent<{
   a?: number;
   b?: string;
   c?: string;
+  arrayValue?: Array<unknown> | null;
   defaultC?: string;
   complexValue?: Record<string, unknown>;
   value?: number;
@@ -257,9 +258,8 @@ describe('option control', () => {
     fireOptionChange('complexOption', {});
     jest.runAllTimers();
 
-    expect(Widget.option.mock.calls.length).toBe(2);
-    expect(Widget.option.mock.calls[0]).toEqual(['complexOption.a', 123]);
-    expect(Widget.option.mock.calls[1]).toEqual(['complexOption.b', 234]);
+    expect(Widget.option.mock.calls.length).toBe(1);
+    expect(Widget.option.mock.calls[0]).toEqual(['complexOption', { a: 123, b: 234 }]);
   });
 
   it('rolls back complex option controlled field', () => {
@@ -273,6 +273,17 @@ describe('option control', () => {
     expect(Widget.option.mock.calls[0]).toEqual(['complexOption.a', 123]);
   });
 
+  it('should not rolls back complex option if shallow equals', () => {
+    render(
+      <ControlledComponent complexOption={{ a: 123, b: 234 }} />,
+    );
+
+    fireOptionChange('complexOption', { a: 123, b: 234 });
+    jest.runAllTimers();
+
+    expect(Widget.option.mock.calls.length).toBe(0);
+  });
+
   it('rolls back one simple option and updates other', () => {
     const { rerender } = render(
       <ControlledComponent everyOption={123} anotherOption="const" />,
@@ -447,9 +458,20 @@ describe('cfg-component option control', () => {
 
     fireOptionChange('nestedOption.complexValue', {});
     jest.runAllTimers();
-    expect(Widget.option.mock.calls.length).toBe(2);
-    expect(Widget.option.mock.calls[0]).toEqual(['nestedOption.complexValue.a', 123]);
-    expect(Widget.option.mock.calls[1]).toEqual(['nestedOption.complexValue.b', 234]);
+    expect(Widget.option.mock.calls.length).toBe(1);
+    expect(Widget.option.mock.calls[0]).toEqual(['nestedOption.complexValue', { a: 123, b: 234 }]);
+  });
+
+  it('should not rolls cfg-component option complex value if shallow equals', () => {
+    render(
+      <ControlledComponent>
+        <NestedComponent complexValue={{ a: 123, b: 234 }} />
+      </ControlledComponent>,
+    );
+
+    fireOptionChange('nestedOption.complexValue', { a: 123, b: 234 });
+    jest.runAllTimers();
+    expect(Widget.option.mock.calls.length).toBe(0);
   });
 
   it('rolls cfg-component option value if parent object changes another field', () => {
@@ -904,6 +926,34 @@ describe('onXXXChange', () => {
       expect(onSubNestedPropChange).toBeCalledWith('4');
     });
 
+    it('is called on nested array option changed', () => {
+      render(
+        <TestComponent>
+          <NestedComponent
+            arrayValue={[1, 2]}
+          />
+        </TestComponent>,
+      );
+      fireOptionChange('nestedOption.arrayValue', [3, 4]);
+      jest.runAllTimers();
+      expect(Widget.option.mock.calls.length).toEqual(1);
+      expect(Widget.option.mock.calls[0]).toEqual(['nestedOption.arrayValue', [1, 2]]);
+    });
+
+    it('is called on nested null array option changed', () => {
+      render(
+        <TestComponent>
+          <NestedComponent
+            arrayValue={null}
+          />
+        </TestComponent>,
+      );
+      fireOptionChange('nestedOption.arrayValue', [1, 2]);
+      jest.runAllTimers();
+      expect(Widget.option.mock.calls.length).toEqual(1);
+      expect(Widget.option.mock.calls[0]).toEqual(['nestedOption.arrayValue', null]);
+    });
+
     it('throws an error if handler is not a function', () => {
       render(
         <TestComponent
diff --git a/packages/devextreme-react/src/core/__tests__/tagbox.test.tsx b/packages/devextreme-react/src/core/__tests__/tagbox.test.tsx
deleted file mode 100644
index 6473680..0000000
--- a/packages/devextreme-react/src/core/__tests__/tagbox.test.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { render, cleanup } from '@testing-library/react';
-import * as React from 'react';
-import { TagBox } from '../../tag-box';
-
-jest.useFakeTimers();
-
-describe('templates', () => {
-  afterEach(() => {
-    jest.clearAllMocks();
-    cleanup();
-  });
-
-  it('should change value without an error', () => {
-    const ref = React.createRef() as React.RefObject<TagBox>;
-    const { container } = render(<TagBox
-      ref={ref}
-      dataSource={['1', '2', '3']}
-      showClearButton
-      value={['1']}
-      tagRender={() => <div>test</div>}
-    />);
-    const instance = ref?.current?.instance;
-
-    instance?.option('value', ['1', '2', '3']);
-    jest.runAllTimers();
-    expect(container.children[0].getElementsByClassName('dx-tag').length).toBe(3);
-  });
-});