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,