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
69 changes: 69 additions & 0 deletions packages/devextreme/js/__internal/events/__tests__/click.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
afterEach, describe, expect, it, jest,
} from '@jest/globals';
import { removeEvent } from '@js/common/core/events/remove';
import eventsEngine from '@ts/events/core/m_events_engine';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path is gone on main. The m_ prefix was dropped after this branch was cut, so it is @ts/events/core/events_engine now. As it stands the suite cannot even load after a rebase.

The rebase also conflicts in click.ts. Worth resolving by hand: git merges most of the file on its own but leaves unsubscribeNodesDisposing(lastFiredEvent, callback, nodes) outside the conflict markers, so just taking your side of the marked block leaves a reference to a callback that no longer exists.

One thing you get for free from the rebase: main's click.ts already imports NodesDisposingSubscription from the util, so the local interface this PR adds at the top of click.ts disappears.


import { name as clickEventName } from '../click';

type ElementEventData = Record<string, { handleObjects: unknown[] } | undefined>;

const noop = (): void => {};

const getRemoveHandlersCount = (element: Element): number => {
const elementData = eventsEngine.elementDataMap.get(element) as ElementEventData | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main elementDataMap is now optional (elementDataMap?: WeakMap<...>), so this line becomes a type error after a rebase. And because the internal tsconfig sets noEmitOnError: true, ts-jest then refuses to emit and the whole suite fails to run with a confusing Unable to process ... outDir message instead of a type error. Took me a minute to trace, so flagging it.

The fix makes the helper smaller rather than bigger. Main types the map as WeakMap<EngineTarget, ElementEventData>, so both the cast and the hand-written ElementEventData above become unnecessary:

const getRemoveHandlersCount = (element: Element): number => {
  const elementData = eventsEngine.elementDataMap?.get(element);

  return elementData?.[removeEvent]?.handleObjects.length ?? 0;
};

I rebased the branch locally with just this and the import above changed, and both tests pass and the project type checks clean.


return elementData?.[removeEvent]?.handleObjects.length ?? 0;
};

