diff --git a/packages/router/src/directives/router_outlet.ts b/packages/router/src/directives/router_outlet.ts index 974481aaa15..80abe166ee1 100644 --- a/packages/router/src/directives/router_outlet.ts +++ b/packages/router/src/directives/router_outlet.ts @@ -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( export class RoutedComponentInputBinder { private outletDataSubscriptions = new Map(); private outletSeenKeys = new Map>(); + private outletEffects = new Map(); - 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); diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index 9f018feb215..424cc528be1 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -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, }, }, ]; diff --git a/packages/router/src/router_resource.ts b/packages/router/src/router_resource.ts index c9cbc3bc156..702350904e0 100644 --- a/packages/router/src/router_resource.ts +++ b/packages/router/src/router_resource.ts @@ -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( 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, + 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}; +} diff --git a/packages/router/src/router_resource_feature.ts b/packages/router/src/router_resource_feature.ts index e466573dcd0..e5b090de7bc 100644 --- a/packages/router/src/router_resource_feature.ts +++ b/packages/router/src/router_resource_feature.ts @@ -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; + createResourceOutletBindingEffects?: ( + componentRef: ComponentRef, + route: ActivatedRoute, + injector: Injector, + ) => {createdEffects: EffectRef[]; handledKeys: string[]}; } export const ROUTER_RESOURCES_FEATURE = new InjectionToken( diff --git a/packages/router/test/directives/router_outlet.spec.ts b/packages/router/test/directives/router_outlet.spec.ts index 6d9ba13d1a8..fc73fb640f0 100644 --- a/packages/router/test/directives/router_outlet.spec.ts +++ b/packages/router/test/directives/router_outlet.spec.ts @@ -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; + 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', () => {