From 4802f29422397109c98327e8b8cb75ebaa24a90e Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Wed, 30 Sep 2026 20:06:19 +0200 Subject: [PATCH 1/2] Add opt-in URLPattern route parser --- README.md | 27 +- bun.lock | 3 + bunfig.toml | 1 + package.json | 11 +- packages/wouter-preact/.gitignore | 1 + packages/wouter-preact/package.json | 9 +- packages/wouter-preact/test/preact.test.tsx | 54 ++++ packages/wouter-preact/types/router.d.ts | 2 +- packages/wouter-preact/types/url-pattern.d.ts | 3 + packages/wouter/package.json | 7 + packages/wouter/src/url-pattern.d.ts | 1 + packages/wouter/src/url-pattern.js | 36 +++ packages/wouter/test/url-pattern.test-d.tsx | 36 +++ packages/wouter/test/url-pattern.test.tsx | 233 ++++++++++++++++++ packages/wouter/types/router.d.ts | 2 +- packages/wouter/types/url-pattern.d.ts | 3 + 16 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 packages/wouter-preact/types/url-pattern.d.ts create mode 100644 packages/wouter/src/url-pattern.d.ts create mode 100644 packages/wouter/src/url-pattern.js create mode 100644 packages/wouter/test/url-pattern.test-d.tsx create mode 100644 packages/wouter/test/url-pattern.test.tsx create mode 100644 packages/wouter/types/url-pattern.d.ts diff --git a/README.md b/README.md index b71a1e9a..1180c8c0 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,28 @@ 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: + +```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. + +If your browser or SSR runtime lacks `URLPattern`, load a polyfill such as [`urlpattern-polyfill`](https://github.com/kenchris/urlpattern-polyfill) that defines `globalThis.URLPattern` before matching routes. The adapter includes no polyfill. + +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/bun.lock b/bun.lock index c1a30678..c3a5f3d6 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "react-dom": "^19", "size-limit": "^11.2.0", "typescript": "^5.8.0", + "urlpattern-polyfill": "^10.1.0", }, }, "packages/magazin": { @@ -531,6 +532,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 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..d30cc436 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": { @@ -152,6 +160,7 @@ "react": "^19", "react-dom": "^19", "size-limit": "^11.2.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "urlpattern-polyfill": "^10.1.0" } } 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..d1d07a09 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -15,6 +15,7 @@ import { import { render } from "preact"; import { act, setupRerender, teardown } from "preact/test-utils"; import renderToString from "preact-render-to-string"; +import { URLPattern } from "urlpattern-polyfill/urlpattern"; import { copyFile, rm } from "fs/promises"; import { join } from "path"; import type * as WouterPreact from "../types/index.js"; @@ -30,8 +31,14 @@ const filesToCopy = [ "use-sync-external-store.js", "use-sync-external-store.native.js", "index.js", + "url-pattern.js", ]; +const originalURLPattern = Object.getOwnPropertyDescriptor( + globalThis, + "URLPattern" +); + async function loadPreact(): Promise { // Import from the copied files in src/ directory const module = (await import( @@ -41,6 +48,11 @@ async function loadPreact(): Promise { } beforeAll(async () => { + Object.defineProperty(globalThis, "URLPattern", { + value: URLPattern, + configurable: true, + writable: true, + }); const wouterSrc = join(import.meta.dir, "../../wouter/src"); const preactSrc = join(import.meta.dir, "../src"); @@ -50,6 +62,9 @@ beforeAll(async () => { }); afterAll(async () => { + if (originalURLPattern) + Object.defineProperty(globalThis, "URLPattern", originalURLPattern); + else Reflect.deleteProperty(globalThis, "URLPattern"); const preactSrc = join(import.meta.dir, "../src"); for (const file of filesToCopy) { @@ -198,6 +213,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..8222e299 --- /dev/null +++ b/packages/wouter/src/url-pattern.js @@ -0,0 +1,36 @@ +/* global URLPattern */ + +// Opt-in URLPattern adapter. Load a polyfill before using it where needed. +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..596889f5 --- /dev/null +++ b/packages/wouter/test/url-pattern.test.tsx @@ -0,0 +1,233 @@ +import { afterAll, beforeAll, 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 { URLPattern } from "urlpattern-polyfill/urlpattern"; + +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"; + +const originalURLPattern = Object.getOwnPropertyDescriptor( + globalThis, + "URLPattern" +); +beforeAll(() => { + Object.defineProperty(globalThis, "URLPattern", { + value: URLPattern, + configurable: true, + writable: true, + }); +}); +afterAll(() => { + if (originalURLPattern) + Object.defineProperty(globalThis, "URLPattern", originalURLPattern); + else Reflect.deleteProperty(globalThis, "URLPattern"); +}); + +test("the optional adapter can be imported without a global URLPattern", () => { + const result = Bun.spawnSync([ + process.execPath, + "--eval", + `delete globalThis.URLPattern; + const { urlPatternParser } = await import("wouter/url-pattern"); + if (typeof urlPatternParser !== "function") process.exit(1);`, + ]); + expect(result.exitCode).toBe(0); +}); + +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; From aede2412eefe53254cca064ae9a8fb8efc5a2261 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Wed, 30 Sep 2026 20:13:20 +0200 Subject: [PATCH 2/2] Use native URLPattern directly in tests and documentation --- README.md | 4 +-- bun.lock | 3 -- package.json | 3 +- packages/wouter-preact/test/preact.test.tsx | 14 ---------- packages/wouter/src/url-pattern.js | 2 +- packages/wouter/test/url-pattern.test.tsx | 31 +-------------------- 6 files changed, 4 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 1180c8c0..dab309cd 100644 --- a/README.md +++ b/README.md @@ -659,7 +659,7 @@ 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: +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"; @@ -675,8 +675,6 @@ import { urlPatternParser } from "wouter/url-pattern"; 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. -If your browser or SSR runtime lacks `URLPattern`, load a polyfill such as [`urlpattern-polyfill`](https://github.com/kenchris/urlpattern-polyfill) that defines `globalThis.URLPattern` before matching routes. The adapter includes no polyfill. - 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 diff --git a/bun.lock b/bun.lock index c3a5f3d6..c1a30678 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,6 @@ "react-dom": "^19", "size-limit": "^11.2.0", "typescript": "^5.8.0", - "urlpattern-polyfill": "^10.1.0", }, }, "packages/magazin": { @@ -532,8 +531,6 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], - "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/package.json b/package.json index d30cc436..5ce330ca 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,6 @@ "react": "^19", "react-dom": "^19", "size-limit": "^11.2.0", - "typescript": "^5.8.0", - "urlpattern-polyfill": "^10.1.0" + "typescript": "^5.8.0" } } diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index d1d07a09..a9173cca 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -15,7 +15,6 @@ import { import { render } from "preact"; import { act, setupRerender, teardown } from "preact/test-utils"; import renderToString from "preact-render-to-string"; -import { URLPattern } from "urlpattern-polyfill/urlpattern"; import { copyFile, rm } from "fs/promises"; import { join } from "path"; import type * as WouterPreact from "../types/index.js"; @@ -34,11 +33,6 @@ const filesToCopy = [ "url-pattern.js", ]; -const originalURLPattern = Object.getOwnPropertyDescriptor( - globalThis, - "URLPattern" -); - async function loadPreact(): Promise { // Import from the copied files in src/ directory const module = (await import( @@ -48,11 +42,6 @@ async function loadPreact(): Promise { } beforeAll(async () => { - Object.defineProperty(globalThis, "URLPattern", { - value: URLPattern, - configurable: true, - writable: true, - }); const wouterSrc = join(import.meta.dir, "../../wouter/src"); const preactSrc = join(import.meta.dir, "../src"); @@ -62,9 +51,6 @@ beforeAll(async () => { }); afterAll(async () => { - if (originalURLPattern) - Object.defineProperty(globalThis, "URLPattern", originalURLPattern); - else Reflect.deleteProperty(globalThis, "URLPattern"); const preactSrc = join(import.meta.dir, "../src"); for (const file of filesToCopy) { diff --git a/packages/wouter/src/url-pattern.js b/packages/wouter/src/url-pattern.js index 8222e299..21f6b4b7 100644 --- a/packages/wouter/src/url-pattern.js +++ b/packages/wouter/src/url-pattern.js @@ -1,6 +1,6 @@ /* global URLPattern */ -// Opt-in URLPattern adapter. Load a polyfill before using it where needed. +// Opt-in adapter for the native URLPattern API. export const urlPatternParser = (route, loose) => { const pattern = new URLPattern({ pathname: route }); diff --git a/packages/wouter/test/url-pattern.test.tsx b/packages/wouter/test/url-pattern.test.tsx index 596889f5..26c83e99 100644 --- a/packages/wouter/test/url-pattern.test.tsx +++ b/packages/wouter/test/url-pattern.test.tsx @@ -1,8 +1,7 @@ -import { afterAll, beforeAll, expect, test } from "bun:test"; +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 { URLPattern } from "urlpattern-polyfill/urlpattern"; import { Link, @@ -19,34 +18,6 @@ import { memoryLocation } from "../src/memory-location.js"; import { urlPatternParser } from "wouter/url-pattern"; import { withoutLocation } from "./setup.js"; -const originalURLPattern = Object.getOwnPropertyDescriptor( - globalThis, - "URLPattern" -); -beforeAll(() => { - Object.defineProperty(globalThis, "URLPattern", { - value: URLPattern, - configurable: true, - writable: true, - }); -}); -afterAll(() => { - if (originalURLPattern) - Object.defineProperty(globalThis, "URLPattern", originalURLPattern); - else Reflect.deleteProperty(globalThis, "URLPattern"); -}); - -test("the optional adapter can be imported without a global URLPattern", () => { - const result = Bun.spawnSync([ - process.execPath, - "--eval", - `delete globalThis.URLPattern; - const { urlPatternParser } = await import("wouter/url-pattern"); - if (typeof urlPatternParser !== "function") process.exit(1);`, - ]); - expect(result.exitCode).toBe(0); -}); - test("matches required, optional and constrained URLPattern parameters", () => { expect( matchRoute(urlPatternParser, "/users/:id", "/users/42")