Skip to content

Commit c84642a

Browse files
atscottalxhub
authored andcommitted
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 angular#63835 and angular#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.
1 parent aab7dd4 commit c84642a

5 files changed

Lines changed: 83 additions & 2 deletions

File tree

adev/src/content/guide/routing/common-router-tasks.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,28 @@ Use `ComponentInputBindingOptions` to disable query parameter binding if you man
6666
provideRouter(appRoutes, withComponentInputBinding({queryParams: false}));
6767
```
6868

69+
### Configure behavior for inputs not available in router data
70+
71+
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.
72+
73+
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'`:
74+
75+
```ts
76+
provideRouter(appRoutes, withComponentInputBinding({unmatchedInputBehavior: 'undefinedIfStale'}));
77+
```
78+
79+
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.
80+
81+
```ts
82+
provideRouter(
83+
appRoutes,
84+
withComponentInputBinding({
85+
queryParams: false,
86+
unmatchedInputBehavior: 'undefinedIfStale',
87+
}),
88+
);
89+
```
90+
6991
### Inherit parent route data
7092

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

goldens/public-api/router/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ export type ComponentInputBindingFeature = RouterFeature<RouterFeatureKind.Compo
202202
// @public
203203
export interface ComponentInputBindingOptions {
204204
queryParams?: boolean;
205+
unmatchedInputBehavior?: 'alwaysUndefined' | 'undefinedIfStale';
205206
}
206207

207208
// @public

packages/router/src/directives/router_outlet.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,15 +448,17 @@ export const INPUT_BINDER = new InjectionToken<RoutedComponentInputBinder>(
448448
* activated. When this happens, the service subscribes to the `ActivatedRoute` observables (params,
449449
* queryParams, data) and sets the inputs of the component using `ComponentRef.setInput`.
450450
* Importantly, when an input does not have an item in the route data with a matching key, this
451-
* input is set to `undefined`. If it were not done this way, the previous information would be
451+
* input is set to `undefined` by default. If it were not done this way, the previous information would be
452452
* retained if the data got removed from the route (i.e. if a query parameter is removed).
453+
* The `unmatchedInputBehavior` option can be used to configure this behavior.
453454
*
454455
* The `RouterOutlet` should unregister itself when destroyed via `unsubscribeFromRouteData` so that
455456
* the subscriptions are cleaned up.
456457
*/
457458
@Injectable()
458459
export class RoutedComponentInputBinder {
459460
private outletDataSubscriptions = new Map<RouterOutlet, Subscription>();
461+
private outletSeenKeys = new Map<RouterOutlet, Set<string>>();
460462

461463
constructor(private options: ComponentInputBindingOptions) {
462464
this.options.queryParams ??= true;
@@ -470,6 +472,7 @@ export class RoutedComponentInputBinder {
470472
unsubscribeFromRouteData(outlet: RouterOutlet): void {
471473
this.outletDataSubscriptions.get(outlet)?.unsubscribe();
472474
this.outletDataSubscriptions.delete(outlet);
475+
this.outletSeenKeys.delete(outlet);
473476
}
474477

475478
private subscribeToRouteData(outlet: RouterOutlet) {
@@ -512,8 +515,23 @@ export class RoutedComponentInputBinder {
512515
return;
513516
}
514517

518+
let seenKeys = this.outletSeenKeys.get(outlet);
519+
if (!seenKeys) {
520+
seenKeys = new Set<string>();
521+
this.outletSeenKeys.set(outlet, seenKeys);
522+
}
523+
524+
for (const key of Object.keys(data)) {
525+
seenKeys.add(key);
526+
}
527+
528+
const behavior = this.options.unmatchedInputBehavior ?? 'alwaysUndefined';
529+
515530
for (const {templateName} of mirror.inputs) {
516-
outlet.activatedComponentRef.setInput(templateName, data[templateName]);
531+
const value = data[templateName];
532+
if (value !== undefined || behavior === 'alwaysUndefined' || seenKeys.has(templateName)) {
533+
outlet.activatedComponentRef.setInput(templateName, value);
534+
}
517535
}
518536
});
519537

packages/router/src/router_config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,17 @@ export interface ComponentInputBindingOptions {
208208
* inputs.
209209
*/
210210
queryParams?: boolean;
211+
212+
/**
213+
* Configures the behavior when an input is not matched by any key in the router data.
214+
*
215+
* - `'alwaysUndefined'`: (Default) Binds `undefined` to the input. This ensures that stale data
216+
* is not retained.
217+
* - `'undefinedIfStale'`: Binds `undefined` only if the input was previously available
218+
* in the router data during the lifetime of the active route in this outlet. This avoids
219+
* setting `undefined` for inputs that were never expected to be set by the router.
220+
*/
221+
unmatchedInputBehavior?: 'alwaysUndefined' | 'undefinedIfStale';
211222
}
212223

213224
/**

packages/router/test/directives/router_outlet.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,35 @@ describe('component input binding', () => {
218218
expect(instance.language).toEqual(undefined);
219219
});
220220

221+
it('omits binding undefined to inputs not available in router data if never available', async () => {
222+
@Component({
223+
template: '',
224+
standalone: false,
225+
})
226+
class MyComponent {
227+
@Input() language: string | undefined = 'default';
228+
}
229+
230+
TestBed.configureTestingModule({
231+
providers: [
232+
provideRouter(
233+
[{path: '**', component: MyComponent}],
234+
withComponentInputBinding({unmatchedInputBehavior: 'undefinedIfStale'}),
235+
),
236+
],
237+
});
238+
const harness = await RouterTestingHarness.create();
239+
240+
const instance = await harness.navigateByUrl('/', MyComponent);
241+
expect(instance.language).toEqual('default');
242+
243+
await harness.navigateByUrl('/?language=english');
244+
expect(instance.language).toEqual('english');
245+
246+
await harness.navigateByUrl('/');
247+
expect(instance.language).toEqual(undefined);
248+
});
249+
221250
it('does not set component inputs from matching query params when queryParam inputs are disabled', async () => {
222251
@Component({
223252
template: '',

0 commit comments

Comments
 (0)