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.
This commit is contained in:
Andrew Scott 2026-04-24 12:11:25 -07:00 committed by Alex Rickabaugh
parent aab7dd48c0
commit c84642ac16
5 changed files with 83 additions and 2 deletions

View file

@ -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.

View file

@ -202,6 +202,7 @@ export type ComponentInputBindingFeature = RouterFeature<RouterFeatureKind.Compo
// @public
export interface ComponentInputBindingOptions {
queryParams?: boolean;
unmatchedInputBehavior?: 'alwaysUndefined' | 'undefinedIfStale';
}
// @public

View file

@ -448,8 +448,9 @@ export const INPUT_BINDER = new InjectionToken<RoutedComponentInputBinder>(
* 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<RoutedComponentInputBinder>(
@Injectable()
export class RoutedComponentInputBinder {
private outletDataSubscriptions = new Map<RouterOutlet, Subscription>();
private outletSeenKeys = new Map<RouterOutlet, Set<string>>();
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<string>();
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);
}
}
});

View file

@ -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';
}
/**

View file

@ -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: '',