Repository: DevExpress/devextreme-react
Pull request #104 (2018-05-23 10:58:28)

Improve nested options controlled mode


Patch:
diff --git a/src/core/component.ts b/src/core/component.ts
index cf0a95b..24e4935 100644
--- a/src/core/component.ts
+++ b/src/core/component.ts
@@ -4,8 +4,7 @@ import * as events from "devextreme/events";
 
 import { addPrefixToKeys } from "./helpers";
 import { createConfigurationComponent } from "./nested-option";
-import { OptionCollection } from "./nested-option-collection";
-import { OptionsSyncer } from "./options-syncer";
+import { OptionsManager } from "./options-manager";
 import { ITemplateMeta, Template } from "./template";
 import { ITemplate, TemplateHelper } from "./template-helper";
 import { separateProps } from "./widget-config";
@@ -42,8 +41,7 @@ class Component<P> extends React.PureComponent<P, IState> {
   protected readonly _templateProps: ITemplateMeta[] = [];
 
   private readonly _templateHelper: TemplateHelper;
-  private readonly _nestedOptions: OptionCollection = new OptionCollection();
-  private readonly _syncer: OptionsSyncer;
+  private readonly _optionsManager: OptionsManager;
 
   private _element: any;
 
@@ -55,7 +53,7 @@ class Component<P> extends React.PureComponent<P, IState> {
       templates: {}
     };
 
-    this._syncer = new OptionsSyncer(this._nestedOptions, (name) => this.props[name]);
+    this._optionsManager = new OptionsManager((name) => this.props[name]);
     this._templateHelper = new TemplateHelper(this);
   }
 
@@ -66,7 +64,7 @@ class Component<P> extends React.PureComponent<P, IState> {
       ...this._getIntegrationOptions(preparedProps.templates, preparedProps.nestedTemplates)
     };
 
