Skip to content

Commit bb3af04

Browse files
committed
feat: route renderer events through Angular's EventManager
1 parent 780e666 commit bb3af04

5 files changed

Lines changed: 247 additions & 14 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Component, ElementRef, NgZone, NO_ERRORS_SCHEMA, ViewChild } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { EVENT_MANAGER_PLUGINS, EventManager, EventManagerPlugin } from '@angular/platform-browser';
4+
import { NativeScriptCommonModule, NativeScriptEventManagerPlugin, NativeScriptRendererHelperService, PREVENT_CHANGE_EVENTS_DURING_CD } from '@nativescript/angular';
5+
import { StackLayout, View } from '@nativescript/core';
6+
7+
describe('NativeScriptEventManagerPlugin', () => {
8+
it('supports every event name', () => {
9+
const plugin = new NativeScriptEventManagerPlugin();
10+
expect(plugin.supports('tap')).toBe(true);
11+
expect(plugin.supports('custom.debounce.500')).toBe(true);
12+
});
13+
14+
it('attaches and detaches handlers through on/off', () => {
15+
const plugin = new NativeScriptEventManagerPlugin();
16+
const view = new StackLayout();
17+
let count = 0;
18+
const remove = plugin.addEventListener(view, 'myEvent', () => count++);
19+
view.notify({ eventName: 'myEvent', object: view });
20+
expect(count).toBe(1);
21+
remove();
22+
view.notify({ eventName: 'myEvent', object: view });
23+
expect(count).toBe(1);
24+
});
25+
26+
it('replays the loaded event when the target is already loaded', () => {
27+
const plugin = new NativeScriptEventManagerPlugin();
28+
const target: any = { isLoaded: true, on() {}, off() {} };
29+
let fired = 0;
30+
plugin.addEventListener(target, View.loadedEvent, () => fired++);
31+
expect(fired).toBe(1);
32+
});
33+
34+
it('does not replay the loaded event when the target is not loaded', () => {
35+
const plugin = new NativeScriptEventManagerPlugin();
36+
const target: any = { isLoaded: false, on() {}, off() {} };
37+
let fired = 0;
38+
plugin.addEventListener(target, View.loadedEvent, () => fired++);
39+
expect(fired).toBe(0);
40+
});
41+
42+
it('delivers events in the zone that registered them', () => {
43+
const plugin = new NativeScriptEventManagerPlugin();
44+
const view = new StackLayout();
45+
let whichZone: string;
46+
Zone.root.fork({ name: 'registration-zone' }).run(() => {
47+
plugin.addEventListener(view, 'myEvent', () => (whichZone = Zone.current.name));
48+
});
49+
Zone.root.run(() => {
50+
view.notify({ eventName: 'myEvent', object: view });
51+
});
52+
expect(whichZone).toBe('registration-zone');
53+
});
54+
});
55+
56+
class TestEventPlugin extends EventManagerPlugin {
57+
calls: string[] = [];
58+
59+
constructor() {
60+
super(null);
61+
}
62+
63+
supports(eventName: string): boolean {
64+
return eventName.startsWith('custom.');
65+
}
66+
67+
addEventListener(element: any, eventName: string, handler: Function): Function {
68+
this.calls.push(eventName);
69+
const view = element as View;
70+
view.on('myCustomEvent', handler as any);
71+
return () => view.off('myCustomEvent', handler as any);
72+
}
73+
}
74+
75+
@Component({
76+
template: `<StackLayout #el (custom.debounce.500)="hits = hits + 1" (myPlainEvent)="plainHits = plainHits + 1"></StackLayout>`,
77+
imports: [NativeScriptCommonModule],
78+
schemas: [NO_ERRORS_SCHEMA],
79+
})
80+
class PluginHostComponent {
81+
@ViewChild('el', { read: ElementRef, static: true }) el: ElementRef<View>;
82+
hits = 0;
83+
plainHits = 0;
84+
}
85+
86+
describe('EVENT_MANAGER_PLUGINS integration', () => {
87+
let testPlugin: TestEventPlugin;
88+
89+
beforeEach(() => {
90+
testPlugin = new TestEventPlugin();
91+
return TestBed.configureTestingModule({
92+
imports: [PluginHostComponent],
93+
providers: [{ provide: EVENT_MANAGER_PLUGINS, useValue: testPlugin, multi: true }],
94+
}).compileComponents();
95+
});
96+
97+
it('provides an EventManager bound to the app NgZone', () => {
98+
expect(TestBed.inject(EventManager).getZone()).toBe(TestBed.inject(NgZone));
99+
});
100+
101+
it('registers the NativeScript plugin as the default fallback', () => {
102+
const plugins = TestBed.inject(EVENT_MANAGER_PLUGINS);
103+
expect(plugins.some((p) => p instanceof NativeScriptEventManagerPlugin)).toBe(true);
104+
});
105+
106+
it('routes sugared event names to the custom plugin', () => {
107+
const fixture = TestBed.createComponent(PluginHostComponent);
108+
fixture.detectChanges();
109+
expect(testPlugin.calls).toContain('custom.debounce.500');
110+
111+
const view = fixture.componentInstance.el.nativeElement;
112+
view.notify({ eventName: 'myCustomEvent', object: view });
113+
expect(fixture.componentInstance.hits).toBe(1);
114+
});
115+
116+
it('routes plain events through the NativeScript fallback plugin', () => {
117+
const fixture = TestBed.createComponent(PluginHostComponent);
118+
fixture.detectChanges();
119+
expect(testPlugin.calls).not.toContain('myPlainEvent');
120+
121+
const view = fixture.componentInstance.el.nativeElement;
122+
view.notify({ eventName: 'myPlainEvent', object: view });
123+
expect(fixture.componentInstance.plainHits).toBe(1);
124+
});
125+
126+
it('stops delivering events after the listener is removed', () => {
127+
const fixture = TestBed.createComponent(PluginHostComponent);
128+
fixture.detectChanges();
129+
const view = fixture.componentInstance.el.nativeElement;
130+
fixture.destroy();
131+
view.notify({ eventName: 'myCustomEvent', object: view });
132+
view.notify({ eventName: 'myPlainEvent', object: view });
133+
expect(fixture.componentInstance.hits).toBe(0);
134+
expect(fixture.componentInstance.plainHits).toBe(0);
135+
});
136+
});
137+
138+
@Component({
139+
template: `<StackLayout #el (somePropChange)="changes = changes + 1"></StackLayout>`,
140+
imports: [NativeScriptCommonModule],
141+
schemas: [NO_ERRORS_SCHEMA],
142+
})
143+
class ChangeEventHostComponent {
144+
@ViewChild('el', { read: ElementRef, static: true }) el: ElementRef<View>;
145+
changes = 0;
146+
}
147+
148+
describe('prevent change events during CD', () => {
149+
beforeEach(() => {
150+
return TestBed.configureTestingModule({
151+
imports: [ChangeEventHostComponent],
152+
providers: [{ provide: PREVENT_CHANGE_EVENTS_DURING_CD, useValue: true }],
153+
}).compileComponents();
154+
});
155+
156+
it('suppresses *Change events while DOM changes are executing', () => {
157+
const fixture = TestBed.createComponent(ChangeEventHostComponent);
158+
fixture.detectChanges();
159+
const view = fixture.componentInstance.el.nativeElement;
160+
const helper = TestBed.inject(NativeScriptRendererHelperService);
161+
162+
helper.beginDomChanges();
163+
view.notify({ eventName: 'somePropChange', object: view });
164+
helper.endDomChanges();
165+
expect(fixture.componentInstance.changes).toBe(0);
166+
167+
view.notify({ eventName: 'somePropChange', object: view });
168+
expect(fixture.componentInstance.changes).toBe(1);
169+
});
170+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Injectable } from '@angular/core';
2+
import { EventManagerPlugin } from '@angular/platform-browser';
3+
import { Observable, View } from '@nativescript/core';
4+
import { NativeScriptDebug } from './trace';
5+
6+
/**
7+
* Default event plugin for NativeScript views. Registered last on
8+
* `EVENT_MANAGER_PLUGINS`, it supports every event name and binds handlers
9+
* through the NativeScript `Observable` event system (`View.on`/`View.off`).
10+
*
11+
* Custom plugins registered by applications take priority over this one, so
12+
* event-name sugar such as `(tap.debounce.500)` can be intercepted exactly as
13+
* described in https://angular.dev/guide/templates/event-listeners#extend-event-handling.
14+
*
15+
* Plugin authors: do not wrap `addEventListener` in `runOutsideAngular` —
16+
* zone capture happens inside the zone-patched `View.on()` in the caller's
17+
* zone, and change detection relies on it.
18+
*/
19+
@Injectable()
20+
export class NativeScriptEventManagerPlugin extends EventManagerPlugin {
21+
constructor() {
22+
// The base class only stores the document reference and this plugin never
23+
// touches it — passing null avoids a hard DOCUMENT dependency.
24+
super(null);
25+
}
26+
27+
supports(eventName: string): boolean {
28+
return true;
29+
}
30+
31+
addEventListener(element: unknown, eventName: string, handler: (data?: unknown) => void): VoidFunction {
32+
const target = element as View;
33+
if (NativeScriptDebug.enabled) {
34+
NativeScriptDebug.rendererLog(`NativeScriptEventManagerPlugin.addEventListener: ${eventName}`);
35+
}
36+
target.on(eventName, handler);
37+
if (eventName === View.loadedEvent && target.isLoaded) {
38+
// we must create a new obervable here to ensure that the event goes through whatever zone patches are applied
39+
const obs = new Observable();
40+
obs.once(eventName, handler);
41+
obs.notify({
42+
eventName,
43+
object: target,
44+
});
45+
}
46+
return () => target.off(eventName, handler);
47+
}
48+
}

packages/angular/src/lib/nativescript-renderer.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,25 @@ import {
22
inject,
33
Injectable,
44
Injector,
5+
ListenerOptions,
56
Renderer2,
67
RendererFactory2,
78
RendererStyleFlags2,
89
RendererType2,
910
runInInjectionContext,
1011
ViewEncapsulation,
1112
} from '@angular/core';
13+
import { EventManager } from '@angular/platform-browser';
1214
import {
1315
addTaggedAdditionalCSS,
1416
Application,
1517
ContentView,
1618
getViewById,
17-
Observable,
1819
profile,
1920
View,
2021
} from '@nativescript/core';
2122
import { isKnownView } from './element-registry';
23+
import { NativeScriptEventManagerPlugin } from './nativescript-event-manager-plugin';
2224
import { NAMESPACE_FILTERS } from './property-filter';
2325
import {
2426
APP_ROOT_VIEW,
@@ -238,6 +240,12 @@ class NativeScriptRenderer implements Renderer2 {
238240
inject(PREVENT_CHANGE_EVENTS_DURING_CD, {
239241
optional: true,
240242
}) ?? false;
243+
private injector = inject(Injector);
244+
// EventManager must be resolved lazily: eager injection would instantiate
245+
// every EVENT_MANAGER_PLUGINS provider while the renderer factory's own DI
246+
// record is still circular, breaking plugins that inject RendererFactory2.
247+
private eventManager: EventManager | null | undefined;
248+
private fallbackEventPlugin: NativeScriptEventManagerPlugin | undefined;
241249

242250
constructor(private rootView: View) {}
243251
get data(): { [key: string]: any } {
@@ -433,8 +441,7 @@ class NativeScriptRenderer implements Renderer2 {
433441
}
434442
// throw new Error("Method not implemented.");
435443
}
436-
listen(target: View, eventName: string, callback: (event: any) => boolean | void): () => void {
437-
// throw new Error("Method not implemented.");
444+
listen(target: View, eventName: string, callback: (event: any) => boolean | void, options?: ListenerOptions): () => void {
438445
if (NativeScriptDebug.enabled) {
439446
NativeScriptDebug.rendererLog(`NativeScriptRenderer.listen: ${eventName}`);
440447
}
@@ -447,17 +454,16 @@ class NativeScriptRenderer implements Renderer2 {
447454
return callback(...args);
448455
};
449456
}
450-
target.on(eventName, modifiedCallback);
451-
if (eventName === View.loadedEvent && target.isLoaded) {
452-
// we must create a new obervable here to ensure that the event goes through whatever zone patches are applied
453-
const obs = new Observable();
454-
obs.once(eventName, modifiedCallback);
455-
obs.notify({
456-
eventName,
457-
object: target,
458-
});
457+
if (this.eventManager === undefined) {
458+
this.eventManager = this.injector.get(EventManager, null);
459+
}
460+
if (this.eventManager) {
461+
return this.eventManager.addEventListener(target as any, eventName, modifiedCallback, options) as () => void;
459462
}
460-
return () => target.off(eventName, modifiedCallback);
463+
// No EventManager provided (e.g. a custom setup that only spreads
464+
// NATIVESCRIPT_MODULE_STATIC_PROVIDERS) — bind through the default plugin.
465+
this.fallbackEventPlugin ??= new NativeScriptEventManagerPlugin();
466+
return this.fallbackEventPlugin.addEventListener(target, eventName, modifiedCallback) as () => void;
461467
}
462468
}
463469

packages/angular/src/lib/nativescript.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { ViewportScroller, XhrFactory, ɵNullViewportScroller as NullViewportScroller } from '@angular/common';
22
import { ApplicationModule, ErrorHandler, Inject, NgModule, NO_ERRORS_SCHEMA, Optional, Provider, RendererFactory2, SkipSelf, StaticProvider, ɵINJECTOR_SCOPE as INJECTOR_SCOPE } from '@angular/core';
3+
import { EVENT_MANAGER_PLUGINS, EventManager } from '@angular/platform-browser';
34
import { Color, Device, View } from '@nativescript/core';
45
import { AppHostView } from './app-host-view';
56
import { NativescriptXhrFactory } from './nativescript-xhr-factory';
7+
import { NativeScriptEventManagerPlugin } from './nativescript-event-manager-plugin';
68
import { NativeScriptRendererFactory } from './nativescript-renderer';
79
import { PlatformNamespaceFilter, NAMESPACE_FILTERS } from './property-filter';
810
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[] = [
4042
{ provide: DEVICE, useValue: Device },
4143
{ provide: XhrFactory, useClass: NativescriptXhrFactory, deps: [] },
4244
];
43-
export const NATIVESCRIPT_MODULE_PROVIDERS: Provider[] = [{ provide: ViewportScroller, useClass: NullViewportScroller }];
45+
export const NATIVESCRIPT_MODULE_PROVIDERS: Provider[] = [
46+
{ provide: ViewportScroller, useClass: NullViewportScroller },
47+
// The EventManager checks plugins in reverse registration order, so plugins
48+
// provided by the application take priority over this default one.
49+
{ provide: EVENT_MANAGER_PLUGINS, useClass: NativeScriptEventManagerPlugin, multi: true },
50+
EventManager,
51+
];
4452

4553
@NgModule({
4654
imports: [ApplicationModule, DetachedLoader, NativeScriptCommonModule],

packages/angular/src/lib/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export {
5757
ApplicationConfig,
5858
} from './application';
5959
export * from './element-registry';
60+
export * from './nativescript-event-manager-plugin';
6061
export * from './nativescript-xhr-factory';
6162
export {
6263
EmulatedRenderer,

0 commit comments

Comments
 (0)