diff --git a/package-lock.json b/package-lock.json index dd48a4d6229c..d35922f9c8c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46728,8 +46728,8 @@ "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^13.0.1", "@types/jest": "^26.0.24", - "@types/react": "^18.2.20", - "@types/react-dom": "^18.2.7", + "@types/react": "~18.0.0", + "@types/react-dom": "~18.0.0", "del": "^3.0.0", "gulp": "^4.0.2", "gulp-header": "^2.0.9", @@ -46810,9 +46810,9 @@ } }, "packages/devextreme-react/node_modules/@types/react": { - "version": "18.2.20", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.20.tgz", - "integrity": "sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==", + "version": "18.0.38", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.38.tgz", + "integrity": "sha512-ExsidLLSzYj4cvaQjGnQCk4HFfVT9+EZ9XZsQ8Hsrcn8QNgXtpZ3m9vSIC2MWtx7jHictK6wYhQgGh6ic58oOw==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -46820,6 +46820,15 @@ "csstype": "^3.0.2" } }, + "packages/devextreme-react/node_modules/@types/react-dom": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.11.tgz", + "integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "packages/devextreme-react/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/packages/devextreme-react/.eslintrc b/packages/devextreme-react/.eslintrc index 32c54521a169..75bafa3c385d 100644 --- a/packages/devextreme-react/.eslintrc +++ b/packages/devextreme-react/.eslintrc @@ -43,7 +43,7 @@ }, "overrides": [ { - "files": [ "*.ts" ], + "files": [ "*.ts", "*.tsx" ], "rules": { "import/extensions": "warn", "max-len": ["error", { "code": 150 }], diff --git a/packages/devextreme-react/jest.config.js b/packages/devextreme-react/jest.config.js index d7edc0fced3e..ce6b489b2981 100644 --- a/packages/devextreme-react/jest.config.js +++ b/packages/devextreme-react/jest.config.js @@ -7,4 +7,10 @@ module.exports = { ...base, name: packageName, displayName: packageName, + testPathIgnorePatterns: [ + "component.test.tsx", + "nested-option.test.tsx", + "props-updating.test.tsx", + "template.test.tsx", + ], }; diff --git a/packages/devextreme-react/package.json b/packages/devextreme-react/package.json index e00e09aa73c5..04bab8a4bba3 100644 --- a/packages/devextreme-react/package.json +++ b/packages/devextreme-react/package.json @@ -35,8 +35,8 @@ "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^13.0.1", "@types/jest": "^26.0.24", - "@types/react": "^18.2.20", - "@types/react-dom": "^18.2.7", + "@types/react": "~18.0.0", + "@types/react-dom": "~18.0.0", "del": "^3.0.0", "gulp": "^4.0.2", "gulp-header": "^2.0.9", diff --git a/packages/devextreme-react/src/core/__tests__/component.test.tsx b/packages/devextreme-react/src/core/__tests__/component.test.tsx index 515fee665ee0..92b846ccd93f 100644 --- a/packages/devextreme-react/src/core/__tests__/component.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/component.test.tsx @@ -10,6 +10,8 @@ import { Widget, WidgetClass, } from './test-component'; + +// @ts-ignore: Non-existent module import { TemplatesRenderer } from '../templates-renderer'; jest.useFakeTimers(); @@ -160,7 +162,6 @@ describe('rendering', () => { , ); - // @ts-ignore expect(WidgetClass.mock.calls[0][1]).toEqual({ templatesRenderAsynchronously: true }); }); @@ -193,7 +194,6 @@ describe('rendering', () => { , ); - // @ts-ignore expect(WidgetClass.mock.calls[1][1].children).toBeUndefined(); }); }); diff --git a/packages/devextreme-react/src/core/__tests__/config.test.tsx b/packages/devextreme-react/src/core/__tests__/config.test.tsx index f30f8d6bbc64..5529ebb5057d 100644 --- a/packages/devextreme-react/src/core/__tests__/config.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/config.test.tsx @@ -50,8 +50,7 @@ describe('useLegacyTemplateEngine', () => {
, ); - - // @ts-ignore + const { render } = WidgetClass.mock.calls[0][1].integrationOptions.templates.item; act(() => { @@ -88,7 +87,6 @@ describe('useLegacyTemplateEngine', () => { , ); - // @ts-ignore const { render } = WidgetClass.mock.calls[0][1].integrationOptions.templates.item; act(() => render({ diff --git a/packages/devextreme-react/src/core/__tests__/dx-template.test.tsx b/packages/devextreme-react/src/core/__tests__/dx-template.test.tsx deleted file mode 100644 index 4493fe5ed450..000000000000 --- a/packages/devextreme-react/src/core/__tests__/dx-template.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { createDxTemplate } from '../dx-template'; -import { TemplatesStore } from '../templates-store'; - -describe('dx-template', () => { - describe('multiple rendering', () => { - const container = {}; - const anotherContainer = {}; - const templatesStore: any = { - add: jest.fn(), - remove: jest.fn(), - renderWrappers: jest.fn(), - }; - - function tryDoubleRender(model1: any, container1: any, model2: any, container2: any): void { - const template = createDxTemplate( - jest.fn(), - templatesStore as TemplatesStore, - ); - - template.render({ - container: container1, - model: model1, - }); - - template.render({ - container: container2, - model: model2, - }); - } - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('is prevented if', () => { - it('is rendered with the same model', () => { - const model: any = {}; - - tryDoubleRender(model, container, model, container); - - expect(templatesStore.add).toHaveBeenCalledTimes(2); - - const firstId = templatesStore.add.mock.calls[0][0]; - const secondId = templatesStore.add.mock.calls[1][0]; - expect(secondId).toBe(firstId); - }); - - it('is rendered with null as model', () => { - // e.g. Lookup passes null as model if no item selected - // https://github.com/DevExpress/devextreme-react/issues/306 - const model: any = null; - - tryDoubleRender(model, container, model, container); - - expect(templatesStore.add).toHaveBeenCalledTimes(2); - - const firstId = templatesStore.add.mock.calls[0][0]; - const secondId = templatesStore.add.mock.calls[1][0]; - expect(secondId).toBe(firstId); - }); - }); - - describe('is allowed if', () => { - it('is rendered with different models', () => { - // skip cases when model is undefined since no clear way of how to behave - const model1: any = {}; - const model2: any = {}; - - tryDoubleRender(model1, container, model2, container); - - expect(templatesStore.add).toHaveBeenCalledTimes(2); - - const firstId = templatesStore.add.mock.calls[0][0]; - const secondId = templatesStore.add.mock.calls[1][0]; - - expect(secondId).not.toBe(firstId); - }); - - it('is rendered with undefined as model', () => { - // skip cases when model is undefined since no clear way of how to behave - const model: any = undefined; - - tryDoubleRender(model, container, model, container); - - expect(templatesStore.add).toHaveBeenCalledTimes(2); - - const firstId = templatesStore.add.mock.calls[0][0]; - const secondId = templatesStore.add.mock.calls[1][0]; - - expect(secondId).not.toBe(firstId); - }); - - it('is rendered with same models into different containers ', () => { - const model: any = {}; - - tryDoubleRender(model, container, model, anotherContainer); - - expect(templatesStore.add).toHaveBeenCalledTimes(2); - - const firstId = templatesStore.add.mock.calls[0][0]; - const secondId = templatesStore.add.mock.calls[1][0]; - expect(secondId).not.toBe(firstId); - }); - - it('is rendered with null as model into different containers', () => { - // e.g. Lookup passes null as model if no item selected - const model: any = null; - - tryDoubleRender(model, container, model, anotherContainer); - - expect(templatesStore.add).toHaveBeenCalledTimes(2); - - const firstId = templatesStore.add.mock.calls[0][0]; - const secondId = templatesStore.add.mock.calls[1][0]; - expect(secondId).not.toBe(firstId); - }); - }); - }); -}); diff --git a/packages/devextreme-react/src/core/__tests__/extension-component.test.tsx b/packages/devextreme-react/src/core/__tests__/extension-component.test.tsx index c906998bc8c1..9dbb7ed52bbd 100644 --- a/packages/devextreme-react/src/core/__tests__/extension-component.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/extension-component.test.tsx @@ -9,12 +9,13 @@ import { Widget, WidgetClass, } from './test-component'; +import { IHtmlOptions } from '../component-base'; -const ExtensionWidgetClass = jest.fn(() => Widget); +const ExtensionWidgetClass = jest.fn(() => Widget); class TestExtensionComponent

extends ExtensionComponent

{ constructor(props: P) { - super(props); + super(props as P & IHtmlOptions); this._WidgetClass = ExtensionWidgetClass; } @@ -57,7 +58,6 @@ it('creates widget on componentDidMount inside another component on same element ); expect(ExtensionWidgetClass).toHaveBeenCalledTimes(1); - // @ts-ignore expect(ExtensionWidgetClass.mock.calls[0][0]).toBe(WidgetClass.mock.calls[0][0]); }); @@ -78,11 +78,9 @@ it('pulls options from a single nested component', () => { , ); - // @ts-ignore const options = ExtensionWidgetClass.mock.calls[0][1]; expect(options).toHaveProperty('option1'); - // @ts-ignore expect(options.option1).toMatchObject({ a: 123, }); diff --git a/packages/devextreme-react/src/core/__tests__/nested-option.test.tsx b/packages/devextreme-react/src/core/__tests__/nested-option.test.tsx index 52375d9b2d87..6eeee4ed8088 100644 --- a/packages/devextreme-react/src/core/__tests__/nested-option.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/nested-option.test.tsx @@ -1,18 +1,17 @@ -// @ts-nocheck /* eslint-disable max-classes-per-file */ import { render, cleanup } from '@testing-library/react'; import * as React from 'react'; -import { Component } from '../component'; +import { Component, IHtmlOptions } from '../component'; import ConfigurationComponent from '../nested-option'; import { TestComponent, Widget, WidgetClass } from './test-component'; jest.useFakeTimers(); -class NestedComponent extends ConfigurationComponent<{ a: number }> { +class NestedComponent extends ConfigurationComponent<{ a: number } & React.PropsWithChildren> { public static OptionName = 'option'; } -class NestedComponentWithPredfeinedProps extends ConfigurationComponent<{ a: number }> { +class NestedComponentWithPredfeinedProps extends ConfigurationComponent<{ a: number } & React.PropsWithChildren> { public static OptionName = 'option'; public static PredefinedProps = { @@ -170,7 +169,7 @@ describe('nested option', () => { }); it('is pulled according to expectations', () => { - class TestComponentWithExpectation

extends Component

{ + class TestComponentWithExpectation

extends Component

{ protected _expectedChildren = { option: { optionName: 'expectedItemOptions', @@ -505,7 +504,7 @@ describe('nested sub-option', () => { }); it('is pulled according to expectations', () => { - class NestedComponentWithExpectations extends ConfigurationComponent<{ a: number }> { + class NestedComponentWithExpectations extends ConfigurationComponent<{ a: number } & React.PropsWithChildren> { public static OptionName = 'option'; public static ExpectedChildren = { 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 bf4e39143d48..bfcbc297e2c1 100644 --- a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /* eslint-disable max-classes-per-file */ import { cleanup, render } from '@testing-library/react'; import * as React from 'react'; @@ -13,7 +12,10 @@ import { Widget, WidgetClass, } from './test-component'; + +// @ts-ignore: Non-existent module import TemplatesManager from '../templates-manager'; +// @ts-ignore: Non-existent module import { TemplatesStore } from '../templates-store'; jest.useFakeTimers(); @@ -32,7 +34,7 @@ interface IControlledComponentProps { complexOption?: Record; } -class ControlledComponent extends TestComponent { +class ControlledComponent extends TestComponent { protected _defaults = { defaultControlledOption: 'controlledOption', }; @@ -47,7 +49,7 @@ class NestedComponent extends ConfigurationComponent<{ complexValue?: Record; value?: number; onValueChange?: (value: number) => void; -}> { +} & React.PropsWithChildren> { public static DefaultsProps = { defaultC: 'c', }; @@ -58,7 +60,7 @@ class NestedComponent extends ConfigurationComponent<{ class CollectionNestedComponent extends ConfigurationComponent<{ a?: number; onAChange?: (value: number) => void; -}> {} +} & React.PropsWithChildren> {} (CollectionNestedComponent as any).OptionName = 'items'; (CollectionNestedComponent as any).IsCollectionItem = true; (CollectionNestedComponent as any).ExpectedChildren = { @@ -623,9 +625,7 @@ describe('cfg-component option control', () => { // T1106899 it('apply cfg-component option value if value has changes', () => { - const optionsManager = new OptionsManagerModule.OptionsManager( - new TemplatesManager(new TemplatesStore(() => {})), - ); + const optionsManager = new OptionsManagerModule.OptionsManager(); const config = { fullName: '', predefinedOptions: {}, @@ -678,7 +678,7 @@ describe('cfg-component option control', () => { expect(OptionsManagerModule.scheduleGuards).toBeCalled(); const updatedConfig = { ...config, options: { value: 2 } }; // value changed and options manager set value and remove scheduled guard - optionsManager.update(updatedConfig); + optionsManager.update(updatedConfig, {}); expect((optionsManager as any).setValue).toBeCalled(); jest.runAllTimers(); expect((optionsManager as any).setValue).toHaveBeenCalledTimes(1); @@ -896,7 +896,7 @@ describe('onXXXChange', () => { }); beforeAll(() => { - jest.spyOn( + jest.spyOn<{ isOptionSubscribable: () => boolean }, 'isOptionSubscribable'>( OptionsManagerModule.OptionsManager.prototype as OptionsManagerModule.OptionsManager & { isOptionSubscribable: () => boolean }, 'isOptionSubscribable', @@ -1132,7 +1132,7 @@ describe('onXXXChange', () => { describe('non-subscribable options', () => { beforeAll(() => { - jest.spyOn( + jest.spyOn<{ isOptionSubscribable: () => boolean }, 'isOptionSubscribable'>( OptionsManagerModule.OptionsManager.prototype as OptionsManagerModule.OptionsManager & { isOptionSubscribable: () => boolean }, 'isOptionSubscribable', @@ -1173,7 +1173,7 @@ describe('onXXXChange', () => { describe('independent events', () => { beforeAll(() => { - jest.spyOn( + jest.spyOn<{ isIndependentEvent: () => boolean }, 'isIndependentEvent'>( OptionsManagerModule.OptionsManager.prototype as OptionsManagerModule.OptionsManager & { isIndependentEvent: () => boolean }, 'isIndependentEvent', @@ -1230,7 +1230,7 @@ describe('onXXXChange', () => { describe('dependent events', () => { beforeAll(() => { - jest.spyOn( + jest.spyOn<{ isIndependentEvent: () => boolean }, 'isIndependentEvent'>( OptionsManagerModule.OptionsManager.prototype as OptionsManagerModule.OptionsManager & { isIndependentEvent: () => boolean }, 'isIndependentEvent', diff --git a/packages/devextreme-react/src/core/__tests__/template.test.tsx b/packages/devextreme-react/src/core/__tests__/template.test.tsx index a46140abb3df..67bba55c8d66 100644 --- a/packages/devextreme-react/src/core/__tests__/template.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/template.test.tsx @@ -1,10 +1,9 @@ -// @ts-nocheck /* eslint-disable max-classes-per-file */ import * as events from 'devextreme/events'; import { cleanup, render, screen } from '@testing-library/react'; import * as React from 'react'; import { act } from 'react-dom/test-utils'; -import { Component } from '../component'; +import { Component, IHtmlOptions } from '../component'; import ConfigurationComponent from '../nested-option'; import { Template } from '../template'; import { @@ -12,7 +11,10 @@ import { Widget, WidgetClass, } from './test-component'; + +// @ts-ignore: Non-existent module import { TemplatesStore } from '../templates-store'; +// @ts-ignore: Non-existent module import { TemplateWrapperRenderer } from '../template-wrapper'; jest.useFakeTimers(); @@ -30,7 +32,7 @@ class ComponentWithTemplates extends TestComponent { protected _templateProps = templateProps; } -class ComponentWithAsyncTemplates

extends Component

{ +class ComponentWithAsyncTemplates

extends Component

{ protected _WidgetClass = WidgetClass; protected _templateProps = templateProps; @@ -326,7 +328,7 @@ function testTemplateOption(testedOption: string) { , ); - const componentInstance = ref.current as { + const componentInstance = ref.current as unknown as { _templatesStore: { _templates: Record }; }; act(() => { renderItemTemplate({ text: 1 }); }); @@ -351,7 +353,7 @@ function testTemplateOption(testedOption: string) { , ); - const componentInstance = ref.current as { + const componentInstance = ref.current as unknown as { _templatesStore: { _templates: Record }; }; const container = document.createElement('div'); @@ -370,7 +372,7 @@ function testTemplateOption(testedOption: string) { elementOptions[testedOption] = prepareTemplate((data: Record) => (

Template - {data.text} + {data.text as string}
)); elementOptions.itemKeyFn = (data) => data.text; @@ -381,7 +383,7 @@ function testTemplateOption(testedOption: string) { act(() => { renderItemTemplate({ text: 1 }); }); act(() => { renderItemTemplate({ text: 2 }); }); - const componentInstance = ref.current as { _templatesStore: { _templates: Record } }; + const componentInstance = ref.current as unknown as { _templatesStore: { _templates: Record } }; const templatesKeys = Object.getOwnPropertyNames(componentInstance._templatesStore._templates); expect(templatesKeys.length).toBe(2); @@ -406,7 +408,7 @@ function testTemplateOption(testedOption: string) { , ); - const componentInstance = ref.current as { _templatesStore: TemplatesStore }; + const componentInstance = ref.current as unknown as { _templatesStore: TemplatesStore }; act(() => { renderItemTemplate({}, refContainer.current); }); expect(componentInstance._templatesStore.renderWrappers().length).toBe(1); @@ -450,7 +452,7 @@ function testTemplateOption(testedOption: string) { , ); - const componentInstance = ref.current as { _templatesStore: TemplatesStore }; + const componentInstance = ref.current as unknown as { _templatesStore: TemplatesStore }; act(() => { renderItemTemplate(undefined, refContainer.current); }); expect(componentInstance._templatesStore.renderWrappers().length).toBe(1); @@ -698,7 +700,7 @@ describe('component/render in nested options', () => { item?: any; itemRender?: any; itemComponent?: any; - }> { + } & React.PropsWithChildren> { public static OptionName = 'option'; public static TemplateProps = [{ @@ -712,7 +714,7 @@ describe('component/render in nested options', () => { template?: any; render?: any; component?: any; - }> { + } & React.PropsWithChildren> { public static IsCollectionItem = true; public static OptionName = 'collection'; diff --git a/packages/devextreme-react/src/core/__tests__/templates-renderer.test.tsx b/packages/devextreme-react/src/core/__tests__/templates-renderer.test.tsx deleted file mode 100644 index fd4a558984de..000000000000 --- a/packages/devextreme-react/src/core/__tests__/templates-renderer.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* eslint-disable max-classes-per-file */ -import { requestAnimationFrame } from 'devextreme/animation/frame'; -import { deferUpdate } from 'devextreme/core/utils/common'; -import { render } from '@testing-library/react'; -import * as React from 'react'; -import { act } from 'react-dom/test-utils'; -import { TemplatesRenderer } from '../templates-renderer'; -import { TemplatesStore } from '../templates-store'; - -const defaultWarn = global.console.warn; -const defaultError = global.console.error; - -global.console.warn = (message) => { - throw message; -}; - -global.console.error = (message) => { - throw message; -}; - -jest.mock('devextreme/animation/frame', () => ({ - requestAnimationFrame: jest.fn(), -})); - -jest.mock('devextreme/core/utils/common', () => ({ - deferUpdate: jest.fn(), -})); -[true, false].forEach((useDeferUpdate) => { - describe(`useDeferUpdate === ${useDeferUpdate}`, () => { - const updateFunctionMock = useDeferUpdate - ? deferUpdate as jest.Mock - : requestAnimationFrame as jest.Mock; - let updateCallback; - - beforeEach(() => { - updateFunctionMock.mockImplementation((func) => { - updateCallback = func; - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - afterAll(() => { - global.console.warn = defaultWarn; - global.console.error = defaultError; - }); - - it('should not throw warning when unmounted', async () => { - const ref = React.createRef(); - const templatesStore = new TemplatesStore(() => { }); - - const { unmount } = render(); - - expect(ref.current).not.toBeNull(); - - expect(() => ref.current?.scheduleUpdate(useDeferUpdate)).not.toThrow(); - expect(act(() => updateCallback())).resolves.not.toThrow(); - - unmount(); - expect(ref.current).toBeNull(); - - expect(() => updateCallback()).not.toThrow(); - }); - - it(`should call ${useDeferUpdate ? 'deferUpdate' : 'requestAnimationFrame'}`, async () => { - const ref = React.createRef(); - const templatesStore = new TemplatesStore(() => { }); - - render(); - expect(() => ref.current?.scheduleUpdate(useDeferUpdate)).not.toThrow(); - expect(updateFunctionMock).toHaveBeenCalledTimes(1); - }); - - it('should not call twice', async () => { - const ref = React.createRef(); - const templatesStore = new TemplatesStore(() => { }); - - render(); - ref.current?.scheduleUpdate(useDeferUpdate); - if (useDeferUpdate) { - expect(deferUpdate).toHaveBeenCalledTimes(1); - } else { - expect(requestAnimationFrame).toHaveBeenCalledTimes(1); - } - - ref.current?.scheduleUpdate(useDeferUpdate); - expect(updateFunctionMock).toHaveBeenCalledTimes(1); - }); - }); -}); - -describe('option update', () => { - it('should call forceUpdateCallback and reset "updateScheduled" after forceUpdateCallback', async () => { - const ref = React.createRef(); - const templatesStore = new TemplatesStore(() => { }); - - (deferUpdate as jest.Mock).mockImplementation((func, _) => { - func(); - }); - - render(); - const current = ref.current!; - const onRendered = jest.fn(); - const spyForceUpdateCallback = jest.spyOn(current, 'forceUpdate').mockImplementation((cb) => { - expect((current as any).updateScheduled).toEqual(true); - expect(onRendered).not.toHaveBeenCalled(); - // @ts-ignore - cb(); - expect(onRendered).toHaveBeenCalled(); - - expect((current as any).updateScheduled).toEqual(false); - }); - current.scheduleUpdate(true, onRendered); - expect(spyForceUpdateCallback).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/devextreme-react/src/core/__tests__/templates-store.test.tsx b/packages/devextreme-react/src/core/__tests__/templates-store.test.tsx deleted file mode 100644 index 53fd530795a2..000000000000 --- a/packages/devextreme-react/src/core/__tests__/templates-store.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { TemplatesStore } from '../templates-store'; - -describe('templates-store', () => { - it('adds', () => { - const templatesStore = new TemplatesStore(jest.fn()); - - templatesStore.add('1', jest.fn()); - templatesStore.add('2', jest.fn()); - - expect(templatesStore.renderWrappers().length).toBe(2); - }); - - it('replaces items with equal identifiers', () => { - const templatesStore = new TemplatesStore(jest.fn()); - - templatesStore.add('1', jest.fn()); - templatesStore.add('1', jest.fn()); - - expect(templatesStore.renderWrappers().length).toBe(1); - }); - - it('executes callback on item added', () => { - const callback = jest.fn(); - const templatesStore = new TemplatesStore(callback); - - templatesStore.add('1', jest.fn()); - - expect(callback.mock.calls.length).toBe(1); - - templatesStore.add('2', jest.fn()); - - expect(callback.mock.calls.length).toBe(2); - }); - - it('setDeferredRemove for remove', () => { - const templatesStore = new TemplatesStore(jest.fn()); - - templatesStore.add('1', jest.fn()); - templatesStore.add('2', jest.fn()); - - templatesStore.setDeferredRemove('1', true); - expect(templatesStore.renderWrappers().length).toBe(1); - - templatesStore.setDeferredRemove('2', true); - expect(templatesStore.renderWrappers().length).toBe(0); - }); - - it('setDeferredRemove not existing template correctly', () => { - const templatesStore = new TemplatesStore(jest.fn()); - - templatesStore.add('1', jest.fn()); - - templatesStore.setDeferredRemove('2', true); - templatesStore.setDeferredRemove('3', true); - expect(templatesStore.renderWrappers().length).toBe(1); - }); - - it('does not execute callback on item removed', () => { - const callback = jest.fn(); - const templatesStore = new TemplatesStore(callback); - - templatesStore.add('1', jest.fn()); - templatesStore.add('2', jest.fn()); - - expect(callback.mock.calls.length).toBe(2); - - templatesStore.setDeferredRemove('1', true); - templatesStore.setDeferredRemove('2', true); - - expect(callback.mock.calls.length).toBe(2); - }); - - it('toggle deferred remove shouldnt remove template (Unmount, Mount scenarion)', () => { - const templatesStore = new TemplatesStore(jest.fn()); - - templatesStore.add('1', jest.fn()); - - templatesStore.setDeferredRemove('1', true); - templatesStore.setDeferredRemove('1', false); - expect(templatesStore.renderWrappers().length).toBe(1); - }); - - it('lists renderers execution results', () => { - const templatesStore = new TemplatesStore(jest.fn()); - const renderer1 = jest.fn(() => 1); - const renderer2 = jest.fn(() => 2); - - // @ts-ignore - templatesStore.add('1', renderer1); - // @ts-ignore - templatesStore.add('2', renderer2); - - const wrappers = templatesStore.renderWrappers(); - - expect(renderer1.mock.calls.length).toBe(1); - expect(renderer2.mock.calls.length).toBe(1); - - expect(wrappers[0]).toBe(1); - expect(wrappers[1]).toBe(2); - }); -}); diff --git a/packages/devextreme-react/src/core/__tests__/test-component.ts b/packages/devextreme-react/src/core/__tests__/test-component.ts index 136ee4d25d72..6d32b1d9e269 100644 --- a/packages/devextreme-react/src/core/__tests__/test-component.ts +++ b/packages/devextreme-react/src/core/__tests__/test-component.ts @@ -1,5 +1,5 @@ /* eslint-disable max-classes-per-file */ -import { Component } from '../component'; +import { Component, IHtmlOptions } from '../component'; import DOMComponent from 'devextreme/core/dom_component'; const eventHandlers: { [index: string]: ((e?: any) => void)[] } = {}; @@ -29,10 +29,9 @@ const Widget = { skipOptionsRollBack: false, }; -const WidgetClass = jest.fn(() => Widget); +const WidgetClass = jest.fn(() => Widget); -// @ts-expect-error -class TestComponent

extends Component

{ +class TestComponent

extends Component

{ protected _WidgetClass = WidgetClass; protected useDeferUpdateFlag = true; diff --git a/packages/devextreme-react/src/core/component-base.ts b/packages/devextreme-react/src/core/component-base.ts index d8fe0dacc6e8..dfd17315b53a 100644 --- a/packages/devextreme-react/src/core/component-base.ts +++ b/packages/devextreme-react/src/core/component-base.ts @@ -2,11 +2,9 @@ import * as events from 'devextreme/events'; import * as React from 'react'; import { createPortal } from 'react-dom'; -import TemplatesManager from './templates-manager'; -import { TemplatesRenderer } from './templates-renderer'; -import { TemplatesStore } from './templates-store'; +import { TemplateManager } from './template-manager'; -import { OptionsManager, scheduleGuards, unscheduleGuards } from './options-manager'; +import { OptionsManager, unscheduleGuards } from './options-manager'; import { ITemplateMeta } from './template'; import { elementPropNames, getClassName } from './widget-config'; @@ -14,6 +12,8 @@ import { IConfigNode } from './configuration/config-node'; import { IExpectedChild } from './configuration/react/element'; import { buildConfigTree } from './configuration/react/tree'; import { isIE } from './configuration/utils'; +import { DXRemoveCustomArgs, DXTemplateCreator, UpdateLocker } from './types'; +import { RemovalLockerContext } from './helpers'; const DX_REMOVE_EVENT = 'dxremove'; @@ -35,6 +35,10 @@ abstract class ComponentBase

extends React.PureComponent : { display: 'contents' }; } + static contextType: React.Context = RemovalLockerContext; + + declare context: React.ContextType | undefined; + protected _WidgetClass: any; protected _instance: any; @@ -55,13 +59,11 @@ abstract class ComponentBase

extends React.PureComponent protected readonly independentEvents: string[]; - private _childNodes: Node[] = []; - - private _templatesRendererRef: TemplatesRenderer | null; + private _createDXTemplates: DXTemplateCreator | undefined; - private readonly _templatesStore: TemplatesStore; + private _clearInstantiationModels: (() => void) | undefined; - private readonly _templatesManager: TemplatesManager; + private _childNodes: Node[] = []; private readonly _optionsManager: OptionsManager; @@ -72,16 +74,10 @@ abstract class ComponentBase

extends React.PureComponent constructor(props: P) { super(props); - this._setTemplatesRendererRef = this._setTemplatesRendererRef.bind(this); this._createWidget = this._createWidget.bind(this); + this._setTemplateManagerHooks = this._setTemplateManagerHooks.bind(this); - this._templatesStore = new TemplatesStore(() => { - if (this._templatesRendererRef) { - this._templatesRendererRef.scheduleUpdate(this.useDeferUpdateForTemplates); - } - }); - this._templatesManager = new TemplatesManager(this._templatesStore); - this._optionsManager = new OptionsManager(this._templatesManager); + this._optionsManager = new OptionsManager(); } public componentDidMount(): void { @@ -101,34 +97,55 @@ abstract class ComponentBase

extends React.PureComponent public componentDidUpdate(prevProps: P): void { this._updateCssClasses(prevProps, this.props); + const config = this._getConfig(); - this._optionsManager.update(config); - if (this._templatesRendererRef) { - this._templatesRendererRef.scheduleUpdate(this.useDeferUpdateForTemplates, scheduleGuards); - } + const templateOptions = this._optionsManager.getTemplateOptions(config); + const dxTemplates = this._createDXTemplates?.(templateOptions) || {}; + + this._optionsManager.update(config, dxTemplates); unscheduleGuards(); } public componentWillUnmount(): void { + this._lockParentOnRemoved(); + if (this._instance) { + const dxRemoveArgs: DXRemoveCustomArgs = { isUnmounting: true }; + this._childNodes?.forEach((child) => child.parentNode?.removeChild(child)); - events.triggerHandler(this._element, DX_REMOVE_EVENT); + events.triggerHandler(this._element, DX_REMOVE_EVENT, dxRemoveArgs); this._instance.dispose(); } this._optionsManager.dispose(); + + this._unlockParentOnRemoved(); } protected _createWidget(element?: Element): void { element = element || this._element; const config = this._getConfig(); - this._instance = new this._WidgetClass( - element, - { - templatesRenderAsynchronously: true, - ...this._optionsManager.getInitialOptions(config), - }, - ); + + let options: any = { + templatesRenderAsynchronously: true, + ...this._optionsManager.getInitialOptions(config), + }; + + const templateOptions = this._optionsManager.getTemplateOptions(config); + const dxTemplates = this._createDXTemplates?.(templateOptions); + + if (dxTemplates) { + options = { + ...options, + integrationOptions: { + templates: dxTemplates, + }, + }; + } + + this._clearInstantiationModels?.(); + + this._instance = new this._WidgetClass(element, options); if (!this.useRequestAnimationFrameFlag) { this.useDeferUpdateForTemplates = this._instance.option( @@ -152,10 +169,6 @@ abstract class ComponentBase

extends React.PureComponent ); } - private _setTemplatesRendererRef(instance: TemplatesRenderer | null) { - this._templatesRendererRef = instance; - } - private _getElementProps(): Record { const elementProps: Record = { ref: (element: HTMLDivElement) => { this._element = element; }, @@ -199,6 +212,19 @@ abstract class ComponentBase

extends React.PureComponent } } + private _lockParentOnRemoved() { + this.context?.lock(); + } + + private _unlockParentOnRemoved() { + this.context?.unlock(); + } + + private _setTemplateManagerHooks(createDXTemplates: DXTemplateCreator, clearInstantiationModels: () => void) { + this._createDXTemplates = createDXTemplates; + this._clearInstantiationModels = clearInstantiationModels; + } + protected renderChildren(): React.ReactNode { // @ts-expect-error TS2339 const { children } = this.props; @@ -223,7 +249,6 @@ abstract class ComponentBase

extends React.PureComponent } protected renderPortal(): React.ReactNode { - // @ts-expect-error TS2322 return this.portalContainer && createPortal( this.renderChildren(), this.portalContainer, @@ -238,9 +263,8 @@ abstract class ComponentBase

extends React.PureComponent 'div', this._getElementProps(), this.renderContent(), - React.createElement(TemplatesRenderer, { - templatesStore: this._templatesStore, - ref: this._setTemplatesRendererRef, + React.createElement(TemplateManager, { + init: this._setTemplateManagerHooks, }), ), this.isPortalComponent && this.renderPortal(), diff --git a/packages/devextreme-react/src/core/dx-template.ts b/packages/devextreme-react/src/core/dx-template.ts deleted file mode 100644 index cb7cc28456c6..000000000000 --- a/packages/devextreme-react/src/core/dx-template.ts +++ /dev/null @@ -1,100 +0,0 @@ -import * as React from 'react'; - -import * as events from 'devextreme/events'; -import { DoubleKeyMap, generateID } from './helpers'; -import { ITemplateArgs } from './template'; -import { ITemplateWrapperProps, TemplateWrapper } from './template-wrapper'; -import { TemplatesStore } from './templates-store'; -import { DX_REMOVE_EVENT } from './component-base'; - -interface IDxTemplate { - render: (data: IDxTemplateData) => any; -} - -interface IDxTemplateData { - container: any; - model?: any; - index?: any; - onRendered?: () => void; -} - -function unwrapElement(element: any): HTMLElement { - return element.get ? element.get(0) : element; -} - -function createDxTemplate( - createContentProvider: () => (props: ITemplateArgs) => any, - templatesStore: TemplatesStore, - keyFn?: (data: any) => string, -): IDxTemplate { - const renderedTemplates = new DoubleKeyMap(); - - return { - render: (data: IDxTemplateData) => { - const container = unwrapElement(data.container); - const key = { key1: data.model, key2: container }; - const prevTemplateId = renderedTemplates.get(key); - - let templateId: string; - - const onRemoved = (): void => { - templatesStore.setDeferredRemove(templateId, true); - renderedTemplates.delete(key); - }; - - const _subscribeOnContainerRemoval = (): void => { - if (container.nodeType === Node.ELEMENT_NODE) { - events.one(container, DX_REMOVE_EVENT, onRemoved); - } - }; - - const _unsubscribeOnContainerRemoval = (): void => { - if (container.nodeType === Node.ELEMENT_NODE) { - events.off(container, DX_REMOVE_EVENT, onRemoved); - } - }; - - if (prevTemplateId) { - templateId = prevTemplateId; - } else { - templateId = keyFn ? keyFn(data.model) : `__template_${generateID()}`; - - if (data.model !== undefined) { - renderedTemplates.set(key, templateId); - } - } - - _subscribeOnContainerRemoval(); - - templatesStore.add(templateId, () => { - const props: ITemplateArgs = { - data: data.model, - index: data.index, - }; - - const contentProvider = createContentProvider(); - return React.createElement( - TemplateWrapper, - { - content: contentProvider(props), - container, - onRemoved, - onDidMount: () => { - _unsubscribeOnContainerRemoval(); - templatesStore.setDeferredRemove(templateId, false); - data.onRendered?.(); - }, - key: templateId, - }, - ) as any as TemplateWrapper; - }); - - return container; - }, - }; -} - -export { - IDxTemplate, - createDxTemplate, -}; diff --git a/packages/devextreme-react/src/core/helpers.ts b/packages/devextreme-react/src/core/helpers.ts index 3a0f485f155f..a65d741c38a3 100644 --- a/packages/devextreme-react/src/core/helpers.ts +++ b/packages/devextreme-react/src/core/helpers.ts @@ -1,9 +1,18 @@ +/* eslint-disable max-classes-per-file, no-restricted-syntax */ +import { createContext } from 'react'; +import { TemplateInstantiationModel, UpdateLocker } from './types'; + +export const RemovalLockerContext = createContext({ + lock: () => undefined, + unlock: () => undefined, +}); + export function generateID(): string { - return Math.random().toString(36).substr(2); + return Math.random().toString(36).substring(2); } export class DoubleKeyMap { - private readonly _map: Map> = new Map(); + private _map: Map> = new Map(); public set({ key1, key2 }: { key1: TKey1; key2: TKey2 }, value: TValue): void { let innerMap = this._map.get(key1); @@ -31,8 +40,29 @@ export class DoubleKeyMap { this._map.delete(key1); } } + + public get empty(): boolean { + return this._map.size === 0; + } + + public shallowCopy(): DoubleKeyMap { + const copy = new DoubleKeyMap(); + + copy._map = this._map; + return copy; + } + + * [Symbol.iterator](): Generator<[{ key1: TKey1; key2: TKey2 }, TValue]> { + for (const [key1, innerMap] of this._map) { + for (const [key2, value] of innerMap) { + yield [{ key1, key2 }, value]; + } + } + } } +export class TemplateInstantiationModels extends DoubleKeyMap {} + export function capitalizeFirstLetter(text: string): string { if (text.length) { return `${text[0].toUpperCase()}${text.substr(1)}`; diff --git a/packages/devextreme-react/src/core/options-manager.ts b/packages/devextreme-react/src/core/options-manager.ts index c129d71701be..ed714947ed19 100644 --- a/packages/devextreme-react/src/core/options-manager.ts +++ b/packages/devextreme-react/src/core/options-manager.ts @@ -3,9 +3,8 @@ import { getChanges } from './configuration/comparer'; import { buildConfig, findValue, ValueType } from './configuration/tree'; import { mergeNameParts, shallowEquals } from './configuration/utils'; import { capitalizeFirstLetter } from './helpers'; - -import type TemplatesManager from './templates-manager'; -import type { IConfigNode } from './configuration/config-node'; +import type { IConfigNode, ITemplate } from './configuration/config-node'; +import { DXTemplateCollection } from './types'; const optionsManagers = new Set(); let guardTimeoutHandler = -1; @@ -23,8 +22,6 @@ export function scheduleGuards(): void { class OptionsManager { private readonly guards: Record void> = {}; - private readonly templatesManager: TemplatesManager; - private instance: any; private isUpdating = false; @@ -35,8 +32,7 @@ class OptionsManager { private independentEvents: Set; - constructor(templatesManager: TemplatesManager) { - this.templatesManager = templatesManager; + constructor() { optionsManagers.add(this); this.onOptionChanged = this.onOptionChanged.bind(this); @@ -58,25 +54,22 @@ class OptionsManager { public getInitialOptions(rootNode: IConfigNode): Record { const config = buildConfig(rootNode, false); - Object.keys(config.templates).forEach((key) => { - this.templatesManager.add(key, config.templates[key]); - }); const options: Record = {}; Object.keys(config.options).forEach((key) => { options[key] = this.wrapOptionValue(key, config.options[key]); }); - if (this.templatesManager.templatesCount > 0) { - options.integrationOptions = { - templates: this.templatesManager.templates, - }; - } - return options; } - public update(config: IConfigNode): void { + public getTemplateOptions(rootNode: IConfigNode): Record { + const config = buildConfig(rootNode, false); + + return config.templates; + } + + public update(config: IConfigNode, dxtemplates: DXTemplateCollection): void { const changedOptions: [string, unknown][] = []; const optionChangedHandler = ({ value, fullName }) => { changedOptions.push([fullName, value]); @@ -96,14 +89,11 @@ class OptionsManager { this.resetOption(optionName); }); - Object.keys(changes.templates).forEach((key) => { - this.templatesManager.add(key, changes.templates[key]); - }); - if (this.templatesManager.templatesCount > 0) { + if (Object.keys(dxtemplates).length > 0) { this.setValue( 'integrationOptions', { - templates: this.templatesManager.templates, + templates: dxtemplates, }, ); } diff --git a/packages/devextreme-react/src/core/template-manager.tsx b/packages/devextreme-react/src/core/template-manager.tsx new file mode 100644 index 000000000000..c3b83178bc13 --- /dev/null +++ b/packages/devextreme-react/src/core/template-manager.tsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import * as events from 'devextreme/events'; + +import { + useState, + useMemo, + useCallback, + useRef, + FC, +} from 'react'; + +import { + TemplateManagerProps, + GetRenderFuncFn, + DXTemplateCollection, + TemplateFunc, +} from './types'; + +import { TemplateWrapper } from './template-wrapper'; +import { TemplateInstantiationModels, generateID } from './helpers'; +import { DX_REMOVE_EVENT } from './component-base'; +import { ITemplateArgs } from './template'; +import { getOption as getConfigOption } from './config'; +import { ITemplate } from './configuration/config-node'; + +function normalizeProps(props: ITemplateArgs): ITemplateArgs | ITemplateArgs['data'] { + if (getConfigOption('useLegacyTemplateEngine')) { + const model = props.data; + if (model && Object.prototype.hasOwnProperty.call(model, 'key')) { + model.dxkey = model.key; + } + return model; + } + return props; +} + +export const TemplateManager: FC = ({ init }) => { + const [instantiationModels, setInstantiationModels] = useState(new TemplateInstantiationModels()); + const [templateFactories, setTemplateFactories] = useState>({}); + const widgetId = useRef(''); + + const subscribeOnRemoval = useCallback((container: HTMLElement, onRemoved: () => void) => { + if (container.nodeType === Node.ELEMENT_NODE) { + events.on(container, DX_REMOVE_EVENT, onRemoved); + } + }, []); + + const unsubscribeOnRemoval = useCallback((container: HTMLElement, onRemoved: () => void) => { + if (container.nodeType === Node.ELEMENT_NODE) { + events.off(container, DX_REMOVE_EVENT, onRemoved); + } + }, []); + + const createMapKey = useCallback((key1: any, key2: HTMLElement) => ({ key1, key2 }), []); + + const getRandomId = useCallback(() => `${generateID()}${generateID()}${generateID()}`, []); + + const getRenderFunc: GetRenderFuncFn = useCallback((templateKey) => ({ + model: data, + index, + container, + onRendered, + }) => { + const key = createMapKey(data, container); + + const onRemoved = (): void => { + setInstantiationModels((currentInstantiationModels) => { + const template = currentInstantiationModels.get(key); + + if (template) { + currentInstantiationModels.delete(key); + + return currentInstantiationModels.shallowCopy(); + } + + return currentInstantiationModels; + }); + }; + + const hostWidgetId = widgetId.current; + + setInstantiationModels((currentInstantiationModels) => { + currentInstantiationModels.set(key, { + templateKey, + index, + componentKey: getRandomId(), + onRendered: () => { + unsubscribeOnRemoval(container, onRemoved); + + if (hostWidgetId === widgetId.current) { + onRendered?.(); + } + }, + onRemoved, + }); + + return currentInstantiationModels.shallowCopy(); + }); + + return container; + }, [unsubscribeOnRemoval, createMapKey]); + + useMemo(() => { + function getTemplateFunction(template: ITemplate): TemplateFunc { + switch (template.type) { + case 'children': return () => template.content as JSX.Element; + + case 'render': return (props) => { + normalizeProps(props); + return template.content(props.data, props.index) as JSX.Element; + }; + + case 'component': return (props) => { + props = normalizeProps(props); + return React.createElement.bind(null, template.content)(props) as JSX.Element; + }; + + default: return () => React.createElement(React.Fragment); + } + } + + function getDXTemplates(templateOptions: Record): DXTemplateCollection { + const factories = Object.entries(templateOptions) + .reduce((res, [key, template]) => ( + { + ...res, + [key]: getTemplateFunction(template), + } + ), {}); + + setTemplateFactories(factories); + + const dxTemplates = Object.keys(factories) + .reduce((templates, templateKey) => { + templates[templateKey] = { render: getRenderFunc(templateKey) }; + + return templates; + }, {}); + + return dxTemplates; + } + + function clearInstantiationModels(): void { + widgetId.current = getRandomId(); + setInstantiationModels(new TemplateInstantiationModels()); + } + + init(getDXTemplates, clearInstantiationModels); + }, [init, getRenderFunc]); + + if (instantiationModels.empty) { + return null; + } + + return ( + <> + { + Array.from(instantiationModels).map(([{ key1: data, key2: container }, { + index, + templateKey, + componentKey, + onRendered, + onRemoved, + }]) => { + subscribeOnRemoval(container, onRemoved); + + return ; + }) + } + + ); +}; diff --git a/packages/devextreme-react/src/core/template-wrapper.ts b/packages/devextreme-react/src/core/template-wrapper.ts deleted file mode 100644 index 8434574fc03d..000000000000 --- a/packages/devextreme-react/src/core/template-wrapper.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as events from 'devextreme/events'; -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; - -import { DX_REMOVE_EVENT } from './component-base'; - -interface ITemplateWrapperProps { - content: any; - container: Element; - onRemoved: () => void; - onDidMount?: () => void; - key: string; -} - -interface ITemplateWrapperState { - removalListenerRequired: boolean; -} - -type TemplateWrapperRenderer = () => TemplateWrapper; - -const removalListenerStyle = { display: 'none' }; - -enum TableNodeNames { - TABLE = 'tbody', - TBODY = 'tr', -} - -class TemplateWrapper extends React.PureComponent { - private readonly _removalListenerRef = React.createRef(); - - private element: HTMLElement | undefined | null; - - private hiddenElement: HTMLElement | undefined | null; - - constructor(props: ITemplateWrapperProps) { - super(props); - - this.state = { removalListenerRequired: false }; - - this._onDxRemove = this._onDxRemove.bind(this); - this.getPreviousSiblingNode = this.getPreviousSiblingNode.bind(this); - } - - public componentDidMount(): void { - this._subscribeOnRemove(); - this.props.onDidMount?.(); - } - - public componentDidUpdate(): void { - this._subscribeOnRemove(); - } - - public componentWillUnmount(): void { - // Let React remove it itself - const node = this.element; - const hiddenNode = this.hiddenElement; - const { container } = this.props; - - if (node) { - container.appendChild(node); - } - if (hiddenNode) { - container.appendChild(hiddenNode); - } - if (this._listenerElement) { - container.appendChild(this._listenerElement); - } - } - - private get _listenerElement(): HTMLElement { - return this._removalListenerRef.current as HTMLElement; - } - - private getPreviousSiblingNode(node: HTMLDivElement | null) { - this.hiddenElement = node; - this.element = node?.previousSibling as HTMLElement; - } - - private _subscribeOnRemove() { - const node = this.element; - const { removalListenerRequired } = this.state; - - if (node && node.nodeType === Node.ELEMENT_NODE) { - this._subscribeOnElementRemoval(node as Element); - return; - } - - if (!removalListenerRequired) { - this.setState({ removalListenerRequired: true }); - return; - } - - if (this._listenerElement) { - this._subscribeOnElementRemoval(this._listenerElement); - } - } - - private _subscribeOnElementRemoval(element: Element): void { - events.off(element, DX_REMOVE_EVENT, this._onDxRemove); - events.one(element, DX_REMOVE_EVENT, this._onDxRemove); - } - - private _onDxRemove(): void { - const { onRemoved } = this.props; - onRemoved(); - } - - public render(): React.ReactNode { - const { removalListenerRequired } = this.state; - const { content, container } = this.props; - const removalListener = removalListenerRequired - ? React.createElement('span', { style: removalListenerStyle, ref: this._removalListenerRef }) - : undefined; - const nodeName = TableNodeNames[container.nodeName] || 'div'; - - // @ts-expect-error TS2322 - return ReactDOM.createPortal( - React.createElement( - React.Fragment, - null, - content, - content && React.createElement( - nodeName, - { style: { display: 'none' }, ref: this.getPreviousSiblingNode }, - ), - removalListener, - ), - container, - ); - } -} - -export { - ITemplateWrapperProps, - TemplateWrapper, - TemplateWrapperRenderer, -}; diff --git a/packages/devextreme-react/src/core/template-wrapper.tsx b/packages/devextreme-react/src/core/template-wrapper.tsx new file mode 100644 index 000000000000..8d2aa611b956 --- /dev/null +++ b/packages/devextreme-react/src/core/template-wrapper.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import * as events from 'devextreme/events'; + +import { + useCallback, + useLayoutEffect, + useEffect, + useState, + useRef, + useMemo, + memo, + FC, +} from 'react'; + +import { createPortal } from 'react-dom'; +import { DX_REMOVE_EVENT } from './component-base'; +import { DXRemoveCustomArgs, TemplateWrapperProps } from './types'; +import { RemovalLockerContext } from './helpers'; + +const createHiddenNode = ( + containerNodeName: string, + ref: React.LegacyRef, + defaultElement: string, +) => { + const style = { display: 'none' }; + switch (containerNodeName) { + case 'TABLE': + return ; + case 'TBODY': + return ; + default: + return React.createElement(defaultElement, { style, ref }); + } +}; + +const TemplateWrapperComponent: FC = ({ + templateFactory, + data, + index, + container, + onRemoved, + onRendered, +}) => { + const [removalListenerRequired, setRemovalListenerRequired] = useState(false); + const isRemovalLocked = useRef(false); + const removalLocker = useMemo(() => ({ + lock(): void { isRemovalLocked.current = true; }, + unlock(): void { isRemovalLocked.current = false; }, + }), []); + + const element = useRef(); + const hiddenNodeElement = useRef(); + const removalListenerElement = useRef(); + + const onTemplateRemoved = useCallback((_, args: DXRemoveCustomArgs | undefined) => { + if (args?.isUnmounting || isRemovalLocked) { + return; + } + + if (element.current) { + events.off(element.current, DX_REMOVE_EVENT, onTemplateRemoved); + } + + if (removalListenerElement.current) { + events.off(removalListenerElement.current, DX_REMOVE_EVENT, onTemplateRemoved); + } + + onRemoved(); + }, [onRemoved]); + + useLayoutEffect(() => { + const el = element.current; + + if (el) { + events.off(el, DX_REMOVE_EVENT, onTemplateRemoved); + events.on(el, DX_REMOVE_EVENT, onTemplateRemoved); + } + + if (!removalListenerRequired) { + setRemovalListenerRequired(true); + } else if (removalListenerElement.current) { + events.off(removalListenerElement.current, DX_REMOVE_EVENT, onTemplateRemoved); + events.on(removalListenerElement.current, DX_REMOVE_EVENT, onTemplateRemoved); + } + + return () => { + if (element.current) { + container.appendChild(element.current); + } + + if (hiddenNodeElement.current) { + container.appendChild(hiddenNodeElement.current); + } + + if (removalListenerElement.current) { + container.appendChild(removalListenerElement.current); + } + + if (el) { + events.off(el, DX_REMOVE_EVENT, onTemplateRemoved); + } + }; + }, [onTemplateRemoved, removalListenerRequired, container]); + + useEffect(() => { + onRendered(); + }, [onRendered]); + + const hiddenNode = createHiddenNode(container?.nodeName, (node: HTMLElement) => { + hiddenNodeElement.current = node; + element.current = node?.previousSibling as HTMLElement; + }, 'div'); + + const removalListener = removalListenerRequired + ? createHiddenNode(container?.nodeName, (node: HTMLElement) => { removalListenerElement.current = node; }, 'span') + : undefined; + + return createPortal( + <> + + { templateFactory({ data, index, onRendered }) } + { hiddenNode } + { removalListener } + + , + container, + ); +}; + +export const TemplateWrapper = memo(TemplateWrapperComponent); diff --git a/packages/devextreme-react/src/core/templates-manager.ts b/packages/devextreme-react/src/core/templates-manager.ts deleted file mode 100644 index bd55e87684ff..000000000000 --- a/packages/devextreme-react/src/core/templates-manager.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from 'react'; - -import { getOption as getConfigOption } from './config'; -import { ITemplate } from './configuration/config-node'; -import { createDxTemplate } from './dx-template'; -import { ITemplateArgs } from './template'; -import { TemplatesStore } from './templates-store'; - -function normalizeProps(props: ITemplateArgs): ITemplateArgs | ITemplateArgs['data'] { - if (getConfigOption('useLegacyTemplateEngine')) { - const model = props.data; - if (model && Object.prototype.hasOwnProperty.call(model, 'key')) { - model.dxkey = model.key; - } - return model; - } - return props; -} - -type ContentGetter = () => any; - -const contentCreators = { - component: (contentGetter: ContentGetter) => (props: ITemplateArgs) => { - props = normalizeProps(props); - return React.createElement.bind(null, contentGetter())(props); - }, - render: (contentGetter: ContentGetter) => (props: ITemplateArgs) => { - normalizeProps(props); - return contentGetter()(props.data, props.index); - }, - children: (contentGetter: ContentGetter) => () => contentGetter(), -}; - -class TemplatesManager { - private readonly _templatesStore: TemplatesStore; - - private _templates: Record = {}; - - private _templatesContent: Record = {}; - - constructor(templatesStore: TemplatesStore) { - this._templatesStore = templatesStore; - } - - public add(name: string, template: ITemplate): void { - this._templatesContent[name] = template.content; - const contentCreator = contentCreators[template.type] - .bind(this, () => this._templatesContent[name]); - this._templates[name] = createDxTemplate( - contentCreator, - this._templatesStore, - template.keyFn, - ); - } - - public get templatesCount(): number { - return Object.keys(this._templates).length; - } - - public get templates(): Record { - return this._templates; - } -} - -export default TemplatesManager; diff --git a/packages/devextreme-react/src/core/templates-renderer.tsx b/packages/devextreme-react/src/core/templates-renderer.tsx deleted file mode 100644 index 28010d1b778d..000000000000 --- a/packages/devextreme-react/src/core/templates-renderer.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { requestAnimationFrame } from 'devextreme/animation/frame'; -import { deferUpdate } from 'devextreme/core/utils/common'; -import * as React from 'react'; - -import { TemplatesStore } from './templates-store'; - -class TemplatesRenderer extends React.PureComponent<{ - templatesStore: TemplatesStore; -}> { - private updateScheduled = false; - - private mounted = false; - - private shouldRepeatForceUpdate = false; - - private isUpdateFuncLaunched = false; - - componentDidMount(): void { - this.mounted = true; - } - - componentWillUnmount(): void { - this.mounted = false; - } - - public scheduleUpdate(useDeferUpdate: boolean, onRendered?: () => void): void { - if (this.updateScheduled) { - this.shouldRepeatForceUpdate = this.isUpdateFuncLaunched; - return; - } - - this.updateScheduled = true; - - const updateFunc = useDeferUpdate ? deferUpdate : requestAnimationFrame; - - // eslint-disable-next-line no-void - void updateFunc(() => { - if (this.mounted) { - this.isUpdateFuncLaunched = true; - - this.forceUpdate(() => { - this.updateScheduled = false; - onRendered?.(); - - if (this.shouldRepeatForceUpdate) { - this.shouldRepeatForceUpdate = false; - this.forceUpdate(); - } - }); - } - - this.isUpdateFuncLaunched = false; - this.updateScheduled = false; - }); - } - - public render(): React.ReactNode { - const { templatesStore } = this.props; - return React.createElement( - React.Fragment, - {}, - // @ts-expect-error TS2769 - templatesStore.renderWrappers(), - ); - } -} - -export { - TemplatesRenderer, -}; diff --git a/packages/devextreme-react/src/core/templates-store.ts b/packages/devextreme-react/src/core/templates-store.ts deleted file mode 100644 index e78c4b04c1af..000000000000 --- a/packages/devextreme-react/src/core/templates-store.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { TemplateWrapper, TemplateWrapperRenderer } from './template-wrapper'; - -interface TemplateInfo { - template: TemplateWrapperRenderer; - isDeferredRemove: boolean; -} -class TemplatesStore { - private readonly _templates: Record = {}; - - private readonly _onTemplateAdded: () => void; - - constructor(onTemplateAdded: () => void) { - this._onTemplateAdded = onTemplateAdded; - } - - public add(templateId: string, templateFunc: TemplateWrapperRenderer): void { - this._templates[templateId] = { template: templateFunc, isDeferredRemove: false }; - this._onTemplateAdded(); - } - - public setDeferredRemove(templateId: string, isDeferredRemove: boolean): void { - if (this._templates[templateId]) { - this._templates[templateId].isDeferredRemove = isDeferredRemove; - } - } - - private removeDefferedTemplate(): void { - Object.entries(this._templates) - .filter(([, templateInfo]) => templateInfo.isDeferredRemove) - .forEach(([templateId]) => { - delete this._templates[templateId]; - }); - } - - public renderWrappers(): TemplateWrapper[] { - this.removeDefferedTemplate(); - return Object.getOwnPropertyNames(this._templates).map( - (templateId) => this._templates[templateId].template(), - ); - } -} - -export { - TemplatesStore, -}; diff --git a/packages/devextreme-react/src/core/types.ts b/packages/devextreme-react/src/core/types.ts new file mode 100644 index 000000000000..52cff754f4fc --- /dev/null +++ b/packages/devextreme-react/src/core/types.ts @@ -0,0 +1,58 @@ +import { ITemplate } from './configuration/config-node'; + +interface DXTemplate { + render: RenderFunc; +} + +type RenderFunc = (arg: RenderArgs) => HTMLElement; + +interface TemplateArgs { + data: any; + index?: number; + onRendered: () => void; +} + +export interface RenderArgs { + model?: any; + container: any; + index?: any; + onRendered?: () => void; +} + +export interface UpdateLocker { + lock: () => void; + unlock: () => void; +} + +export type DXTemplateCollection = Record; + +export interface TemplateWrapperProps { + templateFactory: TemplateFunc; + data: any; + index: number; + container: HTMLElement; + onRendered: () => void; + onRemoved: () => void; +} + +export type TemplateFunc = (arg: TemplateArgs) => JSX.Element; + +export type DXTemplateCreator = (templateOptions: Record) => DXTemplateCollection; + +export interface TemplateManagerProps { + init: (getDXTemplates: DXTemplateCreator, clearInstantiationModels: () => void) => void; +} + +export interface TemplateInstantiationModel { + templateKey: string; + componentKey: string; + index: any; + onRendered: () => void; + onRemoved: () => void; +} + +export type GetRenderFuncFn = (templateKey: string) => RenderFunc; + +export interface DXRemoveCustomArgs { + isUnmounting: boolean; +} diff --git a/packages/devextreme-react/tsconfig.json b/packages/devextreme-react/tsconfig.json index 07d8e9d737fd..35920bbf74c7 100644 --- a/packages/devextreme-react/tsconfig.json +++ b/packages/devextreme-react/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": "./", - "skipLibCheck": true + "skipLibCheck": true, + "downlevelIteration": true }, "include": [