Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ dashboards and more.
- [`<Switch />`](#switch-)
- [`<Redirect to={path} />`](#redirect-topath-)
- [`<Router hook={hook} parser={fn} base={basepath} />`](#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)
Expand Down Expand Up @@ -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).

Expand All @@ -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";

<Router parser={urlPatternParser}>
{/* Matches /users and /users/42 */}
<Route path="/users{/:id}?">
{(params) => <>User: {params.id ?? "all"}</>}
</Route>
</Router>;
```
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 `<Route<{ id?: string }> 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?
Expand Down
1 change: 1 addition & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -93,6 +97,10 @@
"preact",
"preact/hooks"
]
},
{
"path": "packages/wouter-preact/src/url-pattern.js",
"limit": "500 B"
}
],
"husky": {
Expand Down
1 change: 1 addition & 0 deletions packages/wouter-preact/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/wouter-preact/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 <molefrog@gmail.com>",
"repository": {
Expand Down
40 changes: 40 additions & 0 deletions packages/wouter-preact/test/preact.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof WouterPreact> {
Expand Down Expand Up @@ -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(
<Router parser={urlPatternParser} base="/app" hook={hook}>
<Switch>
<Route path={"/users/:id(\\d+)"} nest>
<Route path="/posts/:post">
<Post />
</Route>
</Route>
<Route>Fallback</Route>
</Switch>
</Router>,
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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/wouter-preact/types/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
export type Parser = (
route: Path,
loose?: boolean
) => { pattern: RegExp; keys: string[] };
) => { pattern: Pick<RegExp, "exec">; keys?: string[] };

// Standard navigation options supported by all built-in location hooks
export type NavigateOptions<S = any> = {
Expand Down
3 changes: 3 additions & 0 deletions packages/wouter-preact/types/url-pattern.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Parser } from "./router.js";

export const urlPatternParser: Parser;
7 changes: 7 additions & 0 deletions packages/wouter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -50,6 +54,9 @@
],
"memory-location": [
"types/memory-location.d.ts"
],
"url-pattern": [
"types/url-pattern.d.ts"
]
}
},
Expand Down
1 change: 1 addition & 0 deletions packages/wouter/src/url-pattern.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "../types/url-pattern.js";
36 changes: 36 additions & 0 deletions packages/wouter/src/url-pattern.js
Original file line number Diff line number Diff line change
@@ -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;
},
},
};
};
36 changes: 36 additions & 0 deletions packages/wouter/test/url-pattern.test-d.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ReactURLPatternParser>().toEqualTypeOf<Parser>();
expectTypeOf<typeof PreactURLPatternParser>().toEqualTypeOf<PreactParser>();
});

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<PreactParser>();
<Router parser={parser}>Custom parser</Router>;
});

test("native URLPattern syntax accepts explicit parameter types", () => {
<Route<{ id?: string }> path="/users{/:id}?">
{({ id }) => {
expectTypeOf(id).toEqualTypeOf<string | undefined>();
return id ?? "All users";
}}
</Route>;
const User = () => {
const [matches, params] = useRoute<{ id: string }>("/users/:id(\\d+)");
if (matches) expectTypeOf(params.id).toEqualTypeOf<string>();
return null;
};
expectTypeOf(User).toBeFunction();
});
Loading
Loading