Skip to content

Commit f982a3f

Browse files
atscottdylhunn
authored andcommitted
feat(router): Opt-in for binding Router information to component inputs (#49633)
Adds ability for `RouterOutlet` to bind `Router` information to the routed component's inputs. This commit also exposes some helpers for implementers of custom outlets to do their own input binding if desired. Resolves #18967 PR Close #49633
1 parent f19319e commit f982a3f

File tree

11 files changed

+354
-21
lines changed

11 files changed

+354
-21
lines changed

goldens/public-api/router/index.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export const enum EventType {
293293

294294
// @public
295295
export interface ExtraOptions extends InMemoryScrollingOptions, RouterConfigOptions {
296+
bindToComponentInputs?: boolean;
296297
enableTracing?: boolean;
297298
// @deprecated
298299
errorHandler?: (error: any) => any;
@@ -688,6 +689,7 @@ export class Router {
688689
constructor();
689690
// @deprecated
690691
canceledNavigationResolution: 'replace' | 'computed';
692+
readonly componentInputBindingEnabled: boolean;
691693
// (undocumented)
692694
config: Routes;
693695
createUrlTree(commands: any[], navigationExtras?: UrlCreationOptions): UrlTree;
@@ -780,7 +782,7 @@ export interface RouterFeature<FeatureKind extends RouterFeatureKind> {
780782
}
781783

782784
// @public
783-
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature;
785+
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature;
784786

785787
// @public
786788
export type RouterHashLocationFeature = RouterFeature<RouterFeatureKind.RouterHashLocationFeature>;
@@ -892,6 +894,8 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
892894
// (undocumented)
893895
ngOnInit(): void;
894896
// (undocumented)
897+
readonly supportsBindingToComponentInputs = true;
898+
// (undocumented)
895899
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterOutlet, "router-outlet", ["outlet"], { "name": { "alias": "name"; "required": false; }; }, { "activateEvents": "activate"; "deactivateEvents": "deactivate"; "attachEvents": "attach"; "detachEvents": "detach"; }, never, never, true, never>;
896900
// (undocumented)
897901
static ɵfac: i0.ɵɵFactoryDeclaration<RouterOutlet, never>;
@@ -911,6 +915,7 @@ export interface RouterOutletContract {
911915
detach(): ComponentRef<unknown>;
912916
detachEvents?: EventEmitter<unknown>;
913917
isActivated: boolean;
918+
readonly supportsBindingToComponentInputs?: true;
914919
}
915920

916921
// @public
@@ -1092,6 +1097,9 @@ export class UrlTree {
10921097
// @public (undocumented)
10931098
export const VERSION: Version;
10941099

1100+
// @public
1101+
export function withComponentInputBinding(): ComponentInputBindingFeature;
1102+
10951103
// @public
10961104
export function withDebugTracing(): DebugTracingFeature;
10971105

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@
275275
{
276276
"name": "INJECTOR_SCOPE"
277277
},
278+
{
279+
"name": "INPUT_BINDER"
280+
},
278281
{
279282
"name": "INTERNAL_APPLICATION_ERROR_HANDLER"
280283
},

packages/router/src/directives/router_outlet.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ChangeDetectorRef, ComponentRef, Directive, EnvironmentInjector, EventEmitter, inject, Injector, Input, OnDestroy, OnInit, Output, SimpleChanges, ViewContainerRef, ɵRuntimeError as RuntimeError,} from '@angular/core';
9+
import {ChangeDetectorRef, ComponentRef, Directive, EnvironmentInjector, EventEmitter, inject, Injectable, InjectionToken, Injector, Input, OnDestroy, OnInit, Output, reflectComponentType, SimpleChanges, ViewContainerRef, ɵRuntimeError as RuntimeError,} from '@angular/core';
10+
import {combineLatest, Subscription} from 'rxjs';
11+
import {switchMap} from 'rxjs/operators';
1012

1113
import {RuntimeErrorCode} from '../errors';
1214
import {Data} from '../models';
@@ -96,6 +98,16 @@ export interface RouterOutletContract {
9698
* subtree.
9799
*/
98100
detachEvents?: EventEmitter<unknown>;
101+
102+
/**
103+
* Used to indicate that the outlet is able to bind data from the `Router` to the outlet
104+
* component's inputs.
105+
*
106+
* When this is `undefined` or `false` and the developer has opted in to the
107+
* feature using `withComponentInputBinding`, a warning will be logged in dev mode if this outlet
108+
* is used in the application.
109+
*/
110+
readonly supportsBindingToComponentInputs?: true;
99111
}
100112

101113
/**
@@ -156,6 +168,10 @@ export interface RouterOutletContract {
156168
})
157169
export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
158170
private activated: ComponentRef<any>|null = null;
171+
/** @internal */
172+
get activatedComponentRef(): ComponentRef<any>|null {
173+
return this.activated;
174+
}
159175
private _activatedRoute: ActivatedRoute|null = null;
160176
/**
161177
* The name of the outlet
@@ -181,6 +197,9 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
181197
private location = inject(ViewContainerRef);
182198
private changeDetector = inject(ChangeDetectorRef);
183199
private environmentInjector = inject(EnvironmentInjector);
200+
private inputBinder = inject(INPUT_BINDER, {optional: true});
201+
/** @nodoc */
202+
readonly supportsBindingToComponentInputs = true;
184203

185204
/** @nodoc */
186205
ngOnChanges(changes: SimpleChanges) {
@@ -208,6 +227,7 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
208227
if (this.isTrackedInParentContexts(this.name)) {
209228
this.parentContexts.onChildOutletDestroyed(this.name);
210229
}
230+
this.inputBinder?.unsubscribeFromRouteData(this);
211231
}
212232

213233
private isTrackedInParentContexts(outletName: string) {
@@ -293,6 +313,7 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
293313
this.activated = ref;
294314
this._activatedRoute = activatedRoute;
295315
this.location.insert(ref.hostView);
316+
this.inputBinder?.bindActivatedRouteToOutletComponent(this);
296317
this.attachEvents.emit(ref.instance);
297318
}
298319

@@ -328,6 +349,7 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
328349
// Calling `markForCheck` to make sure we will run the change detection when the
329350
// `RouterOutlet` is inside a `ChangeDetectionStrategy.OnPush` component.
330351
this.changeDetector.markForCheck();
352+
this.inputBinder?.bindActivatedRouteToOutletComponent(this);
331353
this.activateEvents.emit(this.activated.instance);
332354
}
333355
}
@@ -349,3 +371,70 @@ class OutletInjector implements Injector {
349371
return this.parent.get(token, notFoundValue);
350372
}
351373
}
374+
375+
export const INPUT_BINDER = new InjectionToken<RoutedComponentInputBinder>('');
376+
377+
/**
378+
* Injectable used as a tree-shakable provider for opting in to binding router data to component
379+
* inputs.
380+
*
381+
* The RouterOutlet registers itself with this service when an `ActivatedRoute` is attached or
382+
* activated. When this happens, the service subscribes to the `ActivatedRoute` observables (params,
383+
* queryParams, data) and sets the inputs of the component using `ComponentRef.setInput`.
384+
* Importantly, when an input does not have an item in the route data with a matching key, this
385+
* input is set to `undefined`. If it were not done this way, the previous information would be
386+
* retained if the data got removed from the route (i.e. if a query parameter is removed).
387+
*
388+
* The `RouterOutlet` should unregister itself when destroyed via `unsubscribeFromRouteData` so that
389+
* the subscriptions are cleaned up.
390+
*/
391+
@Injectable()
392+
export class RoutedComponentInputBinder {
393+
private outletDataSubscriptions = new Map<RouterOutlet, Subscription>;
394+
395+
bindActivatedRouteToOutletComponent(outlet: RouterOutlet) {
396+
this.unsubscribeFromRouteData(outlet);
397+
this.subscribeToRouteData(outlet);
398+
}
399+
400+
unsubscribeFromRouteData(outlet: RouterOutlet) {
401+
this.outletDataSubscriptions.get(outlet)?.unsubscribe();
402+
this.outletDataSubscriptions.delete(outlet);
403+
}
404+
405+
private subscribeToRouteData(outlet: RouterOutlet) {
406+
const {activatedRoute} = outlet;
407+
const dataSubscription =
408+
combineLatest([
409+
activatedRoute.queryParams,
410+
activatedRoute.params,
411+
activatedRoute.data,
412+
])
413+
.pipe(switchMap(([queryParams, params, data]) => {
414+
// Promise.resolve is used to avoid synchronously writing the wrong data when two of
415+
// the Observables in the `combineLatest` stream emit one after another.
416+
return Promise.resolve({...queryParams, ...params, ...data});
417+
}))
418+
.subscribe(data => {
419+
// Outlet may have been deactivated or changed names to be associated with a different
420+
// route
421+
if (!outlet.isActivated || !outlet.activatedComponentRef ||
422+
outlet.activatedRoute !== activatedRoute || activatedRoute.component === null) {
423+
this.unsubscribeFromRouteData(outlet);
424+
return;
425+
}
426+
427+
const mirror = reflectComponentType(activatedRoute.component);
428+
if (!mirror) {
429+
this.unsubscribeFromRouteData(outlet);
430+
return;
431+
}
432+
433+
for (const {templateName} of mirror.inputs) {
434+
outlet.activatedComponentRef.setInput(templateName, data[templateName]);
435+
}
436+
});
437+
438+
this.outletDataSubscriptions.set(outlet, dataSubscription);
439+
}
440+
}

packages/router/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export {CanActivateChildFn, CanActivateFn, CanDeactivateFn, CanLoadFn, CanMatchF
1616
export * from './models_deprecated';
1717
export {Navigation, NavigationExtras, UrlCreationOptions} from './navigation_transition';
1818
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
19-
export {DebugTracingFeature, DisabledInitialNavigationFeature, EnabledBlockingInitialNavigationFeature, InitialNavigationFeature, InMemoryScrollingFeature, NavigationErrorHandlerFeature, PreloadingFeature, provideRouter, provideRoutes, RouterConfigurationFeature, RouterFeature, RouterFeatures, RouterHashLocationFeature, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withHashLocation, withInMemoryScrolling, withNavigationErrorHandler, withPreloading, withRouterConfig} from './provide_router';
19+
export {DebugTracingFeature, DisabledInitialNavigationFeature, EnabledBlockingInitialNavigationFeature, InitialNavigationFeature, InMemoryScrollingFeature, NavigationErrorHandlerFeature, PreloadingFeature, provideRouter, provideRoutes, RouterConfigurationFeature, RouterFeature, RouterFeatures, RouterHashLocationFeature, withComponentInputBinding, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withHashLocation, withInMemoryScrolling, withNavigationErrorHandler, withPreloading, withRouterConfig} from './provide_router';
2020
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
2121
export {Router} from './router';
2222
export {ExtraOptions, InitialNavigation, InMemoryScrollingOptions, ROUTER_CONFIGURATION, RouterConfigOptions} from './router_config';

packages/router/src/navigation_transition.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {BehaviorSubject, combineLatest, EMPTY, Observable, of, Subject} from 'rx
1111
import {catchError, defaultIfEmpty, filter, finalize, map, switchMap, take, tap} from 'rxjs/operators';
1212

1313
import {createRouterState} from './create_router_state';
14+
import {INPUT_BINDER} from './directives/router_outlet';
1415
import {Event, GuardsCheckEnd, GuardsCheckStart, IMPERATIVE_NAVIGATION, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationSkippedCode, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
1516
import {NavigationBehaviorOptions, QueryParamsHandling, Route, Routes} from './models';
1617
import {isNavigationCancelingError, isRedirectingNavigationCancelingError, redirectingNavigationError} from './navigation_canceling_error';
@@ -292,6 +293,7 @@ export class NavigationTransitions {
292293
private readonly environmentInjector = inject(EnvironmentInjector);
293294
private readonly urlSerializer = inject(UrlSerializer);
294295
private readonly rootContexts = inject(ChildrenOutletContexts);
296+
private readonly inputBindingEnabled = inject(INPUT_BINDER, {optional: true}) !== null;
295297
navigationId = 0;
296298
get hasRequestedNavigation() {
297299
return this.navigationId !== 0;
@@ -641,7 +643,7 @@ export class NavigationTransitions {
641643

642644
activateRoutes(
643645
this.rootContexts, router.routeReuseStrategy,
644-
(evt: Event) => this.events.next(evt)),
646+
(evt: Event) => this.events.next(evt), this.inputBindingEnabled),
645647

646648
tap({
647649
next: (t: NavigationTransition) => {

packages/router/src/operators/activate_routes.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,24 @@ import {ActivatedRoute, advanceActivatedRoute, RouterState} from '../router_stat
1717
import {getClosestRouteInjector} from '../utils/config';
1818
import {nodeChildrenAsMap, TreeNode} from '../utils/tree';
1919

20+
let warnedAboutUnsupportedInputBinding = false;
21+
2022
export const activateRoutes =
2123
(rootContexts: ChildrenOutletContexts, routeReuseStrategy: RouteReuseStrategy,
22-
forwardEvent: (evt: Event) => void): MonoTypeOperatorFunction<NavigationTransition> =>
23-
map(t => {
24-
new ActivateRoutes(
25-
routeReuseStrategy, t.targetRouterState!, t.currentRouterState, forwardEvent)
26-
.activate(rootContexts);
27-
return t;
28-
});
24+
forwardEvent: (evt: Event) => void,
25+
inputBindingEnabled: boolean): MonoTypeOperatorFunction<NavigationTransition> => map(t => {
26+
new ActivateRoutes(
27+
routeReuseStrategy, t.targetRouterState!, t.currentRouterState, forwardEvent,
28+
inputBindingEnabled)
29+
.activate(rootContexts);
30+
return t;
31+
});
2932

3033
export class ActivateRoutes {
3134
constructor(
3235
private routeReuseStrategy: RouteReuseStrategy, private futureState: RouterState,
33-
private currState: RouterState, private forwardEvent: (evt: Event) => void) {}
36+
private currState: RouterState, private forwardEvent: (evt: Event) => void,
37+
private inputBindingEnabled: boolean) {}
3438

3539
activate(parentContexts: ChildrenOutletContexts): void {
3640
const futureRoot = this.futureState._root;
@@ -210,5 +214,16 @@ export class ActivateRoutes {
210214
this.activateChildRoutes(futureNode, null, parentContexts);
211215
}
212216
}
217+
if ((typeof ngDevMode === 'undefined' || ngDevMode)) {
218+
const context = parentContexts.getOrCreateContext(future.outlet);
219+
const outlet = context.outlet;
220+
if (outlet && this.inputBindingEnabled && !outlet.supportsBindingToComponentInputs &&
221+
!warnedAboutUnsupportedInputBinding) {
222+
console.warn(
223+
`'withComponentInputBinding' feature is enabled but ` +
224+
`this application is using an outlet that may not support binding to component inputs.`);
225+
warnedAboutUnsupportedInputBinding = true;
226+
}
227+
}
213228
}
214229
}

packages/router/src/provide_router.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
*/
88

99
import {HashLocationStrategy, LOCATION_INITIALIZED, LocationStrategy, ViewportScroller} from '@angular/common';
10-
import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, ComponentRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EnvironmentProviders, inject, InjectFlags, InjectionToken, Injector, makeEnvironmentProviders, NgZone, Provider, Type} from '@angular/core';
10+
import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, Component, ComponentRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EnvironmentProviders, inject, InjectFlags, InjectionToken, Injector, makeEnvironmentProviders, NgZone, Provider, Type} from '@angular/core';
1111
import {of, Subject} from 'rxjs';
12-
import {filter, map, take} from 'rxjs/operators';
1312

14-
import {Event, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, stringifyEvent} from './events';
13+
import {INPUT_BINDER, RoutedComponentInputBinder} from './directives/router_outlet';
14+
import {Event, NavigationError, stringifyEvent} from './events';
1515
import {Routes} from './models';
1616
import {NavigationTransitions} from './navigation_transition';
1717
import {Router} from './router';
@@ -648,6 +648,46 @@ export function withNavigationErrorHandler(fn: (error: NavigationError) => void)
648648
return routerFeature(RouterFeatureKind.NavigationErrorHandlerFeature, providers);
649649
}
650650

651+
/**
652+
* A type alias for providers returned by `withComponentInputBinding` for use with `provideRouter`.
653+
*
654+
* @see `withComponentInputBinding`
655+
* @see `provideRouter`
656+
*
657+
* @publicApi
658+
*/
659+
export type ComponentInputBindingFeature =
660+
RouterFeature<RouterFeatureKind.ComponentInputBindingFeature>;
661+
662+
/**
663+
* Enables binding information from the `Router` state directly to the inputs of the component in
664+
* `Route` configurations.
665+
*
666+
* @usageNotes
667+
*
668+
* Basic example of how you can enable the feature:
669+
* ```
670+
* const appRoutes: Routes = [];
671+
* bootstrapApplication(AppComponent,
672+
* {
673+
* providers: [
674+
* provideRouter(appRoutes, withComponentInputBinding())
675+
* ]
676+
* }
677+
* );
678+
* ```
679+
*
680+
* @returns A set of providers for use with `provideRouter`.
681+
*/
682+
export function withComponentInputBinding(): ComponentInputBindingFeature {
683+
const providers = [
684+
RoutedComponentInputBinder,
685+
{provide: INPUT_BINDER, useExisting: RoutedComponentInputBinder},
686+
];
687+
688+
return routerFeature(RouterFeatureKind.ComponentInputBindingFeature, providers);
689+
}
690+
651691
/**
652692
* A type alias that represents all Router features available for use with `provideRouter`.
653693
* Features can be enabled by adding special functions to the `provideRouter` call.
@@ -658,8 +698,9 @@ export function withNavigationErrorHandler(fn: (error: NavigationError) => void)
658698
*
659699
* @publicApi
660700
*/
661-
export type RouterFeatures = PreloadingFeature|DebugTracingFeature|InitialNavigationFeature|
662-
InMemoryScrollingFeature|RouterConfigurationFeature|NavigationErrorHandlerFeature;
701+
export type RouterFeatures =
702+
PreloadingFeature|DebugTracingFeature|InitialNavigationFeature|InMemoryScrollingFeature|
703+
RouterConfigurationFeature|NavigationErrorHandlerFeature|ComponentInputBindingFeature;
663704

664705
/**
665706
* The list of features as an enum to uniquely type each feature.
@@ -673,4 +714,5 @@ export const enum RouterFeatureKind {
673714
RouterConfigurationFeature,
674715
RouterHashLocationFeature,
675716
NavigationErrorHandlerFeature,
717+
ComponentInputBindingFeature,
676718
}

packages/router/src/router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {inject, Injectable, NgZone, Type, ɵConsole as Console, ɵInitialRenderP
1111
import {Observable, of, SubscriptionLike} from 'rxjs';
1212

1313
import {createSegmentGroupFromRoute, createUrlTreeFromSegmentGroup} from './create_url_tree';
14+
import {INPUT_BINDER} from './directives/router_outlet';
1415
import {RuntimeErrorCode} from './errors';
1516
import {Event, IMPERATIVE_NAVIGATION, NavigationTrigger} from './events';
1617
import {NavigationBehaviorOptions, OnSameUrlNavigation, Routes} from './models';
@@ -307,6 +308,14 @@ export class Router {
307308
private readonly urlSerializer = inject(UrlSerializer);
308309
private readonly location = inject(Location);
309310

311+
/**
312+
* Indicates whether the the application has opted in to binding Router data to component inputs.
313+
*
314+
* This option is enabled by the `withComponentInputBinding` feature of `provideRouter` or
315+
* `bindToComponentInputs` in the `ExtraOptions` of `RouterModule.forRoot`.
316+
*/
317+
readonly componentInputBindingEnabled = !!inject(INPUT_BINDER, {optional: true});
318+
310319
constructor() {
311320
this.isNgZoneEnabled = inject(NgZone) instanceof NgZone && NgZone.isInAngularZone();
312321

0 commit comments

Comments
 (0)