mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-15 17:42:15 +08:00
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:
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user