diff --git a/packages/angular/src/directives/navigation/ion-router-outlet.ts b/packages/angular/src/directives/navigation/ion-router-outlet.ts index f94eaed05e..c54eaa17f3 100644 --- a/packages/angular/src/directives/navigation/ion-router-outlet.ts +++ b/packages/angular/src/directives/navigation/ion-router-outlet.ts @@ -15,10 +15,14 @@ import { Output, SkipSelf, EnvironmentInjector, + Input, + InjectionToken, + Injectable, + reflectComponentType, } from '@angular/core'; import { OutletContext, Router, ActivatedRoute, ChildrenOutletContexts, PRIMARY_OUTLET, Data } from '@angular/router'; import { componentOnReady } from '@ionic/core'; -import { Observable, BehaviorSubject } from 'rxjs'; +import { Observable, BehaviorSubject, Subscription, combineLatest, of } from 'rxjs'; import { distinctUntilChanged, filter, switchMap } from 'rxjs/operators'; import { AnimationBuilder } from '../../ionic-core'; @@ -39,22 +43,28 @@ import { RouteView, getUrl } from './stack-utils'; // eslint-disable-next-line @angular-eslint/directive-class-suffix export class IonRouterOutlet implements OnDestroy, OnInit { nativeEl: HTMLIonRouterOutletElement; - - private activated: ComponentRef | null = null; activatedView: RouteView | null = null; + tabsPrefix: string | undefined; - private _activatedRoute: ActivatedRoute | null = null; private _swipeGesture?: boolean; - private name: string; private stackCtrl: StackController; // Maintain map of activated route proxies for each component instance private proxyMap = new WeakMap(); - // Keep the latest activated route in a subject for the proxy routes to switch map to private currentActivatedRoute$ = new BehaviorSubject<{ component: any; activatedRoute: ActivatedRoute } | null>(null); - tabsPrefix: string | undefined; + private activated: ComponentRef | null = null; + /** @internal */ + get activatedComponentRef(): ComponentRef | null { + return this.activated; + } + private _activatedRoute: ActivatedRoute | null = null; + + /** + * The name of the outlet + */ + @Input() name = PRIMARY_OUTLET; @Output() stackEvents = new EventEmitter(); // eslint-disable-next-line @angular-eslint/no-output-rename @@ -65,6 +75,9 @@ export class IonRouterOutlet implements OnDestroy, OnInit { private parentContexts = inject(ChildrenOutletContexts); private location = inject(ViewContainerRef); private environmentInjector = inject(EnvironmentInjector); + private inputBinder = inject(INPUT_BINDER, { optional: true }); + /** @nodoc */ + readonly supportsBindingToComponentInputs = true; // Ionic providers private config = inject(Config); @@ -109,6 +122,7 @@ export class IonRouterOutlet implements OnDestroy, OnInit { ngOnDestroy(): void { this.stackCtrl.destroy(); + this.inputBinder?.unsubscribeFromRouteData(this); } getContext(): OutletContext | null { @@ -275,6 +289,8 @@ export class IonRouterOutlet implements OnDestroy, OnInit { this.currentActivatedRoute$.next({ component: cmpRef.instance, activatedRoute }); } + this.inputBinder?.bindActivatedRouteToOutletComponent(this); + this.activatedView = enteringView; /** @@ -415,3 +431,79 @@ class OutletInjector implements Injector { return this.parent.get(token, notFoundValue); } } + +// TODO: FW-4785 - Remove this once Angular 15 support is dropped +export const INPUT_BINDER = new InjectionToken(''); + +/** + * Injectable used as a tree-shakable provider for opting in to binding router data to component + * inputs. + * + * The RouterOutlet registers itself with this service when an `ActivatedRoute` is attached or + * activated. When this happens, the service subscribes to the `ActivatedRoute` observables (params, + * queryParams, data) and sets the inputs of the component using `ComponentRef.setInput`. + * Importantly, when an input does not have an item in the route data with a matching key, this + * input is set to `undefined`. If it were not done this way, the previous information would be + * retained if the data got removed from the route (i.e. if a query parameter is removed). + * + * The `RouterOutlet` should unregister itself when destroyed via `unsubscribeFromRouteData` so that + * the subscriptions are cleaned up. + */ +@Injectable() +export class RoutedComponentInputBinder { + private outletDataSubscriptions = new Map(); + + bindActivatedRouteToOutletComponent(outlet: IonRouterOutlet): void { + this.unsubscribeFromRouteData(outlet); + this.subscribeToRouteData(outlet); + } + + unsubscribeFromRouteData(outlet: IonRouterOutlet): void { + this.outletDataSubscriptions.get(outlet)?.unsubscribe(); + this.outletDataSubscriptions.delete(outlet); + } + + private subscribeToRouteData(outlet: IonRouterOutlet) { + const { activatedRoute } = outlet; + const dataSubscription = combineLatest([activatedRoute.queryParams, activatedRoute.params, activatedRoute.data]) + .pipe( + switchMap(([queryParams, params, data], index) => { + data = { ...queryParams, ...params, ...data }; + // Get the first result from the data subscription synchronously so it's available to + // the component as soon as possible (and doesn't require a second change detection). + if (index === 0) { + return of(data); + } + // Promise.resolve is used to avoid synchronously writing the wrong data when + // two of the Observables in the `combineLatest` stream emit one after + // another. + return Promise.resolve(data); + }) + ) + .subscribe((data) => { + // Outlet may have been deactivated or changed names to be associated with a different + // route + if ( + !outlet.isActivated || + !outlet.activatedComponentRef || + outlet.activatedRoute !== activatedRoute || + activatedRoute.component === null + ) { + this.unsubscribeFromRouteData(outlet); + return; + } + + const mirror = reflectComponentType(activatedRoute.component); + if (!mirror) { + this.unsubscribeFromRouteData(outlet); + return; + } + + for (const { templateName } of mirror.inputs) { + outlet.activatedComponentRef.setInput(templateName, data[templateName]); + } + }); + + this.outletDataSubscriptions.set(outlet, dataSubscription); + } +} diff --git a/packages/angular/src/ionic-module.ts b/packages/angular/src/ionic-module.ts index 2db4f26d47..32ad4eaa4d 100644 --- a/packages/angular/src/ionic-module.ts +++ b/packages/angular/src/ionic-module.ts @@ -1,5 +1,6 @@ import { CommonModule, DOCUMENT } from '@angular/common'; import { ModuleWithProviders, APP_INITIALIZER, NgModule, NgZone } from '@angular/core'; +import { Router } from '@angular/router'; import { IonicConfig } from '@ionic/core'; import { appInitialize } from './app-initialize'; @@ -11,7 +12,7 @@ import { TextValueAccessorDirective, } from './directives/control-value-accessors'; import { IonBackButtonDelegateDirective } from './directives/navigation/ion-back-button'; -import { IonRouterOutlet } from './directives/navigation/ion-router-outlet'; +import { INPUT_BINDER, IonRouterOutlet, RoutedComponentInputBinder } from './directives/navigation/ion-router-outlet'; import { IonTabs } from './directives/navigation/ion-tabs'; import { NavDelegate } from './directives/navigation/nav-delegate'; import { @@ -71,7 +72,23 @@ export class IonicModule { multi: true, deps: [ConfigToken, DOCUMENT, NgZone], }, + { + provide: INPUT_BINDER, + useFactory: componentInputBindingFactory, + deps: [Router], + }, ], }; } } + +function componentInputBindingFactory(router?: Router) { + /** + * We cast the router to any here, since the componentInputBindingEnabled + * property is not available until Angular v16. + */ + if ((router as any)?.componentInputBindingEnabled) { + return new RoutedComponentInputBinder(); + } + return null; +} diff --git a/packages/angular/src/util/ionic-router-reuse-strategy.ts b/packages/angular/src/util/ionic-router-reuse-strategy.ts index 62454bfd8b..9176060949 100644 --- a/packages/angular/src/util/ionic-router-reuse-strategy.ts +++ b/packages/angular/src/util/ionic-router-reuse-strategy.ts @@ -1,30 +1,43 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'; +/** + * Provides a way to customize when activated routes get reused. + */ export class IonicRouteStrategy implements RouteReuseStrategy { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /** + * Whether the given route should detach for later reuse. + */ shouldDetach(_route: ActivatedRouteSnapshot): boolean { return false; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /** + * Returns `false`, meaning the route (and its subtree) is never reattached + */ shouldAttach(_route: ActivatedRouteSnapshot): boolean { return false; } - store( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _route: ActivatedRouteSnapshot, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _detachedTree: DetachedRouteHandle - ): void { + /** + * A no-op; the route is never stored since this strategy never detaches routes for later re-use. + */ + store(_route: ActivatedRouteSnapshot, _detachedTree: DetachedRouteHandle): void { return; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /** + * Returns `null` because this strategy does not store routes for later re-use. + */ retrieve(_route: ActivatedRouteSnapshot): DetachedRouteHandle | null { return null; } + /** + * Determines if a route should be reused. + * This strategy returns `true` when the future route config and + * current route config are identical and all route parameters are identical. + */ shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { if (future.routeConfig !== curr.routeConfig) { return false; diff --git a/packages/angular/test/apps/ng16/e2e/src/bind-component-inputs.spec.ts b/packages/angular/test/apps/ng16/e2e/src/bind-component-inputs.spec.ts new file mode 100644 index 0000000000..9f60cb0f8e --- /dev/null +++ b/packages/angular/test/apps/ng16/e2e/src/bind-component-inputs.spec.ts @@ -0,0 +1,8 @@ +it("binding route data to inputs should work", () => { + cy.visit('/version-test/bind-route/test?query=test'); + + cy.get('#route-params').contains('test'); + cy.get('#query-params').contains('test'); + cy.get('#data').contains('data:bindToComponentInputs'); + cy.get('#resolve').contains('resolve:bindToComponentInputs'); +}); diff --git a/packages/angular/test/apps/ng16/src/app/app-routing.module.ts b/packages/angular/test/apps/ng16/src/app/app-routing.module.ts new file mode 100644 index 0000000000..3cf2c268f8 --- /dev/null +++ b/packages/angular/test/apps/ng16/src/app/app-routing.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { routes } from './app.routes'; + +@NgModule({ + imports: [RouterModule.forRoot(routes, { bindToComponentInputs: true })], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/packages/angular/test/apps/ng16/src/app/version-test/bind-component-inputs/bind-component-inputs.component.ts b/packages/angular/test/apps/ng16/src/app/version-test/bind-component-inputs/bind-component-inputs.component.ts new file mode 100644 index 0000000000..5eeed55d22 --- /dev/null +++ b/packages/angular/test/apps/ng16/src/app/version-test/bind-component-inputs/bind-component-inputs.component.ts @@ -0,0 +1,41 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { IonicModule } from '@ionic/angular'; + +@Component({ + selector: 'app-bind-route', + template: ` + + + bindToComponentInputs + + + +
+

