Repository: DevExpress/devextreme-react
Pull request #498 (2021-01-27 15:14:09)

Collect and pass free functions (Fix T942875)

LazyLahtak: I think that the `freeFunctions` is bad naming.
Probably we can name this the `independentEvents`.

Patch:
diff --git a/src/core/component-base.ts b/src/core/component-base.ts
index 960d0b6..f5661ec 100644
--- a/src/core/component-base.ts
+++ b/src/core/component-base.ts
@@ -35,6 +35,8 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
 
   protected readonly subscribableOptions: string[];
 
+  protected readonly independentEvents: string[];
+
   private _templatesRendererRef: TemplatesRenderer | null;
 
   private _templatesStore: TemplatesStore;
@@ -94,8 +96,12 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
       },
     );
 
-    this._useDeferUpdateForTemplates = this._instance.option('integrationOptions.useDeferUpdateForTemplates');
-    this._optionsManager.setInstance(this._instance, config, this.subscribableOptions);
+    this._useDeferUpdateForTemplates = this._instance.option(
+      'integrationOptions.useDeferUpdateForTemplates',
+    );
+    this._optionsManager.setInstance(
+      this._instance, config, this.subscribableOptions, this.independentEvents,
+    );
     this._instance.on('optionChanged', this._optionsManager.onOptionChanged);
   }
 
