Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions e2e/wrappers/builders/react19/src/utils/componentFinder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
];

Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div className="custom-card">
{`${card.data.id}: ${card.data.updateCount}`}
</div>
);

function App() {
const [items, setItems] = useState(initialData);

const updateItems = () => {
setItems((prev) => prev.map((item) => ({
...item,
updateCount: item.updateCount + 1,
})));
};

return (
<div>
<button onClick={updateItems}>Update Data</button>

<CardView
dataSource={items}
keyExpr="id"
cardsPerRow={2}
cardRender={renderCard}
>
<Column dataField="id" />
<Column dataField="updateCount" />
</CardView>
</div>
);
}

export default App;
20 changes: 20 additions & 0 deletions e2e/wrappers/tests/card-view-template-rerender.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -601,4 +601,72 @@ describe('Template Manager', () => {
expect(document.querySelector('.render-template-container')?.innerHTML)
.toBe('<div class="render-template">Render template text-1</div><div style="display: none;" class="__dx_react_guard_node__"></div>');
});
});

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(
<React.Fragment>
<div className='render-template-container'></div>
<TemplateManager init={init} onTemplatesRendered={() => undefined} />
</React.Fragment>
);

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(
<React.Fragment>
<div className='render-template-container'></div>
<TemplateManager init={init} onTemplatesRendered={() => undefined} />
</React.Fragment>
);

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('<div class="render-template">Render template text-1</div><div style="display: none;" class="__dx_react_guard_node__"></div>');
});
});
10 changes: 7 additions & 3 deletions packages/devextreme-react/src/core/template-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export const TemplateManager: FC<TemplateManagerProps> = ({ init, onTemplatesRen
};

const hostWidgetId = widgetId.current;
const replacedModel = collection.get(key);

if (replacedModel) {
unsubscribeOnRemoval(containerElement, replacedModel.onContainerRemoved);
}

collection.set(key, {
templateKey,
Expand All @@ -105,6 +110,8 @@ export const TemplateManager: FC<TemplateManagerProps> = ({ init, onTemplatesRen
onContainerRemoved,
});

subscribeOnRemoval(containerElement, onContainerRemoved);
Comment thread
bit-byte0 marked this conversation as resolved.

setInstantiationModels({ collection });

return containerElement;
Expand Down Expand Up @@ -193,10 +200,7 @@ export const TemplateManager: FC<TemplateManagerProps> = ({ init, onTemplatesRen
componentKey,
onRendered,
onRemoved,
onContainerRemoved,
}]) => {
subscribeOnRemoval(container, onContainerRemoved);

const factory = templateFactories.current[templateKey];

if (factory) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T> {
Expand All @@ -9,19 +11,30 @@ interface TemplateType<T> {

// 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<TProps = {}>(template: TemplateType<TProps>) {
export function TemplateWrapper<
TProps extends Record<string, unknown> = Record<string, unknown>,
>(template: TemplateType<TProps>) {
return class Template extends BaseInfernoComponent<TProps> {
private readonly ref = createRef<HTMLDivElement>();

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),
model: this.props,
});
}

public shouldComponentUpdate(nextProps: TProps): boolean {
return !shallowEquals(this.props, nextProps);
}

public render(): JSX.Element {
return <div ref={this.ref}></div>;
}
Expand All @@ -33,5 +46,9 @@ export function TemplateWrapper<TProps = {}>(template: TemplateType<TProps>) {
public componentDidMount(): void {
this.renderTemplate();
}

public componentWillUnmount(): void {
this.clearContainer();
}
};
}
Loading