diff --git a/README.md b/README.md index b71a1e9a..dab309cd 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ dashboards and more. - [``](#switch-) - [``](#redirect-topath-) - [``](#router-hookhook-parserfn-basebasepath-hrefsfn-) + - [URLPattern route matching](#urlpattern-route-matching) - [FAQ and Code Recipes](#faq-and-code-recipes) - [I deploy my app to the subfolder. Can I specify a base path?](#i-deploy-my-app-to-the-subfolder-can-i-specify-a-base-path) @@ -637,8 +638,8 @@ available options: application routes will be relative to that path. To navigate out to an absolute path, prefix your path with an `~`. [See the FAQ](#are-relative-routes-and-links-supported). - **`parser: (path: string, loose?: boolean) => { pattern, keys }`** — a pattern parsing - function. Produces a RegExp for matching the current location against the user-defined patterns like - `/app/users/:id`. Has the same interface as the [`parse`](https://github.com/lukeed/regexparam?tab=readme-ov-file#regexparamparseinput-regexp) function from `regexparam`. See [this example](#are-strict-routes-supported) that demonstrates custom parser feature. + function. Produces a RegExp or an object with a compatible `exec` method for matching the current location against user-defined patterns like + `/app/users/:id`. `keys` can be omitted when matches provide named `groups`. The default parser is [`parse`](https://github.com/lukeed/regexparam?tab=readme-ov-file#regexparamparseinput-regexp) from `regexparam`. See [this example](#are-strict-routes-supported) that demonstrates custom parser feature. - **`ssrPath: string`** and **`ssrSearch: string`** use these when [rendering your app on the server](#server-side-rendering-support-ssr). @@ -656,6 +657,26 @@ available options: }; ``` +### URLPattern route matching + +To use native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) syntax for string routes, opt in with the separate `wouter/url-pattern` module. It requires a browser or runtime with `URLPattern` support: + +```jsx +import { Router, Route } from "wouter"; +import { urlPatternParser } from "wouter/url-pattern"; + + + {/* Matches /users and /users/42 */} + + {(params) => <>User: {params.id ?? "all"}} + +; +``` + +For Preact, import from `wouter-preact/url-pattern`. The adapter matches only the pathname and supports nested routes with `nest`. It follows native URLPattern semantics: matching is case-sensitive, and `/users` and `/users/` are distinct. Parameters are the native pathname groups, including numeric names for unnamed groups, and their values remain URL-encoded. Regular expression routes still work. The default parser and its syntax remain unchanged unless you select this adapter. + +TypeScript's automatic parameter inference follows the default parser's syntax. For more complex URLPattern patterns, specify parameter types explicitly, such as `useRoute<{ id?: string }>("/users{/:id}?")` or ` path="/users{/:id}?">`. You can also use the exported `DefaultParams` type for arbitrary parameter names. + ## FAQ and Code Recipes ### I deploy my app to the subfolder. Can I specify a base path? diff --git a/bunfig.toml b/bunfig.toml index ede45b06..2941161b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -7,6 +7,7 @@ coveragePathIgnorePatterns = [ # already measured at their original path (react-deps.js is preact-specific and stays measured) "packages/wouter-preact/src/index.js", "packages/wouter-preact/src/paths.js", + "packages/wouter-preact/src/url-pattern.js", "packages/wouter-preact/src/memory-location.js", "packages/wouter-preact/src/use-browser-location.js", "packages/wouter-preact/src/use-hash-location.js", diff --git a/package.json b/package.json index c35342eb..5ce330ca 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,10 @@ "use-sync-external-store" ] }, + { + "path": "packages/wouter/src/url-pattern.js", + "limit": "500 B" + }, { "path": "packages/wouter/src/use-hash-location.js", "limit": "1000 B", @@ -93,6 +97,10 @@ "preact", "preact/hooks" ] + }, + { + "path": "packages/wouter-preact/src/url-pattern.js", + "limit": "500 B" } ], "husky": { diff --git a/packages/wouter-preact/.gitignore b/packages/wouter-preact/.gitignore index d671cdd5..55bda900 100644 --- a/packages/wouter-preact/.gitignore +++ b/packages/wouter-preact/.gitignore @@ -2,6 +2,7 @@ src/index.js src/memory-location.js src/paths.js +src/url-pattern.js src/use-browser-location.js src/use-hash-location.js src/use-sync-external-store.js diff --git a/packages/wouter-preact/package.json b/packages/wouter-preact/package.json index a4af3f1c..07693e03 100644 --- a/packages/wouter-preact/package.json +++ b/packages/wouter-preact/package.json @@ -34,6 +34,10 @@ "./memory-location": { "types": "./types/memory-location.d.ts", "default": "./src/memory-location.js" + }, + "./url-pattern": { + "types": "./types/url-pattern.d.ts", + "default": "./src/url-pattern.js" } }, "types": "types/index.d.ts", @@ -50,11 +54,14 @@ ], "memory-location": [ "types/memory-location.d.ts" + ], + "url-pattern": [ + "types/url-pattern.d.ts" ] } }, "scripts": { - "prepublishOnly": "cp ../wouter/src/index.js ../wouter/src/memory-location.js ../wouter/src/paths.js ../wouter/src/use-browser-location.js ../wouter/src/use-hash-location.js ../wouter/src/use-sync-external-store.js ../wouter/src/use-sync-external-store.native.js src && cp ../../README.md ." + "prepublishOnly": "cp ../wouter/src/index.js ../wouter/src/memory-location.js ../wouter/src/paths.js ../wouter/src/url-pattern.js ../wouter/src/use-browser-location.js ../wouter/src/use-hash-location.js ../wouter/src/use-sync-external-store.js ../wouter/src/use-sync-external-store.native.js src && cp ../../README.md ." }, "author": "Alexey Taktarov ", "repository": { diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index f2868150..a9173cca 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -30,6 +30,7 @@ const filesToCopy = [ "use-sync-external-store.js", "use-sync-external-store.native.js", "index.js", + "url-pattern.js", ]; async function loadPreact(): Promise { @@ -198,6 +199,45 @@ describe("Preact support", () => { act(() => render(null, container)); container.remove(); }); + + test("URLPattern routes inherit nested params and react to navigation", async () => { + const { Router, Route, Switch, useParams, useRouter } = await loadPreact(); + const { urlPatternParser } = await import("wouter-preact/url-pattern"); + const { memoryLocation } = await import("wouter-preact/memory-location"); + const { hook, navigate } = memoryLocation({ + path: "/app/users/42/posts/7", + }); + const container = document.body.appendChild(document.createElement("div")); + const Post = () => { + const { id, post } = useParams<{ id: string; post: string }>(); + return <>{`${id}:${post}:${useRouter().base}`}; + }; + try { + act(() => { + render( + + + + + + + + Fallback + + , + container + ); + }); + expect(container.textContent).toBe("42:7:/app/users/42"); + act(() => navigate("/app/users/42/posts/8")); + expect(container.textContent).toBe("42:8:/app/users/42"); + act(() => navigate("/app/users/alex/posts/8")); + expect(container.textContent).toBe("Fallback"); + } finally { + act(() => render(null, container)); + container.remove(); + } + }); }); describe("useSyncExternalStore shim", () => { diff --git a/packages/wouter-preact/types/router.d.ts b/packages/wouter-preact/types/router.d.ts index 56a0872e..1d662920 100644 --- a/packages/wouter-preact/types/router.d.ts +++ b/packages/wouter-preact/types/router.d.ts @@ -9,7 +9,7 @@ import { export type Parser = ( route: Path, loose?: boolean -) => { pattern: RegExp; keys: string[] }; +) => { pattern: Pick; keys?: string[] }; // Standard navigation options supported by all built-in location hooks export type NavigateOptions = { diff --git a/packages/wouter-preact/types/url-pattern.d.ts b/packages/wouter-preact/types/url-pattern.d.ts new file mode 100644 index 00000000..b55a05bd --- /dev/null +++ b/packages/wouter-preact/types/url-pattern.d.ts @@ -0,0 +1,3 @@ +import { Parser } from "./router.js"; + +export const urlPatternParser: Parser; diff --git a/packages/wouter/package.json b/packages/wouter/package.json index 1794329a..f53e24ba 100644 --- a/packages/wouter/package.json +++ b/packages/wouter/package.json @@ -34,6 +34,10 @@ "./memory-location": { "types": "./types/memory-location.d.ts", "default": "./src/memory-location.js" + }, + "./url-pattern": { + "types": "./types/url-pattern.d.ts", + "default": "./src/url-pattern.js" } }, "types": "types/index.d.ts", @@ -50,6 +54,9 @@ ], "memory-location": [ "types/memory-location.d.ts" + ], + "url-pattern": [ + "types/url-pattern.d.ts" ] } }, diff --git a/packages/wouter/src/url-pattern.d.ts b/packages/wouter/src/url-pattern.d.ts new file mode 100644 index 00000000..887c3160 --- /dev/null +++ b/packages/wouter/src/url-pattern.d.ts @@ -0,0 +1 @@ +export * from "../types/url-pattern.js"; diff --git a/packages/wouter/src/url-pattern.js b/packages/wouter/src/url-pattern.js new file mode 100644 index 00000000..21f6b4b7 --- /dev/null +++ b/packages/wouter/src/url-pattern.js @@ -0,0 +1,36 @@ +/* global URLPattern */ + +// Opt-in adapter for the native URLPattern API. +export const urlPatternParser = (route, loose) => { + const pattern = new URLPattern({ pathname: route }); + + return { + pattern: { + exec(path) { + let end = path.length; + do { + const prefix = path.slice(0, end); + const result = pattern.exec({ pathname: prefix }); + if (result) { + const groups = result.pathname.groups; + return Object.assign( + [ + // Keep the original prefix; URLPattern canonicalizes its input. + loose && path[end] !== "/" ? prefix.replace(/\/$/, "") : prefix, + ], + { index: 0, input: path, groups } + ); + } + + if (!loose || !end) break; + // Try segment boundaries, including trailing slashes, longest first. + end = + path[end - 1] === "/" + ? end - 1 + : path.lastIndexOf("/", end - 1) + 1; + } while (end > 0 || path[0] === "/"); + return null; + }, + }, + }; +}; diff --git a/packages/wouter/test/url-pattern.test-d.tsx b/packages/wouter/test/url-pattern.test-d.tsx new file mode 100644 index 00000000..fccf6594 --- /dev/null +++ b/packages/wouter/test/url-pattern.test-d.tsx @@ -0,0 +1,36 @@ +import { expectTypeOf, test } from "bun:test"; +import { Route, Router, useRoute, type Parser } from "wouter"; +import type { Parser as PreactParser } from "wouter-preact"; +import type { urlPatternParser as ReactURLPatternParser } from "wouter/url-pattern"; +import type { urlPatternParser as PreactURLPatternParser } from "wouter-preact/url-pattern"; + +test("both package subpaths export a compatible URLPattern parser", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test("custom parsers need only exec and may omit keys for named groups", () => { + const parser: Parser = (route, loose) => ({ + pattern: { + exec: (input) => new RegExp(route + (loose ? "" : "$")).exec(input), + }, + }); + const preactParser: PreactParser = parser; + expectTypeOf(preactParser).toEqualTypeOf(); + Custom parser; +}); + +test("native URLPattern syntax accepts explicit parameter types", () => { + path="/users{/:id}?"> + {({ id }) => { + expectTypeOf(id).toEqualTypeOf(); + return id ?? "All users"; + }} + ; + const User = () => { + const [matches, params] = useRoute<{ id: string }>("/users/:id(\\d+)"); + if (matches) expectTypeOf(params.id).toEqualTypeOf(); + return null; + }; + expectTypeOf(User).toBeFunction(); +}); diff --git a/packages/wouter/test/url-pattern.test.tsx b/packages/wouter/test/url-pattern.test.tsx new file mode 100644 index 00000000..26c83e99 --- /dev/null +++ b/packages/wouter/test/url-pattern.test.tsx @@ -0,0 +1,204 @@ +import { expect, test } from "bun:test"; +import { act, fireEvent, render, renderHook } from "@testing-library/react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parse } from "regexparam"; + +import { + Link, + Route, + Router, + Switch, + matchRoute, + useLocation, + useParams, + useRoute, + useRouter, +} from "../src/index.js"; +import { memoryLocation } from "../src/memory-location.js"; +import { urlPatternParser } from "wouter/url-pattern"; +import { withoutLocation } from "./setup.js"; + +test("matches required, optional and constrained URLPattern parameters", () => { + expect( + matchRoute(urlPatternParser, "/users/:id", "/users/42") + ).toStrictEqual([true, { id: "42" }]); + expect( + matchRoute(urlPatternParser, "/users/:id", "/users") + ).toStrictEqual([false, null]); + expect( + matchRoute(urlPatternParser, "/users{/:id}?", "/users") + ).toStrictEqual([true, { id: undefined }]); + expect( + matchRoute(urlPatternParser, "/users{/:id}?", "/users/42") + ).toStrictEqual([true, { id: "42" }]); + expect( + matchRoute(urlPatternParser, "/users/:id(\\d+)", "/users/42") + ).toStrictEqual([true, { id: "42" }]); + expect( + matchRoute(urlPatternParser, "/users/:id(\\d+)", "/users/alex") + ).toStrictEqual([false, null]); +}); + +test("retains native named, wildcard and repeated groups", () => { + expect( + matchRoute(urlPatternParser, "/:name/*", "/first/second/third") + ).toStrictEqual([true, { 0: "second/third", name: "first" }]); + expect( + matchRoute(urlPatternParser, "/files/:parts+", "/files/a/b") + ).toStrictEqual([true, { parts: "a/b" }]); + expect( + matchRoute(urlPatternParser, "/files/*", "/files/") + ).toStrictEqual([true, { 0: "" }]); +}); + +test("keeps native case and trailing slash semantics", () => { + expect( + matchRoute(urlPatternParser, "/users", "/Users") + ).toStrictEqual([false, null]); + expect( + matchRoute(urlPatternParser, "/users", "/users/") + ).toStrictEqual([false, null]); + expect( + matchRoute(urlPatternParser, "/users/", "/users") + ).toStrictEqual([false, null]); + expect( + matchRoute(urlPatternParser, "/users/", "/users/") + ).toStrictEqual([true, {}]); +}); + +test.each([ + ["/users/:id", "/users/42/settings", { id: "42" }, "/users/42"], + ["/users{/:id}?", "/users/42/settings", { id: "42" }, "/users/42"], + ["/users{/:id}?", "/users", { id: undefined }, "/users"], + ["/files/:parts+", "/files/a/b", { parts: "a/b" }, "/files/a/b"], + ["/users/", "/users/settings", {}, "/users"], + ["/users/", "/users//settings", {}, "/users/"], + ["/users/", "/users/", {}, "/users"], + ["/users//", "/users//settings", {}, "/users/"], + ["/", "/users/42", {}, ""], + ["/", "/", {}, ""], + ["/:id?", "/", { id: undefined }, ""], +] as const)( + "matches the longest slash-delimited prefix for nested %s at %s", + (route, path, params, base) => { + expect( + matchRoute(urlPatternParser, route, path, true) + ).toStrictEqual([true, params, base]); + } +); + +test("loose matching respects boundaries without adding synthetic parameters", () => { + expect( + matchRoute(urlPatternParser, "/users", "/users-extra/settings", true) + ).toStrictEqual([false, null]); + expect( + matchRoute( + urlPatternParser, + "/users/:__wouter_rest", + "/users/42/settings", + true + ) + ).toStrictEqual([true, { __wouter_rest: "42" }, "/users/42"]); + expect( + matchRoute(urlPatternParser, "/users/:name", "/users/José/settings", true) + ).toStrictEqual([true, { name: "Jos%C3%A9" }, "/users/José"]); +}); + +test("leaves the default parser and RegExp routes compatible", () => { + expect(matchRoute(parse, "/users/:id", "/Users/42/")).toStrictEqual([ + true, + { 0: "42", id: "42" }, + ]); + expect( + matchRoute( + urlPatternParser, + /^\/users\/(?\d+)/, + "/users/42/settings", + true + ) + ).toStrictEqual([true, { 0: "42", id: "42" }, "/users/42"]); + const { result } = renderHook(() => useRouter().parser); + expect(result.current).toBe(parse); +}); + +test("useRoute supports router bases, absolute escapes and navigation", () => { + const { hook, navigate } = memoryLocation({ path: "/app/users/42" }); + const { result } = renderHook( + () => [useRoute("/users/:id(\\d+)"), useRoute("~/app/users/:id")], + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + expect(result.current).toStrictEqual([ + [true, { id: "42" }], + [true, { id: "42" }], + ]); + act(() => navigate("/app/users/alex")); + expect(result.current).toStrictEqual([ + [false, null], + [true, { id: "alex" }], + ]); +}); + +test("Switch, inherited params and links work with nested URLPattern routes", () => { + const { hook, navigate } = memoryLocation({ path: "/app/users/42/posts/7" }); + const Post = () => { + const params = useParams<{ id: string; post: string }>(); + const [path] = useLocation(); + return ( + <> + {`${params.id}:${params.post}:${useRouter().base}:${path}`} + Next + Outside + + ); + }; + const { container } = render( + + + About + + + + + + Fallback + + + ); + expect(container.querySelector("span")?.textContent).toBe( + "42:7:/app/users/42:/posts/7" + ); + const [next, outside] = Array.from(container.querySelectorAll("a")); + expect(next.getAttribute("href")).toBe("/app/users/42/posts/8"); + expect(outside.getAttribute("href")).toBe("/outside"); + fireEvent.click(next); + expect(container.querySelector("span")?.textContent).toBe( + "42:8:/app/users/42:/posts/8" + ); + fireEvent.click(outside); + expect(container.textContent).toBe("Fallback"); + act(() => navigate("/app/about")); + expect(container.textContent).toBe("About"); +}); + +test("root and trailing slash nesting leave the child pathname intact in SSR", () => { + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + path={"/:id(\\d+)"}> + {({ id }) => `User ${id}`} + + + + + ) + ); + expect(rendered).toBe("User 42"); +}); diff --git a/packages/wouter/types/router.d.ts b/packages/wouter/types/router.d.ts index 56a0872e..1d662920 100644 --- a/packages/wouter/types/router.d.ts +++ b/packages/wouter/types/router.d.ts @@ -9,7 +9,7 @@ import { export type Parser = ( route: Path, loose?: boolean -) => { pattern: RegExp; keys: string[] }; +) => { pattern: Pick; keys?: string[] }; // Standard navigation options supported by all built-in location hooks export type NavigateOptions = { diff --git a/packages/wouter/types/url-pattern.d.ts b/packages/wouter/types/url-pattern.d.ts new file mode 100644 index 00000000..b55a05bd --- /dev/null +++ b/packages/wouter/types/url-pattern.d.ts @@ -0,0 +1,3 @@ +import { Parser } from "./router.js"; + +export const urlPatternParser: Parser;