diff --git a/src/core/options-manager.ts b/src/core/options-manager.ts
index 0c919a0..ad49ae5 100644
--- a/src/core/options-manager.ts
+++ b/src/core/options-manager.ts
@@ -19,6 +19,8 @@ class OptionsManager {
 
   private subscribableOptions: Set<string>;
 
+  private independentEvents: Set<string>;
+
   constructor(templatesManager: TemplatesManager) {
     this.templatesManager = templatesManager;
 
@@ -30,10 +32,12 @@ class OptionsManager {
     instance: unknown,
     config: IConfigNode,
     subscribableOptions: string[],
+    independentEvents: string[],
   ): void {
     this.instance = instance;
     this.currentConfig = config;
     this.subscribableOptions = new Set(subscribableOptions);
+    this.independentEvents = new Set(independentEvents);
   }
 
   public getInitialOptions(rootNode: IConfigNode): Record<string, unknown> {
@@ -135,6 +139,10 @@ class OptionsManager {
     return this.subscribableOptions.has(optionName);
   }
 
+  private isIndependentEvent(optionName: string): boolean {
+    return this.independentEvents.has(optionName);
+  }
+
   private callOptionChangeHandler(optionName: string, optionValue: unknown) {
     if (!this.isOptionSubscribable(optionName)) {
       return;
@@ -167,7 +175,7 @@ class OptionsManager {
   private wrapOptionValue(name: string, value: unknown) {
     if (name.substr(0, 2) === 'on' && typeof value === 'function') {
       return (...args: unknown[]) => {
-        if (!this.isUpdating) {
+        if (!this.isUpdating || this.isIndependentEvent(name)) {
           value(...args);
         }
       };
diff --git a/tools/src/component-generator.ts b/tools/src/component-generator.ts
index 0cc0654..fa18a90 100644
--- a/tools/src/component-generator.ts
+++ b/tools/src/component-generator.ts
@@ -15,6 +15,7 @@ type IComponent = {
   expectedChildren?: IExpectedChild[];
   isExtension?: boolean;
   subscribableOptions?: ISubscribableOption[];
+  independentEvents?: IIndependentEvents[],
   templates?: string[];
   propTypings?: IPropTyping[];
 } & {
@@ -39,6 +40,10 @@ interface ISubscribableOption {
   isSubscribable?: true;
 }
 
+interface IIndependentEvents {
+  name: string;
+}
+
 type IOption = {
   name: string;
   isSubscribable?: true;
@@ -300,6 +305,7 @@ const renderComponent: (model: {
   widgetName: string;
   optionsName: string;
   subscribableOptions?: string[];
+  independentEvents?: string[];
   expectedChildren?: IExpectedChild[];
   renderedDefaultProps?: string[];
   renderedTemplateProps?: string[];
@@ -317,6 +323,10 @@ const renderComponent: (model: {
   L1}protected subscribableOptions = [<#= it.subscribableOptions.join(',') #>];\n`
 + '<#?#>'
 
++ `<#? it.independentEvents #>${
+  L1}protected independentEvents = [<#= it.independentEvents.join(',') #>];\n`
++ '<#?#>'
+
 + `<#? it.renderedDefaultProps #>${
   L1}protected _defaults = {<#= it.renderedDefaultProps.join(',') #>${
   L1}};\n`
@@ -518,6 +528,7 @@ function generate(component: IComponent): string {
       optionsName,
       subscribableOptions: component.subscribableOptions
         ?.map((o) => renderStringEntry(o.name)),
+      independentEvents: component.independentEvents?.map((f) => renderStringEntry(f.name)),
       renderedTemplateProps: templates && templates.map(renderTemplateOption),
       renderedDefaultProps: defaultProps && defaultProps.map((o) => renderObjectEntry({
         key: o.name,
@@ -537,6 +548,7 @@ function generate(component: IComponent): string {
 export default generate;
 export {
   IComponent,
+  IIndependentEvents,
   INestedComponent,
   IOption,
   ISubscribableOption,
diff --git a/tools/src/generator.ts b/tools/src/generator.ts
index b74bba1..dba290d 100644
--- a/tools/src/generator.ts
+++ b/tools/src/generator.ts
@@ -23,6 +23,7 @@ import generateIndex, { IReExport } from './index-generator';
 import generateComponent, {
   generateReExport,
   IComponent,
+  IIndependentEvents,
   INestedComponent,
   IOption,
   IPropTyping,
@@ -46,6 +47,12 @@ export function mapSubscribableOption(prop: IProp): ISubscribableOption {
   };
 }
 
+export function mapIndependentEvents(prop: IProp): IIndependentEvents {
+  return {
+    name: prop.name,
+  };
+}
+
 export function isNestedOptionArray(prop: IProp): boolean {
   return isNotEmptyArray(prop.types) && (prop.types[0].type === 'Array');
 }
@@ -126,8 +133,20 @@ export function extractPropTypings(
     .filter((t) => t != null);
 }
 
+export function collectIndependentEvents(options: IProp[]): IProp[] {
+  return options.reduce((acc, option) => {
+    if (option.types.filter((type) => type.type === 'Function').length === 1
+        && (!option.firedEvents || option.firedEvents.length === 0)
+        && option.name.substr(0, 2) === 'on'
+    ) {
+      acc.push(option);
+    }
+    return acc;
+  }, [] as IProp[]);
+}
+
 export function collectSubscribableRecursively(options: IProp[], prefix = ''): IProp[] {
-  const result = options.reduce((acc, option) => {
+  return options.reduce((acc, option) => {
     if (option.isSubscribable) {
       acc.push({
         ...option,
@@ -142,8 +161,6 @@ export function collectSubscribableRecursively(options: IProp[], prefix = ''): I
     }
     return acc;
   }, [] as IProp[]);
-
-  return result;
 }
 
 export function mapWidget(
@@ -162,6 +179,9 @@ export function mapWidget(
   const subscribableOptions: ISubscribableOption[] = collectSubscribableRecursively(raw.options)
     .map(mapSubscribableOption);
 
+  const independentEvents: IIndependentEvents[] = collectIndependentEvents(raw.options)
+    .map(mapIndependentEvents);
+
   const nestedOptions = raw.complexOptions
     ? extractNestedComponents(raw.complexOptions, raw.name, name)
     : null;
@@ -184,6 +204,7 @@ export function mapWidget(
       isExtension: raw.isExtension,
       templates: raw.templates,
       subscribableOptions: subscribableOptions.length > 0 ? subscribableOptions : undefined,
+      independentEvents: independentEvents.length > 0 ? independentEvents : undefined,
       nestedComponents: nestedOptions && nestedOptions.length > 0 ? nestedOptions : undefined,
       expectedChildren: raw.nesteds,
       propTypings: propTypings.length > 0 ? propTypings : undefined,


Test patch:
diff --git a/src/core/__tests__/props-updating.test.tsx b/src/core/__tests__/props-updating.test.tsx
index b8d3224..a9b204f 100644
--- a/src/core/__tests__/props-updating.test.tsx
+++ b/src/core/__tests__/props-updating.test.tsx
@@ -14,6 +14,7 @@ interface IControlledComponentProps {
   defaultControlledOption?: string;
   controlledOption?: string;
   onControlledOptionChanged?: () => void;
+  onIndependentEvent?: () => void;
   everyOption?: number | boolean | null;
   anotherOption?: string;
   complexOption?: Record<string, unknown>;
@@ -854,4 +855,84 @@ describe('onXXXChange', () => {
       expect(onPropChange).toHaveBeenCalledTimes(0);
     });
   });
+
+  describe('independent events', () => {
+    beforeAll(() => {
+      jest.spyOn(
+        OptionsManager.prototype as OptionsManager & { isIndependentEvent: () => boolean; },
+        'isIndependentEvent',
+      )
+        .mockImplementation(() => true);
+    });
+    afterAll(() => {
+      jest.clearAllMocks();
+    });
+
+    it('is not called on create', () => {
+      const onPropChange = jest.fn();
+      mount(
+        <TestComponent
+          text="0"
+          onTextChange={onPropChange}
+        />,
+      );
+
+      expect(onPropChange).toHaveBeenCalledTimes(0);
+    });
+
+    it('it is fired on outher change', () => {
+      const onPropChange = jest.fn();
+      const component = mount(
+        <TestComponent
+          text="0"
+          onTextChange={onPropChange}
+        />,
+      );
+      expect(onPropChange).not.toBeCalled();
+      Widget.option.mockImplementation(
+        (name: string) => {
+          if (name === 'text') {
+            WidgetClass.mock.calls[0][1].onTextChange();
+          }
+        },
+      );
+      const sampleProps = { text: '1' };
+      component.setProps(sampleProps);
+      expect(onPropChange).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('dependent events', () => {
+    beforeAll(() => {
+      jest.spyOn(
+        OptionsManager.prototype as OptionsManager & { isIndependentEvent: () => boolean; },
+        'isIndependentEvent',
+      )
+        .mockImplementation(() => false);
+    });
+    afterAll(() => {
+      jest.clearAllMocks();
+    });
+
+    it('it is fired on outher change', () => {
+      const onPropChange = jest.fn();
+      const component = mount(
+        <TestComponent
+          text="0"
+          onTextChange={onPropChange}
+        />,
+      );
+      expect(onPropChange).not.toBeCalled();
+      Widget.option.mockImplementation(
+        (name: string) => {
+          if (name === 'text') {
+            WidgetClass.mock.calls[0][1].onTextChange();
+          }
+        },
+      );
+      const sampleProps = { text: '1' };
+      component.setProps(sampleProps);
+      expect(onPropChange).toHaveBeenCalledTimes(0);
+    });
+  });
 });
diff --git a/tools/src/generator.test.ts b/tools/src/generator.test.ts
index 2946682..0419b7c 100644
--- a/tools/src/generator.test.ts
+++ b/tools/src/generator.test.ts
@@ -1,5 +1,6 @@
 import { IProp, IComplexProp } from 'tools/integration-data-model';
 import {
+  collectIndependentEvents,
   collectSubscribableRecursively,
   mapSubscribableOption,
   isNestedOptionArray,
@@ -10,6 +11,90 @@ import {
   mapWidget,
 } from './generator';
 
+describe('collectIndependentEvents', () => {
+  it('discard dependent events', () => {
+    const options: IProp[] = [{
+      name: 'option1',
+      isSubscribable: true,
+      types: [
+        {
+          type: 'Function',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+      ],
+      props: [],
+      firedEvents: ['func1'],
+    }, {
+      name: 'onOption',
+      isSubscribable: false,
+      types: [
+        {
+          type: 'Function',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+      ],
+      props: [],
+      firedEvents: [],
+    }, {
+      name: 'option3',
+      isSubscribable: true,
+      types: [
+        {
+          type: 'Function',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+        {
+          type: 'Number',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+      ],
+      props: [],
+      firedEvents: [],
+    }, {
+      name: 'onOption4',
+      isSubscribable: true,
+      types: [
+        {
+          type: 'Function',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+        {
+          type: 'Number',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+      ],
+      props: [],
+      firedEvents: ['func1'],
+    }, {
+      name: 'option5',
+      isSubscribable: true,
+      types: [
+        {
+          type: 'Function',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+        {
+          type: 'Number',
+          acceptableValues: [],
+          isCustomType: true,
+        },
+      ],
+      props: [],
+      firedEvents: [],
+    }];
+    const independentEvents = collectIndependentEvents(options);
+    expect(independentEvents.length).toBe(1);
+    expect(independentEvents[0]).toEqual(options[1]);
+  });
+});
+
 describe('collectSubscribableRecursively', () => {
   it('subscribable options', () => {
     const options: IProp[] = [{