From 16aa7b666cc11891a3a08888b50b3383e38ec01d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 22 May 2026 02:30:57 -0300 Subject: [PATCH 1/2] feat: use upstream angular router link --- .../src/tests/ns-router-link.spec.ts | 76 ++-- .../legacy/router/ns-router-link-active.ts | 223 ++++++++--- .../src/lib/legacy/router/ns-router-link.ts | 368 ++++++++++++++---- .../router/private-imports/router-url-tree.ts | 83 ---- 4 files changed, 513 insertions(+), 237 deletions(-) delete mode 100644 packages/angular/src/lib/legacy/router/private-imports/router-url-tree.ts diff --git a/apps/nativescript-demo-ng/src/tests/ns-router-link.spec.ts b/apps/nativescript-demo-ng/src/tests/ns-router-link.spec.ts index febabe3..d12edf6 100644 --- a/apps/nativescript-demo-ng/src/tests/ns-router-link.spec.ts +++ b/apps/nativescript-demo-ng/src/tests/ns-router-link.spec.ts @@ -1,37 +1,51 @@ -import { NSRouterLink } from '@nativescript/angular'; -import { ActivatedRoute, Router } from '@angular/router'; +import { NSRouterLink, NativeScriptRouterModule } from '@nativescript/angular'; import { RouterExtensions } from '@nativescript/angular'; -import { fake, spy, stub } from './test-config.spec'; -import { SinonStub } from 'sinon'; -import { Label } from '@nativescript/core'; +import { fake } from './test-config.spec'; +import { Component, ViewChild } from '@angular/core'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { NativeScriptModule } from '@nativescript/angular'; -describe('NSRouterLink', () => { - const mockRouter = {} as Router; - const mockRouterExtensions = { - navigateByUrl: fake(), - navigate: fake(), - }; - const mockActivatedRoute = {} as ActivatedRoute; - let nsRouterLink: NSRouterLink; - let urlTreeStub: SinonStub; +@Component({ + imports: [NativeScriptRouterModule, NSRouterLink], + template: ``, +}) +class RouterLinkTestComponent { + @ViewChild(NSRouterLink, { static: false }) nsRouterLink: NSRouterLink; +} - beforeEach(() => { - const el = { - nativeElement: new Label(), - }; - nsRouterLink = new NSRouterLink(null, mockRouter, mockRouterExtensions as unknown as RouterExtensions, mockActivatedRoute, el); - urlTreeStub = stub(nsRouterLink, 'urlTree').get(() => null); - }); +describe('NSRouterLink', () => { + let mockNavigate: ReturnType; + let fixture: ComponentFixture; - afterEach(() => { - urlTreeStub.restore(); + beforeEach(async () => { + mockNavigate = fake(); + TestBed.configureTestingModule({ + imports: [ + NativeScriptModule, + NativeScriptRouterModule.forRoot([{ path: 'test', component: RouterLinkTestComponent }]), + RouterLinkTestComponent, + ], + providers: [ + { + provide: RouterExtensions, + useValue: { + navigateByUrl: fake(), + navigate: mockNavigate, + }, + }, + ], + }); + await TestBed.compileComponents(); + fixture = TestBed.createComponent(RouterLinkTestComponent); + fixture.detectChanges(); + await fixture.whenStable(); }); it('#tap should call navigate with undefined transition in extras when boolean is given for pageTransition input', () => { - nsRouterLink.pageTransition = false; - nsRouterLink.onTap(); - expect(mockRouterExtensions.navigate.lastCall.args[1].transition).toBeUndefined(); - // assert.isUndefined(mockRouterExtensions.navigateByUrl.lastCall.args[1].transition); + const directive = fixture.componentInstance.nsRouterLink; + directive.pageTransition = false; + directive['onTap'](); + expect(mockNavigate.lastCall.args[1].transition).toBeUndefined(); }); it('#tap should call navigate with correct transition in extras when NavigationTransition object is given for pageTransition input', () => { @@ -39,9 +53,9 @@ describe('NSRouterLink', () => { name: 'slide', duration: 500, }; - nsRouterLink.pageTransition = pageTransition; - stub(nsRouterLink, 'urlTree').get(() => null); - nsRouterLink.onTap(); - expect(mockRouterExtensions.navigate.lastCall.args[1].transition).toBe(pageTransition); + const directive = fixture.componentInstance.nsRouterLink; + directive.pageTransition = pageTransition; + directive['onTap'](); + expect(mockNavigate.lastCall.args[1].transition).toBe(pageTransition); }); }); diff --git a/packages/angular/src/lib/legacy/router/ns-router-link-active.ts b/packages/angular/src/lib/legacy/router/ns-router-link-active.ts index 331ae90..0d71ff9 100644 --- a/packages/angular/src/lib/legacy/router/ns-router-link-active.ts +++ b/packages/angular/src/lib/legacy/router/ns-router-link-active.ts @@ -1,19 +1,43 @@ -import { AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, QueryList, Renderer2 } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, Output, QueryList, Renderer2, SimpleChanges, untracked } from '@angular/core'; +import { from, of, Subscription } from 'rxjs'; +import { mergeAll } from 'rxjs/operators'; -import { NavigationEnd, Router, UrlTree } from '@angular/router'; -import { containsTree } from './private-imports/router-url-tree'; +import { IsActiveMatchOptions, NavigationEnd, Router, isActive } from '@angular/router'; import { NSRouterLink } from './ns-router-link'; +// Inline equivalent of upstream's exactMatchOptions +const exactMatchOptions: IsActiveMatchOptions = { + paths: 'exact', + fragment: 'ignored', + matrixParams: 'ignored', + queryParams: 'exact', +}; + +// Inline equivalent of upstream's subsetMatchOptions +const subsetMatchOptions: IsActiveMatchOptions = { + paths: 'subset', + fragment: 'ignored', + matrixParams: 'ignored', + queryParams: 'subset', +}; + /** - * The NSRouterLinkActive directive lets you add a CSS class to an element when the link"s route + * Use instead of `'paths' in options` to be compatible with property renaming + */ +function isActiveMatchOptions(options: { exact: boolean } | Partial): options is Partial { + const o = options as Partial; + return !!(o.paths || o.matrixParams || o.queryParams || o.fragment); +} + +/** + * The NSRouterLinkActive directive lets you add a CSS class to an element when the link's route * becomes active. * * Consider the following example: * * ``` - * Bob + * * ``` * * When the url is either "/user" or "/user/bob", the active-link class will @@ -22,31 +46,48 @@ import { NSRouterLink } from './ns-router-link'; * You can set more than one class, as follows: * * ``` - * Bob - * Bob + * + * * ``` * * You can configure NSRouterLinkActive by passing `exact: true`. This will add the * classes only when the url matches the link exactly. * * ``` - * Bob + * + * ``` + * + * To directly check the `isActive` status of the link, assign the `NSRouterLinkActive` + * instance to a template variable. + * For example, the following checks the status without assigning any CSS classes: + * + * ``` + * * ``` * - * Finally, you can apply the NSRouterLinkActive directive to an ancestor of a RouterLink. + * You can apply the NSRouterLinkActive directive to an ancestor of a RouterLink. * * ``` - *
- * Jim - * Bob - *
+ * + * + * + * * ``` * - * This will set the active-link class on the div tag if the url is either "/user/jim" or + * This will set the active-link class on the StackLayout if the url is either "/user/jim" or * "/user/bob". * - * @stable + * The `NSRouterLinkActive` directive can also be used to set the aria-current attribute + * to provide an alternative distinction for active elements to visually impaired users. + * + * For example, the following code adds the 'active' class to the Home Page link when it is + * indeed active and in such case also sets its aria-current attribute to 'page': + * + * ``` + * + * ``` */ @Directive({ selector: '[nsRouterLinkActive]', @@ -54,79 +95,151 @@ import { NSRouterLink } from './ns-router-link'; standalone: true, }) export class NSRouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { - // tslint:disable-line:max-line-length directive-class-suffix - @ContentChildren(NSRouterLink) links: QueryList; + @ContentChildren(NSRouterLink, { descendants: true }) links!: QueryList; private classes: string[] = []; - private subscription: Subscription; - private active = false; + private routerEventsSubscription: Subscription; + private linkInputChangesSubscription?: Subscription; + private _isActive = false; + + get isActive(): boolean { + return this._isActive; + } + + /** + * Options to configure how to determine if the router link is active. + * + * These options are passed to the `isActive()` function. + * + * @see {@link isActive} + */ + @Input() nsRouterLinkActiveOptions: { exact: boolean } | Partial = { exact: false }; - @Input() nsRouterLinkActiveOptions: { exact: boolean } = { exact: false }; + /** + * Aria-current attribute to apply when the router link is active. + * + * Possible values: `'page'` | `'step'` | `'location'` | `'date'` | `'time'` | `true` | `false`. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current} + */ + @Input() ariaCurrentWhenActive?: 'page' | 'step' | 'location' | 'date' | 'time' | true | false; - constructor(private router: Router, private element: ElementRef, private renderer: Renderer2) { - this.subscription = router.events.subscribe((s) => { + /** + * + * You can use the output `isActiveChange` to get notified each time the link becomes + * active or inactive. + * + * Emits: + * true -> Route is active + * false -> Route is inactive + * + * ```html + * + * ``` + */ + @Output() readonly isActiveChange: EventEmitter = new EventEmitter(); + + private readonly link = inject(NSRouterLink, { optional: true }); + private readonly router = inject(Router); + private readonly element = inject(ElementRef); + private readonly renderer = inject(Renderer2); + private readonly cdr = inject(ChangeDetectorRef); + + constructor() { + this.routerEventsSubscription = this.router.events.subscribe((s) => { if (s instanceof NavigationEnd) { this.update(); } }); } - get isActive(): boolean { - return this.active; + ngAfterContentInit(): void { + // `of(null)` is used to force subscribe body to execute once immediately (like `startWith`). + of(this.links.changes, of(null)) + .pipe(mergeAll()) + .subscribe(() => { + this.update(); + this.subscribeToEachLinkOnChanges(); + }); } - ngAfterContentInit(): void { - this.links.changes.subscribe(() => this.update()); - this.update(); + private subscribeToEachLinkOnChanges() { + this.linkInputChangesSubscription?.unsubscribe(); + const allLinkChanges = [...this.links.toArray(), this.link] + .filter((link): link is NSRouterLink => !!link) + .map((link) => link.onChanges); + this.linkInputChangesSubscription = from(allLinkChanges) + .pipe(mergeAll()) + .subscribe((link) => { + if (this._isActive !== this.isLinkActive(this.router)(link)) { + this.update(); + } + }); } @Input() set nsRouterLinkActive(data: string[] | string) { - if (Array.isArray(data)) { - this.classes = data; - } else { - this.classes = data.split(' '); - } + const classes = Array.isArray(data) ? data : data.split(' '); + this.classes = classes.filter((c) => !!c); } - ngOnChanges() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ngOnChanges(_changes: SimpleChanges): void { this.update(); } - ngOnDestroy() { - this.subscription.unsubscribe(); + + ngOnDestroy(): void { + this.routerEventsSubscription.unsubscribe(); + this.linkInputChangesSubscription?.unsubscribe(); } private update(): void { - if (!this.links) { - return; - } - const hasActiveLinks = this.hasActiveLinks(); - // react only when status has changed to prevent unnecessary dom updates - if (this.active !== hasActiveLinks) { - const currentUrlTree = this.router.parseUrl(this.router.url); - const isActiveLinks = this.reduceList(currentUrlTree, this.links); + if (!this.links || !this.router.navigated) return; + + queueMicrotask(() => { + const hasActiveLinks = this.hasActiveLinks(); this.classes.forEach((c) => { - if (isActiveLinks) { + if (hasActiveLinks) { this.renderer.addClass(this.element.nativeElement, c); } else { this.renderer.removeClass(this.element.nativeElement, c); } }); - } - Promise.resolve(hasActiveLinks).then((active) => (this.active = active)); - } + if (hasActiveLinks && this.ariaCurrentWhenActive !== undefined) { + this.renderer.setAttribute(this.element.nativeElement, 'aria-current', this.ariaCurrentWhenActive.toString()); + } else { + this.renderer.removeAttribute(this.element.nativeElement, 'aria-current'); + } - private reduceList(currentUrlTree: UrlTree, q: QueryList): boolean { - return q.reduce((res: boolean, link: NSRouterLink) => { - return res || containsTree(currentUrlTree, link.urlTree, this.nsRouterLinkActiveOptions.exact); - }, false); + // Only emit change if the active state changed. + if (this._isActive !== hasActiveLinks) { + this._isActive = hasActiveLinks; + this.cdr.markForCheck(); + // Emit on isActiveChange after classes are updated + this.isActiveChange.emit(hasActiveLinks); + } + }); } private isLinkActive(router: Router): (link: NSRouterLink) => boolean { - return (link: NSRouterLink) => router.isActive(link.urlTree, this.nsRouterLinkActiveOptions.exact); + const options: Partial = isActiveMatchOptions(this.nsRouterLinkActiveOptions) + ? this.nsRouterLinkActiveOptions + : // While the types should disallow `undefined` here, it's possible without strict inputs + (this.nsRouterLinkActiveOptions.exact ?? false) + ? { ...exactMatchOptions } + : { ...subsetMatchOptions }; + + return (link: NSRouterLink) => { + const urlTree = link.urlTree; + return urlTree ? untracked(isActive(urlTree, router, options)) : false; + }; } private hasActiveLinks(): boolean { - return this.links.some(this.isLinkActive(this.router)); + const isActiveCheckFn = this.isLinkActive(this.router); + return (this.link && isActiveCheckFn(this.link)) || this.links.some(isActiveCheckFn); } } diff --git a/packages/angular/src/lib/legacy/router/ns-router-link.ts b/packages/angular/src/lib/legacy/router/ns-router-link.ts index b71bc0b..86b036b 100644 --- a/packages/angular/src/lib/legacy/router/ns-router-link.ts +++ b/packages/angular/src/lib/legacy/router/ns-router-link.ts @@ -1,128 +1,364 @@ -import { Directive, Input, ElementRef, NgZone, AfterViewInit } from '@angular/core'; -import { NavigationExtras } from '@angular/router'; -import { ActivatedRoute, Router, UrlTree } from '@angular/router'; +import { booleanAttribute, computed, Directive, ElementRef, inject, Input, NgZone, OnChanges, OnDestroy, signal, SimpleChanges, untracked } from '@angular/core'; +import { ActivatedRoute, NavigationExtras, QueryParamsHandling, Router, UrlTree } from '@angular/router'; import { NavigationTransition } from '@nativescript/core'; +import { Subject } from 'rxjs'; import { NativeScriptDebug } from '../../trace'; import { RouterExtensions } from './router-extensions'; import { NavigationOptions } from './ns-location-utils'; -// Copied from "@angular/router/src/config" -export type QueryParamsHandling = 'merge' | 'preserve' | ''; +function isUrlTree(value: any): value is UrlTree { + return value instanceof UrlTree; +} /** * The nsRouterLink directive lets you link to specific parts of your app. * * Consider the following route configuration: * ``` - * [{ path: "/user", component: UserCmp }] + * [{ path: 'user/:name', component: UserCmp }] * ``` * * When linking to this `User` route, you can write: * * ``` - * link to user component + * * ``` * - * NSRouterLink expects the value to be an array of path segments, followed by the params - * for that level of routing. For instance `["/team", {teamId: 1}, "user", {userId: 2}]` - * means that we want to generate a link to `/team;teamId=1/user;userId=2`. + * You can use dynamic values to generate the link. + * For a dynamic link, pass an array of path segments, + * followed by the params for each segment. + * For example, `['/team', teamId, 'user', userName, {details: true}]` + * generates a link to `/team/11/user/bob;details=true`. + * + * Multiple static segments can be merged into one term and combined with + * dynamic segments. For example, `['/team/11/user', userName, {details: true}]` + * + * The input that you provide to the link is treated as a delta to the current + * URL. For instance, suppose the current URL is `/user/(box//aux:team)`. The + * link `` creates the URL + * `/user/(jim//aux:team)`. + * See {@link Router#createUrlTree} for more information. + * + * @usageNotes + * + * You can use absolute or relative paths in a link, set query parameters, + * control how parameters are handled, and keep a history of navigation states. + * + * ### Relative link paths * * The first segment name can be prepended with `/`, `./`, or `../`. - * If the segment begins with `/`, the router will look up the route from the root of the app. - * If the segment begins with `./`, or doesn"t begin with a slash, the router will - * instead look in the current component"s children for the route. - * And if the segment begins with `../`, the router will go up one level. + * * If the first segment begins with `/`, the router looks up the route from + * the root of the app. + * * If the first segment begins with `./`, or doesn't begin with a slash, the + * router looks in the children of the current activated route. + * * If the first segment begins with `../`, the router goes up one level in the + * route tree. + * + * ### Setting and handling query params and fragments + * + * The following link adds a query parameter and a fragment to the generated URL: + * + * ```html + * + * ``` + * + * By default, the directive constructs the new URL using the given query + * parameters. The example generates the link: `/user/bob?debug=true#education`. + * + * You can instruct the directive to handle query parameters differently + * by specifying the `queryParamsHandling` option in the link. + * Allowed values are: + * + * - `'merge'`: Merge the given `queryParams` into the current query params. + * - `'preserve'`: Preserve the current query params. + * + * For example: + * + * ```html + * + * ``` + * + * `queryParams`, `fragment`, `queryParamsHandling`, `preserveFragment`, and + * `relativeTo` cannot be used when the `nsRouterLink` input is a `UrlTree`. + * + * ### NativeScript-specific options + * + * NativeScript adds support for page transitions and history clearing: + * + * ```html + * + * ``` */ -@Directive({ - selector: '[nsRouterLink]', +@Directive({ + selector: '[nsRouterLink]', standalone: true, }) -export class NSRouterLink implements AfterViewInit { - // tslint:disable-line:directive-class-suffix - @Input() target: string; - @Input() queryParams: { [k: string]: any }; - @Input() fragment: string; - - @Input() queryParamsHandling: QueryParamsHandling; - @Input() preserveQueryParams: boolean; - @Input() preserveFragment: boolean; - @Input() skipLocationChange: boolean; - @Input() replaceUrl: boolean; +export class NSRouterLink implements OnChanges, OnDestroy { + private readonly ngZone = inject(NgZone); + private readonly router = inject(Router); + private readonly navigator = inject(RouterExtensions); + private readonly route = inject(ActivatedRoute); + private readonly el = inject(ElementRef); + + /** + * Passed to {@link Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * @see {@link UrlCreationOptions#queryParams} + * @see {@link Router#createUrlTree} + */ + @Input() set queryParams(value: { [k: string]: any } | null | undefined) { + this._queryParams.set(value); + } + get queryParams(): { [k: string]: any } | null | undefined { + return untracked(this._queryParams); + } + // Rather than trying deep equality checks or serialization, just allow urlTree to recompute + // whenever queryParams change (which will be rare). + private _queryParams = signal<{ [k: string]: any } | null | undefined>(undefined, { equal: () => false }); + + /** + * Passed to {@link Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * @see {@link UrlCreationOptions#fragment} + * @see {@link Router#createUrlTree} + */ + @Input() set fragment(value: string | undefined) { + this._fragment.set(value); + } + get fragment(): string | undefined { + return untracked(this._fragment); + } + private _fragment = signal(undefined); + + /** + * Passed to {@link Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * @see {@link UrlCreationOptions#queryParamsHandling} + * @see {@link Router#createUrlTree} + */ + @Input() set queryParamsHandling(value: QueryParamsHandling | null | undefined) { + this._queryParamsHandling.set(value); + } + get queryParamsHandling(): QueryParamsHandling | null | undefined { + return untracked(this._queryParamsHandling); + } + private _queryParamsHandling = signal(undefined); + + /** + * Passed to {@link Router#navigateByUrl} as part of the + * `NavigationBehaviorOptions`. + * @see {@link NavigationBehaviorOptions#state} + * @see {@link Router#navigateByUrl} + */ + @Input() set state(value: { [k: string]: any } | undefined) { + this._state.set(value); + } + get state(): { [k: string]: any } | undefined { + return untracked(this._state); + } + private _state = signal<{ [k: string]: any } | undefined>(undefined, { equal: () => false }); + + /** + * Passed to {@link Router#navigateByUrl} as part of the + * `NavigationBehaviorOptions`. + * @see {@link NavigationBehaviorOptions#info} + * @see {@link Router#navigateByUrl} + */ + @Input() set info(value: unknown) { + this._info.set(value); + } + get info(): unknown { + return untracked(this._info); + } + private _info = signal(undefined, { equal: () => false }); + + /** + * Passed to {@link Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * Specify a value here when you do not want to use the default value + * for `nsRouterLink`, which is the current activated route. + * Note that a value of `undefined` here will use the `nsRouterLink` default. + * @see {@link UrlCreationOptions#relativeTo} + * @see {@link Router#createUrlTree} + */ + @Input() set relativeTo(value: ActivatedRoute | null | undefined) { + this._relativeTo.set(value); + } + get relativeTo(): ActivatedRoute | null | undefined { + return untracked(this._relativeTo); + } + private _relativeTo = signal(undefined); + + /** + * Passed to {@link Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * @see {@link UrlCreationOptions#preserveFragment} + * @see {@link Router#createUrlTree} + */ + @Input({ transform: booleanAttribute }) set preserveFragment(value: boolean) { + this._preserveFragment.set(value); + } + get preserveFragment(): boolean { + return untracked(this._preserveFragment); + } + private _preserveFragment = signal(false); + /** + * Passed to {@link Router#navigateByUrl} as part of the + * `NavigationBehaviorOptions`. + * @see {@link NavigationBehaviorOptions#skipLocationChange} + * @see {@link Router#navigateByUrl} + */ + @Input({ transform: booleanAttribute }) set skipLocationChange(value: boolean) { + this._skipLocationChange.set(value); + } + get skipLocationChange(): boolean { + return untracked(this._skipLocationChange); + } + private _skipLocationChange = signal(false); + + /** + * Passed to {@link Router#navigateByUrl} as part of the + * `NavigationBehaviorOptions`. + * @see {@link NavigationBehaviorOptions#replaceUrl} + * @see {@link Router#navigateByUrl} + */ + @Input({ transform: booleanAttribute }) set replaceUrl(value: boolean) { + this._replaceUrl.set(value); + } + get replaceUrl(): boolean { + return untracked(this._replaceUrl); + } + private _replaceUrl = signal(false); + + // NativeScript-specific inputs @Input() clearHistory: boolean; @Input() pageTransition: boolean | string | NavigationTransition = true; @Input() pageTransitionDuration; - private commands: any[] = []; + /** @internal */ + onChanges = new Subject(); + + private routerLinkInput = signal(null); - constructor(private ngZone: NgZone, private router: Router, private navigator: RouterExtensions, private route: ActivatedRoute, private el: ElementRef) {} + private tapHandler: () => void; - ngAfterViewInit() { - this.el.nativeElement.on('tap', () => { + constructor() { + // NativeScript uses tap events instead of click events + this.tapHandler = () => { this.ngZone.run(() => { this.onTap(); }); - }); + }; + this.el.nativeElement.on('tap', this.tapHandler); } - @Input('nsRouterLink') - set params(data: any[] | string) { - if (Array.isArray(data)) { - this.commands = data; + /** + * Commands to pass to {@link Router#createUrlTree} or a `UrlTree`. + * - **array**: commands to pass to {@link Router#createUrlTree}. + * - **string**: shorthand for array of commands with just the string, i.e. `['/route']` + * - **UrlTree**: a `UrlTree` for this link rather than creating one from + * the commands and other inputs that correspond to properties of `UrlCreationOptions`. + * - **null|undefined**: effectively disables the `nsRouterLink` + * @see {@link Router#createUrlTree} + */ + @Input() + set nsRouterLink(commandsOrUrlTree: readonly any[] | string | UrlTree | null | undefined) { + if (commandsOrUrlTree == null) { + this.routerLinkInput.set(null); } else { - this.commands = [data]; + if (isUrlTree(commandsOrUrlTree)) { + this.routerLinkInput.set(commandsOrUrlTree); + } else { + this.routerLinkInput.set(Array.isArray(commandsOrUrlTree) ? commandsOrUrlTree : [commandsOrUrlTree]); + } } } - onTap() { + // This is subscribed to by `NSRouterLinkActive` so that it knows to update + // when there are changes to the RouterLinks it's tracking. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ngOnChanges(_changes?: SimpleChanges): void { + this.onChanges.next(this); + } + + ngOnDestroy(): void { + this.el.nativeElement.off('tap', this.tapHandler); + } + + /** @internal */ + _urlTree = computed(() => { + const routerLinkInput = this.routerLinkInput(); + if (routerLinkInput === null || !this.router.createUrlTree) { + return null; + } else if (isUrlTree(routerLinkInput)) { + return routerLinkInput; + } + return this.router.createUrlTree(routerLinkInput, { + // If the `relativeTo` input is not defined, we want to use `this.route` + // by default. + // Otherwise, we should use the value provided by the user in the input. + relativeTo: this._relativeTo() !== undefined ? this._relativeTo() : this.route, + queryParams: this._queryParams(), + fragment: this._fragment(), + queryParamsHandling: this._queryParamsHandling(), + preserveFragment: this._preserveFragment(), + }); + }); + + get urlTree(): UrlTree | null { + return untracked(this._urlTree); + } + + // NativeScript tap handler - replaces click handler from upstream + private onTap() { + const urlTree = this.urlTree; + + if (urlTree === null) { + return; + } + if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.routerLog(`nsRouterLink.tapped: ${this.commands} ` + `clear: ${this.clearHistory} ` + `transition: ${JSON.stringify(this.pageTransition)} ` + `duration: ${this.pageTransitionDuration}`); + NativeScriptDebug.routerLog(`nsRouterLink.tapped: ${this.routerLinkInput()} ` + `clear: ${this.clearHistory} ` + `transition: ${JSON.stringify(this.pageTransition)} ` + `duration: ${this.pageTransitionDuration}`); } const extras = this.getExtras(); - // this.navigator.navigateByUrl(this.urlTree, extras); - this.navigator.navigate(this.commands, { + this.navigator.navigate(this.routerLinkInput() as any[], { ...extras, - relativeTo: this.route, - queryParams: this.queryParams, - fragment: this.fragment, - queryParamsHandling: this.queryParamsHandling, - preserveFragment: attrBoolValue(this.preserveFragment), + // If the `relativeTo` input is not defined, we want to use `this.route` + // by default. + relativeTo: this._relativeTo() !== undefined ? this._relativeTo() : this.route, + queryParams: this._queryParams(), + fragment: this._fragment(), + queryParamsHandling: this._queryParamsHandling(), + preserveFragment: this._preserveFragment(), }); } private getExtras(): NavigationExtras & NavigationOptions { const transition = this.getTransition(); return { - skipLocationChange: attrBoolValue(this.skipLocationChange), - replaceUrl: attrBoolValue(this.replaceUrl), + skipLocationChange: this.skipLocationChange, + replaceUrl: this.replaceUrl, + state: this.state, + info: this.info, + // NativeScript-specific navigation options clearHistory: this.convertClearHistory(this.clearHistory), animated: transition.animated, transition: transition.transition, }; } - get urlTree(): UrlTree { - const urlTree = this.router.createUrlTree(this.commands, { - relativeTo: this.route, - queryParams: this.queryParams, - fragment: this.fragment, - queryParamsHandling: this.queryParamsHandling, - preserveFragment: attrBoolValue(this.preserveFragment), - }); - - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.routerLog(`nsRouterLink urlTree created: ${urlTree}`); - } - - return urlTree; - } - private convertClearHistory(value: boolean | string): boolean { return value === true || value === 'true'; } + // NativeScript-specific page transition handling private getTransition(): { animated: boolean; transition?: NavigationTransition } { let transition: NavigationTransition; let animated: boolean; @@ -152,7 +388,3 @@ export class NSRouterLink implements AfterViewInit { return { animated, transition }; } } - -function attrBoolValue(s: any): boolean { - return s === '' || !!s; -} diff --git a/packages/angular/src/lib/legacy/router/private-imports/router-url-tree.ts b/packages/angular/src/lib/legacy/router/private-imports/router-url-tree.ts deleted file mode 100644 index 23dcbe2..0000000 --- a/packages/angular/src/lib/legacy/router/private-imports/router-url-tree.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* tslint:disable:forin */ -// Copied unexported functions from @angular/router/src/url_tree -import { UrlTree, UrlSegment, PRIMARY_OUTLET } from '@angular/router'; -// UrlSegmentGroup not exported, just use any. -type UrlSegmentGroup = any; - -export function containsTree(container: UrlTree, containee: UrlTree, exact: boolean): boolean { - if (exact) { - return equalSegmentGroups(container.root, containee.root); - } else { - return containsSegmentGroup(container.root, containee.root); - } -} - -function equalSegmentGroups(container: UrlSegmentGroup, containee: UrlSegmentGroup): boolean { - if (!equalPath(container.segments, containee.segments)) { - return false; - } - if (container.numberOfChildren !== containee.numberOfChildren) { - return false; - } - for (const c in containee.children) { - if (!container.children[c]) { - return false; - } - if (!equalSegmentGroups(container.children[c], containee.children[c])) { - return false; - } - } - return true; -} - -function containsSegmentGroup(container: UrlSegmentGroup, containee: UrlSegmentGroup): boolean { - return containsSegmentGroupHelper(container, containee, containee.segments); -} - -function containsSegmentGroupHelper(container: UrlSegmentGroup, containee: UrlSegmentGroup, containeePaths: UrlSegment[]): boolean { - if (container.segments.length > containeePaths.length) { - const current = container.segments.slice(0, containeePaths.length); - if (!equalPath(current, containeePaths)) { - return false; - } - if (containee.hasChildren()) { - return false; - } - return true; - } else if (container.segments.length === containeePaths.length) { - if (!equalPath(container.segments, containeePaths)) { - return false; - } - for (const c in containee.children) { - if (!container.children[c]) { - return false; - } - if (!containsSegmentGroup(container.children[c], containee.children[c])) { - return false; - } - } - return true; - } else { - const current = containeePaths.slice(0, container.segments.length); - const next = containeePaths.slice(container.segments.length); - if (!equalPath(container.segments, current)) { - return false; - } - if (!container.children[PRIMARY_OUTLET]) { - return false; - } - return containsSegmentGroupHelper(container.children[PRIMARY_OUTLET], containee, next); - } -} - -export function equalPath(a: UrlSegment[], b: UrlSegment[]): boolean { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; ++i) { - if (a[i].path !== b[i].path) { - return false; - } - } - return true; -} From 13c0cdd82295876cdf2df73de6ac90eee7320c36 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 22 May 2026 02:43:27 -0300 Subject: [PATCH 2/2] fix: properly handle urltree navigation --- .../src/lib/legacy/router/ns-router-link.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/angular/src/lib/legacy/router/ns-router-link.ts b/packages/angular/src/lib/legacy/router/ns-router-link.ts index 86b036b..28fa173 100644 --- a/packages/angular/src/lib/legacy/router/ns-router-link.ts +++ b/packages/angular/src/lib/legacy/router/ns-router-link.ts @@ -327,16 +327,24 @@ export class NSRouterLink implements OnChanges, OnDestroy { } const extras = this.getExtras(); - this.navigator.navigate(this.routerLinkInput() as any[], { - ...extras, - // If the `relativeTo` input is not defined, we want to use `this.route` - // by default. - relativeTo: this._relativeTo() !== undefined ? this._relativeTo() : this.route, - queryParams: this._queryParams(), - fragment: this._fragment(), - queryParamsHandling: this._queryParamsHandling(), - preserveFragment: this._preserveFragment(), - }); + const routerLinkInput = this.routerLinkInput(); + + // When the input is a UrlTree, use navigateByUrl directly. + // Otherwise, use navigate with commands array. + if (isUrlTree(routerLinkInput)) { + this.navigator.navigateByUrl(urlTree, extras); + } else { + this.navigator.navigate(routerLinkInput as any[], { + ...extras, + // If the `relativeTo` input is not defined, we want to use `this.route` + // by default. + relativeTo: this._relativeTo() !== undefined ? this._relativeTo() : this.route, + queryParams: this._queryParams(), + fragment: this._fragment(), + queryParamsHandling: this._queryParamsHandling(), + preserveFragment: this._preserveFragment(), + }); + } } private getExtras(): NavigationExtras & NavigationOptions {