Repository: DevExpress/devextreme-react (js)

Issue:
Form items will unmount after form rerendering. 
<!--
  Note: if the issue is a general React question, please check React Docs first: https://reactjs.org/docs
  If your question is about  DevExtreme widgets' APIs, use the DevExpress Support Center: https://www.devexpress.com/Support/Center
-->

### Issue Type
<!-- E.g. 'question', 'bug report', 'feature request' -->
'bug report'
### Description
<!-- Specify what exactly the issue is related to (e.g. a component name) -->
The issue is related to the Form

#### Steps to Reproduce
<!-- If the current behavior is a bug, please provide the steps to reproduce -->
1) Open the demo and the developer console.
2) Wait 3 sec and see that the form item component will unmount after setState in Example component

#### Current Behavior
Form item component will unmount after rerendering.
#### Expected Behavior
Expected that the form item will not unmount or even will not rerender (if item props the same) after setState in Example component.
#### Demo
<!--
  If you provide a minimal demo of the problem\proposal, it helps us to solve it much faster.
  Paste the link to your online code editor (like https://stackblitz.com) below:
-->
https://stackblitz.com/edit/devextreme-react-ft5hi8?file=index.js
### Environment Details
<!--
Specify DevExtreme version, DevExtreme React Components version, React version and other environment details or notes you consider important.
-->
- devextreme version: "^18.2.2-pre-beta"
- devextreme-react version: "^18.2.3"
- react version: "^16.6.0"


Pull request:
Add keyFn for Templates
partially resolves #249 

Patch:
diff --git a/example/list-example.tsx b/example/list-example.tsx
index 3a80ffb..5a5e445 100644
--- a/example/list-example.tsx
+++ b/example/list-example.tsx
@@ -38,6 +38,10 @@ class Item extends React.Component<IListItemProps, { counter: number }> {
     }
 }
 
