Skip to content

Commit 1c1a6da

Browse files
authored
fix: correct TypeScript types for singleParse and multiParse (#241)
- Add explicit generic type parameter T with default string - singleParse codec now correctly typed as (value: string) => T - multiParse codec now correctly typed as (entry: [string, string]) => [string, T] - Return types properly reflect generic T - Add comprehensive tests for both functions Fixes FE-584
1 parent 937e392 commit 1c1a6da

2 files changed

Lines changed: 163 additions & 26 deletions

File tree

src/location.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,41 @@
11
import { identity } from './function';
22

33
export const hashUrl = () =>
4-
new URL(
5-
location.hash.replace(/^#!?/iu, '').replace('%23', '#'),
6-
location.origin,
7-
),
8-
singleParse = (hashParam: string, codec = identity) => {
9-
const values = new URLSearchParams(hashUrl().hash.replace('#', '')).getAll(
10-
hashParam,
11-
);
4+
new URL(
5+
location.hash.replace(/^#!?/iu, '').replace('%23', '#'),
6+
location.origin,
7+
);
128

13-
switch (values.length) {
14-
case 0:
15-
return undefined;
16-
case 1:
17-
return codec(values[0]);
18-
default:
19-
return values.map(codec);
20-
}
21-
},
22-
multiParse = (hashParam: string, codec = identity) => {
23-
const params = Array.from(
24-
new URLSearchParams(hashUrl().hash.replace('#', '')).entries(),
25-
)
26-
.filter(([param]) => param.startsWith(hashParam))
27-
.map(([param, value]) => codec([param.replace(hashParam, ''), value]))
28-
.filter(([, value]) => value != null);
9+
export const singleParse = <T = string>(
10+
hashParam: string,
11+
codec: (value: string) => T = identity as (value: string) => T,
12+
): T | T[] | undefined => {
13+
const values = new URLSearchParams(hashUrl().hash.replace('#', '')).getAll(
14+
hashParam,
15+
);
2916

30-
return Object.fromEntries(params);
31-
};
17+
switch (values.length) {
18+
case 0:
19+
return undefined;
20+
case 1:
21+
return codec(values[0]);
22+
default:
23+
return values.map(codec);
24+
}
25+
};
26+
27+
export const multiParse = <T = string>(
28+
hashParam: string,
29+
codec: (entry: [string, string]) => [string, T] = identity as (
30+
entry: [string, string],
31+
) => [string, T],
32+
): Record<string, T> => {
33+
const params = Array.from(
34+
new URLSearchParams(hashUrl().hash.replace('#', '')).entries(),
35+
)
36+
.filter(([param]) => param.startsWith(hashParam))
37+
.map(([param, value]) => codec([param.replace(hashParam, ''), value]))
38+
.filter(([, value]) => value != null);
39+
40+
return Object.fromEntries(params) as Record<string, T>;
41+
};

test/location.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { assert } from '@open-wc/testing';
2+
import { hashUrl, multiParse, singleParse } from '../src/location';
3+
4+
suite('location', () => {
5+
const originalHash = window.location.hash;
6+
7+
const setHash = (hash: string) => {
8+
window.location.hash = hash;
9+
};
10+
11+
teardown(() => {
12+
window.location.hash = originalHash;
13+
});
14+
15+
suite('hashUrl', () => {
16+
test('returns URL from hash', () => {
17+
setHash('#!/path#foo=bar');
18+
const url = hashUrl();
19+
assert.instanceOf(url, URL);
20+
assert.equal(url.pathname, '/path');
21+
assert.equal(url.hash, '#foo=bar');
22+
});
23+
24+
test('handles #! prefix', () => {
25+
setHash('#!/test');
26+
const url = hashUrl();
27+
assert.equal(url.pathname, '/test');
28+
});
29+
30+
test('handles %23 in hash', () => {
31+
setHash('#!%23anchor');
32+
const url = hashUrl();
33+
assert.equal(url.hash, '#anchor');
34+
});
35+
});
36+
37+
suite('singleParse', () => {
38+
test('returns undefined when param not found', () => {
39+
setHash('#!/path');
40+
const result = singleParse('nonexistent');
41+
assert.isUndefined(result);
42+
});
43+
44+
test('returns string for single value', () => {
45+
setHash('#!/path#name=value');
46+
const result = singleParse('name');
47+
assert.equal(result, 'value');
48+
});
49+
50+
test('returns array for multiple values', () => {
51+
setHash('#!/path#name=value1&name=value2');
52+
const result = singleParse('name');
53+
assert.deepEqual(result, ['value1', 'value2']);
54+
});
55+
56+
test('applies custom codec to single value', () => {
57+
setHash('#!/path#count=42');
58+
const result = singleParse('count', (v) => parseInt(v, 10));
59+
assert.equal(result, 42);
60+
});
61+
62+
test('applies custom codec to multiple values', () => {
63+
setHash('#!/path#count=1&count=2&count=3');
64+
const result = singleParse('count', (v) => parseInt(v, 10));
65+
assert.deepEqual(result, [1, 2, 3]);
66+
});
67+
68+
test('preserves string type with default codec', () => {
69+
setHash('#!/path#name=test');
70+
const result: string | string[] | undefined = singleParse('name');
71+
assert.equal(result, 'test');
72+
});
73+
74+
test('works with hash params (not query string)', () => {
75+
setHash('##foo=bar&baz=qux');
76+
const result = singleParse('foo');
77+
assert.equal(result, 'bar');
78+
});
79+
});
80+
81+
suite('multiParse', () => {
82+
test('returns empty object when no params match', () => {
83+
setHash('#!/path');
84+
const result = multiParse('filter.');
85+
assert.deepEqual(result, {});
86+
});
87+
88+
test('parses params with prefix', () => {
89+
setHash('#!/path#filter.status=active&filter.type=admin');
90+
const result = multiParse('filter.');
91+
assert.deepEqual(result, { status: 'active', type: 'admin' });
92+
});
93+
94+
test('returns Record<string, string> with default codec', () => {
95+
setHash('#!/path#name=john&age=30');
96+
const result: Record<string, string> = multiParse('');
97+
assert.deepEqual(result, { name: 'john', age: '30' });
98+
});
99+
100+
test('applies custom codec for value transformation', () => {
101+
setHash('#!/path#count.a=1&count.b=2');
102+
const result = multiParse(
103+
'count.',
104+
([key, value]) => [key, parseInt(value, 10)] as const,
105+
);
106+
assert.deepEqual(result, { a: 1, b: 2 });
107+
});
108+
109+
test('applies custom codec with type transformation', () => {
110+
setHash('#!/path#flag.enabled=true&flag.visible=false');
111+
const result = multiParse(
112+
'flag.',
113+
([key, value]) => [key, value === 'true'] as const,
114+
);
115+
assert.deepEqual(result, { enabled: true, visible: false });
116+
});
117+
118+
test('filters out null/undefined values', () => {
119+
setHash('#!/path#item.a=value&item.b=');
120+
const result = multiParse('item.', ([key, value]) =>
121+
value ? ([key, value] as const) : ([key, null] as const),
122+
);
123+
// The empty string for 'b' becomes null, filter removes it
124+
assert.deepEqual(result, { a: 'value' });
125+
});
126+
});
127+
});

0 commit comments

Comments
 (0)