refactor(router): add component input binding support for router resources

Integrates component input binding (via `withComponentInputBinding()`) with router resources.
This commit is contained in:
Andrew Scott
2026-08-25 15:20:56 -07:00
committed by GitHub
parent 3848b7f8bc
commit df83a34088
5 changed files with 304 additions and 17 deletions
+49 -12
View File
@@ -10,6 +10,8 @@ import {
ChangeDetectorRef,
ComponentRef,
Directive,
effect,
EffectRef,
EnvironmentInjector,
EventEmitter,
inject,
@@ -27,14 +29,15 @@ import {
SimpleChanges,
ViewContainerRef,
} from '@angular/core';
import {combineLatest, Observable, of, Subscription} from 'rxjs';
import {combineLatest, of, Subscription} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {RuntimeErrorCode} from '../errors';
import type {RouterResourcesFeatureImplementation} from '../router_resource_feature';
import {Data} from '../models';
import {ChildrenOutletContexts} from '../router_outlet_context';
import {ActivatedRoute} from '../router_state';
import {Params, PRIMARY_OUTLET} from '../shared';
import {PRIMARY_OUTLET} from '../shared';
import {ComponentInputBindingOptions} from '../router_config';
/**
@@ -364,7 +367,7 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
this.activated = ref;
this._activatedRoute = activatedRoute;
this.location.insert(ref.hostView);
this.inputBinder?.bindActivatedRouteToOutletComponent(this);
this.inputBinder?.bindActivatedRouteToOutletComponent(this, this.location.injector);
this.attachEvents.emit(ref.instance);
}
@@ -406,7 +409,7 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
// Calling `markForCheck` to make sure we will run the change detection when the
// `RouterOutlet` is inside a `ChangeDetectionStrategy.OnPush` component.
this.changeDetector.markForCheck();
this.inputBinder?.bindActivatedRouteToOutletComponent(this);
this.inputBinder?.bindActivatedRouteToOutletComponent(this, this.location.injector);
this.activateEvents.emit(this.activated.instance);
}
}
@@ -459,24 +462,48 @@ export const INPUT_BINDER = new InjectionToken<RoutedComponentInputBinder>(
export class RoutedComponentInputBinder {
private outletDataSubscriptions = new Map<RouterOutlet, Subscription>();
private outletSeenKeys = new Map<RouterOutlet, Set<string>>();
private outletEffects = new Map<RouterOutlet, EffectRef[]>();
constructor(private options: ComponentInputBindingOptions) {
constructor(
private options: ComponentInputBindingOptions,
private feature: RouterResourcesFeatureImplementation | null = null,
) {
this.options.queryParams ??= true;
}
bindActivatedRouteToOutletComponent(outlet: RouterOutlet): void {
bindActivatedRouteToOutletComponent(outlet: RouterOutlet, injector: Injector): void {
this.unsubscribeFromRouteData(outlet);
this.subscribeToRouteData(outlet);
this.subscribeToRouteData(outlet, injector);
}
unsubscribeFromRouteData(outlet: RouterOutlet): void {
this.outletDataSubscriptions.get(outlet)?.unsubscribe();
this.outletDataSubscriptions.delete(outlet);
this.outletSeenKeys.delete(outlet);
this.outletEffects.get(outlet)?.forEach((effect) => effect.destroy());
this.outletEffects.delete(outlet);
}
private subscribeToRouteData(outlet: RouterOutlet) {
private subscribeToRouteData(outlet: RouterOutlet, injector: Injector) {
const {activatedRoute} = outlet;
const effects: EffectRef[] = [];
let keysBoundToBlockingResources: string[] = [];
if (this.feature?.createResourceOutletBindingEffects && outlet.activatedComponentRef) {
const {handledKeys, createdEffects} = this.feature.createResourceOutletBindingEffects(
outlet.activatedComponentRef,
activatedRoute,
injector,
);
effects.push(...createdEffects);
keysBoundToBlockingResources = handledKeys;
}
if (effects.length > 0) {
this.outletEffects.set(outlet, effects);
}
const dataSubscription = combineLatest([
this.options.queryParams ? activatedRoute.queryParams : of({}),
activatedRoute.params,
@@ -484,7 +511,14 @@ export class RoutedComponentInputBinder {
])
.pipe(
switchMap(([queryParams, params, data], index) => {
data = {...queryParams, ...params, ...data};
// Precedence when keys collide is determined by the spread order:
// resources > data (including resolvers) > path params > query params
data = {
...queryParams,
...params,
...data,
...(activatedRoute.resources || {}),
};
// 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) {
@@ -509,8 +543,8 @@ export class RoutedComponentInputBinder {
return;
}
const mirror = reflectComponentType(activatedRoute.component);
if (!mirror) {
const currentMirror = reflectComponentType(activatedRoute.component);
if (!currentMirror) {
this.unsubscribeFromRouteData(outlet);
return;
}
@@ -527,7 +561,10 @@ export class RoutedComponentInputBinder {
const behavior = this.options.unmatchedInputBehavior ?? 'alwaysUndefined';
for (const {templateName} of mirror.inputs) {
for (const {templateName} of currentMirror.inputs) {
if (keysBoundToBlockingResources.includes(templateName)) {
continue;
}
const value = data[templateName];
if (value !== undefined || behavior === 'alwaysUndefined' || seenKeys.has(templateName)) {
outlet.activatedComponentRef.setInput(templateName, value);
+7 -1
View File
@@ -36,6 +36,7 @@ import {
import {of, Subject} from 'rxjs';
import {INPUT_BINDER, RoutedComponentInputBinder} from './directives/router_outlet';
import {createResourceOutletBindingEffects} from './router_resource';
import {Event, NavigationError, stringifyEvent} from './events';
import {RedirectCommand, Routes} from './models';
import {NAVIGATION_ERROR_HANDLER, NavigationTransitions} from './navigation_transition';
@@ -839,7 +840,11 @@ export function withComponentInputBinding(
options: ComponentInputBindingOptions = {},
): ComponentInputBindingFeature {
const providers = [
{provide: INPUT_BINDER, useFactory: () => new RoutedComponentInputBinder(options)},
{
provide: INPUT_BINDER,
useFactory: () =>
new RoutedComponentInputBinder(options, inject(ROUTER_RESOURCES_FEATURE, {optional: true})),
},
];
return routerFeature(RouterFeatureKind.ComponentInputBindingFeature, providers);
@@ -923,6 +928,7 @@ export function withRouterResources(): RouterResourcesFeature {
provide: ROUTER_RESOURCES_FEATURE,
useValue: {
setupAndRunResources,
createResourceOutletBindingEffects,
},
},
];
+46
View File
@@ -7,6 +7,8 @@
*/
import {
ComponentRef,
EffectRef,
inject,
Injector,
Resource,
@@ -18,8 +20,10 @@ import {
effect,
computed,
assertInInjectionContext,
reflectComponentType,
} from '@angular/core';
import {Router} from './router';
import type {ActivatedRoute} from './router_state';
import {
NavigationStart,
NavigationEnd,
@@ -178,3 +182,45 @@ function createTransactionalSnapshot<T>(
frozenSnapshot,
};
}
/**
* Creates reactive effects to bind the unwrapped values of blocking router resources
* to matching component inputs.
*
* Non-blocking resources are bound as the `Resource` instance itself through the standard
* route data stream in `RoutedComponentInputBinder`. In contrast, blocking resources have
* resolved before navigation completes and bind their unwrapped `.value()` to the component
* input. This sets up an `effect` for each matching blocking resource to keep the component
* input reactively updated, and returns the handled keys so the standard data subscription
* skips them.
*/
export function createResourceOutletBindingEffects(
componentRef: ComponentRef<unknown>,
route: ActivatedRoute,
injector: Injector,
): {createdEffects: EffectRef[]; handledKeys: string[]} {
const createdEffects: EffectRef[] = [];
const handledKeys: string[] = [];
const mirror = route.component ? reflectComponentType(route.component) : null;
if (!mirror) {
return {createdEffects, handledKeys};
}
for (const {templateName} of mirror.inputs) {
const resource = route.resources?.[templateName];
if (!resource || !(resource as InternalRouterResource)[BLOCKING_SYMBOL]) {
continue;
}
const effectRef = effect(
() => {
componentRef.setInput(templateName, resource.value());
},
{injector},
);
createdEffects.push(effectRef);
handledKeys.push(templateName);
}
return {createdEffects, handledKeys};
}
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '@angular/core';
import {ComponentRef, EffectRef, InjectionToken, Injector} from '@angular/core';
import {OperatorFunction} from 'rxjs';
import type {NavigationTransition} from './navigation_transition';
@@ -16,6 +16,11 @@ export interface RouterResourcesFeatureImplementation {
setupAndRunResources(
abortSignal: AbortSignal,
): OperatorFunction<NavigationTransition, NavigationTransition>;
createResourceOutletBindingEffects?: (
componentRef: ComponentRef<unknown>,
route: ActivatedRoute,
injector: Injector,
) => {createdEffects: EffectRef[]; handledKeys: string[]};
}
export const ROUTER_RESOURCES_FEATURE = new InjectionToken<RouterResourcesFeatureImplementation>(
@@ -7,20 +7,43 @@
*/
import {CommonModule, NgForOf} from '@angular/common';
import {Component, inject, Input, Type, NgModule, signal} from '@angular/core';
import {Component, inject, Input, Type, NgModule, signal, resource} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {
provideRouter,
provideRouter as internalProvideRouter,
Router,
RouterModule,
RouterOutlet,
withComponentInputBinding,
ROUTER_OUTLET_DATA,
ɵwithRouterResources as withRouterResources,
ɵnonBlocking as nonBlocking,
Route,
RouterFeatures,
} from '../../index';
import {RouterTestingHarness} from '../../testing';
import {InjectionToken} from '../../../core/src/di';
import {EnvironmentProviders, InjectionToken} from '../../../core/src/di';
import {useAutoTick, timeout} from '@angular/private/testing';
import {ResourceContext, ResourceResult} from '../../src/models';
// TODO: Use the public @angular/router API once exposed
type InternalRoute = Route & {
/**
* A function that returns a map of resources.
* This function is executed during the Main Loading Phase of a navigation.
* @experimental
* @internal
*/
resources?: (ctx: ResourceContext) => ResourceResult | Promise<ResourceResult>;
children?: InternalRoute[];
};
export function provideRouter(
routes: InternalRoute[],
...features: RouterFeatures[]
): EnvironmentProviders {
return internalProvideRouter(routes, ...features);
}
describe('router outlet name', () => {
useAutoTick();
it('should support name binding', async () => {
@@ -472,6 +495,176 @@ describe('component input binding', () => {
await harness.navigateByUrl('/root/child?myInput=2');
expect(harness.routeNativeElement!.innerText).toBe('2');
});
it('when keys conflict, sets inputs based on priority: resources > resolvers > data', async () => {
@Component({
template: '',
standalone: false,
})
class MyComponent {
@Input() result?: any;
}
@Component({
template: '',
standalone: false,
})
class MyComponentWithoutResource {
@Input() result?: any;
}
@Component({
template: '',
standalone: false,
})
class MyComponentWithoutResolver {
@Input() result?: any;
}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'all',
component: MyComponent,
data: {'result': 'from data'},
resolve: {'result': () => 'from resolver'},
resources: () => ({
result: resource({loader: async () => 'from resource'}),
}),
},
{
path: 'no-resource',
component: MyComponentWithoutResource,
data: {'result': 'from data'},
resolve: {'result': () => 'from resolver'},
},
{
path: 'no-resolver',
component: MyComponentWithoutResolver,
data: {'result': 'from data'},
},
],
withComponentInputBinding(),
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
let instance = await harness.navigateByUrl('/all', MyComponent);
// Precedence: resources > resolvers > data
// resources wins, and it binds ONLY THE VALUE for blocking resources!
expect(typeof instance.result).toBe('string');
expect(instance.result).toEqual('from resource');
const instance2 = await harness.navigateByUrl('/no-resource', MyComponentWithoutResource);
// No resources, so resolver wins!
expect(typeof instance2.result).toBe('string');
expect(instance2.result).toEqual('from resolver');
const instance3 = await harness.navigateByUrl('/no-resolver', MyComponentWithoutResolver);
// No resource, no resolver, so data wins!
expect(typeof instance3.result).toBe('string');
expect(instance3.result).toEqual('from data');
});
it('binds the actual resource object for non-blocking resources', async () => {
@Component({
template: '',
standalone: false,
})
class MyComponent {
@Input() result?: any;
}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: '**',
component: MyComponent,
resources: () => ({
result: nonBlocking(resource({loader: async () => 'from non-blocking resource'})),
}),
},
],
withComponentInputBinding(),
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const instance = await harness.navigateByUrl('/', MyComponent);
await harness.fixture.whenStable();
expect(typeof instance.result).toBe('object');
expect(instance.result?.value()).toEqual('from non-blocking resource');
});
it('updates component inputs reactively and cleans up binding effects on outlet deactivation', async () => {
const trigger = signal('initial');
@Component({
template: '',
standalone: false,
})
class MyComponent {
@Input() result?: string;
}
@Component({
template: '',
standalone: false,
})
class OtherComponent {}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'resource',
component: MyComponent,
resources: () => ({
result: resource({
params: () => trigger(),
loader: async ({params}) => `data: ${params}`,
}),
}),
},
{
path: 'other',
component: OtherComponent,
},
],
withComponentInputBinding(),
withRouterResources(),
),
],
});
const harness = await RouterTestingHarness.create();
const instance = await harness.navigateByUrl('/resource', MyComponent);
await harness.fixture.whenStable();
expect(instance.result).toEqual('data: initial');
// Trigger reactive update while active
trigger.set('updated');
await harness.fixture.whenStable();
expect(instance.result).toEqual('data: updated');
// Navigate away to deactivate outlet component
await harness.navigateByUrl('/other', OtherComponent);
await harness.fixture.whenStable();
// Trigger update after deactivation - effect should have been destroyed
trigger.set('after-destroy');
await harness.fixture.whenStable();
expect(instance.result).toEqual('data: updated');
});
});
describe('injectors', () => {