diff --git a/apps/nativescript-demo-ng/src/tests/event-manager-plugin.spec.ts b/apps/nativescript-demo-ng/src/tests/event-manager-plugin.spec.ts new file mode 100644 index 0000000..451f9b1 --- /dev/null +++ b/apps/nativescript-demo-ng/src/tests/event-manager-plugin.spec.ts @@ -0,0 +1,170 @@ +import { Component, ElementRef, NgZone, NO_ERRORS_SCHEMA, ViewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EVENT_MANAGER_PLUGINS, EventManager, EventManagerPlugin } from '@angular/platform-browser'; +import { NativeScriptCommonModule, NativeScriptEventManagerPlugin, NativeScriptRendererHelperService, PREVENT_CHANGE_EVENTS_DURING_CD } from '@nativescript/angular'; +import { StackLayout, View } from '@nativescript/core'; + +describe('NativeScriptEventManagerPlugin', () => { + it('supports every event name', () => { + const plugin = new NativeScriptEventManagerPlugin(); + expect(plugin.supports('tap')).toBe(true); + expect(plugin.supports('custom.debounce.500')).toBe(true); + }); + + it('attaches and detaches handlers through on/off', () => { + const plugin = new NativeScriptEventManagerPlugin(); + const view = new StackLayout(); + let count = 0; + const remove = plugin.addEventListener(view, 'myEvent', () => count++); + view.notify({ eventName: 'myEvent', object: view }); + expect(count).toBe(1); + remove(); + view.notify({ eventName: 'myEvent', object: view }); + expect(count).toBe(1); + }); + + it('replays the loaded event when the target is already loaded', () => { + const plugin = new NativeScriptEventManagerPlugin(); + const target: any = { isLoaded: true, on() {}, off() {} }; + let fired = 0; + plugin.addEventListener(target, View.loadedEvent, () => fired++); + expect(fired).toBe(1); + }); + + it('does not replay the loaded event when the target is not loaded', () => { + const plugin = new NativeScriptEventManagerPlugin(); + const target: any = { isLoaded: false, on() {}, off() {} }; + let fired = 0; + plugin.addEventListener(target, View.loadedEvent, () => fired++); + expect(fired).toBe(0); + }); + + it('delivers events in the zone that registered them', () => { + const plugin = new NativeScriptEventManagerPlugin(); + const view = new StackLayout(); + let whichZone: string; + Zone.root.fork({ name: 'registration-zone' }).run(() => { + plugin.addEventListener(view, 'myEvent', () => (whichZone = Zone.current.name)); + }); + Zone.root.run(() => { + view.notify({ eventName: 'myEvent', object: view }); + }); + expect(whichZone).toBe('registration-zone'); + }); +}); + +class TestEventPlugin extends EventManagerPlugin { + calls: string[] = []; + + constructor() { + super(null); + } + + supports(eventName: string): boolean { + return eventName.startsWith('custom.'); + } + + addEventListener(element: any, eventName: string, handler: Function): Function { + this.calls.push(eventName); + const view = element as View; + view.on('myCustomEvent', handler as any); + return () => view.off('myCustomEvent', handler as any); + } +} + +@Component({ + template: ``, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +class PluginHostComponent { + @ViewChild('el', { read: ElementRef, static: true }) el: ElementRef; + hits = 0; + plainHits = 0; +} + +describe('EVENT_MANAGER_PLUGINS integration', () => { + let testPlugin: TestEventPlugin; + + beforeEach(() => { + testPlugin = new TestEventPlugin(); + return TestBed.configureTestingModule({ + imports: [PluginHostComponent], + providers: [{ provide: EVENT_MANAGER_PLUGINS, useValue: testPlugin, multi: true }], + }).compileComponents(); + }); + + it('provides an EventManager bound to the app NgZone', () => { + expect(TestBed.inject(EventManager).getZone()).toBe(TestBed.inject(NgZone)); + }); + + it('registers the NativeScript plugin as the default fallback', () => { + const plugins = TestBed.inject(EVENT_MANAGER_PLUGINS); + expect(plugins.some((p) => p instanceof NativeScriptEventManagerPlugin)).toBe(true); + }); + + it('routes sugared event names to the custom plugin', () => { + const fixture = TestBed.createComponent(PluginHostComponent); + fixture.detectChanges(); + expect(testPlugin.calls).toContain('custom.debounce.500'); + + const view = fixture.componentInstance.el.nativeElement; + view.notify({ eventName: 'myCustomEvent', object: view }); + expect(fixture.componentInstance.hits).toBe(1); + }); + + it('routes plain events through the NativeScript fallback plugin', () => { + const fixture = TestBed.createComponent(PluginHostComponent); + fixture.detectChanges(); + expect(testPlugin.calls).not.toContain('myPlainEvent'); + + const view = fixture.componentInstance.el.nativeElement; + view.notify({ eventName: 'myPlainEvent', object: view }); + expect(fixture.componentInstance.plainHits).toBe(1); + }); + + it('stops delivering events after the listener is removed', () => { + const fixture = TestBed.createComponent(PluginHostComponent); + fixture.detectChanges(); + const view = fixture.componentInstance.el.nativeElement; + fixture.destroy(); + view.notify({ eventName: 'myCustomEvent', object: view }); + view.notify({ eventName: 'myPlainEvent', object: view }); + expect(fixture.componentInstance.hits).toBe(0); + expect(fixture.componentInstance.plainHits).toBe(0); + }); +}); + +@Component({ + template: ``, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +class ChangeEventHostComponent { + @ViewChild('el', { read: ElementRef, static: true }) el: ElementRef; + changes = 0; +} + +describe('prevent change events during CD', () => { + beforeEach(() => { + return TestBed.configureTestingModule({ + imports: [ChangeEventHostComponent], + providers: [{ provide: PREVENT_CHANGE_EVENTS_DURING_CD, useValue: true }], + }).compileComponents(); + }); + + it('suppresses *Change events while DOM changes are executing', () => { + const fixture = TestBed.createComponent(ChangeEventHostComponent); + fixture.detectChanges(); + const view = fixture.componentInstance.el.nativeElement; + const helper = TestBed.inject(NativeScriptRendererHelperService); + + helper.beginDomChanges(); + view.notify({ eventName: 'somePropChange', object: view }); + helper.endDomChanges(); + expect(fixture.componentInstance.changes).toBe(0); + + view.notify({ eventName: 'somePropChange', object: view }); + expect(fixture.componentInstance.changes).toBe(1); + }); +}); diff --git a/packages/angular/src/lib/nativescript-event-manager-plugin.ts b/packages/angular/src/lib/nativescript-event-manager-plugin.ts new file mode 100644 index 0000000..50278e0 --- /dev/null +++ b/packages/angular/src/lib/nativescript-event-manager-plugin.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { EventManagerPlugin } from '@angular/platform-browser'; +import { Observable, View } from '@nativescript/core'; +import { NativeScriptDebug } from './trace'; + +/** + * Default event plugin for NativeScript views. Registered last on + * `EVENT_MANAGER_PLUGINS`, it supports every event name and binds handlers + * through the NativeScript `Observable` event system (`View.on`/`View.off`). + * + * Custom plugins registered by applications take priority over this one, so + * event-name sugar such as `(tap.debounce.500)` can be intercepted exactly as + * described in https://angular.dev/guide/templates/event-listeners#extend-event-handling. + * + * Plugin authors: do not wrap `addEventListener` in `runOutsideAngular` — + * zone capture happens inside the zone-patched `View.on()` in the caller's + * zone, and change detection relies on it. + */ +@Injectable() +export class NativeScriptEventManagerPlugin extends EventManagerPlugin { + constructor() { + // The base class only stores the document reference and this plugin never + // touches it — passing null avoids a hard DOCUMENT dependency. + super(null); + } + + supports(eventName: string): boolean { + return true; + } + + addEventListener(element: unknown, eventName: string, handler: (data?: unknown) => void): VoidFunction { + const target = element as View; + if (NativeScriptDebug.enabled) { + NativeScriptDebug.rendererLog(`NativeScriptEventManagerPlugin.addEventListener: ${eventName}`); + } + target.on(eventName, handler); + if (eventName === View.loadedEvent && target.isLoaded) { + // we must create a new obervable here to ensure that the event goes through whatever zone patches are applied + const obs = new Observable(); + obs.once(eventName, handler); + obs.notify({ + eventName, + object: target, + }); + } + return () => target.off(eventName, handler); + } +} diff --git a/packages/angular/src/lib/nativescript-renderer.ts b/packages/angular/src/lib/nativescript-renderer.ts index 03ef3af..0a521e9 100644 --- a/packages/angular/src/lib/nativescript-renderer.ts +++ b/packages/angular/src/lib/nativescript-renderer.ts @@ -2,6 +2,7 @@ import { inject, Injectable, Injector, + ListenerOptions, Renderer2, RendererFactory2, RendererStyleFlags2, @@ -9,16 +10,17 @@ import { runInInjectionContext, ViewEncapsulation, } from '@angular/core'; +import { EventManager } from '@angular/platform-browser'; import { addTaggedAdditionalCSS, Application, ContentView, getViewById, - Observable, profile, View, } from '@nativescript/core'; import { isKnownView } from './element-registry'; +import { NativeScriptEventManagerPlugin } from './nativescript-event-manager-plugin'; import { NAMESPACE_FILTERS } from './property-filter'; import { APP_ROOT_VIEW, @@ -238,6 +240,12 @@ class NativeScriptRenderer implements Renderer2 { inject(PREVENT_CHANGE_EVENTS_DURING_CD, { optional: true, }) ?? false; + private injector = inject(Injector); + // EventManager must be resolved lazily: eager injection would instantiate + // every EVENT_MANAGER_PLUGINS provider while the renderer factory's own DI + // record is still circular, breaking plugins that inject RendererFactory2. + private eventManager: EventManager | null | undefined; + private fallbackEventPlugin: NativeScriptEventManagerPlugin | undefined; constructor(private rootView: View) {} get data(): { [key: string]: any } { @@ -433,8 +441,7 @@ class NativeScriptRenderer implements Renderer2 { } // throw new Error("Method not implemented."); } - listen(target: View, eventName: string, callback: (event: any) => boolean | void): () => void { - // throw new Error("Method not implemented."); + listen(target: View, eventName: string, callback: (event: any) => boolean | void, options?: ListenerOptions): () => void { if (NativeScriptDebug.enabled) { NativeScriptDebug.rendererLog(`NativeScriptRenderer.listen: ${eventName}`); } @@ -447,17 +454,16 @@ class NativeScriptRenderer implements Renderer2 { return callback(...args); }; } - target.on(eventName, modifiedCallback); - if (eventName === View.loadedEvent && target.isLoaded) { - // we must create a new obervable here to ensure that the event goes through whatever zone patches are applied - const obs = new Observable(); - obs.once(eventName, modifiedCallback); - obs.notify({ - eventName, - object: target, - }); + if (this.eventManager === undefined) { + this.eventManager = this.injector.get(EventManager, null); + } + if (this.eventManager) { + return this.eventManager.addEventListener(target as any, eventName, modifiedCallback, options) as () => void; } - return () => target.off(eventName, modifiedCallback); + // No EventManager provided (e.g. a custom setup that only spreads + // NATIVESCRIPT_MODULE_STATIC_PROVIDERS) — bind through the default plugin. + this.fallbackEventPlugin ??= new NativeScriptEventManagerPlugin(); + return this.fallbackEventPlugin.addEventListener(target, eventName, modifiedCallback) as () => void; } } diff --git a/packages/angular/src/lib/nativescript.ts b/packages/angular/src/lib/nativescript.ts index 7fb656d..fcf08f2 100644 --- a/packages/angular/src/lib/nativescript.ts +++ b/packages/angular/src/lib/nativescript.ts @@ -1,8 +1,10 @@ import { ViewportScroller, XhrFactory, ɵNullViewportScroller as NullViewportScroller } from '@angular/common'; import { ApplicationModule, ErrorHandler, Inject, NgModule, NO_ERRORS_SCHEMA, Optional, Provider, RendererFactory2, SkipSelf, StaticProvider, ɵINJECTOR_SCOPE as INJECTOR_SCOPE } from '@angular/core'; +import { EVENT_MANAGER_PLUGINS, EventManager } from '@angular/platform-browser'; import { Color, Device, View } from '@nativescript/core'; import { AppHostView } from './app-host-view'; import { NativescriptXhrFactory } from './nativescript-xhr-factory'; +import { NativeScriptEventManagerPlugin } from './nativescript-event-manager-plugin'; import { NativeScriptRendererFactory } from './nativescript-renderer'; import { PlatformNamespaceFilter, NAMESPACE_FILTERS } from './property-filter'; import { APP_ROOT_VIEW, DEVICE, ENABLE_REUSABE_VIEWS, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; @@ -40,7 +42,13 @@ export const NATIVESCRIPT_MODULE_STATIC_PROVIDERS: StaticProvider[] = [ { provide: DEVICE, useValue: Device }, { provide: XhrFactory, useClass: NativescriptXhrFactory, deps: [] }, ]; -export const NATIVESCRIPT_MODULE_PROVIDERS: Provider[] = [{ provide: ViewportScroller, useClass: NullViewportScroller }]; +export const NATIVESCRIPT_MODULE_PROVIDERS: Provider[] = [ + { provide: ViewportScroller, useClass: NullViewportScroller }, + // The EventManager checks plugins in reverse registration order, so plugins + // provided by the application take priority over this default one. + { provide: EVENT_MANAGER_PLUGINS, useClass: NativeScriptEventManagerPlugin, multi: true }, + EventManager, +]; @NgModule({ imports: [ApplicationModule, DetachedLoader, NativeScriptCommonModule], diff --git a/packages/angular/src/lib/public_api.ts b/packages/angular/src/lib/public_api.ts index fa617f8..fa698b9 100644 --- a/packages/angular/src/lib/public_api.ts +++ b/packages/angular/src/lib/public_api.ts @@ -57,6 +57,7 @@ export { ApplicationConfig, } from './application'; export * from './element-registry'; +export * from './nativescript-event-manager-plugin'; export * from './nativescript-xhr-factory'; export { EmulatedRenderer,