From af81d14e27510db932e0adbf98b5e79d7f46badb Mon Sep 17 00:00:00 2001 From: Jonah Henriksson <33059163+JonahPlusPlus@users.noreply.github.com> Date: Wed, 29 May 2024 13:31:05 -0400 Subject: [PATCH] Make params consistent for string paths --- README.md | 5 +- packages/wouter-preact/types/index.d.ts | 11 ++- packages/wouter/src/index.js | 18 ++-- packages/wouter/test/parser.test.tsx | 4 +- packages/wouter/test/use-params.test-d.ts | 8 +- packages/wouter/test/use-params.test.tsx | 33 ++++++- packages/wouter/test/use-route.test-d.ts | 3 + packages/wouter/test/use-route.test.tsx | 107 +++++++++++++++++----- packages/wouter/types/index.d.ts | 11 ++- 9 files changed, 150 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 96e1c522..0be0e9c3 100644 --- a/README.md +++ b/README.md @@ -318,12 +318,15 @@ const User = () => { const params = useParams(); params.id; // "1" + + // alternatively, use the index to access the prop + params[0]; // "1" }; /> ``` -For regex paths, parameters are accessible as indices or through their group name. +It is the same for regex paths. Capture groups can be accessed by their index, or if there is a named capture group, that can be used instead. ```js import { Route, useParams } from "wouter"; diff --git a/packages/wouter-preact/types/index.d.ts b/packages/wouter-preact/types/index.d.ts index f8de84f3..aa5030b4 100644 --- a/packages/wouter-preact/types/index.d.ts +++ b/packages/wouter-preact/types/index.d.ts @@ -30,6 +30,9 @@ export * from "./router.js"; import { RouteParams } from "regexparam"; +export type StringRouteParams = RouteParams & { + [param: number]: string | undefined; +}; export type RegexRouteParams = { [key: string | number]: string | undefined }; /** @@ -67,7 +70,7 @@ export interface RouteProps< params: T extends DefaultParams ? T : RoutePath extends string - ? RouteParams + ? StringRouteParams : RegexRouteParams ) => ComponentChildren) | ComponentChildren; @@ -77,7 +80,7 @@ export interface RouteProps< T extends DefaultParams ? T : RoutePath extends string - ? RouteParams + ? StringRouteParams : RegexRouteParams > >; @@ -163,7 +166,7 @@ export function useRoute< T extends DefaultParams ? T : RoutePath extends string - ? RouteParams + ? StringRouteParams : RegexRouteParams >; @@ -176,7 +179,7 @@ export function useSearch< >(): ReturnType; export function useParams(): T extends string - ? RouteParams + ? StringRouteParams : T extends undefined ? DefaultParams : T; diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 99262c35..b5b28ce5 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -99,20 +99,22 @@ const matchRoute = (parser, route, path, loose) => { true, (() => { - /// for regex paths, `keys` will always be false - if (keys !== false) { - // an object with parameters matched, e.g. { foo: "bar" } for "/:foo" - // we "zip" two arrays here to construct the object - // ["foo"], ["bar"] → { foo: "bar" } - return Object.fromEntries(keys.map((key, i) => [key, matches[i]])); - } + // for regex paths, `keys` will always be false + + // an object with parameters matched, e.g. { foo: "bar" } for "/:foo" + // we "zip" two arrays here to construct the object + // ["foo"], ["bar"] → { foo: "bar" } + const groups = + keys !== false + ? Object.fromEntries(keys.map((key, i) => [key, matches[i]])) + : result.groups; // convert the array to an instance of object // this makes it easier to integrate with the existing param implementation let obj = { ...matches }; // merge named capture groups with matches array - result.groups && Object.assign(obj, result.groups); + groups && Object.assign(obj, groups); return obj; })(), diff --git a/packages/wouter/test/parser.test.tsx b/packages/wouter/test/parser.test.tsx index 54cd0f06..8b595dd9 100644 --- a/packages/wouter/test/parser.test.tsx +++ b/packages/wouter/test/parser.test.tsx @@ -7,7 +7,7 @@ import { Router, useRouter, useRoute, Parser } from "wouter"; import { memoryLocation } from "wouter/memory-location"; // Custom parser that uses `path-to-regexp` instead of `regexparam` -const pathToRegexpParser: Parser = (route: string | RegExp) => { +const pathToRegexpParser: Parser = (route: string) => { const keys: Key[] = []; const pattern = pathToRegexp(route, keys); @@ -42,6 +42,6 @@ it("allows to change the behaviour of route matching", () => { expect(result.current).toStrictEqual([ true, - { pages: undefined, rest: "10/bio", 0: "home" }, + { 0: "home", 1: undefined, 2: "10/bio", pages: undefined, rest: "10/bio" }, ]); }); diff --git a/packages/wouter/test/use-params.test-d.ts b/packages/wouter/test/use-params.test-d.ts index 29d4de1e..972c379d 100644 --- a/packages/wouter/test/use-params.test-d.ts +++ b/packages/wouter/test/use-params.test-d.ts @@ -10,12 +10,18 @@ it("returns an object with arbitrary parameters", () => { expectTypeOf(params).toBeObject(); expectTypeOf(params.any).toEqualTypeOf(); + expectTypeOf(params[0]).toEqualTypeOf(); }); it("can infer the type of parameters from the route path", () => { const params = useParams<"/app/users/:name?/:id">(); - expectTypeOf(params).toMatchTypeOf<{ id: string; name?: string }>(); + expectTypeOf(params).toMatchTypeOf<{ + 0?: string; + 1?: string; + id: string; + name?: string; + }>(); }); it("can accept the custom type of parameters as a generic argument", () => { diff --git a/packages/wouter/test/use-params.test.tsx b/packages/wouter/test/use-params.test.tsx index 7528ce1d..409d6d75 100644 --- a/packages/wouter/test/use-params.test.tsx +++ b/packages/wouter/test/use-params.test.tsx @@ -18,7 +18,10 @@ it("contains a * parameter when used inside an empty ", () => { ), }); - expect(result.current).toEqual({ "*": "app-2/goods/tees" }); + expect(result.current).toEqual({ + 0: "app-2/goods/tees", + "*": "app-2/goods/tees", + }); }); it("returns an empty object when there are no params", () => { @@ -40,7 +43,12 @@ it("returns parameters from the closest parent match", () => { ), }); - expect(result.current).toEqual({ id: "1", name: "maria" }); + expect(result.current).toEqual({ + 0: "1", + 1: "maria", + id: "1", + name: "maria", + }); }); it("rerenders with parameters change", () => { @@ -57,10 +65,20 @@ it("rerenders with parameters change", () => { expect(result.current).toBeNull(); act(() => navigate("/posts/all")); - expect(result.current).toEqual({ a: "posts", b: "all" }); + expect(result.current).toEqual({ + 0: "posts", + 1: "all", + a: "posts", + b: "all", + }); act(() => navigate("/posts/latest")); - expect(result.current).toEqual({ a: "posts", b: "latest" }); + expect(result.current).toEqual({ + 0: "posts", + 1: "latest", + a: "posts", + b: "latest", + }); }); it("extracts parameters of the nested route", () => { @@ -79,5 +97,10 @@ it("extracts parameters of the nested route", () => { ), }); - expect(result.current).toEqual({ version: "v2", chain: "eth" }); + expect(result.current).toEqual({ + 0: "v2", + 1: "eth", + version: "v2", + chain: "eth", + }); }); diff --git a/packages/wouter/test/use-route.test-d.ts b/packages/wouter/test/use-route.test-d.ts index 9836f7e0..8638ea86 100644 --- a/packages/wouter/test/use-route.test-d.ts +++ b/packages/wouter/test/use-route.test-d.ts @@ -40,6 +40,9 @@ it("infers parameters from the route path", () => { if (inferedParams) { expectTypeOf(inferedParams).toMatchTypeOf<{ + 0?: string; + 1?: string; + 2?: string; name?: string; id: string; wildcard?: string; diff --git a/packages/wouter/test/use-route.test.tsx b/packages/wouter/test/use-route.test.tsx index 9bf0ea2b..61e3a850 100644 --- a/packages/wouter/test/use-route.test.tsx +++ b/packages/wouter/test/use-route.test.tsx @@ -6,21 +6,24 @@ import { memoryLocation } from "wouter/memory-location"; it("is case insensitive", () => { assertRoute("/Users", "/users", {}); assertRoute("/HomePage", "/Homepage", {}); - assertRoute("/Users/:Name", "/users/alex", { Name: "alex" }); + assertRoute("/Users/:Name", "/users/alex", { 0: "alex", Name: "alex" }); }); it("supports required segments", () => { - assertRoute("/:page", "/users", { page: "users" }); + assertRoute("/:page", "/users", { 0: "users", page: "users" }); assertRoute("/:page", "/users/all", false); - assertRoute("/:page", "/1", { page: "1" }); + assertRoute("/:page", "/1", { 0: "1", page: "1" }); - assertRoute("/home/:page/etc", "/home/users/etc", { page: "users" }); + assertRoute("/home/:page/etc", "/home/users/etc", { + 0: "users", + page: "users", + }); assertRoute("/home/:page/etc", "/home/etc", false); assertRoute( "/root/payments/:id/refunds/:refId", "/root/payments/1/refunds/2", - [true, { id: "1", refId: "2" }] + [true, { 0: "1", 1: "2", id: "1", refId: "2" }] ); }); @@ -31,22 +34,41 @@ it("ignores the trailing slash", () => { assertRoute("/home/", "/home/", {}); assertRoute("/home/", "/home", {}); - assertRoute("/:page", "/users/", [true, { page: "users" }]); - assertRoute("/catalog/:section?", "/catalog/", { section: undefined }); + assertRoute("/:page", "/users/", [true, { 0: "users", page: "users" }]); + assertRoute("/catalog/:section?", "/catalog/", { + 0: undefined, + section: undefined, + }); }); it("supports trailing wildcards", () => { - assertRoute("/app/*", "/app/", { "*": "" }); - assertRoute("/app/*", "/app/dashboard/intro", { "*": "dashboard/intro" }); - assertRoute("/app/*", "/app/charges/1", { "*": "charges/1" }); + assertRoute("/app/*", "/app/", { 0: "", "*": "" }); + assertRoute("/app/*", "/app/dashboard/intro", { + 0: "dashboard/intro", + "*": "dashboard/intro", + }); + assertRoute("/app/*", "/app/charges/1", { 0: "charges/1", "*": "charges/1" }); }); it("supports wildcards in the middle of the pattern", () => { - assertRoute("/app/*/settings", "/app/users/settings", { "*": "users" }); - assertRoute("/app/*/settings", "/app/users/1/settings", { "*": "users/1" }); + assertRoute("/app/*/settings", "/app/users/settings", { + 0: "users", + "*": "users", + }); + assertRoute("/app/*/settings", "/app/users/1/settings", { + 0: "users/1", + "*": "users/1", + }); - assertRoute("/*/payments/:id", "/home/payments/1", { "*": "home", id: "1" }); + assertRoute("/*/payments/:id", "/home/payments/1", { + 0: "home", + 1: "1", + "*": "home", + id: "1", + }); assertRoute("/*/payments/:id?", "/home/payments", { + 0: "home", + 1: undefined, "*": "home", id: undefined, }); @@ -54,56 +76,78 @@ it("supports wildcards in the middle of the pattern", () => { it("uses a question mark to define optional segments", () => { assertRoute("/books/:genre/:title?", "/books/scifi", { + 0: "scifi", + 1: undefined, genre: "scifi", title: undefined, }); assertRoute("/books/:genre/:title?", "/books/scifi/dune", { + 0: "scifi", + 1: "dune", genre: "scifi", title: "dune", }); assertRoute("/books/:genre/:title?", "/books/scifi/dune/all", false); assertRoute("/app/:company?/blog/:post", "/app/apple/blog/mac", { + 0: "apple", + 1: "mac", company: "apple", post: "mac", }); assertRoute("/app/:company?/blog/:post", "/app/blog/mac", { + 0: undefined, + 1: "mac", company: undefined, post: "mac", }); }); it("supports optional wildcards", () => { - assertRoute("/app/*?", "/app/blog/mac", { "*": "blog/mac" }); - assertRoute("/app/*?", "/app", { "*": undefined }); - assertRoute("/app/*?/dashboard", "/app/v1/dashboard", { "*": "v1" }); - assertRoute("/app/*?/dashboard", "/app/dashboard", { "*": undefined }); + assertRoute("/app/*?", "/app/blog/mac", { 0: "blog/mac", "*": "blog/mac" }); + assertRoute("/app/*?", "/app", { 0: undefined, "*": undefined }); + assertRoute("/app/*?/dashboard", "/app/v1/dashboard", { 0: "v1", "*": "v1" }); + assertRoute("/app/*?/dashboard", "/app/dashboard", { + 0: undefined, + "*": undefined, + }); assertRoute("/app/*?/users/:name", "/app/users/karen", { + 0: undefined, + 1: "karen", "*": undefined, name: "karen", }); }); it("supports other characters in segments", () => { - assertRoute("/users/:name", "/users/1-alex", { name: "1-alex" }); + assertRoute("/users/:name", "/users/1-alex", { 0: "1-alex", name: "1-alex" }); assertRoute("/staff/:name/:bio?", "/staff/John Doe 3", { + 0: "John Doe 3", + 1: undefined, name: "John Doe 3", bio: undefined, }); assertRoute("/staff/:name/:bio?", "/staff/John Doe 3/bio", { + 0: "John Doe 3", + 1: "bio", name: "John Doe 3", bio: "bio", }); assertRoute("/users/:name/bio", "/users/$102_Kathrine&/bio", { + 0: "$102_Kathrine&", name: "$102_Kathrine&", }); }); it("ignores escaped slashes", () => { - assertRoute("/:param/bar", "/foo%2Fbar/bar", { param: "foo%2Fbar" }); + assertRoute("/:param/bar", "/foo%2Fbar/bar", { + 0: "foo%2Fbar", + param: "foo%2Fbar", + }); assertRoute("/:param", "/foo%2Fbar%D1%81%D0%B0%D0%BD%D1%8F", { + 0: "foo%2Fbarсаня", param: "foo%2Fbarсаня", }); }); @@ -138,17 +182,27 @@ it("reacts to pattern updates", () => { rerender({ pattern: "/blog/:category/:post/:action" }); expect(result.current).toStrictEqual([ true, - { category: "products", post: "40", action: "read-all" }, + { + 0: "products", + 1: "40", + 2: "read-all", + category: "products", + post: "40", + action: "read-all", + }, ]); rerender({ pattern: "/blog/products/:id?/read-all" }); - expect(result.current).toStrictEqual([true, { id: "40" }]); + expect(result.current).toStrictEqual([true, { 0: "40", id: "40" }]); rerender({ pattern: "/blog/products/:name" }); expect(result.current).toStrictEqual([false, null]); rerender({ pattern: "/blog/*" }); - expect(result.current).toStrictEqual([true, { "*": "products/40/read-all" }]); + expect(result.current).toStrictEqual([ + true, + { 0: "products/40/read-all", "*": "products/40/read-all" }, + ]); }); it("reacts to location updates", () => { @@ -161,16 +215,19 @@ it("reacts to location updates", () => { expect(result.current).toStrictEqual([false, null]); act(() => navigate("/cities/berlin")); - expect(result.current).toStrictEqual([true, { city: "berlin" }]); + expect(result.current).toStrictEqual([true, { 0: "berlin", city: "berlin" }]); act(() => navigate("/cities/Tokyo")); - expect(result.current).toStrictEqual([true, { city: "Tokyo" }]); + expect(result.current).toStrictEqual([true, { 0: "Tokyo", city: "Tokyo" }]); act(() => navigate("/about")); expect(result.current).toStrictEqual([false, null]); act(() => navigate("/cities")); - expect(result.current).toStrictEqual([true, { city: undefined }]); + expect(result.current).toStrictEqual([ + true, + { 0: undefined, city: undefined }, + ]); }); /** diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index c9c0faf6..449c1337 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -34,6 +34,9 @@ export * from "./router.js"; import { RouteParams } from "regexparam"; +export type StringRouteParams = RouteParams & { + [param: number]: string | undefined; +}; export type RegexRouteParams = { [key: string | number]: string | undefined }; /** @@ -71,7 +74,7 @@ export interface RouteProps< params: T extends DefaultParams ? T : RoutePath extends string - ? RouteParams + ? StringRouteParams : RegexRouteParams ) => ReactNode) | ReactNode; @@ -81,7 +84,7 @@ export interface RouteProps< T extends DefaultParams ? T : RoutePath extends string - ? RouteParams + ? StringRouteParams : RegexRouteParams > >; @@ -170,7 +173,7 @@ export function useRoute< T extends DefaultParams ? T : RoutePath extends string - ? RouteParams + ? StringRouteParams : RegexRouteParams >; @@ -183,7 +186,7 @@ export function useSearch< >(): ReturnType; export function useParams(): T extends string - ? RouteParams + ? StringRouteParams : T extends undefined ? DefaultParams : T;