feat(angular): support binding routing data to component inputs (#27694)

Issue number: Resolves #27476

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Ionic Angular application on Angular v16 cannot use the
[`bindToComponentInputs`](https://angular.io/api/router/ExtraOptions#bindToComponentInputs)
feature to assign route parameters, query parameters, route data and
route resolve data to component inputs.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Ionic Angular developers can use the option on `RouterModule.forRoot`
to enable the Angular feature for binding the route snapshot data to the
component inputs.

**Modules**
```ts
@NgModule({
  imports: [
    RouterModule.forRoot([/* your routes */], { 
      bindToComponentInputs: true // <-- enable this feature
    })
  ]
})
export class AppModule { }
```

**Standalone**

```ts
import { withComponentInputBinding } from '@angular/router';

bootstrapApplication(App, {
  providers: [
    provideRouter(routes, 
      //... other features
      withComponentInputBinding() // <-- enable this feature
    )
  ],
});
```

With this feature enabled, developers can bind route parameters, query
parameters, route data and the returned value from a resolver to input
bindings on their component.

For example, with a route configuration of:
```ts
RouterModule.forChild([
  {
    path: ':id',
    data: {
      title: 'Hello world'
    },
    resolve: {
      name: () => 'Resolved name'
    },
    loadComponent: () => import('./example-component/example.component').then(c => c.ExampleComponent)
  }
])
```
and a component configuration of:

```ts
@Component({ }) 
export class ExampleComponent {
  @Input() id?: string;
  @Input() title?: string;
  @Input() name?: string;
  @Input() query?: string;
}
```

Navigating to the component with a url of: `/2?query=searchphrase`

The following would occur:
- `id` would return `2`
- `title` would return `Hello world`
- `name` would return `Resolved name`
- `query` would return `searchphrase`

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

This PR will need to be targeted to a minor release once a design doc is
approved by the team.

Dev-build: `7.1.3-dev.11689276547.129acb40`
This commit is contained in:
Sean Perkins
2023-07-18 16:26:37 -04:00
committed by GitHub
parent 71310372c9
commit 90f41243d9
9 changed files with 293 additions and 98 deletions

View File

@ -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<any> | 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<any, ActivatedRoute>();
// 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<any> | null = null;
/** @internal */
get activatedComponentRef(): ComponentRef<any> | null {
return this.activated;
}
private _activatedRoute: ActivatedRoute | null = null;
/**
* The name of the outlet
*/
@Input() name = PRIMARY_OUTLET;
@Output() stackEvents = new EventEmitter<any>();
// 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<RoutedComponentInputBinder>('');
/**
* 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<IonRouterOutlet, Subscription>();
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);
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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');
});

View File

@ -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 { }

View File

@ -0,0 +1,41 @@
import { Component, Input, OnInit } from "@angular/core";
import { IonicModule } from '@ionic/angular';
@Component({
selector: 'app-bind-route',
template: `
<ion-header>
<ion-toolbar>
<ion-title>bindToComponentInputs</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div>
<h3>Bind Route</h3>
<p id="route-params">Route params: id: {{id}}</p>
<p id="query-params">Query params: query: {{query}}</p>
<p id="data">Data: title: {{title}}</p>
<p id="resolve">Resolve: name: {{name}}</p>
</div>
</ion-content>
`,
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
});
}
}

View File

@ -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)
}
])
],

View File

@ -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)],

View File

@ -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)
}
];