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
170 changes: 170 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,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: `<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);
});
});

@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);
});
});
48 changes: 48 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,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);
}
}
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;
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) as () => void;
}
}

Expand Down
10 changes: 9 additions & 1 deletion packages/angular/src/lib/nativescript.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions packages/angular/src/lib/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down