From c84642ac16bf3588c071bbdcc684daa8d4e494b3 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 24 Apr 2026 12:11:25 -0700 Subject: [PATCH] feat(router): add unmatchedInputBehavior option to componentInputBinding Introduce a new configuration option `unmatchedInputBehavior` to the `componentInputBinding` feature. This option allows users to configure the behavior when a component input is not matched by any key in the router data. The available values are: - 'alwaysUndefined': (Default) Always binds undefined to unmatched inputs. - 'undefinedIfStale': Binds undefined only if the input was previously available in the router data for the active route in the outlet. This feature addresses concerns raised in #63835 and #52946 regarding the retention of default values for inputs that were never targeted by the router, while still ensuring that stale data is cleared when a parameter is removed. --- .../guide/routing/common-router-tasks.md | 22 ++++++++++++++ goldens/public-api/router/index.api.md | 1 + .../router/src/directives/router_outlet.ts | 22 ++++++++++++-- packages/router/src/router_config.ts | 11 +++++++ .../test/directives/router_outlet.spec.ts | 29 +++++++++++++++++++ 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/adev/src/content/guide/routing/common-router-tasks.md b/adev/src/content/guide/routing/common-router-tasks.md index 6975ba1c0f8..3efa5fc3e79 100644 --- a/adev/src/content/guide/routing/common-router-tasks.md +++ b/adev/src/content/guide/routing/common-router-tasks.md @@ -66,6 +66,28 @@ Use `ComponentInputBindingOptions` to disable query parameter binding if you man provideRouter(appRoutes, withComponentInputBinding({queryParams: false})); ``` +### Configure behavior for inputs not available in router data + +By default, the router sets an input to `undefined` if it was not available in the router data during a navigation. This ensures that stale data is not retained. + +If you want to avoid setting `undefined` for inputs that have _never_ been available in the router data for the active component instance, you can set the `unmatchedInputBehavior` option to `'undefinedIfStale'`: + +```ts +provideRouter(appRoutes, withComponentInputBinding({unmatchedInputBehavior: 'undefinedIfStale'})); +``` + +When you combine `unmatchedInputBehavior: 'undefinedIfStale'` with `queryParams: false`, inputs retain their initial values unless they are explicitly provided by the router. The exception is matrix parameters: if a matrix parameter is provided in one navigation and removed in a subsequent one, the router will set the input to `undefined` to avoid retaining stale data. + +```ts +provideRouter( + appRoutes, + withComponentInputBinding({ + queryParams: false, + unmatchedInputBehavior: 'undefinedIfStale', + }), +); +``` + ### Inherit parent route data By default, child routes inherit parameters and data from parent routes (equivalent to `paramsInheritanceStrategy: 'always'`). This means you can access parent route info directly in child components. diff --git a/goldens/public-api/router/index.api.md b/goldens/public-api/router/index.api.md index d12ae1ccd3f..f9ea2f3b1ea 100644 --- a/goldens/public-api/router/index.api.md +++ b/goldens/public-api/router/index.api.md @@ -202,6 +202,7 @@ export type ComponentInputBindingFeature = RouterFeature( * 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 + * input is set to `undefined` by default. 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 `unmatchedInputBehavior` option can be used to configure this behavior. * * The `RouterOutlet` should unregister itself when destroyed via `unsubscribeFromRouteData` so that * the subscriptions are cleaned up. @@ -457,6 +458,7 @@ export const INPUT_BINDER = new InjectionToken( @Injectable() export class RoutedComponentInputBinder { private outletDataSubscriptions = new Map(); + private outletSeenKeys = new Map>(); constructor(private options: ComponentInputBindingOptions) { this.options.queryParams ??= true; @@ -470,6 +472,7 @@ export class RoutedComponentInputBinder { unsubscribeFromRouteData(outlet: RouterOutlet): void { this.outletDataSubscriptions.get(outlet)?.unsubscribe(); this.outletDataSubscriptions.delete(outlet); + this.outletSeenKeys.delete(outlet); } private subscribeToRouteData(outlet: RouterOutlet) { @@ -512,8 +515,23 @@ export class RoutedComponentInputBinder { return; } + let seenKeys = this.outletSeenKeys.get(outlet); + if (!seenKeys) { + seenKeys = new Set(); + this.outletSeenKeys.set(outlet, seenKeys); + } + + for (const key of Object.keys(data)) { + seenKeys.add(key); + } + + const behavior = this.options.unmatchedInputBehavior ?? 'alwaysUndefined'; + for (const {templateName} of mirror.inputs) { - outlet.activatedComponentRef.setInput(templateName, data[templateName]); + const value = data[templateName]; + if (value !== undefined || behavior === 'alwaysUndefined' || seenKeys.has(templateName)) { + outlet.activatedComponentRef.setInput(templateName, value); + } } }); diff --git a/packages/router/src/router_config.ts b/packages/router/src/router_config.ts index 57dda4335be..a7744cca423 100644 --- a/packages/router/src/router_config.ts +++ b/packages/router/src/router_config.ts @@ -208,6 +208,17 @@ export interface ComponentInputBindingOptions { * inputs. */ queryParams?: boolean; + + /** + * Configures the behavior when an input is not matched by any key in the router data. + * + * - `'alwaysUndefined'`: (Default) Binds `undefined` to the input. This ensures that stale data + * is not retained. + * - `'undefinedIfStale'`: Binds `undefined` only if the input was previously available + * in the router data during the lifetime of the active route in this outlet. This avoids + * setting `undefined` for inputs that were never expected to be set by the router. + */ + unmatchedInputBehavior?: 'alwaysUndefined' | 'undefinedIfStale'; } /** diff --git a/packages/router/test/directives/router_outlet.spec.ts b/packages/router/test/directives/router_outlet.spec.ts index c30d87565cd..6d9ba13d1a8 100644 --- a/packages/router/test/directives/router_outlet.spec.ts +++ b/packages/router/test/directives/router_outlet.spec.ts @@ -218,6 +218,35 @@ describe('component input binding', () => { expect(instance.language).toEqual(undefined); }); + it('omits binding undefined to inputs not available in router data if never available', async () => { + @Component({ + template: '', + standalone: false, + }) + class MyComponent { + @Input() language: string | undefined = 'default'; + } + + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [{path: '**', component: MyComponent}], + withComponentInputBinding({unmatchedInputBehavior: 'undefinedIfStale'}), + ), + ], + }); + const harness = await RouterTestingHarness.create(); + + const instance = await harness.navigateByUrl('/', MyComponent); + expect(instance.language).toEqual('default'); + + await harness.navigateByUrl('/?language=english'); + expect(instance.language).toEqual('english'); + + await harness.navigateByUrl('/'); + expect(instance.language).toEqual(undefined); + }); + it('does not set component inputs from matching query params when queryParam inputs are disabled', async () => { @Component({ template: '',