Skip to content

Commit f586b1b

Browse files
authored
Merge pull request #489 from markojerkic/arrays-in-search-params
feat(search-params): Enable arrays in useSearchParams
2 parents 272218f + 6e9191d commit f586b1b

File tree

5 files changed

+138
-22
lines changed

5 files changed

+138
-22
lines changed

src/routing.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ import type {
3232
RouterContext,
3333
RouterIntegration,
3434
SetParams,
35-
Submission
35+
Submission,
36+
SearchParams,
37+
SetSearchParams
3638
} from "./types.js";
3739
import {
3840
mockBase,
@@ -100,13 +102,13 @@ export const useCurrentMatches = () => useRouter().matches;
100102

101103
export const useParams = <T extends Params>() => useRouter().params as T;
102104

103-
export const useSearchParams = <T extends Params>(): [
105+
export const useSearchParams = <T extends SearchParams>(): [
104106
Partial<T>,
105-
(params: SetParams, options?: Partial<NavigateOptions>) => void
107+
(params: SetSearchParams, options?: Partial<NavigateOptions>) => void
106108
] => {
107109
const location = useLocation();
108110
const navigate = useNavigate();
109-
const setSearchParams = (params: SetParams, options?: Partial<NavigateOptions>) => {
111+
const setSearchParams = (params: SetSearchParams, options?: Partial<NavigateOptions>) => {
110112
const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
111113
navigate(searchString, {
112114
scroll: false,

src/types.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ declare module "solid-js/web" {
55
response: {
66
status?: number;
77
statusText?: string;
8-
headers: Headers
8+
headers: Headers;
99
};
10-
router? : {
10+
router?: {
1111
matches?: OutputMatch[];
1212
cache?: Map<string, CacheEntry>;
1313
submission?: {
@@ -18,14 +18,22 @@ declare module "solid-js/web" {
1818
dataOnly?: boolean | string[];
1919
data?: Record<string, any>;
2020
previousUrl?: string;
21-
}
21+
};
2222
serverOnly?: boolean;
2323
}
2424
}
2525

2626
export type Params = Record<string, string>;
27+
export type SearchParams = Record<string, string | string[]>;
2728

28-
export type SetParams = Record<string, string | number | boolean | null | undefined>;
29+
export type SetParams = Record<
30+
string,
31+
string | number | boolean | null | undefined
32+
>;
33+
export type SetSearchParams = Record<
34+
string,
35+
string | string[] | number | number[] | boolean | boolean[] | null | undefined
36+
>;
2937

3038
export interface Path {
3139
pathname: string;
@@ -34,7 +42,7 @@ export interface Path {
3442
}
3543

3644
export interface Location<S = unknown> extends Path {
37-
query: Params;
45+
query: SearchParams;
3846
state: Readonly<Partial<S>> | null;
3947
key: string;
4048
}
@@ -227,9 +235,12 @@ export type NarrowResponse<T> = T extends CustomResponse<infer U> ? U : Exclude<
227235
export type RouterResponseInit = Omit<ResponseInit, "body"> & { revalidate?: string | string[] };
228236
// export type CustomResponse<T> = Response & { customBody: () => T };
229237
// hack to avoid it thinking it inherited from Response
230-
export type CustomResponse<T> = Omit<Response, "clone"> & { customBody: () => T; clone(...args: readonly unknown[]): CustomResponse<T> };
238+
export type CustomResponse<T> = Omit<Response, "clone"> & {
239+
customBody: () => T;
240+
clone(...args: readonly unknown[]): CustomResponse<T>;
241+
};
231242

232243
/** @deprecated */
233244
export type RouteLoadFunc = RoutePreloadFunc;
234245
/** @deprecated */
235-
export type RouteLoadFuncArgs = RoutePreloadFuncArgs;
246+
export type RouteLoadFuncArgs = RoutePreloadFuncArgs;

src/utils.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { createMemo, getOwner, runWithOwner } from "solid-js";
2-
import type { MatchFilter, MatchFilters, Params, PathMatch, RouteDescription, SetParams } from "./types.ts";
2+
import type {
3+
MatchFilter,
4+
MatchFilters,
5+
Params,
6+
PathMatch,
7+
RouteDescription,
8+
SearchParams,
9+
SetParams,
10+
SetSearchParams
11+
} from "./types.ts";
312

413
const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
514
const trimPathRegex = /^\/+|(\/)\/+$/g;
6-
export const mockBase = "http://sr"
15+
export const mockBase = "http://sr";
716

817
export function normalizePath(path: string, omitSlash: boolean = false) {
918
const s = path.replace(trimPathRegex, "$1");
@@ -38,10 +47,16 @@ export function joinPaths(from: string, to: string): string {
3847
return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
3948
}
4049

41-
export function extractSearchParams(url: URL): Params {
42-
const params: Params = {};
50+
export function extractSearchParams(url: URL): SearchParams {
51+
const params: SearchParams = {};
4352
url.searchParams.forEach((value, key) => {
44-
params[key] = value;
53+
if (key in params) {
54+
params[key] = Array.isArray(params[key])
55+
? ([...params[key], value] as string[])
56+
: ([params[key], value] as string[]);
57+
} else {
58+
params[key] = value;
59+
}
4560
});
4661
return params;
4762
}
@@ -150,13 +165,21 @@ export function createMemoObject<T extends Record<string | symbol, unknown>>(fn:
150165
});
151166
}
152167

153-
export function mergeSearchString(search: string, params: SetParams) {
168+
export function mergeSearchString(search: string, params: SetSearchParams) {
154169
const merged = new URLSearchParams(search);
155170
Object.entries(params).forEach(([key, value]) => {
156-
if (value == null || value === "") {
171+
if (value == null || value === "" || (value instanceof Array && !value.length)) {
157172
merged.delete(key);
158173
} else {
159-
merged.set(key, String(value));
174+
if (value instanceof Array) {
175+
// Delete all instances of the key before appending
176+
merged.delete(key);
177+
value.forEach(v => {
178+
merged.append(key, String(v));
179+
});
180+
} else {
181+
merged.set(key, String(value));
182+
}
160183
}
161184
});
162185
const s = merged.toString();

test/route.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { vi } from 'vitest'
1+
import { vi } from "vitest";
22
import { createBranch, createBranches, createRoutes } from "../src/routing.js";
33
import type { RouteDefinition } from "../src/index.js";
44

@@ -174,7 +174,6 @@ describe("createRoutes should", () => {
174174
expect(match).not.toBeNull();
175175
expect(match.path).toBe("/foo/123/bar/solid.html");
176176
});
177-
178177
});
179178

180179
describe(`expand optional parameters`, () => {

test/utils.spec.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
joinPaths,
44
resolvePath,
55
createMemoObject,
6-
expandOptionals
6+
expandOptionals,
7+
mergeSearchString,
8+
extractSearchParams
79
} from "../src/utils";
810

911
describe("resolvePath should", () => {
@@ -86,6 +88,85 @@ describe("resolvePath should", () => {
8688
});
8789
});
8890

91+
describe("mergeSearchString should", () => {
92+
test("return empty string when current and new params are empty", () => {
93+
const expected = "";
94+
const actual = mergeSearchString("", {});
95+
expect(actual).toBe(expected);
96+
});
97+
98+
test("return new params when current params are empty", () => {
99+
const expected = "?foo=bar";
100+
const actual = mergeSearchString("", { foo: "bar" });
101+
expect(actual).toBe(expected);
102+
});
103+
104+
test("return current params when new params are empty", () => {
105+
const expected = "?foo=bar";
106+
const actual = mergeSearchString("?foo=bar", {});
107+
expect(actual).toBe(expected);
108+
});
109+
110+
test("return merged params when current and new params are not empty", () => {
111+
const expected = "?foo=bar&baz=qux";
112+
const actual = mergeSearchString("?foo=bar", { baz: "qux" });
113+
expect(actual).toBe(expected);
114+
});
115+
116+
test("return ampersand-separated params when new params is an array", () => {
117+
const expected = "?foo=bar&foo=baz";
118+
const actual = mergeSearchString("", { foo: ["bar", "baz"] });
119+
expect(actual).toBe(expected);
120+
});
121+
122+
test("return ampersand-separated params when current params is an array of numbers", () => {
123+
const expected = "?foo=1&foo=2";
124+
const actual = mergeSearchString("", { foo: [1, 2] });
125+
expect(actual).toBe(expected);
126+
});
127+
128+
test("return empty string when new is an empty array", () => {
129+
const expected = "";
130+
const actual = mergeSearchString("", { foo: [] });
131+
expect(actual).toBe(expected);
132+
});
133+
134+
test("return empty string when current is present and new is an empty array", () => {
135+
const expected = "";
136+
const actual = mergeSearchString("?foo=2&foo=3", { foo: [] });
137+
expect(actual).toBe(expected);
138+
});
139+
140+
test("return array containing only new value when current is present and new is an array with one value", () => {
141+
const expected = "?foo=1&foo=2";
142+
const actual = mergeSearchString("?foo=3&foo=4", { foo: [1, 2] });
143+
expect(actual).toBe(expected);
144+
});
145+
});
146+
147+
describe("extractSearchParams should", () => {
148+
test("return empty object when URL has no search params", () => {
149+
const url = new URL("http://localhost/");
150+
const expected = {};
151+
const actual = extractSearchParams(url);
152+
expect(actual).toEqual(expected);
153+
});
154+
155+
test("return search params as object", () => {
156+
const url = new URL("http://localhost/?foo=bar&baz=qux");
157+
const expected = { foo: "bar", baz: "qux" };
158+
const actual = extractSearchParams(url);
159+
expect(actual).toEqual(expected);
160+
});
161+
162+
test("return search params as object with array values", () => {
163+
const url = new URL("http://localhost/?foo=bar&foo=baz");
164+
const expected = { foo: ["bar", "baz"] };
165+
const actual = extractSearchParams(url);
166+
expect(actual).toEqual(expected);
167+
});
168+
});
169+
89170
describe("createMatcher should", () => {
90171
test("return empty object when location matches simple path", () => {
91172
const expected = { path: "/foo/bar", params: {} };

0 commit comments

Comments
 (0)