Bind Route

+

Route params: id: {{id}}

+

Query params: query: {{query}}

+

Data: title: {{title}}

+

Resolve: name: {{name}}

+
+
+ `, + standalone: true, + imports: [IonicModule] +}) +export class BindComponentInputsComponent implements OnInit { + + @Input() id?: string; // path parameter + @Input() query?: string; // query parameter + @Input() title?: string; // data property + @Input() name?: string; // resolve property + + ngOnInit(): void { + console.log('BindComponentInputsComponent.ngOnInit', { + id: this.id, + query: this.query, + title: this.title, + name: this.name + }); + } + +} diff --git a/packages/angular/test/apps/ng16/src/app/version-test/version-test-routing.module.ts b/packages/angular/test/apps/ng16/src/app/version-test/version-test-routing.module.ts index 7cd8857683..0aedfecabe 100644 --- a/packages/angular/test/apps/ng16/src/app/version-test/version-test-routing.module.ts +++ b/packages/angular/test/apps/ng16/src/app/version-test/version-test-routing.module.ts @@ -6,7 +6,17 @@ import { RouterModule } from "@angular/router"; RouterModule.forChild([ { path: 'modal-nav-params', - loadComponent: () => import('./modal-nav-params/modal-nav-params.component').then(m => m.ModalNavParamsComponent) + loadComponent: () => import('./modal-nav-params/modal-nav-params.component').then(m => m.ModalNavParamsComponent), + }, + { + path: 'bind-route/:id', + data: { + title: 'data:bindToComponentInputs' + }, + resolve: { + name: () => 'resolve:bindToComponentInputs' + }, + loadComponent: () => import('./bind-component-inputs/bind-component-inputs.component').then(c => c.BindComponentInputsComponent) } ]) ], diff --git a/packages/angular/test/base/src/app/app-routing.module.ts b/packages/angular/test/base/src/app/app-routing.module.ts index 183d235e94..0feced8896 100644 --- a/packages/angular/test/base/src/app/app-routing.module.ts +++ b/packages/angular/test/base/src/app/app-routing.module.ts @@ -1,85 +1,7 @@ import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; -import { InputsComponent } from './inputs/inputs.component'; -import { ModalComponent } from './modal/modal.component'; -import { RouterLinkComponent } from './router-link/router-link.component'; -import { RouterLinkPageComponent } from './router-link-page/router-link-page.component'; -import { RouterLinkPage2Component } from './router-link-page2/router-link-page2.component'; -import { RouterLinkPage3Component } from './router-link-page3/router-link-page3.component'; -import { HomePageComponent } from './home-page/home-page.component'; -import { NestedOutletComponent } from './nested-outlet/nested-outlet.component'; -import { NestedOutletPageComponent } from './nested-outlet-page/nested-outlet-page.component'; -import { NestedOutletPage2Component } from './nested-outlet-page2/nested-outlet-page2.component'; -import { ViewChildComponent } from './view-child/view-child.component'; -import { ProvidersComponent } from './providers/providers.component'; -import { FormComponent } from './form/form.component'; -import { NavigationPage1Component } from './navigation-page1/navigation-page1.component'; -import { NavigationPage2Component } from './navigation-page2/navigation-page2.component'; -import { NavigationPage3Component } from './navigation-page3/navigation-page3.component'; -import { AlertComponent } from './alert/alert.component'; -import { AccordionComponent } from './accordion/accordion.component'; +import { RouterModule } from '@angular/router'; -const routes: Routes = [ - { path: '', component: HomePageComponent }, - { path: 'version-test', loadChildren: () => import('./version-test').then(m => m.VersionTestModule) }, - { path: 'accordions', component: AccordionComponent }, - { path: 'alerts', component: AlertComponent }, - { path: 'inputs', component: InputsComponent }, - { path: 'textarea', loadChildren: () => import('./textarea/textarea.module').then(m => m.TextareaModule) }, - { path: 'searchbar', loadChildren: () => import('./searchbar/searchbar.module').then(m => m.SearchbarModule) }, - { path: 'form', component: FormComponent }, - { path: 'modals', component: ModalComponent }, - { path: 'modal-inline', loadChildren: () => import('./modal-inline').then(m => m.ModalInlineModule) }, - { path: 'view-child', component: ViewChildComponent }, - { path: 'keep-contents-mounted', loadChildren: () => import('./keep-contents-mounted').then(m => m.OverlayAutoMountModule) }, - { path: 'overlays-inline', loadChildren: () => import('./overlays-inline').then(m => m.OverlaysInlineModule) }, - { path: 'popover-inline', loadChildren: () => import('./popover-inline').then(m => m.PopoverInlineModule) }, - { path: 'providers', component: ProvidersComponent }, - { path: 'router-link', component: RouterLinkComponent }, - { path: 'router-link-page', component: RouterLinkPageComponent }, - { path: 'router-link-page2/:id', component: RouterLinkPage2Component }, - { path: 'router-link-page3', component: RouterLinkPage3Component }, - { path: 'standalone', loadComponent: () => import('./standalone/standalone.component').then(c => c.StandaloneComponent) }, - { path: 'tabs', redirectTo: '/tabs/account', pathMatch: 'full' }, - { - path: 'navigation', - children: [ - { path: 'page1', component: NavigationPage1Component }, - { path: 'page2', component: NavigationPage2Component }, - { path: 'page3', component: NavigationPage3Component } - ] - }, - { - path: 'tabs', - loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule) - }, - { - path: 'tabs-global', - loadChildren: () => import('./tabs-global/tabs-global.module').then(m => m.TabsGlobalModule) - }, - { - path: 'tabs-slots', - loadComponent: () => import('./tabs-slots.component').then(c => c.TabsSlotsComponent) - }, - { - path: 'nested-outlet', - component: NestedOutletComponent, - children: [ - { - path: 'page', - component: NestedOutletPageComponent - }, - { - path: 'page2', - component: NestedOutletPage2Component - } - ] - }, - { - path: 'form-controls/range', - loadChildren: () => import('./form-controls/range/range.module').then(m => m.RangeModule) - } -]; +import { routes } from './app.routes'; @NgModule({ imports: [RouterModule.forRoot(routes)], diff --git a/packages/angular/test/base/src/app/app.routes.ts b/packages/angular/test/base/src/app/app.routes.ts new file mode 100644 index 0000000000..1d709b16bb --- /dev/null +++ b/packages/angular/test/base/src/app/app.routes.ts @@ -0,0 +1,82 @@ +import { Routes } from '@angular/router'; +import { InputsComponent } from './inputs/inputs.component'; +import { ModalComponent } from './modal/modal.component'; +import { RouterLinkComponent } from './router-link/router-link.component'; +import { RouterLinkPageComponent } from './router-link-page/router-link-page.component'; +import { RouterLinkPage2Component } from './router-link-page2/router-link-page2.component'; +import { RouterLinkPage3Component } from './router-link-page3/router-link-page3.component'; +import { HomePageComponent } from './home-page/home-page.component'; +import { NestedOutletComponent } from './nested-outlet/nested-outlet.component'; +import { NestedOutletPageComponent } from './nested-outlet-page/nested-outlet-page.component'; +import { NestedOutletPage2Component } from './nested-outlet-page2/nested-outlet-page2.component'; +import { ViewChildComponent } from './view-child/view-child.component'; +import { ProvidersComponent } from './providers/providers.component'; +import { FormComponent } from './form/form.component'; +import { NavigationPage1Component } from './navigation-page1/navigation-page1.component'; +import { NavigationPage2Component } from './navigation-page2/navigation-page2.component'; +import { NavigationPage3Component } from './navigation-page3/navigation-page3.component'; +import { AlertComponent } from './alert/alert.component'; +import { AccordionComponent } from './accordion/accordion.component'; + +export const routes: Routes = [ + { path: '', component: HomePageComponent }, + { path: 'version-test', loadChildren: () => import('./version-test').then(m => m.VersionTestModule) }, + { path: 'accordions', component: AccordionComponent }, + { path: 'alerts', component: AlertComponent }, + { path: 'inputs', component: InputsComponent }, + { path: 'textarea', loadChildren: () => import('./textarea/textarea.module').then(m => m.TextareaModule) }, + { path: 'searchbar', loadChildren: () => import('./searchbar/searchbar.module').then(m => m.SearchbarModule) }, + { path: 'form', component: FormComponent }, + { path: 'modals', component: ModalComponent }, + { path: 'modal-inline', loadChildren: () => import('./modal-inline').then(m => m.ModalInlineModule) }, + { path: 'view-child', component: ViewChildComponent }, + { path: 'keep-contents-mounted', loadChildren: () => import('./keep-contents-mounted').then(m => m.OverlayAutoMountModule) }, + { path: 'overlays-inline', loadChildren: () => import('./overlays-inline').then(m => m.OverlaysInlineModule) }, + { path: 'popover-inline', loadChildren: () => import('./popover-inline').then(m => m.PopoverInlineModule) }, + { path: 'providers', component: ProvidersComponent }, + { path: 'router-link', component: RouterLinkComponent }, + { path: 'router-link-page', component: RouterLinkPageComponent }, + { path: 'router-link-page2/:id', component: RouterLinkPage2Component }, + { path: 'router-link-page3', component: RouterLinkPage3Component }, + { path: 'standalone', loadComponent: () => import('./standalone/standalone.component').then(c => c.StandaloneComponent) }, + { path: 'tabs', redirectTo: '/tabs/account', pathMatch: 'full' }, + { + path: 'navigation', + children: [ + { path: 'page1', component: NavigationPage1Component }, + { path: 'page2', component: NavigationPage2Component }, + { path: 'page3', component: NavigationPage3Component } + ] + }, + { + path: 'tabs', + loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule) + }, + { + path: 'tabs-global', + loadChildren: () => import('./tabs-global/tabs-global.module').then(m => m.TabsGlobalModule) + }, + { + path: 'tabs-slots', + loadComponent: () => import('./tabs-slots.component').then(c => c.TabsSlotsComponent) + }, + { + path: 'nested-outlet', + component: NestedOutletComponent, + children: [ + { + path: 'page', + component: NestedOutletPageComponent + }, + { + path: 'page2', + component: NestedOutletPage2Component + } + ] + }, + { + path: 'form-controls/range', + loadChildren: () => import('./form-controls/range/range.module').then(m => m.RangeModule) + } +]; +