From a2a68fc7e2b7f3eeb525389ee92b6f0f4e255de6 Mon Sep 17 00:00:00 2001 From: brysonbw Date: Sat, 8 Aug 2026 02:46:19 -0500 Subject: [PATCH] feat(router): add options to convertToParamMap Allows configuring ParamMap behavior, starting with case-insensitive key lookups. --- goldens/public-api/router/index.api.md | 2 +- packages/router/src/shared.ts | 66 +++++++++++++++++++++----- packages/router/test/shared.spec.ts | 47 +++++++++++++++++- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/goldens/public-api/router/index.api.md b/goldens/public-api/router/index.api.md index 765ae3156336..237e36476727 100644 --- a/goldens/public-api/router/index.api.md +++ b/goldens/public-api/router/index.api.md @@ -206,7 +206,7 @@ export interface ComponentInputBindingOptions { } // @public -export function convertToParamMap(params: Params): ParamMap; +export function convertToParamMap(params: Params, options?: ParamMapOptions): ParamMap; // @public export function createUrlTreeFromSnapshot(relativeTo: ActivatedRouteSnapshot, commands: readonly any[], queryParams?: Params | null, fragment?: string | null, urlSerializer?: DefaultUrlSerializer): UrlTree; diff --git a/packages/router/src/shared.ts b/packages/router/src/shared.ts index 2ccfb2e10c72..b54914e29113 100644 --- a/packages/router/src/shared.ts +++ b/packages/router/src/shared.ts @@ -72,32 +72,74 @@ export interface ParamMap { readonly keys: string[]; } +/** + * Options to configure the behavior of a `ParamMap`. + * @see {@link convertToParamMap} + * @see {@link ParamMap} + * + * @publicApi + */ +export type ParamMapOptions = { + /** Whether to perform case-insensitive matching for parameter names. */ + caseInsensitive?: boolean; +}; + +const DEFAULT_PARAM_MAP_OPTIONS: ParamMapOptions = Object.freeze({ + caseInsensitive: false, +}); + class ParamsAsMap implements ParamMap { private params: Params; + private readonly options: ParamMapOptions; + + constructor(params: Params, options?: ParamMapOptions) { + const rawParams = params || {}; + this.options = { + ...DEFAULT_PARAM_MAP_OPTIONS, + ...(options || {}), + }; - constructor(params: Params) { - this.params = params || {}; + // Normalize the parameter keys based on the caseInsensitive option. + if (this.options.caseInsensitive) { + this.params = {}; + for (const key of Object.keys(rawParams)) { + this.params[key.toLowerCase()] = rawParams[key]; + } + } else { + this.params = rawParams; + } + } + + private getKey(name: string): string | null { + // Guard against invalid lookup keys (null, undefined, non-strings, or empty strings) + if (typeof name !== 'string' || name.trim() === '') { + return null; + } + + const key = this.options.caseInsensitive ? name.toLowerCase() : name; + + return Object.prototype.hasOwnProperty.call(this.params, key) ? key : null; } has(name: string): boolean { - return Object.prototype.hasOwnProperty.call(this.params, name); + return this.getKey(name) !== null; } get(name: string): string | null { - if (this.has(name)) { - const v = this.params[name]; + const key = this.getKey(name); + if (key !== null) { + const v = this.params[key]; return Array.isArray(v) ? v[0] : v; } - return null; } getAll(name: string): string[] { - if (this.has(name)) { - const v = this.params[name]; + const key = this.getKey(name); + if (key !== null) { + const v = this.params[key]; return Array.isArray(v) ? v : [v]; } - return []; } @@ -109,12 +151,14 @@ class ParamsAsMap implements ParamMap { /** * Converts a `Params` instance to a `ParamMap`. * @param params The instance to convert. + * @param options Optional, Options to configure the conversion. + * - `caseInsensitive`: Whether to perform case-insensitive matching for parameter names. Defaults to `false`. * @returns The new map instance. * * @publicApi */ -export function convertToParamMap(params: Params): ParamMap { - return new ParamsAsMap(params); +export function convertToParamMap(params: Params, options?: ParamMapOptions): ParamMap { + return new ParamsAsMap(params, options); } function matchParts( diff --git a/packages/router/test/shared.spec.ts b/packages/router/test/shared.spec.ts index 0031c556c5ff..1c011ebb4abe 100644 --- a/packages/router/test/shared.spec.ts +++ b/packages/router/test/shared.spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {convertToParamMap, ParamMap, Params} from '../src/shared'; +import {convertToParamMap, ParamMap, ParamMapOptions, Params} from '../src/shared'; describe('ParamsMap', () => { it('should returns whether a parameter is present', () => { @@ -51,4 +51,49 @@ describe('ParamsMap', () => { expect(() => paramMaps.get('single')).not.toThrow(); expect(paramMaps.get('single')).toEqual('s'); }); + + describe('options configuration', () => { + it('should use default options when options parameter is omitted', () => { + const map = convertToParamMap({Single: 's'}); + expect(map.get('Single')).toEqual('s'); + expect(map.get('single')).toEqual(null); + }); + + it('should handle undefined or null options gracefully', () => { + const mapWithUndefined = convertToParamMap({key: 'val'}, undefined); + const mapWithNull = convertToParamMap({key: 'val'}, null as unknown as ParamMapOptions); + + expect(mapWithUndefined.get('key')).toEqual('val'); + expect(mapWithNull.get('key')).toEqual('val'); + }); + + describe('case sensitivity', () => { + it('should be case-sensitive by default', () => { + const map = convertToParamMap({ParamKey: 'value'}); + + expect(map.has('ParamKey')).toEqual(true); + expect(map.has('paramkey')).toEqual(false); + expect(map.get('paramkey')).toEqual(null); + }); + + it('should respect explicit caseInsensitive: false', () => { + const map = convertToParamMap({ParamKey: 'value'}, {caseInsensitive: false}); + + expect(map.has('ParamKey')).toEqual(true); + expect(map.has('paramkey')).toEqual(false); + }); + + it('should handle case-insensitive lookups across all getter methods when enabled', () => { + const map = convertToParamMap( + {SingleKey: 'one', ArrayKey: ['a', 'b']}, + {caseInsensitive: true}, + ); + + expect(map.has('singlekey')).toEqual(true); + expect(map.get('SINGLEKEY')).toEqual('one'); + expect(map.getAll('ARRAYKEY')).toEqual(['a', 'b']); + expect(map.keys).toEqual(['singlekey', 'arraykey']); + }); + }); + }); });