describe('dxclick nodes disposing (5025)', () => {
const clickableNodes: HTMLElement[] = [];

const createClickableNode = (): HTMLElement => {
const node = document.createElement('div');

document.body.appendChild(node);
eventsEngine.on(node, clickEventName, noop);
clickableNodes.push(node);

return node;
};

afterEach(() => {
clickableNodes.forEach((node) => {
eventsEngine.off(node);
node.remove();
});
clickableNodes.length = 0;
});

it('keeps a foreign dxremove handler on the previously clicked node', () => {
const clicked = createClickableNode();
const other = createClickableNode();

clicked.click();

const foreignHandler = jest.fn();
eventsEngine.on(clicked, removeEvent, foreignHandler);

other.click();
eventsEngine.triggerHandler(clicked, { type: removeEvent });

expect(foreignHandler).toHaveBeenCalledTimes(1);
});

it('removes only its own dxremove handler from the previously clicked node', () => {
const clicked = createClickableNode();
const other = createClickableNode();

clicked.click();
expect(getRemoveHandlersCount(clicked)).toBe(1);

eventsEngine.on(clicked, removeEvent, noop);
expect(getRemoveHandlersCount(clicked)).toBe(2);

other.click();

expect(getRemoveHandlersCount(clicked)).toBe(1);
});
});
23 changes: 9 additions & 14 deletions packages/devextreme/js/__internal/events/click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ interface NodesDisposingSubscription {

let prevented: boolean | null = null;
let lastFiredEvent: NativeClickEvent | null = null;
const subscriptions = new Map<NativeClickEvent, NodesDisposingSubscription>();
let lastSubscription: NodesDisposingSubscription | null = null;

const onNodeRemove = (): void => {
lastFiredEvent = null;
lastSubscription = null;
};

const clickHandler = function (e: EmitterEvent & { originalEvent: NativeClickEvent }): void {
Expand All @@ -45,25 +46,19 @@ const clickHandler = function (e: EmitterEvent & { originalEvent: NativeClickEve
originalEvent.DXCLICK_FIRED = true;
}

if (lastFiredEvent && subscriptions.has(lastFiredEvent)) {
// @ts-expect-error the subscription stores onceCallback, not callback, so this
// destructured callback is always undefined and off() drops every dxremove
// handler from the nodes
const { nodes, callback } = subscriptions.get(lastFiredEvent) as NodesDisposingSubscription;
if (lastFiredEvent && lastSubscription) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one, the code is correct as it stands.

lastFiredEvent && is not doing any work here. The two variables are always set and cleared together, so lastSubscription on its own is enough. The first argument to unsubscribeNodesDisposing is dead too, since nodes is always a real array and the util only falls back to the event when nodes is missing.

if (originalEvent) also appears twice in the same block. Putting the pairing in one place makes the "these two always move together" rule visible instead of implied:

if (lastSubscription) {
  unsubscribeNodesDisposing(null, lastSubscription.onceCallback, lastSubscription.nodes);
}

lastFiredEvent = originalEvent;
lastSubscription = originalEvent ? subscribeNodesDisposing(originalEvent, onNodeRemove) : null;

const { nodes, onceCallback } = lastSubscription;

unsubscribeNodesDisposing(lastFiredEvent, callback, nodes);
unsubscribeNodesDisposing(lastFiredEvent, onceCallback, nodes);

subscriptions.delete(lastFiredEvent);
lastSubscription = null;
}

lastFiredEvent = originalEvent;

const subscriptionData: NodesDisposingSubscription = subscribeNodesDisposing(
lastFiredEvent,
onNodeRemove,
);

subscriptions.set(lastFiredEvent, subscriptionData);
if (originalEvent) {
lastSubscription = subscribeNodesDisposing(originalEvent, onNodeRemove);
}

fireEvent({
type: CLICK_EVENT_NAME,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { noop } from 'core/utils/common';
import clickEvent from 'common/core/events/click';
import { removeEvent } from 'common/core/events/remove';
import domUtils from '__internal/core/utils/m_dom';
import support from '__internal/core/utils/m_support';
import devices from '__internal/core/m_devices';
Expand Down Expand Up @@ -425,3 +426,21 @@ QUnit.test('dxclick should not be fired twice when \'click\' is triggered from i
pointer.start().down().up();
$(document).off('dxclick', $.noop);
});

QUnit.test('foreign dxremove handler on the previously clicked node should survive a dxclick on another node (5025)', function(assert) {

@EugeniyKiyashko EugeniyKiyashko Sep 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test never checks that the two clicks actually reached the dxclick handler. If they ever stop firing, foreignHandlerCallCount stays at 1 and the test keeps passing while testing nothing. The Jest version is protected because it asserts the handler count is 1 right after the click, but this one has no such guard. Counting dxclick calls instead of passing noop would cover it.

const $clicked = $('#first').on('dxclick', noop);
const $other = $('#second').on('dxclick', noop);
let foreignHandlerCallCount = 0;

nativePointerMock($clicked).start().click();

$clicked.on(removeEvent, function() {
foreignHandlerCallCount++;
});

nativePointerMock($other).start().click();

$clicked.triggerHandler({ type: removeEvent });

assert.equal(foreignHandlerCallCount, 1, `foreign ${removeEvent} handler is still subscribed`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ QUnit.test('should clean elementDataMap when using subscribeNodesDisposing and u
? afterSubscribeElementData[removeEvent].handleObjects.length
: 0;

unsubscribeNodesDisposing(clickEvent, subscriptionData.callback, subscriptionData.nodes);
unsubscribeNodesDisposing(clickEvent, subscriptionData.onceCallback, subscriptionData.nodes);

const finalElementData = eventsEngine.elementDataMap.get(document);
const afterUnsubscribeHandleObjectsCount = finalElementData && finalElementData[removeEvent]
Expand Down
Loading