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..5f4e1e4ed11a 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' }); } }, ); @@ -1670,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(); @@ -1727,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); 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;