From 1a2f85ab7477998495f1922252ff2f7ad93d586b Mon Sep 17 00:00:00 2001
From: Andrei Vorobev <738482+vorobey@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:34:56 +0300
Subject: [PATCH 1/2] ReactWrappers: change independent events gate
---
apps/react/App.tsx | 20 +++--
apps/react/examples/OnChangedTrimExample.tsx | 47 +++++++++++
.../examples/OnChangedTrimGridExample.tsx | 78 +++++++++++++++++++
.../core/__tests__/props-updating.test.tsx | 10 ++-
.../src/core/__tests__/test-component.tsx | 3 +-
.../src/core/options-manager.ts | 56 ++++++++++---
6 files changed, 194 insertions(+), 20 deletions(-)
create mode 100644 apps/react/examples/OnChangedTrimExample.tsx
create mode 100644 apps/react/examples/OnChangedTrimGridExample.tsx
diff --git a/apps/react/App.tsx b/apps/react/App.tsx
index 1e4635616073..8e83e813c196 100644
--- a/apps/react/App.tsx
+++ b/apps/react/App.tsx
@@ -1,8 +1,18 @@
-import React from 'react';
-import Examples from './examples/Examples';
+import React, { useState } from 'react';
+import NumberBoxExample from './examples/OnChangedTrimExample';
+import GridExample from './examples/OnChangedTrimGridExample';
-const App = () => (
-
-);
+const App = () => {
+ const [probe, setProbe] = useState<'value' | 'selection'>('value');
+ return (
+
+
+
+
+
+ {probe === 'value' ?
:
}
+
+ );
+};
export default App;
diff --git a/apps/react/examples/OnChangedTrimExample.tsx b/apps/react/examples/OnChangedTrimExample.tsx
new file mode 100644
index 000000000000..afac4136dac1
--- /dev/null
+++ b/apps/react/examples/OnChangedTrimExample.tsx
@@ -0,0 +1,47 @@
+import React, { useState, useCallback } from 'react';
+import NumberBox from 'devextreme-react/number-box';
+
+export default function PlanBProbe() {
+ const [value, setValue] = useState(50);
+ const [max, setMax] = useState(100);
+
+ const onValueChanged = useCallback((e: any) => {
+ // eslint-disable-next-line no-console
+ console.log('%c[handler] onValueChanged', 'color:green', {
+ value: e.value,
+ previousValue: e.previousValue,
+ });
+ // controlled pattern
+ setValue(e.value);
+ }, []);
+
+ return (
+
+
NumberBox echo vs cascade
+
value={value} max={max}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react/examples/OnChangedTrimGridExample.tsx b/apps/react/examples/OnChangedTrimGridExample.tsx
new file mode 100644
index 000000000000..974d7b127b70
--- /dev/null
+++ b/apps/react/examples/OnChangedTrimGridExample.tsx
@@ -0,0 +1,78 @@
+import React, { useState, useRef, useCallback } from 'react';
+import DataGrid, { Selection } from 'devextreme-react/data-grid';
+
+const FIRE_CAP = 40;
+
+const rows = [
+ { id: 1, name: 'One' },
+ { id: 2, name: 'Two' },
+ { id: 3, name: 'Three' },
+];
+
+export default function PlanBSelectionProbe() {
+ const [selectedRowKeys, setSelectedRowKeys] = useState([1]);
+ const [renders, setRenders] = useState(0);
+ const fireCount = useRef(0);
+ const frozen = useRef(false);
+
+ // count renders (StrictMode double-invokes in dev; relative growth is what matters)
+ React.useEffect(() => { setRenders((r) => r + 1); }, [selectedRowKeys]);
+
+ const onSelectionChanged = useCallback((e: any) => {
+ fireCount.current += 1;
+ // eslint-disable-next-line no-console
+ console.log('%c[handler] onSelectionChanged', 'color:green', {
+ fire: fireCount.current,
+ selectedRowKeys: e.selectedRowKeys,
+ });
+
+ if (fireCount.current >= FIRE_CAP) {
+ if (!frozen.current) {
+ frozen.current = true;
+ // eslint-disable-next-line no-console
+ console.error(`LOOP DETECTED — onSelectionChanged fired ${FIRE_CAP}+ times. Stopping.`);
+ }
+ return;
+ }
+ setSelectedRowKeys([...e.selectedRowKeys]);
+ }, []);
+
+ const reset = () => {
+ fireCount.current = 0;
+ frozen.current = false;
+ setSelectedRowKeys([1]);
+ };
+
+ return (
+
+
Controlled selectedRowKeys loop test
+
+ selectedRowKeys={JSON.stringify(selectedRowKeys)} |
+ onSelectionChanged fires: {fireCount.current} | renders: {renders}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 c841f276a9c3..9be9e4371379 100644
--- a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx
+++ b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx
@@ -386,9 +386,10 @@ describe('option control', () => {
/>,
);
Widget.option.mockImplementation(
- (name: string) => {
+ (name: string, value: unknown) => {
if (name === 'controlledOption') {
- WidgetClass.mock.calls[0][1].onControlledOptionChanged();
+ fireOptionChange('controlledOption', value, 'controlled');
+ WidgetClass.mock.calls[0][1].onControlledOptionChanged({ value, previousValue: 'controlled' });
}
},
);
@@ -412,9 +413,10 @@ describe('option control', () => {
);
Widget.option.mockImplementation(
- (name: string) => {
+ (name: string, value: unknown) => {
if (name === 'controlledOption') {
- Widget.option.mock.calls[0][1]();
+ fireOptionChange('controlledOption', value, 'controlled');
+ Widget.option.mock.calls[0][1]({ value, previousValue: 'controlled' });
}
},
);
diff --git a/packages/devextreme-react/src/core/__tests__/test-component.tsx b/packages/devextreme-react/src/core/__tests__/test-component.tsx
index 491818148a94..755c8162f4d8 100644
--- a/packages/devextreme-react/src/core/__tests__/test-component.tsx
+++ b/packages/devextreme-react/src/core/__tests__/test-component.tsx
@@ -126,11 +126,12 @@ const TestRestoreTreeComponent = forwardRef((_, ref: React.ForwardedRef<{ restor
return Context Component
;
});
-function fireOptionChange(fullName: string, value: unknown): void {
+function fireOptionChange(fullName: string, value: unknown, previousValue?: unknown): void {
eventHandlers.optionChanged?.forEach((e) => e({
name: fullName.split('.')[0],
fullName,
value,
+ previousValue,
}));
}
diff --git a/packages/devextreme-react/src/core/options-manager.ts b/packages/devextreme-react/src/core/options-manager.ts
index df00edd60b6b..542479ba6336 100644
--- a/packages/devextreme-react/src/core/options-manager.ts
+++ b/packages/devextreme-react/src/core/options-manager.ts
@@ -39,8 +39,19 @@ class OptionsManager {
private subscribableOptions: Set;
+ // @ts-expect-error more args than needed
private independentEvents: Set;
+ private currentWrite: { fullName: string; value: unknown } | null = null;
+
+ private batchChanges: {
+ fullName: string;
+ value: unknown;
+ previousValue: unknown;
+ isEcho: boolean;
+ consumed?: boolean;
+ }[] = [];
+
constructor() {
this.onOptionChanged = this.onOptionChanged.bind(this);
this.wrapOptionValue = this.wrapOptionValue.bind(this);
@@ -80,7 +91,13 @@ class OptionsManager {
public update(config: IConfigNode, dxtemplates: DXTemplateCollection): void {
const changedOptions: [string, unknown][] = [];
- const optionChangedHandler = ({ value, fullName }) => {
+ this.batchChanges = [];
+ const optionChangedHandler = ({ value, previousValue, fullName }) => {
+ const cw = this.currentWrite;
+ const isEcho = !!cw && cw.fullName === fullName && cw.value === value;
+ this.batchChanges.push({
+ fullName, value, previousValue, isEcho,
+ });
changedOptions.push([fullName, value]);
};
this.instance.on('optionChanged', optionChangedHandler);
@@ -108,21 +125,22 @@ class OptionsManager {
}
Object.keys(changes.options).forEach((key) => {
- this.setValue(key, changes.options[key]);
+ this.writeControlledOption(key, changes.options[key]);
});
this.isUpdating = false;
- this.instance.off('optionChanged', optionChangedHandler);
this.currentConfig = config;
changedOptions.forEach(([name, value]) => {
const currentPropValue = config.options[name];
if (Object.prototype.hasOwnProperty.call(config.options, name)
&& currentPropValue !== value) {
- this.setValue(name, currentPropValue);
+ this.writeControlledOption(name, currentPropValue);
}
});
+ this.instance.off('optionChanged', optionChangedHandler);
this.instance.endUpdate();
+ this.batchChanges = [];
}
public onOptionChanged(e: { name: string; fullName: string; value: unknown }): void {
@@ -192,10 +210,6 @@ class OptionsManager {
return this.subscribableOptions.has(optionName);
}
- private isIndependentEvent(optionName: string): boolean {
- return this.independentEvents.has(optionName);
- }
-
private callOptionChangeHandler(optionName: string, optionValue: unknown) {
if (!this.isOptionSubscribable(optionName)) {
return;
@@ -226,9 +240,10 @@ class OptionsManager {
}
private wrapOptionValue(name: string, value: unknown) {
- if (name.substr(0, 2) === 'on' && typeof value === 'function') {
+ if (name.startsWith('on') && typeof value === 'function') {
return (...args: unknown[]) => {
- if (!this.isUpdating || this.isIndependentEvent(name)) {
+ const isEcho = this.isEchoAction(args[0]);
+ if (!isEcho) {
value(...args);
}
};
@@ -237,6 +252,27 @@ class OptionsManager {
return value;
}
+ private isEchoAction(eventArgs: unknown): boolean {
+ const e = eventArgs as { value?: unknown; previousValue?: unknown } | undefined;
+ if (!e || typeof e !== 'object' || !('value' in e) || !('previousValue' in e)) {
+ return false;
+ }
+ const index = this.batchChanges.findIndex(
+ (change) => !change.consumed
+ && change.value === e.value
+ && change.previousValue === e.previousValue,
+ );
+ if (index < 0) return false;
+ this.batchChanges[index].consumed = true;
+ return this.batchChanges[index].isEcho;
+ }
+
+ private writeControlledOption(name: string, value: unknown): void {
+ this.currentWrite = { fullName: name, value };
+ this.setValue(name, value);
+ this.currentWrite = null;
+ }
+
private addGuard(optionName: string, initialValue: unknown, latestValue: unknown): void {
if (this.guards[optionName] !== undefined) {
this.guards[optionName].latestValue = latestValue;
From e7b62035502baaccccbf467fa8b804d8949ccb5e Mon Sep 17 00:00:00 2001
From: Andrei Vorobev <738482+vorobey@users.noreply.github.com>
Date: Wed, 22 Jul 2026 13:51:26 +0300
Subject: [PATCH 2/2] ReactWrappers: fix tests, return app.tsx
---
apps/react/App.tsx | 20 ++---
apps/react/examples/OnChangedTrimExample.tsx | 47 -----------
.../examples/OnChangedTrimGridExample.tsx | 78 -------------------
.../core/__tests__/props-updating.test.tsx | 38 +++------
4 files changed, 17 insertions(+), 166 deletions(-)
delete mode 100644 apps/react/examples/OnChangedTrimExample.tsx
delete mode 100644 apps/react/examples/OnChangedTrimGridExample.tsx
diff --git a/apps/react/App.tsx b/apps/react/App.tsx
index 8e83e813c196..1e4635616073 100644
--- a/apps/react/App.tsx
+++ b/apps/react/App.tsx
@@ -1,18 +1,8 @@
-import React, { useState } from 'react';
-import NumberBoxExample from './examples/OnChangedTrimExample';
-import GridExample from './examples/OnChangedTrimGridExample';
+import React from 'react';
+import Examples from './examples/Examples';
-const App = () => {
- const [probe, setProbe] = useState<'value' | 'selection'>('value');
- return (
-
-
-
-
-
- {probe === 'value' ?
:
}
-
- );
-};
+const App = () => (
+
+);
export default App;
diff --git a/apps/react/examples/OnChangedTrimExample.tsx b/apps/react/examples/OnChangedTrimExample.tsx
deleted file mode 100644
index afac4136dac1..000000000000
--- a/apps/react/examples/OnChangedTrimExample.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React, { useState, useCallback } from 'react';
-import NumberBox from 'devextreme-react/number-box';
-
-export default function PlanBProbe() {
- const [value, setValue] = useState(50);
- const [max, setMax] = useState(100);
-
- const onValueChanged = useCallback((e: any) => {
- // eslint-disable-next-line no-console
- console.log('%c[handler] onValueChanged', 'color:green', {
- value: e.value,
- previousValue: e.previousValue,
- });
- // controlled pattern
- setValue(e.value);
- }, []);
-
- return (
-
-
NumberBox echo vs cascade
-
value={value} max={max}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/react/examples/OnChangedTrimGridExample.tsx b/apps/react/examples/OnChangedTrimGridExample.tsx
deleted file mode 100644
index 974d7b127b70..000000000000
--- a/apps/react/examples/OnChangedTrimGridExample.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React, { useState, useRef, useCallback } from 'react';
-import DataGrid, { Selection } from 'devextreme-react/data-grid';
-
-const FIRE_CAP = 40;
-
-const rows = [
- { id: 1, name: 'One' },
- { id: 2, name: 'Two' },
- { id: 3, name: 'Three' },
-];
-
-export default function PlanBSelectionProbe() {
- const [selectedRowKeys, setSelectedRowKeys] = useState([1]);
- const [renders, setRenders] = useState(0);
- const fireCount = useRef(0);
- const frozen = useRef(false);
-
- // count renders (StrictMode double-invokes in dev; relative growth is what matters)
- React.useEffect(() => { setRenders((r) => r + 1); }, [selectedRowKeys]);
-
- const onSelectionChanged = useCallback((e: any) => {
- fireCount.current += 1;
- // eslint-disable-next-line no-console
- console.log('%c[handler] onSelectionChanged', 'color:green', {
- fire: fireCount.current,
- selectedRowKeys: e.selectedRowKeys,
- });
-
- if (fireCount.current >= FIRE_CAP) {
- if (!frozen.current) {
- frozen.current = true;
- // eslint-disable-next-line no-console
- console.error(`LOOP DETECTED — onSelectionChanged fired ${FIRE_CAP}+ times. Stopping.`);
- }
- return;
- }
- setSelectedRowKeys([...e.selectedRowKeys]);
- }, []);
-
- const reset = () => {
- fireCount.current = 0;
- frozen.current = false;
- setSelectedRowKeys([1]);
- };
-
- return (
-
-
Controlled selectedRowKeys loop test
-
- selectedRowKeys={JSON.stringify(selectedRowKeys)} |
- onSelectionChanged fires: {fireCount.current} | renders: {renders}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
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 9be9e4371379..5f4e1e4ed11a 100644
--- a/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx
+++ b/packages/devextreme-react/src/core/__tests__/props-updating.test.tsx
@@ -1672,16 +1672,10 @@ describe('onXXXChange', () => {
});
});
- describe('independent events', () => {
- beforeAll(() => {
- jest.spyOn<{ isIndependentEvent: () => boolean }, 'isIndependentEvent'>(
- OptionsManagerModule.OptionsManager.prototype as
- OptionsManagerModule.OptionsManager & { isIndependentEvent: () => boolean },
- 'isIndependentEvent',
- )
- .mockImplementation(() => true);
- });
-
+ // Plan B: there is no longer an "independent vs dependent" split decided by event
+ // name. A handler fires unless the change it reports is an echo of React's own write
+ // (correlated at runtime by value + previousValue). These blocks cover both sides.
+ describe('non-echo events', () => {
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
@@ -1729,23 +1723,14 @@ describe('onXXXChange', () => {
});
});
- describe('dependent events', () => {
- beforeAll(() => {
- jest.spyOn<{ isIndependentEvent: () => boolean }, 'isIndependentEvent'>(
- OptionsManagerModule.OptionsManager.prototype as
- OptionsManagerModule.OptionsManager & { isIndependentEvent: () => boolean },
- 'isIndependentEvent',
- )
- .mockImplementation(() => false);
- });
-
+ describe('echo events', () => {
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
cleanup();
});
- it('it is fired on outher change', () => {
+ it('is not fired when the change echoes React\'s own write', () => {
const onPropChange = jest.fn();
const { rerender } = render(
{
);
expect(onPropChange).not.toBeCalled();
Widget.option.mockImplementation(
- (name: string) => {
+ (name: string, value: unknown) => {
if (name === 'text') {
- WidgetClass.mock.calls[0][1].onTextChange();
+ // widget echoes exactly what React wrote (optionChanged before action),
+ // then raises the action with matching value/previousValue
+ fireOptionChange('text', value, '0');
+ WidgetClass.mock.calls[0][1].onTextChange({ value, previousValue: '0' });
}
},
);
- const sampleProps = { text: '1' };
rerender(
,
);
expect(onPropChange).toHaveBeenCalledTimes(0);