diff --git a/packages/devextreme-angular/.gitignore b/packages/devextreme-angular/.gitignore index 850a120dc4da..741d02ab23c6 100644 --- a/packages/devextreme-angular/.gitignore +++ b/packages/devextreme-angular/.gitignore @@ -1 +1,2 @@ metadata +Chrome_Headless** diff --git a/packages/devextreme-angular/eslint.config.mjs b/packages/devextreme-angular/eslint.config.mjs index f2d90d5ea364..2ba5886a93c4 100644 --- a/packages/devextreme-angular/eslint.config.mjs +++ b/packages/devextreme-angular/eslint.config.mjs @@ -60,7 +60,7 @@ export default [ rules: { 'import/no-cycle': 'warn', 'no-param-reassign': 'warn', - 'no-underscore-dangle': 'warn', + 'no-underscore-dangle': 'off', 'spellcheck/spell-checker': [1, { lang: 'en_US', comments: false, diff --git a/packages/devextreme-angular/karma.common.test.shim.js b/packages/devextreme-angular/karma.common.test.shim.js index ffd110f4e6c1..2f96a619113f 100644 --- a/packages/devextreme-angular/karma.common.test.shim.js +++ b/packages/devextreme-angular/karma.common.test.shim.js @@ -4,7 +4,4 @@ require('core-js/es6'); require('core-js/client/shim.min'); require('reflect-metadata'); -require('zone.js'); -require('zone.js/testing'); - __karma__.loaded = function () {}; diff --git a/packages/devextreme-angular/karma.hydration.test.shim.js b/packages/devextreme-angular/karma.hydration.test.shim.js index c080d3380edd..26bfa44dd8b9 100644 --- a/packages/devextreme-angular/karma.hydration.test.shim.js +++ b/packages/devextreme-angular/karma.hydration.test.shim.js @@ -1,4 +1,4 @@ -require('./karma.common.test.shim'); +require('./karma.zone-polyfills.test.shim'); const testing = require('@angular/core/testing'); const server = require('@angular/platform-server/testing'); diff --git a/packages/devextreme-angular/karma.server.test.shim.js b/packages/devextreme-angular/karma.server.test.shim.js index 2ec94db2445b..e9610a09f967 100644 --- a/packages/devextreme-angular/karma.server.test.shim.js +++ b/packages/devextreme-angular/karma.server.test.shim.js @@ -1,4 +1,4 @@ -require('./karma.common.test.shim'); +require('./karma.zone-polyfills.test.shim'); const testing = require('@angular/core/testing'); const server = require('@angular/platform-server/testing'); diff --git a/packages/devextreme-angular/karma.test.shim.js b/packages/devextreme-angular/karma.test.shim.js index 3b727b730463..a583013927d8 100644 --- a/packages/devextreme-angular/karma.test.shim.js +++ b/packages/devextreme-angular/karma.test.shim.js @@ -1,4 +1,4 @@ -require('./karma.common.test.shim'); +require('./karma.zone-polyfills.test.shim'); const testing = require('@angular/core/testing'); const browser = require('@angular/platform-browser-dynamic/testing'); @@ -9,6 +9,6 @@ testing.TestBed.initTestEnvironment( browser.platformBrowserDynamicTesting(), ); -const context = require.context('./tests/dist', true, /^.\/(?!.*\/(ssr-components|hydration)\.spec\.js$).*\.spec\.js$/); +const context = require.context('./tests/dist', true, /^.\/(?!zone-modes\/)(?!.*\/(ssr-components|hydration)\.spec\.js$).*\.spec\.js$/); context.keys().map(context); __karma__.start(); diff --git a/packages/devextreme-angular/karma.zone-polyfills.test.shim.js b/packages/devextreme-angular/karma.zone-polyfills.test.shim.js new file mode 100644 index 000000000000..a8df54b53b99 --- /dev/null +++ b/packages/devextreme-angular/karma.zone-polyfills.test.shim.js @@ -0,0 +1,4 @@ +require('./karma.common.test.shim'); + +require('zone.js'); +require('zone.js/testing'); diff --git a/packages/devextreme-angular/karma.zone.test.shim.js b/packages/devextreme-angular/karma.zone.test.shim.js new file mode 100644 index 000000000000..aa5fc82b6aed --- /dev/null +++ b/packages/devextreme-angular/karma.zone.test.shim.js @@ -0,0 +1,14 @@ +require('./karma.zone-polyfills.test.shim'); + +const testing = require('@angular/core/testing'); +const browser = require('@angular/platform-browser-dynamic/testing'); +const { ZoneTestingModule } = require('./karma.zone-testing.module'); + +testing.TestBed.initTestEnvironment( + [browser.BrowserDynamicTestingModule, ZoneTestingModule], + browser.platformBrowserDynamicTesting(), +); + +const context = require.context('./tests/dist/zone-modes', true, /\.spec\.js$/); +context.keys().map(context); +__karma__.start(); diff --git a/packages/devextreme-angular/karma.zoneless-testing.module.js b/packages/devextreme-angular/karma.zoneless-testing.module.js new file mode 100644 index 000000000000..f9d360dbc3dc --- /dev/null +++ b/packages/devextreme-angular/karma.zoneless-testing.module.js @@ -0,0 +1,7 @@ +const { NgModule, provideZonelessChangeDetection } = require('@angular/core'); + +const ZonelessTestingModule = NgModule({ + providers: [provideZonelessChangeDetection()], +})(class ZonelessTestingModule {}); + +module.exports = { ZonelessTestingModule }; diff --git a/packages/devextreme-angular/karma.zoneless.test.shim.js b/packages/devextreme-angular/karma.zoneless.test.shim.js new file mode 100644 index 000000000000..d21ce6813ccf --- /dev/null +++ b/packages/devextreme-angular/karma.zoneless.test.shim.js @@ -0,0 +1,18 @@ +require('./karma.common.test.shim'); + +if (typeof Zone !== 'undefined') { + throw new Error('zone.js is loaded in the zoneless test environment'); +} + +const testing = require('@angular/core/testing'); +const browser = require('@angular/platform-browser-dynamic/testing'); +const { ZonelessTestingModule } = require('./karma.zoneless-testing.module'); + +testing.TestBed.initTestEnvironment( + [browser.BrowserDynamicTestingModule, ZonelessTestingModule], + browser.platformBrowserDynamicTesting(), +); + +const context = require.context('./tests/dist/zone-modes', true, /\.spec\.js$/); +context.keys().map(context); +__karma__.start(); diff --git a/packages/devextreme-angular/project.json b/packages/devextreme-angular/project.json index 3144fe8c6145..784d82cbfc1c 100644 --- a/packages/devextreme-angular/project.json +++ b/packages/devextreme-angular/project.json @@ -350,7 +350,9 @@ "environments": [ "client", "server", - "hydration" + "hydration", + "zone", + "zoneless" ] }, "dependsOn": [ @@ -428,6 +430,50 @@ "{projectRoot}/tests/dist/**/*" ] }, + "test:components-zone-modes": { + "executor": "devextreme-nx-infra-plugin:karma-multi-env", + "options": { + "karmaConfig": "karma.conf.js", + "environments": [ + "zone", + "zoneless" + ] + }, + "dependsOn": [ + "build:tests" + ], + "inputs": [ + "default", + "test", + "{projectRoot}/tests/dist/**/*" + ] + }, + "test:components-zone-debug": { + "executor": "devextreme-nx-infra-plugin:karma-multi-env", + "options": { + "karmaConfig": "karma.conf.js", + "environments": [ + "zone" + ], + "debug": true + }, + "dependsOn": [ + "build:tests" + ] + }, + "test:components-zoneless-debug": { + "executor": "devextreme-nx-infra-plugin:karma-multi-env", + "options": { + "karmaConfig": "karma.conf.js", + "environments": [ + "zoneless" + ], + "debug": true + }, + "dependsOn": [ + "build:tests" + ] + }, "watch:test": { "executor": "devextreme-nx-infra-plugin:karma-multi-env", "options": { @@ -453,7 +499,9 @@ "environments": [ "client", "server", - "hydration" + "hydration", + "zone", + "zoneless" ] }, "dependsOn": [ @@ -498,7 +546,12 @@ "{projectRoot}/karma.test.shim.js", "{projectRoot}/karma.server.test.shim.js", "{projectRoot}/karma.hydration.test.shim.js", + "{projectRoot}/karma.common.test.shim.js", + "{projectRoot}/karma.zone-polyfills.test.shim.js", + "{projectRoot}/karma.zone.test.shim.js", + "{projectRoot}/karma.zoneless.test.shim.js", "{projectRoot}/karma.zone-testing.module.js", + "{projectRoot}/karma.zoneless-testing.module.js", "{projectRoot}/webpack.test.js", "{projectRoot}/tsconfig.tests.json", "{projectRoot}/src/**/*.spec.ts", diff --git a/packages/devextreme-angular/src/core/component.ts b/packages/devextreme-angular/src/core/component.ts index ae9f62957529..b59d8c302c23 100644 --- a/packages/devextreme-angular/src/core/component.ts +++ b/packages/devextreme-angular/src/core/component.ts @@ -1,6 +1,8 @@ +/* eslint-disable max-classes-per-file */ import { TransferState, makeStateKey, + ChangeDetectorRef, Component, ElementRef, NgZone, @@ -39,7 +41,7 @@ import { CollectionNestedOptionContainerImpl, } from './nested-option'; -import { DxIntegrationModule } from './integration'; +import { DxIntegrationModule, isInOutsideAngularHandler } from './integration'; config({ buyNowLink: 'https://go.devexpress.com/Licensing_Installer_Watermark_DevExtremeAngular.aspx', @@ -86,6 +88,8 @@ export abstract class DxComponent implements OnChanges, OnInit, DoCheck, AfterCo templateUpdateRequired = false; + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private _updateTemplates() { if (this.templates.length && this.templateUpdateRequired) { const updatedTemplates = {}; @@ -105,6 +109,14 @@ export abstract class DxComponent implements OnChanges, OnInit, DoCheck, AfterCo const value = e.name === e.fullName ? e.value : e.component.option(e.name); this.eventHelper.fireNgEvent(`${e.name}Change`, [value]); this.optionChangedHandlers.emit(e); + + if ( + !this.widgetUpdateLocked + && !isInOutsideAngularHandler() + && this[`${e.name}Change`] + ) { + this.changeDetectorRef.markForCheck(); + } }); } @@ -122,10 +134,11 @@ export abstract class DxComponent implements OnChanges, OnInit, DoCheck, AfterCo protected _createEventEmitters(events) { const zone = this.ngZone; + const { changeDetectorRef } = this; this.eventHelper.createEmitters(events); this._initialOptions.eventsStrategy = (instance) => { - const strategy = new NgEventsStrategy(instance, zone); + const strategy = new NgEventsStrategy(instance, zone, changeDetectorRef); events.filter((event) => event.subscribe).forEach((event) => { strategy.addEmitter(event.subscribe, this[event.emit]); @@ -166,6 +179,7 @@ export abstract class DxComponent implements OnChanges, OnInit, DoCheck, AfterCo if (!this.widgetUpdateLocked && this.instance) { this.instance.beginUpdate(); this.widgetUpdateLocked = true; + this.changeDetectorRef.markForCheck(); } } @@ -237,7 +251,7 @@ export abstract class DxComponent implements OnChanges, OnInit, DoCheck, AfterCo this.templates = []; templateHost.setHost(this); this._collectionContainerImpl = new CollectionNestedOptionContainerImpl(this._setOption.bind(this)); - this.eventHelper = new EmitterHelper(ngZone, this); + this.eventHelper = new EmitterHelper(ngZone, this, this.changeDetectorRef); } ngOnChanges(changes: SimpleChanges) { diff --git a/packages/devextreme-angular/src/core/events-strategy.ts b/packages/devextreme-angular/src/core/events-strategy.ts index 8d4aad7aad37..de1783bfba20 100644 --- a/packages/devextreme-angular/src/core/events-strategy.ts +++ b/packages/devextreme-angular/src/core/events-strategy.ts @@ -1,4 +1,5 @@ -import { EventEmitter, NgZone } from '@angular/core'; +/* eslint-disable max-classes-per-file */ +import { ChangeDetectorRef, EventEmitter, NgZone } from '@angular/core'; import { DxComponent } from './component'; interface IEventSubscription { @@ -10,7 +11,11 @@ export class NgEventsStrategy { private events: { [key: string]: EventEmitter } = {}; - constructor(private readonly instance: any, private readonly zone: NgZone) { } + constructor( + private readonly instance: any, + private readonly zone: NgZone, + private readonly changeDetectorRef?: ChangeDetectorRef, + ) { } hasEvent(name: string) { return this.getEmitter(name).observers.length !== 0; @@ -25,6 +30,9 @@ export class NgEventsStrategy { } else { this.zone.run(() => emitter.next(args?.[0])); } + if (name !== 'optionChanged' || emitter.observers.length > internalSubs.length) { + this.changeDetectorRef?.markForCheck(); + } } } @@ -80,7 +88,11 @@ export class NgEventsStrategy { export class EmitterHelper { lockedValueChangeEvent = false; - constructor(private readonly zone: NgZone, private component: DxComponent) { } + constructor( + private readonly zone: NgZone, + private component: DxComponent, + private readonly changeDetectorRef?: ChangeDetectorRef, + ) { } fireNgEvent(eventName: string, eventArgs: any) { if (this.lockedValueChangeEvent && eventName === 'valueChange') { @@ -91,6 +103,7 @@ export class EmitterHelper { this.zone.run(() => { emitter.next(eventArgs?.[0]); }); + this.changeDetectorRef?.markForCheck(); } } diff --git a/packages/devextreme-angular/src/core/integration.ts b/packages/devextreme-angular/src/core/integration.ts index 64c08c719278..b825b6cb0355 100644 --- a/packages/devextreme-angular/src/core/integration.ts +++ b/packages/devextreme-angular/src/core/integration.ts @@ -9,8 +9,12 @@ import domAdapter from 'devextreme/core/dom_adapter'; import readyCallbacks from 'devextreme/core/utils/ready_callbacks'; import eventsEngine from 'devextreme/common/core/events/core/events_engine'; -const outsideZoneEvents = ['mousemove', 'mouseover', 'mouseout']; -const insideZoneEvents = ['mouseup', 'click', 'mousedown', 'transitionend', 'wheel']; +const outsideAngularEvents = ['mousemove', 'mouseover', 'mouseout']; +const insideAngularEvents = ['mouseup', 'click', 'mousedown', 'transitionend', 'wheel']; + +let inOutsideAngularHandler = false; + +export const isInOutsideAngularHandler = () => inOutsideAngularHandler; type ReadyCallback = () => void; type ReadyCallbackAdd = (callback: ReadyCallback) => void; @@ -42,29 +46,37 @@ let doInjections = (document: any, ngZone: NgZone, xhrFactory: XhrFactory) => { domAdapter.inject({ _document: document, - listen(this: { callBase: (...args: any[]) => any }, ...args) { - const eventName = args[1]; - if (outsideZoneEvents.includes(eventName)) { - return ngZone.runOutsideAngular(() => this.callBase.apply(this, args)); + listen(this: { callBase: (...args: any[]) => any }, element, event: string, callback, options) { + if (outsideAngularEvents.includes(event)) { + const trackedCallback = function (this: unknown, e: Event) { + const prevInOutsideAngularHandler = inOutsideAngularHandler; + inOutsideAngularHandler = true; + try { + return callback.call(this, e); + } finally { + inOutsideAngularHandler = prevInOutsideAngularHandler; + } + }; + return ngZone.runOutsideAngular(() => this.callBase(element, event, trackedCallback, options)); } - if (ngZone.isStable && insideZoneEvents.includes(eventName)) { - return ngZone.run(() => this.callBase.apply(this, args)); + if (ngZone.isStable && insideAngularEvents.includes(event)) { + return ngZone.run(() => this.callBase(element, event, callback, options)); } - return this.callBase.apply(this, args); + return this.callBase(element, event, callback, options); }, isElementNode(element) { - return element && element.nodeType === 1; + return element?.nodeType === 1; }, isTextNode(element) { - return element && element.nodeType === 3; + return element?.nodeType === 3; }, isDocument(element) { - return element && element.nodeType === 9; + return element?.nodeType === 9; }, }); diff --git a/packages/devextreme-angular/src/core/nested-option.ts b/packages/devextreme-angular/src/core/nested-option.ts index b54ff6917f22..12beb5b097b1 100644 --- a/packages/devextreme-angular/src/core/nested-option.ts +++ b/packages/devextreme-angular/src/core/nested-option.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Component, QueryList, ElementRef, Renderer2, EventEmitter, } from '@angular/core'; diff --git a/packages/devextreme-angular/src/ui/form/index.ts b/packages/devextreme-angular/src/ui/form/index.ts index 6d4ceaf9b49a..fd01896491ef 100644 --- a/packages/devextreme-angular/src/ui/form/index.ts +++ b/packages/devextreme-angular/src/ui/form/index.ts @@ -1,6 +1,7 @@ /* tslint:disable:max-line-length */ import { TransferState, + ChangeDetectorRef, Component, NgModule, ElementRef, @@ -15,7 +16,8 @@ import { DoCheck, SimpleChanges, ContentChildren, - QueryList + QueryList, + inject } from '@angular/core'; @@ -706,6 +708,8 @@ export class DxFormComponent extends DxComponent implements OnDestroy, OnChanges */ @Output() widthChange: EventEmitter; + private readonly _changeDetectorRef = inject(ChangeDetectorRef); + constructor(elementRef: ElementRef, ngZone: NgZone, templateHost: DxTemplateHost, private _watcherHelper: WatcherHelper, private _idh: IterableDifferHelper, @@ -770,6 +774,10 @@ export class DxFormComponent extends DxComponent implements OnDestroy, OnChanges return new DxForm(element, options); } + _createWidget(element: any) { + super._createWidget(element); + this.instance.on('fieldDataChanged', () => this._changeDetectorRef.markForCheck()); + } ngOnDestroy() { this._destroyWidget(); diff --git a/packages/devextreme-angular/tests/src/zone-modes/change-detection.spec.ts b/packages/devextreme-angular/tests/src/zone-modes/change-detection.spec.ts new file mode 100644 index 000000000000..e11bbda811e6 --- /dev/null +++ b/packages/devextreme-angular/tests/src/zone-modes/change-detection.spec.ts @@ -0,0 +1,603 @@ +/* tslint:disable:component-selector */ +import { + Component, + ViewChild, + signal, +} from '@angular/core'; + +import { + TestBed, + ComponentFixture, +} from '@angular/core/testing'; + +import { FormControl, ReactiveFormsModule } from '@angular/forms'; + +import eventsEngine from 'devextreme/common/core/events/core/events_engine'; + +import { + DxTextBoxModule, + DxTextBoxComponent, + DxDataGridModule, + DxDataGridComponent, + DxPopupModule, + DxPopupComponent, + DxFormModule, + DxFormComponent, + DxListModule, + DxListComponent, + DxTemplateModule, + DxDraggableModule, + DxChartModule, +} from 'devextreme-angular'; + +const isZoneless = typeof (window as any).Zone === 'undefined'; +const mode = isZoneless ? 'zoneless' : 'zone'; + +@Component({ + selector: 'test-host', + imports: [ + DxTextBoxModule, DxDataGridModule, DxPopupModule, DxFormModule, DxListModule, + DxTemplateModule, DxDraggableModule, DxChartModule, ReactiveFormsModule, + ], + template: '', +}) +class TestHostComponent { + @ViewChild(DxTextBoxComponent) textBox: DxTextBoxComponent; + + @ViewChild(DxDataGridComponent) grid: DxDataGridComponent; + + @ViewChild(DxPopupComponent) popup: DxPopupComponent; + + @ViewChild(DxFormComponent) form: DxFormComponent; + + @ViewChild(DxListComponent) list: DxListComponent; + + text = signal(''); + + plain = ''; + + selectedKeys = signal([]); + + visible = signal(true); + + pageIndex = signal(0); + + suffix = signal('1'); + + clicks = 0; + + dragMoves = 0; + + renders = 0; + + rows = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + manyRows = Array.from({ length: 200 }, (_, i) => ({ id: i + 1 })); + + points = [{ arg: 1, val: 1 }, { arg: 2, val: 3 }, { arg: 3, val: 2 }]; + + items = ['a', 'b']; + + itemObjects = [{ text: 'a', disabled: false }, { text: 'b', disabled: false }]; + + formData = { name: 'initial' }; + + control = new FormControl(''); + + countRender(): string { + this.renders += 1; + return ''; + } +} + +async function render(template: string): Promise> { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + TestBed.overrideComponent(TestHostComponent, { set: { template } }); + + const fixture = TestBed.createComponent(TestHostComponent); + if (!isZoneless) { + fixture.autoDetectChanges(); + } + await fixture.whenStable(); + + return fixture; +} + +// Runs a widget-side change the way it arrives in a real app: in a zone app user +// interaction runs inside the Angular zone, in a zoneless app there is no zone. +function act(fixture: ComponentFixture, fn: () => T): T { + return isZoneless ? fn() : fixture.ngZone.run(fn); +} + +function outputText(fixture: ComponentFixture): string { + return fixture.nativeElement.querySelector('.out').textContent.trim(); +} + +function mouse(target: EventTarget, type: string, x: number, y: number): void { + target.dispatchEvent(new MouseEvent(type, { + bubbles: true, cancelable: true, button: 0, buttons: 1, clientX: x, clientY: y, + })); +} + +const wait = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +describe(`Change detection [${mode}]: environment`, () => { + it(isZoneless ? 'runs without zone.js' : 'runs with zone.js', () => { + expect(typeof (window as any).Zone).toBe(isZoneless ? 'undefined' : 'function'); + }); + + if (isZoneless) { + it('does not refresh the view without a change detection trigger', async () => { + const fixture = await render(` + + {{ plain }} + `); + + fixture.componentInstance.plain = 'changed'; + await wait(50); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe(''); + }); + } +}); + +describe(`Change detection [${mode}]: template-bound outputs`, () => { + it('[(value)] bound to a signal receives a widget-side change', async () => { + const fixture = await render(` + + {{ text() }} + `); + + act(fixture, () => fixture.componentInstance.textBox.instance.option('value', 'changed')); + await fixture.whenStable(); + + expect(fixture.componentInstance.text()).toBe('changed'); + expect(outputText(fixture)).toBe('changed'); + }); + + it('[(value)] bound to a plain field receives a widget-side change', async () => { + const fixture = await render(` + + {{ plain }} + `); + + act(fixture, () => fixture.componentInstance.textBox.instance.option('value', 'changed')); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('changed'); + }); + + it('(onValueChanged) handler that sets a plain field updates the view', async () => { + const fixture = await render(` + + {{ plain }} + `); + + act(fixture, () => fixture.componentInstance.textBox.instance.option('value', 'changed')); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('changed'); + }); + + it('[(selectedRowKeys)] receives a selection made by the grid', async () => { + const fixture = await render(` + + + + {{ selectedKeys().join(',') }} + `); + + await act(fixture, () => fixture.componentInstance.grid.instance.selectRows([2], false)); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('2'); + }); + + it('[(visible)] receives a popup closed by the widget', async () => { + const fixture = await render(` + + {{ visible() }} + `); + + act(fixture, () => fixture.componentInstance.popup.instance.option('visible', false)); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('false'); + }); + + it('nested option [(pageIndex)] receives a page change made by the grid', async () => { + const fixture = await render(` + + + + {{ pageIndex() }} + `); + + await act(fixture, () => fixture.componentInstance.grid.instance.pageIndex(2)); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('2'); + }); +}); + +describe(`Change detection [${mode}]: events registered outside the Angular zone (mousemove)`, () => { + it('(onDragMove) driven by mousemove updates the view', async () => { + const fixture = await render(` + +
+
+ {{ dragMoves > 0 }} + `); + const handle = fixture.nativeElement.querySelector('.drag-handle'); + + mouse(handle, 'mousedown', 10, 10); + await fixture.whenStable(); + + mouse(document, 'mousemove', 40, 40); + mouse(document, 'mousemove', 60, 60); + await fixture.whenStable(); + + expect(fixture.componentInstance.dragMoves).toBeGreaterThan(0); + expect(outputText(fixture)).toBe('true'); + + mouse(document, 'mouseup', 60, 60); + }); + + it('mouse movement over a widget without handlers does not run change detection', async () => { + const fixture = await render(` + + + + + {{ countRender() }} + `); + const host = fixture.componentInstance; + const surface = fixture.nativeElement.querySelector('dx-chart svg'); + const rendersBefore = host.renders; + + for (let i = 0; i < 20; i += 1) { + mouse(surface, 'mouseover', 10 + i * 5, 10 + i * 5); + mouse(surface, 'mousemove', 10 + i * 5, 10 + i * 5); + mouse(surface, 'mouseout', 10 + i * 5, 10 + i * 5); + } + await wait(50); + await fixture.whenStable(); + + expect(host.renders).toBe(rendersBefore); + + if (!isZoneless) { + act(fixture, () => {}); + await fixture.whenStable(); + + expect(host.renders).toBeGreaterThan(rendersBefore); + } + }); + + it('public option changed by the widget during mousemove does not run change detection', async () => { + const fixture = await render(` + + {{ countRender() }} + `); + await wait(50); + await fixture.whenStable(); + const host = fixture.componentInstance; + let width = 100; + const resize = () => { + width += 10; + host.grid.instance.columnOption(0, 'width', width); + }; + eventsEngine.on(document, 'mousemove', resize); + const rendersBefore = host.renders; + + try { + for (let i = 0; i < 5; i += 1) { + mouse(document, 'mousemove', 10 + i * 5, 10); + } + await wait(50); + await fixture.whenStable(); + } finally { + eventsEngine.off(document, 'mousemove', resize); + } + + expect(host.grid.instance.columnOption(0, 'width')).toBe(150); + expect(host.renders).toBe(rendersBefore); + }); +}); + +if (isZoneless) { + describe(`Change detection [${mode}]: widget-internal state`, () => { + it('hovering over List items does not run change detection', async () => { + const fixture = await render(` + + {{ countRender() }} + `); + const host = fixture.componentInstance; + const listItems = fixture.nativeElement.querySelectorAll('.dx-list-item'); + const rendersBefore = host.renders; + + listItems.forEach((item: Element) => { + mouse(item, 'mouseover', 5, 5); + mouse(item, 'mouseenter', 5, 5); + }); + const hovered = host.list.instance.option('hoveredElement'); + await wait(50); + await fixture.whenStable(); + + expect(hovered).toBeTruthy(); + expect(host.renders).toBe(rendersBefore); + }); + + it('internal option changed by the widget in a timer does not run change detection', async () => { + const fixture = await render(` + + {{ countRender() }} + `); + const host = fixture.componentInstance; + const lastItem = fixture.nativeElement.querySelectorAll('.dx-list-item')[1]; + const rendersBefore = host.renders; + + setTimeout(() => host.list.instance.option('focusedElement', lastItem)); + await wait(50); + await fixture.whenStable(); + + expect(host.list.instance.option('focusedElement')).toBe(lastItem); + expect(host.renders).toBe(rendersBefore); + }); + + it('scrolling a DataGrid without handlers does not run change detection', async () => { + const fixture = await render(` + + + + {{ countRender() }} + `); + await wait(50); + await fixture.whenStable(); + const host = fixture.componentInstance; + const scrollable = host.grid.instance.getScrollable(); + const scrolled = jasmine.createSpy('scrolled'); + scrollable.on('scroll', scrolled); + const rendersBefore = host.renders; + + const container = fixture.nativeElement.querySelector('.dx-datagrid-rowsview .dx-scrollable-container'); + + for (let i = 0; i < 5; i += 1) { + container.dispatchEvent(new Event('scroll')); + } + await wait(50); + await fixture.whenStable(); + + expect(scrolled).toHaveBeenCalled(); + expect(host.renders).toBe(rendersBefore); + }); + }); +} + +describe(`Change detection [${mode}]: dxTemplate content`, () => { + it('re-renders when a parent signal used in the template changes', async () => { + const fixture = await render(` + +
{{ item }}-{{ suffix() }}
+
+ `); + + act(fixture, () => fixture.componentInstance.suffix.set('2')); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.tpl').textContent).toBe('a-2'); + }); + + it('updates the view after a (click) handler inside the template', async () => { + const fixture = await render(` + +
+
+ {{ clicks }} + `); + + fixture.nativeElement.querySelector('.tpl-btn').click(); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('1'); + }); +}); + +describe(`Change detection [${mode}]: state changed without a template binding`, () => { + it('output subscribed in code updates the view', async () => { + const fixture = await render(` + + + + {{ plain }} + `); + const host = fixture.componentInstance; + + host.grid.onSelectionChanged.subscribe((e) => { host.plain = e.selectedRowKeys.join(','); }); + await act(fixture, () => host.grid.instance.selectRows([2], false)); + await fixture.whenStable(); + + expect(host.plain).toBe('2'); + expect(outputText(fixture)).toBe('2'); + }); + + it('handler attached with instance.on() updates the view', async () => { + const fixture = await render(` + + + + {{ plain }} + `); + const host = fixture.componentInstance; + + host.grid.instance.on('selectionChanged', (e) => { host.plain = e.selectedRowKeys.join(','); }); + await act(fixture, () => host.grid.instance.selectRows([2], false)); + await fixture.whenStable(); + + expect(host.plain).toBe('2'); + expect(outputText(fixture)).toBe('2'); + }); + + it('onOptionChanged subscribed in code updates the view for an unbound option', async () => { + const fixture = await render(` + + {{ plain }} + `); + const host = fixture.componentInstance; + + host.textBox.onOptionChanged.subscribe((e) => { + if (e.name === 'hint') { + host.plain = e.value; + } + }); + act(fixture, () => host.textBox.instance.option('hint', 'changed')); + await fixture.whenStable(); + + expect(host.plain).toBe('changed'); + expect(outputText(fixture)).toBe('changed'); + }); + + it('instance.on(\'optionChanged\') handler for an unbound public option updates the view', async () => { + const fixture = await render(` + + {{ plain }} + `); + const host = fixture.componentInstance; + + host.textBox.instance.on('optionChanged', (e) => { + if (e.name === 'hint') { + host.plain = e.value; + } + }); + act(fixture, () => host.textBox.instance.option('hint', 'changed')); + await wait(50); + await fixture.whenStable(); + + expect(host.plain).toBe('changed'); + expect(outputText(fixture)).toBe('changed'); + }); + + it('template reading an unbound component option updates the view', async () => { + const fixture = await render(` + + + + {{ gridRef.selectedRowKeys.length }} + `); + + expect(outputText(fixture)).toBe('0'); + + await act(fixture, () => fixture.componentInstance.grid.instance.selectRows([2], false)); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('1'); + }); + + it('one-way [formData] changed in place by the Form updates the view', async () => { + const fixture = await render(` + + {{ formData.name }} + `); + + const editor = fixture.componentInstance.form.instance.getEditor('name'); + + act(fixture, () => editor.option('value', 'first')); + await fixture.whenStable(); + + expect(outputText(fixture)).toBe('first'); + + act(fixture, () => editor.option('value', 'second')); + await fixture.whenStable(); + + expect(fixture.componentInstance.formData.name).toBe('second'); + expect(outputText(fixture)).toBe('second'); + }); + + it('FormControl.setValue() in a timer reaches the widget and releases the update lock', async () => { + const fixture = await render(` + + `); + const host = fixture.componentInstance; + + act(fixture, () => setTimeout(() => host.control.setValue('changed'))); + await wait(50); + await fixture.whenStable(); + + expect(host.textBox.instance.option('value')).toBe('changed'); + expect(host.textBox.widgetUpdateLocked).toBe(false); + }); + + it('FormControl.setValue() in a (click) handler reaches the widget and releases the update lock', async () => { + const fixture = await render(` + + + `); + const host = fixture.componentInstance; + + fixture.nativeElement.querySelector('.set').click(); + await fixture.whenStable(); + + expect(host.textBox.instance.option('value')).toBe('clicked'); + expect(host.textBox.widgetUpdateLocked).toBe(false); + }); + + it('component property set in a timer releases the update lock', async () => { + const fixture = await render(` + + `); + const host = fixture.componentInstance; + + act(fixture, () => setTimeout(() => { host.textBox.hint = 'changed'; })); + await wait(50); + await fixture.whenStable(); + + expect(host.textBox.instance.option('hint')).toBe('changed'); + expect(host.textBox.widgetUpdateLocked).toBe(false); + }); + + it('bound input change runs a single change detection pass', async () => { + const fixture = await render(` + + {{ countRender() }} + `); + const host = fixture.componentInstance; + const rendersBefore = host.renders; + + act(fixture, () => host.text.set('changed')); + await wait(50); + await fixture.whenStable(); + + expect(host.textBox.instance.option('value')).toBe('changed'); + // one pass plus the dev-mode checkNoChanges pass + expect(host.renders - rendersBefore).toBe(2); + }); + + it(isZoneless + ? 'item pushed to a bound array in a timer does not reach the widget (documented limitation)' + : 'item pushed to a bound array in a timer reaches the widget', async () => { + const fixture = await render(` + + `); + + act(fixture, () => setTimeout(() => fixture.componentInstance.items.push('c'))); + await wait(50); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelectorAll('.dx-list-item').length).toBe(isZoneless ? 2 : 3); + }); + + it(isZoneless + ? 'item field changed in a timer does not reach the widget (documented limitation)' + : 'item field changed in a timer reaches the widget', async () => { + const fixture = await render(` + + `); + + act(fixture, () => setTimeout(() => { fixture.componentInstance.itemObjects[0].disabled = true; })); + await wait(50); + await fixture.whenStable(); + + const firstItem = fixture.nativeElement.querySelector('.dx-list-item'); + expect(firstItem.classList.contains('dx-state-disabled')).toBe(!isZoneless); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts b/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts index 35e23a722189..952c1c852695 100644 --- a/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts @@ -82,7 +82,13 @@ function createExecutionOrder( } const order: KarmaEnvironment[] = []; - const validEnvironments: KarmaEnvironment[] = ['client', 'server', 'hydration']; + const validEnvironments: KarmaEnvironment[] = [ + 'client', + 'server', + 'hydration', + 'zone', + 'zoneless', + ]; for (const environment of validEnvironments) { if (environments.includes(environment)) { diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/karma.types.ts b/packages/nx-infra-plugin/src/executors/karma-multi-env/karma.types.ts index f639c5a31f6f..b6c3d15c31c8 100644 --- a/packages/nx-infra-plugin/src/executors/karma-multi-env/karma.types.ts +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/karma.types.ts @@ -1,4 +1,4 @@ -export type KarmaEnvironment = 'client' | 'server' | 'hydration'; +export type KarmaEnvironment = 'client' | 'server' | 'hydration' | 'zone' | 'zoneless'; export interface EnvironmentConfig { shimPath: string; @@ -14,6 +14,12 @@ export const ENVIRONMENT_CONFIGS: Record = hydration: { shimPath: 'karma.hydration.test.shim.js', }, + zone: { + shimPath: 'karma.zone.test.shim.js', + }, + zoneless: { + shimPath: 'karma.zoneless.test.shim.js', + }, }; export const DEFAULT_ENVIRONMENTS: KarmaEnvironment[] = ['client', 'server', 'hydration']; diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.d.ts b/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.d.ts index 86cabc3c2fe5..ef34f4008093 100644 --- a/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.d.ts +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.d.ts @@ -6,4 +6,4 @@ export interface KarmaMultiEnvExecutorSchema { timeout?: number; } -export type KarmaEnvironment = 'client' | 'server' | 'hydration'; +export type KarmaEnvironment = 'client' | 'server' | 'hydration' | 'zone' | 'zoneless'; diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json b/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json index 869b099e6eee..67d00ee129bd 100644 --- a/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json @@ -12,7 +12,7 @@ "type": "array", "items": { "type": "string", - "enum": ["client", "server", "hydration"] + "enum": ["client", "server", "hydration", "zone", "zoneless"] }, "default": ["client", "server", "hydration"] },