-    this._syncer.processChangedValues(options, this.props);
+    this._optionsManager.processChangedValues(options, this.props);
   }
 
   public render() {
@@ -77,7 +75,7 @@ class Component<P> extends React.PureComponent<P, IState> {
 
       if (!!this.props.children) {
           React.Children.forEach(this.props.children, (c) => {
-            args.push(this._registerNestedOption(c as React.ReactElement<any>));
+            args.push(this._registerNestedOption(c as React.ReactElement<any>) || c);
           });
       }
 
@@ -90,7 +88,7 @@ class Component<P> extends React.PureComponent<P, IState> {
   }
 
   public componentDidMount() {
-    const nestedProps = this.getNestedProps();
+    const nestedProps = this._optionsManager.getNestedOptionsObjects();
     const props = {
         ...(this.props as any),
         ...nestedProps
@@ -106,8 +104,8 @@ class Component<P> extends React.PureComponent<P, IState> {
     };
 
     this._instance = new this._WidgetClass(this._element, options);
-    this._syncer.instance = this._instance;
-    this._instance.on("optionChanged", this._syncer.optionChangedHandler);
+    this._optionsManager.setInstance(this._instance);
+    this._instance.on("optionChanged", this._optionsManager.handleOptionChange);
   }
 
   public componentWillUnmount() {
@@ -122,7 +120,7 @@ class Component<P> extends React.PureComponent<P, IState> {
     if (separatedProps.options) {
         options = {};
         Object.keys(separatedProps.options).forEach((key) => {
-            options[key] = this._syncer.wrapEventHandler(separatedProps.options[key], key);
+            options[key] = this._wrapEventHandler(separatedProps.options[key], key);
         });
     }
 
@@ -147,6 +145,18 @@ class Component<P> extends React.PureComponent<P, IState> {
     };
   }
 
+    private _wrapEventHandler(optionValue: any, optionName: string): any {
+        if (optionName.substr(0, 2) === "on" && typeof optionValue === "function") {
+            return (...args: any[]) => {
+                if (!this._optionsManager.updatingProps) {
+                    optionValue(...args);
+                }
+            };
+        }
+
+        return optionValue;
+    }
+
   private _getIntegrationOptions(options: Record<string, any>, nestedOptions: Record<string, any>): any {
     const templates: Record<string, ITemplate> = {};
     const result = {
@@ -180,7 +190,7 @@ class Component<P> extends React.PureComponent<P, IState> {
     ) {
       const optionName = configComponent.type.OptionName;
 
-      this._nestedOptions.add(
+      this._optionsManager.addNestedOption(
           optionName,
           component,
           configComponent.type.DefaultsProps,
@@ -191,7 +201,7 @@ class Component<P> extends React.PureComponent<P, IState> {
         component,
         (newProps, prevProps) => {
           const newOptions = separateProps(newProps, configComponent.type.DefaultsProps, []).options;
-          this._syncer.processChangedValues(
+          this._optionsManager.processChangedValues(
             addPrefixToKeys(newOptions, optionName + "."),
             addPrefixToKeys(prevProps, optionName + ".")
           );
@@ -199,26 +209,7 @@ class Component<P> extends React.PureComponent<P, IState> {
       );
     }
 
-    return component;
-  }
-
-  private getNestedProps(): Record<string, any> {
-
-    const nestedOptions: Record<string, any> = {};
-
-    this._nestedOptions.forEach((o) => {
-        const options = o.elements.map((e) => {
-            const props = separateProps(e.props, o.defaults, []);
-            return {
-                ...props.defaults,
-                ...props.options
-            };
-        });
-
-        nestedOptions[o.name] = o.isCollectionItem ? options : options[options.length - 1];
-    });
-
-    return nestedOptions;
+    return null;
   }
 }
 
diff --git a/src/core/nested-option-collection.ts b/src/core/nested-option-collection.ts
deleted file mode 100644
index eb9cdae..0000000
--- a/src/core/nested-option-collection.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { ReactElement } from "react";
-
-interface INestedOptionDescr {
-    name: string;
-    defaults: Record<string, any>;
-    elements: Array<React.ReactElement<any>>;
-    isCollectionItem: boolean;
-}
-
-class OptionCollection {
-    private readonly _nestedOptions: Record<string, INestedOptionDescr> = {};
-
-    public add(
-        name: string,
-        element: ReactElement<any>,
-        defaults: Record<string, any>,
-        isCollectionItem: boolean
-    ): void {
-        if (this._nestedOptions[name] === null ||
-            this._nestedOptions[name] === undefined
-        ) {
-            this._nestedOptions[name] = {
-                name,
-                defaults,
-                elements: [],
-                isCollectionItem
-            };
-        }
-
-        this._nestedOptions[name].elements.push(element);
-    }
-
-    public get(name: string): INestedOptionDescr {
-        return this._nestedOptions[name];
-    }
-
-    public forEach(callback: (item: INestedOptionDescr) => void): void {
-        Object.keys(this._nestedOptions).forEach((key) => callback(this._nestedOptions[key]));
-    }
-}
-
-export { INestedOptionDescr, OptionCollection };
diff --git a/src/core/options-manager.ts b/src/core/options-manager.ts
new file mode 100644
index 0000000..eb2fa5a
--- /dev/null
+++ b/src/core/options-manager.ts
@@ -0,0 +1,155 @@
+import { ReactElement } from "react";
+
+import { getNestedValue } from "./helpers";
+import { separateProps } from "./widget-config";
+
+interface INestedOptionDescr {
+    name: string;
+    defaults: Record<string, any>;
+    elements: Array<React.ReactElement<any>>;
+    isCollectionItem: boolean;
+}
+
+class OptionsManager {
+
+    private readonly _guards: Record<string, number> = {};
+    private readonly _nestedOptions: Record<string, INestedOptionDescr> = {};
+    private readonly _optionValueGetter: (name: string) => any;
+
+    private _instance: any;
+
+    private _updatingProps: boolean;
+
+    constructor(optionValueGetter: (name: string) => any) {
+        this._optionValueGetter = optionValueGetter;
+
+        this.addNestedOption = this.addNestedOption.bind(this);
+        this.handleOptionChange = this.handleOptionChange.bind(this);
+        this.processChangedValues = this.processChangedValues.bind(this);
+    }
+
+    public get updatingProps(): boolean {
+        return this._updatingProps;
+    }
+
+    public setInstance(instance: any) {
+        this._instance = instance;
+    }
+
+    public handleOptionChange(e: { name: string, fullName: string, value: any }) {
+        if (this._updatingProps) {
+            return;
+        }
+
+        let optionValue;
+
+        const nestedOption = this._nestedOptions[e.name];
+        if (nestedOption) {
+            const nestedOptionObj = separateProps(nestedOption.elements[0].props, nestedOption.defaults, []).options;
+
+            if (e.name === e.fullName) {
+                Object.keys(nestedOptionObj).forEach((key) => this.handleOptionChange({
+                    name: e.name,
+                    fullName: `${e.fullName}.${key}`,
+                    value: e.value[key]
+                }));
+
+                return;
+            }
+
+            if (!nestedOption.isCollectionItem) {
+                optionValue = getNestedValue(nestedOptionObj, e.fullName.split(".").slice(1));
+            }
+        } else {
+            optionValue = this._optionValueGetter(e.name);
+        }
+
+        if (optionValue === undefined || optionValue === null) {
+            return;
+        }
+
+        this._setGuard(e.fullName, optionValue);
+    }
+
+    public processChangedValues(newProps: Record<string, any>, prevProps: Record<string, any>): void {
+        this._updatingProps = false;
+
+        for (const optionName of Object.keys(newProps)) {
+            if (newProps[optionName] === prevProps[optionName]) {
+                continue;
+            }
+
+            if (this._guards[optionName]) {
+                window.clearTimeout(this._guards[optionName]);
+                delete this._guards[optionName];
+            }
+
+            if (!this._updatingProps) {
+                this._instance.beginUpdate();
+                this._updatingProps = true;
+            }
+            this._instance.option(optionName, newProps[optionName]);
+        }
+
+        if (this._updatingProps) {
+            this._updatingProps = false;
+            this._instance.endUpdate();
+        }
+    }
+
+    public addNestedOption(
+        name: string,
+        element: ReactElement<any>,
+        defaults: Record<string, any>,
+        isCollectionItem: boolean
+    ): void {
+        if (this._nestedOptions[name] === null ||
+            this._nestedOptions[name] === undefined
+        ) {
+            this._nestedOptions[name] = {
+                name,
+                defaults,
+                elements: [],
+                isCollectionItem
+            };
+        }
+
+        this._nestedOptions[name].elements.push(element);
+    }
+
+    public getNestedOptionsObjects(): Record<string, any> {
+
+        const nestedOptions: Record<string, any> = {};
+
+        Object.keys(this._nestedOptions).forEach((key) => {
+            const nestedOption = this._nestedOptions[key];
+            const options = nestedOption.elements.map((e) => {
+                const props = separateProps(e.props, nestedOption.defaults, []);
+                return {
+                    ...props.defaults,
+                    ...props.options
+                };
+            });
+
+            nestedOptions[nestedOption.name] = nestedOption.isCollectionItem ? options : options[options.length - 1];
+        });
+
+        return nestedOptions;
+    }
+
+    private _setGuard(optionName, optionValue): void {
+        if (this._guards[optionName] !== undefined) {
+            return;
+        }
+
+        const guardId = window.setTimeout(() => {
+            this._instance.option(optionName, optionValue);
+            window.clearTimeout(guardId);
+            delete this._guards[optionName];
+        });
+
+        this._guards[optionName] = guardId;
+    }
+}
+
+export { INestedOptionDescr, OptionsManager };
diff --git a/src/core/options-syncer.ts b/src/core/options-syncer.ts
deleted file mode 100644
index 36d11d8..0000000
--- a/src/core/options-syncer.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { getNestedValue } from "./helpers";
-import { OptionCollection } from "./nested-option-collection";
-import { separateProps } from "./widget-config";
-
-class OptionsSyncer {
-
-    private readonly _guards: Record<string, number> = {};
-    private readonly _nestedOptions: OptionCollection;
-    private readonly _optionValueGetter: (name: string) => any;
-
-    private _instance: any;
-
-    private _updatingProps: boolean;
-
-    constructor(nestedOptions: OptionCollection, optionValueGetter: (name: string) => any) {
-        this._nestedOptions = nestedOptions;
-        this._optionValueGetter = optionValueGetter;
-
-        this.optionChangedHandler = this.optionChangedHandler.bind(this);
-        this.processChangedValues = this.processChangedValues.bind(this);
-        this.wrapEventHandler = this.wrapEventHandler.bind(this);
-    }
-
-    public get instance(): any {
-        return this._instance;
-    }
-
-    public set instance(instance: any) {
-        this._instance = instance;
-    }
-
-    public optionChangedHandler(e: { name: string, fullName: string, value: any }) {
-        if (this._updatingProps) {
-            return;
-        }
-
-        const optionName = e.fullName;
-        let optionValue = this._optionValueGetter(e.name);
-
-        const nestedOption = this._nestedOptions.get(e.name);
-        if (nestedOption && !nestedOption.isCollectionItem) {
-            const separatedProps = separateProps(nestedOption.elements[0].props, nestedOption.defaults, []);
-
-            optionValue = getNestedValue(separatedProps.options, e.fullName.split(".").slice(1));
-        }
-
-        if (optionValue === undefined || optionValue === null) {
-            return;
-        }
-
-        if (this._guards[optionName] !== undefined) {
-            return;
-        }
-
-        const guardId = window.setTimeout(() => {
-            this._instance.option(optionName, optionValue);
-            window.clearTimeout(guardId);
-            delete this._guards[optionName];
-        });
-
-        this._guards[optionName] = guardId;
-    }
-
-    public processChangedValues(newProps: Record<string, any>, prevProps: Record<string, any>): void {
-        this._updatingProps = false;
-
-        for (const optionName of Object.keys(newProps)) {
-            if (newProps[optionName] === prevProps[optionName]) {
-                continue;
-            }
-
-            if (this._guards[optionName]) {
-                window.clearTimeout(this._guards[optionName]);
-                delete this._guards[optionName];
-            }
-
-            if (!this._updatingProps) {
-                this._instance.beginUpdate();
-                this._updatingProps = true;
-            }
-            this._instance.option(optionName, newProps[optionName]);
-        }
-
-        if (this._updatingProps) {
-            this._updatingProps = false;
-            this._instance.endUpdate();
-        }
-    }
-
-    public wrapEventHandler(optionValue: any, optionName: string): any {
-        if (optionName.substr(0, 2) === "on" && typeof optionValue === "function") {
-            return (...args: any[]) => {
-                if (!this._updatingProps) {
-                    optionValue(...args);
-                }
-            };
-        }
-
-        return optionValue;
-    }
-}
-
-export { OptionsSyncer };
diff --git a/tools/src/helpers.ts b/tools/src/helpers.ts
index 620dd3f..d69aac3 100644
--- a/tools/src/helpers.ts
+++ b/tools/src/helpers.ts
@@ -43,3 +43,7 @@ export function isNotEmptyArray(array: any[]): boolean {
 export function isEmptyArray(array: any[]): boolean {
     return array === undefined || array === null || array.length === 0;
 }
+
+export function isPlainObject(value: any): boolean {
+    return value.constructor === Object;
+}


Test patch:
diff --git a/src/core/component.test.tsx b/src/core/component.test.tsx
index 075cec1..271c9b1 100644
--- a/src/core/component.test.tsx
+++ b/src/core/component.test.tsx
@@ -17,6 +17,14 @@ const Widget = {
     dispose: jest.fn()
 };
 
+function fireOptionChange(fullName: string, value: any) {
+    eventHandlers.optionChanged({
+        name: fullName.split(".")[0],
+        fullName,
+        value
+    });
+}
+
 const WidgetClass = jest.fn(() => Widget);
 
 class TestComponent<P = any> extends Component<P> {
@@ -381,12 +389,36 @@ describe("controlled mode", () => {
         expect(eventHandlers).toHaveProperty("optionChanged");
     });
 
+    it("does not fire events when option changed while props updating", () => {
+        const controlledOptionChanged = jest.fn();
+        const component = mount(
+            <ControlledComponent controlledOption={"controlled"} onControlledOptionChanged={controlledOptionChanged}/>
+        );
+        Widget.option.mockImplementation(
+            (name: string) => {
+                if (name === "controlledOption") {
+                    WidgetClass.mock.calls[0][1].onControlledOptionChanged();
+                }
+            }
+        );
+        component.setProps({
+            controlledOption: "changed"
+        });
+
+        expect(controlledOptionChanged.mock.calls.length).toBe(0);
+
+        Widget.option("controlledOption", "controlled");
+
+        expect(controlledOptionChanged.mock.calls.length).toBe(1);
+
+    });
+
     it("rolls option value back", () => {
         mount(
             <ControlledComponent everyOption={123} />
         );
 
-        eventHandlers.optionChanged({ name: "everyOption", fullName: "everyOption", value: 234});
+        fireOptionChange("everyOption", 234);
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual([ "everyOption", 123 ]);
@@ -399,7 +431,33 @@ describe("controlled mode", () => {
             </ControlledComponent>
         );
 
-        eventHandlers.optionChanged({ name: "nestedOption", fullName: "nestedOption.a", value: 234});
+        fireOptionChange("nestedOption.a", 234);
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(1);
+        expect(Widget.option.mock.calls[0]).toEqual([ "nestedOption.a", 123 ]);
+    });
+
+    it("rolls nested option value if parent object changes another field", () => {
+        mount(
+            <ControlledComponent>
+                <NestedComponent a={123} />
+            </ControlledComponent>
+        );
+
+        fireOptionChange("nestedOption", { b: "abc" });
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(1);
+        expect(Widget.option.mock.calls[0]).toEqual([ "nestedOption.a", 123 ]);
+    });
+
+    it("rolls nested option value and preserves parent object", () => {
+        mount(
+            <ControlledComponent>
+                <NestedComponent a={123} />
+            </ControlledComponent>
+        );
+
+        fireOptionChange("nestedOption", { a: 456, b: "abc" });
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual([ "nestedOption.a", 123 ]);
@@ -410,7 +468,7 @@ describe("controlled mode", () => {
             <ControlledComponent everyOption={123} anotherOption={"const"}/>
         );
 
-        eventHandlers.optionChanged({ name: "anotherOption", fullName: "anotherOption", value: "changed"});
+        fireOptionChange("anotherOption", "changed");
         component.setProps({everyOption: 234});
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(2);
@@ -426,7 +484,7 @@ describe("controlled mode", () => {
         );
         const nested = component.find(NestedComponent).dive();
 
-        eventHandlers.optionChanged({ name: "nestedOption", fullName: "nestedOption.b", value: "changed"});
+        fireOptionChange("nestedOption.b", "changed");
         nested.setProps({a: 234});
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(2);
@@ -439,7 +497,7 @@ describe("controlled mode", () => {
             <ControlledComponent everyOption={123}/>
         );
 
-        eventHandlers.optionChanged({ fullName: "everyOption", value: 234});
+        fireOptionChange("everyOption", 234);
         component.setProps({everyOption: 234});
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
@@ -454,7 +512,7 @@ describe("controlled mode", () => {
         );
         const nested = component.find(NestedComponent).dive();
 
-        eventHandlers.optionChanged({ fullName: "nestedOption.a", value: 234});
+        fireOptionChange("nestedOption.a", 234);
         nested.setProps({a: 234});
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
@@ -468,121 +526,115 @@ describe("controlled mode", () => {
             </ControlledComponent>
         );
 
-        eventHandlers.optionChanged({ fullName: "nestedOption.b", value: "abc"});
+        fireOptionChange("nestedOption.b", "abc");
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(0);
     });
 
-    it("pass default values to widget", () => {
-        mount(
-            <ControlledComponent defaultControlledOption={"default"}/>
-        );
+    describe("default values", () => {
 
-        expect(WidgetClass.mock.calls[0][1].controlledOption).toBe("default");
-        expect(WidgetClass.mock.calls[0][1]).not.toHaveProperty("defaultControlledOption");
-    });
+        it("pass default values to widget", () => {
+            mount(
+                <ControlledComponent defaultControlledOption={"default"}/>
+            );
 
-    it("pass nested default values to widget", () => {
-        mount(
-            <ControlledComponent>
-                <NestedComponent defaultC="default" />
-            </ControlledComponent>
-        );
+            expect(WidgetClass.mock.calls[0][1].controlledOption).toBe("default");
+            expect(WidgetClass.mock.calls[0][1]).not.toHaveProperty("defaultControlledOption");
+        });
 
-        expect(WidgetClass.mock.calls[0][1].nestedOption.c).toBe("default");
-        expect(WidgetClass.mock.calls[0][1].nestedOption).not.toHaveProperty("defaultC");
-    });
+        it("pass nested default values to widget", () => {
+            mount(
+                <ControlledComponent>
+                    <NestedComponent defaultC="default" />
+                </ControlledComponent>
+            );
 
-    it("does not control option with default prefix", () => {
-        mount(
-            <ControlledComponent defaultControlledOption={"default"}/>
-        );
-        eventHandlers.optionChanged({ fullName: "controlledOption", value: "changed"});
-        jest.runAllTimers();
-        expect(Widget.option.mock.calls.length).toBe(0);
-    });
+            expect(WidgetClass.mock.calls[0][1].nestedOption.c).toBe("default");
+            expect(WidgetClass.mock.calls[0][1].nestedOption).not.toHaveProperty("defaultC");
+        });
 
-    it("does not control nested option with default prefix", () => {
-        mount(
-            <ControlledComponent>
-                <NestedComponent defaultC="default" />
-            </ControlledComponent>
-        );
-        eventHandlers.optionChanged({ fullName: "nestedOption.c", value: "changed"});
-        jest.runAllTimers();
-        expect(Widget.option.mock.calls.length).toBe(0);
-    });
+        it("ignores option with default prefix", () => {
+            mount(
+                <ControlledComponent defaultControlledOption={"default"}/>
+            );
 
-    it("ignores 3rd-party changes in default props", () => {
-        const component = mount(
-            <ControlledComponent defaultControlledOption={"default"}/>
-        );
-        component.setProps({
-            defaultControlledOption: "changed"
+            fireOptionChange("controlledOption", "changed");
+            jest.runAllTimers();
+            expect(Widget.option.mock.calls.length).toBe(0);
         });
-        jest.runAllTimers();
-        expect(Widget.option.mock.calls.length).toBe(0);
-    });
 
-    it("ignores 3rd-party changes in nested default props", () => {
-        const component = shallow(
-            <ControlledComponent>
-                <NestedComponent defaultC="default" />
-            </ControlledComponent>
+        it("ignores nested option with default prefix", () => {
+            mount(
+                <ControlledComponent>
+                    <NestedComponent defaultC="default" />
+                </ControlledComponent>
+            );
 
-        );
-        const nested = component.find(NestedComponent).dive();
+            fireOptionChange("nestedOption.c", "changed");
+            jest.runAllTimers();
+            expect(Widget.option.mock.calls.length).toBe(0);
+        });
 
-        nested.setProps({
-            defaultC: "changed"
+        it("ignores 3rd-party changes in default props", () => {
+            const component = mount(
+                <ControlledComponent defaultControlledOption={"default"}/>
+            );
+            component.setProps({
+                defaultControlledOption: "changed"
+            });
+            jest.runAllTimers();
+            expect(Widget.option.mock.calls.length).toBe(0);
         });
-        jest.runAllTimers();
-        expect(Widget.option.mock.calls.length).toBe(0);
-    });
 
-    it("does not pass default values to widget if controlledOption set", () => {
-        mount(
-            <ControlledComponent defaultControlledOption={"default"} controlledOption={"controlled"}/>
-        );
+        it("ignores 3rd-party changes in nested default props", () => {
+            const component = shallow(
+                <ControlledComponent>
+                    <NestedComponent defaultC="default" />
+                </ControlledComponent>
 
-        expect(Widget.option.mock.calls.length).toBe(0);
-        expect(WidgetClass.mock.calls[0][1].controlledOption).toBe("controlled");
-        expect(WidgetClass.mock.calls[0][1]).not.toHaveProperty("defaultControlledOption");
-    });
+            );
+            const nested = component.find(NestedComponent).dive();
 
-    it("does not pass nested default values to widget if controlledOption set", () => {
-        mount(
-            <ControlledComponent>
-                <NestedComponent defaultC="default" c="controlled" />
-            </ControlledComponent>
-        );
+            nested.setProps({
+                defaultC: "changed"
+            });
+            jest.runAllTimers();
+            expect(Widget.option.mock.calls.length).toBe(0);
+        });
 
-        expect(Widget.option.mock.calls.length).toBe(0);
-        expect(WidgetClass.mock.calls[0][1].nestedOption.c).toBe("controlled");
-        expect(WidgetClass.mock.calls[0][1].nestedOption).not.toHaveProperty("defaultC");
-    });
+        it("ignores 3rd-party changes in nested default props if parent object changes", () => {
+            mount(
+                <ControlledComponent>
+                    <NestedComponent defaultC="default" />
+                </ControlledComponent>
+            );
 
-    it("does not fire events when option changed while props updating", () => {
-        const controlledOptionChanged = jest.fn();
-        const component = mount(
-            <ControlledComponent controlledOption={"controlled"} onControlledOptionChanged={controlledOptionChanged}/>
-        );
-        Widget.option.mockImplementation(
-            (name: string) => {
-                if (name === "controlledOption") {
-                    WidgetClass.mock.calls[0][1].onControlledOptionChanged();
-                }
-            }
-        );
-        component.setProps({
-            controlledOption: "changed"
+            fireOptionChange("nestedOption", { a: 456, b: "abc" });
+            jest.runAllTimers();
+            expect(Widget.option.mock.calls.length).toBe(0);
         });
 
-        expect(controlledOptionChanged.mock.calls.length).toBe(0);
+        it("does not pass default values to widget if controlledOption set", () => {
+            mount(
+                <ControlledComponent defaultControlledOption={"default"} controlledOption={"controlled"}/>
+            );
 
-        Widget.option("controlledOption", "controlled");
+            expect(Widget.option.mock.calls.length).toBe(0);
+            expect(WidgetClass.mock.calls[0][1].controlledOption).toBe("controlled");
+            expect(WidgetClass.mock.calls[0][1]).not.toHaveProperty("defaultControlledOption");
+        });
 
-        expect(controlledOptionChanged.mock.calls.length).toBe(1);
+        it("does not pass nested default values to widget if controlledOption set", () => {
+            mount(
+                <ControlledComponent>
+                    <NestedComponent defaultC="default" c="controlled" />
+                </ControlledComponent>
+            );
+
+            expect(Widget.option.mock.calls.length).toBe(0);
+            expect(WidgetClass.mock.calls[0][1].nestedOption.c).toBe("controlled");
+            expect(WidgetClass.mock.calls[0][1].nestedOption).not.toHaveProperty("defaultC");
+        });
 
     });
 
@@ -641,7 +693,7 @@ describe("disposing", () => {
     });
 });
 
-describe("nested objects", () => {
+describe("nested options", () => {
 
     it("pulls options from a single nested component", () => {
         mount(