mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
refactor(router): add component input binding support for router resources
Integrates component input binding (via `withComponentInputBinding()`) with router resources.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user