Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions apps/nativescript-demo-ng/src/tests/event-manager-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { Component, ElementRef, inject, NgZone, NO_ERRORS_SCHEMA, RendererFactory2, 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('removes a { once: true } handler after the first event', () => {
const plugin = new NativeScriptEventManagerPlugin();
const view = new StackLayout();
let count = 0;
plugin.addEventListener(view, 'myEvent', () => count++, { once: true });
view.notify({ eventName: 'myEvent', object: view });
view.notify({ eventName: 'myEvent', object: view });
expect(count).toBe(1);
});

it('does not leave a registration behind when { once: true } is satisfied by the loaded replay', () => {
const plugin = new NativeScriptEventManagerPlugin();
const handlers: ((data: unknown) => void)[] = [];
const track = (eventName: string, handler: (data: unknown) => void) => handlers.push(handler);
const target: any = {
isLoaded: true,
on: track,
once: track,
off(eventName: string, handler: (data: unknown) => void) {
const index = handlers.indexOf(handler);
if (index >= 0) {
handlers.splice(index, 1);
}
},
};
let fired = 0;
plugin.addEventListener(target, View.loadedEvent, () => fired++, { once: true });
expect(fired).toBe(1);
handlers.forEach((handler) => handler({ eventName: View.loadedEvent, object: target }));
expect(fired).toBe(1);
});

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: `<StackLayout #el (custom.debounce.500)="hits = hits + 1" (myPlainEvent)="plainHits = plainHits + 1"></StackLayout>`,
imports: [NativeScriptCommonModule],
schemas: [NO_ERRORS_SCHEMA],
})
class PluginHostComponent {
@ViewChild('el', { read: ElementRef, static: true }) el: ElementRef<View>;
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);
});
});

class RendererFactoryAwarePlugin extends EventManagerPlugin {
rendererFactory = inject(RendererFactory2);

constructor() {
super(null);
}

supports(eventName: string): boolean {
return eventName.startsWith('factory.');
}

addEventListener(element: any, eventName: string, handler: Function): Function {
const view = element as View;
view.on('factoryEvent', handler as any);
return () => view.off('factoryEvent', handler as any);
}
}

@Component({
template: `<StackLayout #el (factory.event)="hits = hits + 1"></StackLayout>`,
imports: [NativeScriptCommonModule],
schemas: [NO_ERRORS_SCHEMA],
})
class FactoryPluginHostComponent {
@ViewChild('el', { read: ElementRef, static: true }) el: ElementRef<View>;
hits = 0;
}

describe('plugins that inject RendererFactory2', () => {
beforeEach(() => {
return TestBed.configureTestingModule({
imports: [FactoryPluginHostComponent],
providers: [{ provide: EVENT_MANAGER_PLUGINS, useClass: RendererFactoryAwarePlugin, multi: true }],
}).compileComponents();
});

it('creates components and registers listeners without a DI cycle', () => {
const fixture = TestBed.createComponent(FactoryPluginHostComponent);
fixture.detectChanges();

const plugins = TestBed.inject(EVENT_MANAGER_PLUGINS);
const plugin = plugins.find((p): p is RendererFactoryAwarePlugin => p instanceof RendererFactoryAwarePlugin);
expect(plugin.rendererFactory).toBe(TestBed.inject(RendererFactory2));

const view = fixture.componentInstance.el.nativeElement;
view.notify({ eventName: 'factoryEvent', object: view });
expect(fixture.componentInstance.hits).toBe(1);
});
});

@Component({
template: `<StackLayout #el (somePropChange)="changes = changes + 1"></StackLayout>`,
imports: [NativeScriptCommonModule],
schemas: [NO_ERRORS_SCHEMA],
})
class ChangeEventHostComponent {
@ViewChild('el', { read: ElementRef, static: true }) el: ElementRef<View>;
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);
});
});
63 changes: 63 additions & 0 deletions packages/angular/src/lib/nativescript-event-manager-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Injectable, ListenerOptions } 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,
options?: ListenerOptions,
): VoidFunction {
const target = element as View;
if (NativeScriptDebug.enabled) {
NativeScriptDebug.rendererLog(`NativeScriptEventManagerPlugin.addEventListener: ${eventName}`);
}
const once = options?.once ?? false;
const replayLoaded = eventName === View.loadedEvent && target.isLoaded;
// The synchronous replay below already consumes a one-shot listener, so it
// must not stay registered on the target for the next real event.
if (!(once && replayLoaded)) {
if (once) {
target.once(eventName, handler);
} else {
target.on(eventName, handler);
}
}
if (replayLoaded) {
// we must create a new observable 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);
}
}
32 changes: 19 additions & 13 deletions packages/angular/src/lib/nativescript-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@ import {
inject,
Injectable,
Injector,
ListenerOptions,
Renderer2,
RendererFactory2,
RendererStyleFlags2,
RendererType2,
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,
Expand Down Expand Up @@ -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;
Comment on lines +244 to +247
private fallbackEventPlugin: NativeScriptEventManagerPlugin | undefined;

constructor(private rootView: View) {}
get data(): { [key: string]: any } {
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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, options) as () => void;
}
}

Expand Down
Loading