diff --git a/e2e/wrappers/builders/react19/src/utils/componentFinder.js b/e2e/wrappers/builders/react19/src/utils/componentFinder.js index a5a3b48bd2b7..20ba8e84a497 100644 --- a/e2e/wrappers/builders/react19/src/utils/componentFinder.js +++ b/e2e/wrappers/builders/react19/src/utils/componentFinder.js @@ -28,6 +28,11 @@ const COMPONENTS = [ path: 'gantt-template-state-update', name: 'GanttTemplateStateUpdate', component: () => import('@examples/gantt-template-state-update/react19/index.jsx') + }, + { + path: 'card-view-template-rerender', + name: 'CardViewTemplateRerender', + component: () => import('@examples/card-view-template-rerender/react19/index.jsx') } ]; diff --git a/e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx b/e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx new file mode 100644 index 000000000000..67f9f9f5334a --- /dev/null +++ b/e2e/wrappers/examples/card-view-template-rerender/react19/index.jsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import CardView, { Column } from "devextreme-react/card-view"; + +const initialData = [ + { id: 1, updateCount: 0 }, + { id: 2, updateCount: 0 }, +]; + +const renderCard = ({ card }) => ( +
+ {`${card.data.id}: ${card.data.updateCount}`} +
+); + +function App() { + const [items, setItems] = useState(initialData); + + const updateItems = () => { + setItems((prev) => prev.map((item) => ({ + ...item, + updateCount: item.updateCount + 1, + }))); + }; + + return ( +
+ + + + + + +
+ ); +} + +export default App; diff --git a/e2e/wrappers/tests/card-view-template-rerender.spec.ts b/e2e/wrappers/tests/card-view-template-rerender.spec.ts new file mode 100644 index 000000000000..d572bd15aad3 --- /dev/null +++ b/e2e/wrappers/tests/card-view-template-rerender.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '../fixtures'; + +test.describe('CardView template re-rendering', () => { + test.skip(({ framework }) => framework !== 'react19', 'The example is implemented for React only'); + + test.beforeEach(async ({ page }) => { + await page.goto('/examples/card-view-template-rerender'); + }); + + test('CardView should not duplicate card templates after a dataSource update (T1336765)', async ({ page }) => { + const updateButton = page.locator('button', { hasText: 'Update Data' }); + const cards = page.locator('.custom-card'); + + await expect(cards).toHaveText(['1: 0', '2: 0']); + + await updateButton.click(); + + await expect(cards).toHaveText(['1: 1', '2: 1']); + }); +}); diff --git a/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx b/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx index 61f9528b11c5..f519b710a5e5 100644 --- a/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx +++ b/packages/devextreme-react/src/core/__tests__/template-manager.test.tsx @@ -601,4 +601,72 @@ describe('Template Manager', () => { expect(document.querySelector('.render-template-container')?.innerHTML) .toBe('
Render template text-1
'); }); -}); \ No newline at end of file + + it('template is not rendered if the container was removed before the template manager re-rendered (T1336765)', () => { + let createDXTemplates; + + const init = ({ createDXTemplates: createDXTemplatesFn }: InitArgument) => { + createDXTemplates = createDXTemplatesFn; + }; + + const templateOptions = getTemplateOptions([{ type: 'render' }]); + + render( + +
+ undefined} /> +
+ ); + + let dxTemplates; + + act(() => { dxTemplates = createDXTemplates(templateOptions); }); + + const container = document.querySelector('.render-template-container')!; + + act(() => { + dxTemplates.renderKey.render({ + model: { text: 'Render template text' }, + index: 1, + container, + }); + events.triggerHandler(container, 'dxremove'); + }); + + expect(container.innerHTML).toBe(''); + }); + + it('does not keep a container removal handler of a replaced template registration (T1336765)', () => { + let createDXTemplates; + + const init = ({ createDXTemplates: createDXTemplatesFn }: InitArgument) => { + createDXTemplates = createDXTemplatesFn; + }; + + const templateOptions = getTemplateOptions([{ type: 'render' }]); + + render( + +
+ undefined} /> +
+ ); + + let dxTemplates; + + act(() => { dxTemplates = createDXTemplates(templateOptions); }); + + const container = document.querySelector('.render-template-container')!; + const model = { text: 'Render template text' }; + + act(() => { + dxTemplates.renderKey.render({ model, index: 1, container }); + dxTemplates.renderKey.render({ model, index: 1, container }); + }); + + act(() => { events.triggerHandler(container, 'dxremove'); }); + + expect(container.innerHTML) + .toBe('
Render template text-1
'); + }); +}); diff --git a/packages/devextreme-react/src/core/template-manager.tsx b/packages/devextreme-react/src/core/template-manager.tsx index 82c7b6a90a85..5286d0c4a745 100644 --- a/packages/devextreme-react/src/core/template-manager.tsx +++ b/packages/devextreme-react/src/core/template-manager.tsx @@ -89,6 +89,11 @@ export const TemplateManager: FC = ({ init, onTemplatesRen }; const hostWidgetId = widgetId.current; + const replacedModel = collection.get(key); + + if (replacedModel) { + unsubscribeOnRemoval(containerElement, replacedModel.onContainerRemoved); + } collection.set(key, { templateKey, @@ -105,6 +110,8 @@ export const TemplateManager: FC = ({ init, onTemplatesRen onContainerRemoved, }); + subscribeOnRemoval(containerElement, onContainerRemoved); + setInstantiationModels({ collection }); return containerElement; @@ -193,10 +200,7 @@ export const TemplateManager: FC = ({ init, onTemplatesRen componentKey, onRendered, onRemoved, - onContainerRemoved, }]) => { - subscribeOnRemoval(container, onContainerRemoved); - const factory = templateFactories.current[templateKey]; if (factory) { diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts index d6d8b4573f3c..f57230dd6237 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it, jest, } from '@jest/globals'; import $ from '@js/core/renderer'; +import { on } from '@js/events/index'; +import type { CardInfo } from '@ts/grids/new/grid_core/columns_controller/types'; import { rerender } from 'inferno'; import type { Options as CardViewOptions } from './options'; @@ -69,6 +71,49 @@ describe('regressions', () => { }); }); +describe('card template container', () => { + it('should notify card template container removal before re-rendering on data update (T1336765)', () => { + const onContainerRemoved = jest.fn(); + const cardTemplate = (_: unknown, templateContainer: Element): void => { + on($(templateContainer).get(0), 'dxremove', onContainerRemoved); + }; + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource: [{ id: 1, name: 'Audi' }], + cardTemplate, + } as CardViewOptions); + + cardView.option('dataSource', [{ id: 1, name: 'BMW' }]); + + expect(onContainerRemoved).toHaveBeenCalledTimes(1); + }); + + it('should remove card template container when the card is removed (T1336765)', () => { + const removedCardKeys: unknown[] = []; + const cardTemplate = (model: unknown, templateContainer: Element): void => { + const { card } = model as { card: CardInfo }; + + on($(templateContainer).get(0), 'dxremove', () => { + removedCardKeys.push(card.key); + }); + }; + + const container = document.createElement('div'); + const dataSource = [{ id: 1, name: 'Audi' }, { id: 2, name: 'BMW' }]; + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + cardTemplate, + } as CardViewOptions); + + cardView.option('dataSource', [dataSource[0]]); + + expect(removedCardKeys).toContain(2); + }); +}); + describe('absence of multiple re-render', () => { const dataSource = [ { id: 1, name: 'Audi' }, @@ -150,6 +195,23 @@ describe('absence of multiple re-render', () => { expect(cardTemplate).toHaveBeenCalledTimes(calledTimes); }); + + it('should not render card template when integration templates are re-set (T1336765)', () => { + const cardTemplate = jest.fn(); + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + columns, + cardTemplate, + } as CardViewOptions); + + cardTemplate.mockClear(); + cardView.option('integrationOptions.templates', {}); + + expect(cardTemplate).not.toHaveBeenCalled(); + }); }); describe('card footer template', () => { diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx index 808ac2c4bf8f..7afe325ce374 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx @@ -1,6 +1,8 @@ +import { cleanDataRecursive } from '@js/core/element_data'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { BaseInfernoComponent } from '@ts/core/r1/runtime/inferno/base_component'; +import { shallowEquals } from '@ts/core/r1/utils/shallow_equals'; import { createRef } from 'inferno'; interface TemplateType { @@ -9,12 +11,19 @@ interface TemplateType { // eslint-disable-next-line @stylistic/max-len // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -export function TemplateWrapper(template: TemplateType) { +export function TemplateWrapper< + TProps extends Record = Record, +>(template: TemplateType) { return class Template extends BaseInfernoComponent { private readonly ref = createRef(); - private renderTemplate(): void { + private clearContainer(): void { + cleanDataRecursive(this.ref.current, true); $(this.ref.current).empty(); + } + + private renderTemplate(): void { + this.clearContainer(); template.render({ container: $(this.ref.current), @@ -22,6 +31,10 @@ export function TemplateWrapper(template: TemplateType) { }); } + public shouldComponentUpdate(nextProps: TProps): boolean { + return !shallowEquals(this.props, nextProps); + } + public render(): JSX.Element { return
; } @@ -33,5 +46,9 @@ export function TemplateWrapper(template: TemplateType) { public componentDidMount(): void { this.renderTemplate(); } + + public componentWillUnmount(): void { + this.clearContainer(); + } }; }