+function ItemKeyGetter(data: any) {
+    return data.text;
+}
+
 // tslint:disable-next-line:max-classes-per-file
 export default class extends React.Component<any, { text: string; items: IListItemProps[]; }> {
 
@@ -79,8 +83,10 @@ export default class extends React.Component<any, { text: string; items: IListIt
                 <hr />
                 <h4>List with component template</h4>
                 <List
+                    repaintChangesOnly={true}
                     items={this.state.items}
                     itemComponent={Item}
+                    itemKeyFn={ItemKeyGetter}
                 />
 
                 <hr />
diff --git a/package.json b/package.json
index 1f6663a..b025196 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
     "prop-types": "^15.6.1"
   },
   "peerDependencies": {
-    "devextreme": "^18.2.3",
+    "devextreme": "~18.2.3",
     "react": "^16.2.0",
     "react-dom": "^16.2.0"
   },
@@ -28,7 +28,7 @@
     "css-loader": "^0.28.9",
     "dasherize": "^2.0.0",
     "del": "^3.0.0",
-    "devextreme": "^18.2.3",
+    "devextreme": "~18.2.3",
     "dot": "^1.1.2",
     "enzyme": "^3.7.0",
     "enzyme-adapter-react-16": "^1.6.0",
diff --git a/src/core/template-host.ts b/src/core/template-host.ts
index 41cf199..6e10b04 100644
--- a/src/core/template-host.ts
+++ b/src/core/template-host.ts
@@ -83,7 +83,7 @@ class TemplateHost {
 
             const name = ownerName ? `${ownerName}.${tmpl.tmplOption}` : tmpl.tmplOption;
             stubs[name] = name;
-            templates[name] = wrapTemplate(contentCreator, this._stateUpdater);
+            templates[name] = wrapTemplate(contentCreator, this._stateUpdater, meta.propsGetter(tmpl.keyFn));
         }
 
         this._templates = {
@@ -109,7 +109,7 @@ class TemplateHost {
         const propsGetter: PropsGetter = (prop) => this._nestedTemplateProps[name][prop];
 
         const contentCreator = contentCreators[type].bind(this, type, propsGetter);
-        this._templates[name] = wrapTemplate(contentCreator, this._stateUpdater);
+        this._templates[name] = wrapTemplate(contentCreator, this._stateUpdater, props.keyFn);
     }
 
     public get options(): Record<string, any> | undefined {
@@ -126,10 +126,14 @@ class TemplateHost {
     }
 }
 
-function wrapTemplate(createContentProvider: () => (model: any) => any, stateUpdater: StateUpdater): IDxTemplate {
+function wrapTemplate(
+    createContentProvider: () => (model: any) => any,
+    stateUpdater: StateUpdater,
+    keyFn?: (data) => string
+): IDxTemplate {
     return {
         render: (data: IDxTemplateData) => {
-            const templateId = "__template_" + generateID();
+            const templateId = keyFn ? keyFn(data.model) : "__template_" + generateID();
             const container = unwrapElement(data.container);
             const createWrapper = () => {
                 const model = data.model;
diff --git a/src/core/template.ts b/src/core/template.ts
index df87dd0..48965ec 100644
--- a/src/core/template.ts
+++ b/src/core/template.ts
@@ -5,6 +5,7 @@ interface ITemplateMeta {
     tmplOption: string;
     component: string;
     render: string;
+    keyFn: string;
 }
 
 interface ITemplateProps {
@@ -12,6 +13,7 @@ interface ITemplateProps {
     component?: any;
     render?: any;
     children?: any;
+    keyFn?: (data: any) => string;
 }
 
 class Template extends React.PureComponent<ITemplateProps, any> {
@@ -42,7 +44,8 @@ function findProps(child: React.ReactElement<any>): ITemplateProps | undefined {
         name: child.props.name,
         render: child.props.render,
         component: child.props.component,
-        children: child.props.children
+        children: child.props.children,
+        keyFn: child.props.keyFn
     };
 }
 
diff --git a/tools/src/component-generator.ts b/tools/src/component-generator.ts
index 19e0848..bf3f1bb 100644
--- a/tools/src/component-generator.ts
+++ b/tools/src/component-generator.ts
@@ -56,6 +56,7 @@ interface IRenderedPropTyping {
     renderedTypes: string[];
 }
 
+const TYPE_KEY_FN = "(data: any) => string";
 const TYPE_RENDER = "(props: any) => React.ReactNode";
 const TYPE_COMPONENT = "React.ComponentType<any>";
 
@@ -100,6 +101,9 @@ function generate(component: IComponent): string {
                         }, {
                             name: t.component,
                             type: TYPE_COMPONENT
+                        }, {
+                            name: t.keyFn,
+                            type: TYPE_KEY_FN
                         });
                     });
                 }
@@ -204,7 +208,8 @@ function createTemplateDto(templates: string[]) {
     ? templates.map((actualOptionName) => ({
         actualOptionName,
         render: formatTemplatePropName(actualOptionName, "Render"),
-        component: formatTemplatePropName(actualOptionName, "Component")
+        component: formatTemplatePropName(actualOptionName, "Component"),
+        keyFn: formatTemplatePropName(actualOptionName, "KeyFn")
     }))
     : null;
 }
@@ -302,6 +307,7 @@ const renderOptionsInterface: (model: {
 `<#~ it.templates :template #>` +
     `  <#= template.render #>?: ${TYPE_RENDER};` + `\n` +
     `  <#= template.component #>?: ${TYPE_COMPONENT};` + `\n` +
+    `  <#= template.keyFn #>?: ${TYPE_KEY_FN};` + `\n` +
 `<#~#>` +
 
 `<#~ it.defaultProps :prop #>` +
@@ -420,7 +426,8 @@ const renderTemplateOption: (model: {
   {
     tmplOption: "<#= it.actualOptionName #>",
     render: "<#= it.render #>",
-    component: "<#= it.component #>"
+    component: "<#= it.component #>",
+    keyFn: "<#= it.keyFn #>"
   }
 `.trim());
 


Test patch:
diff --git a/src/core/__tests__/template.test.tsx b/src/core/__tests__/template.test.tsx
index 0126a52..f37496e 100644
--- a/src/core/__tests__/template.test.tsx
+++ b/src/core/__tests__/template.test.tsx
@@ -11,7 +11,8 @@ class ComponentWithTemplates extends TestComponent {
     protected _templateProps = [{
         tmplOption: "item",
         render: "itemRender",
-        component: "itemComponent"
+        component: "itemComponent",
+        keyFn: "itemKeyFn"
     }];
 
     constructor(props: any) {
@@ -183,6 +184,21 @@ function testTemplateOption(testedOption: string) {
         expect(templatesKeys[0]).not.toBe(templatesKeys[1]);
     });
 
+    it("has templates in state with ids genetated with keyExpr", () => {
+        const elementOptions: Record<string, any> = {};
+        elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
+        elementOptions.itemKeyFn = (data) => data.text;
+        const component = shallow(React.createElement(ComponentWithTemplates, elementOptions));
+
+        renderItemTemplate({ text: 1 });
+        renderItemTemplate({ text: 2 });
+
+        const templatesKeys = Object.getOwnPropertyNames(component.state("templates"));
+        expect(templatesKeys.length).toBe(2);
+        expect(templatesKeys[0]).toBe("1");
+        expect(templatesKeys[1]).toBe("2");
+    });
+
     it("removes deleted nodes from state", () => {
         const elementOptions: Record<string, any> = {};
         elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
@@ -336,6 +352,22 @@ describe("nested template", () => {
         expect(component.find(".template").html()).toBe('<div class="template">Second Template</div>');
     });
 
+    it("has templates in state with ids genetated with keyFn", () => {
+        const FirstTemplate = () => <div className={"template"}>Template</div>;
+        const keyExpr = (data) => data.text;
+        const component = mount(
+            <ComponentWithTemplates>
+                <Template name={"item1"} render={FirstTemplate} keyFn={keyExpr} />
+            </ComponentWithTemplates >
+        );
+
+        renderTemplate("item1", { text: 1 });
+
+        const templatesKeys = Object.getOwnPropertyNames(component.state("templates"));
+        expect(templatesKeys.length).toBe(1);
+        expect(templatesKeys[0]).toBe("1");
+    });
+
 });
 
 describe("component/render in nested options", () => {
diff --git a/tools/src/component-generator.test.ts b/tools/src/component-generator.test.ts
index 378c405..784f225 100644
--- a/tools/src/component-generator.test.ts
+++ b/tools/src/component-generator.test.ts
@@ -92,6 +92,7 @@ import { Component as BaseComponent, IHtmlOptions } from "BASE_COMPONENT_PATH";
 interface ICLASS_NAMEOptions extends IOptions, IHtmlOptions {
   optionRender?: (props: any) => React.ReactNode;
   optionComponent?: React.ComponentType<any>;
+  optionKeyFn?: (data: any) => string;
 }
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
@@ -105,7 +106,8 @@ class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
   protected _templateProps = [{
     tmplOption: "optionTemplate",
     render: "optionRender",
-    component: "optionComponent"
+    component: "optionComponent",
+    keyFn: "optionKeyFn"
   }];
 }
 export default CLASS_NAME;
@@ -141,8 +143,10 @@ import { Component as BaseComponent, IHtmlOptions } from "BASE_COMPONENT_PATH";
 interface ICLASS_NAMEOptions extends IOptions, IHtmlOptions {
   optionRender?: (props: any) => React.ReactNode;
   optionComponent?: React.ComponentType<any>;
+  optionKeyFn?: (data: any) => string;
   anotherOptionRender?: (props: any) => React.ReactNode;
   anotherOptionComponent?: React.ComponentType<any>;
+  anotherOptionKeyFn?: (data: any) => string;
 }
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
@@ -156,11 +160,13 @@ class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
   protected _templateProps = [{
     tmplOption: "optionTemplate",
     render: "optionRender",
-    component: "optionComponent"
+    component: "optionComponent",
+    keyFn: "optionKeyFn"
   }, {
     tmplOption: "anotherOptionTemplate",
     render: "anotherOptionRender",
-    component: "anotherOptionComponent"
+    component: "anotherOptionComponent",
+    keyFn: "anotherOptionKeyFn"
   }];
 }
 export default CLASS_NAME;
@@ -196,6 +202,7 @@ import { Component as BaseComponent, IHtmlOptions } from "BASE_COMPONENT_PATH";
 interface ICLASS_NAMEOptions extends IOptions, IHtmlOptions {
   render?: (props: any) => React.ReactNode;
   component?: React.ComponentType<any>;
+  keyFn?: (data: any) => string;
 }
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
@@ -209,7 +216,8 @@ class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
   protected _templateProps = [{
     tmplOption: "template",
     render: "render",
-    component: "component"
+    component: "component",
+    keyFn: "keyFn"
   }];
 }
 export default CLASS_NAME;
@@ -635,12 +643,14 @@ class Opt_1_Component extends NestedOption<{
   optionTemplate?: TYPE_1;
   optionRender?: (props: any) => React.ReactNode;
   optionComponent?: React.ComponentType<any>;
+  optionKeyFn?: (data: any) => string;
 }> {
   public static OptionName = "opt_1";
   public static TemplateProps = [{
     tmplOption: "optionTemplate",
     render: "optionRender",
-    component: "optionComponent"
+    component: "optionComponent",
+    keyFn: "optionKeyFn"
   }];
 }