Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion goldens/public-api/router/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 55 additions & 11 deletions packages/router/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}

Expand All @@ -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(
Expand Down
47 changes: 46 additions & 1 deletion packages/router/test/shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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']);
});
});
});
});
Loading