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
5 changes: 2 additions & 3 deletions .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ jobs:
bun-version: latest
- name: Install Dependencies
run: bun install --frozen-lockfile
- name: Prepare wouter-preact (copy source files)
run: cd packages/wouter-preact && npm run prepublishOnly
- name: Symlink npm to bun
run: |
sudo ln -sf $(which bun) /usr/local/bin/npm
Expand All @@ -22,4 +20,5 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
skip_step: install
build_script: build
# Run after each checkout so the base comparison uses its own sources.
build_script: --cwd packages/wouter-preact prepublishOnly
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Checks if the current location matches the pattern provided and returns an objec

You can use `useRoute` to perform manual routing or implement custom logic, such as route transitions, etc.

Patterns are relative to the current router's base. To match the full path from inside a nested router, prefix a string pattern with `~`: `useRoute("~/app/users/:id")`. This reads from the configured location hook, so it also works with hash and memory routing. The `~` prefix is handled by `useRoute`; use base-relative patterns for `Route` and `Switch`.

```js
import { useRoute } from "wouter";

Expand Down Expand Up @@ -310,6 +312,8 @@ const App = () => (
);
```

With `useHashLocation`, navigating to `/about` preserves the current search parameters. Include a query to replace them (`/about?tab=1`), or an explicit empty query to clear them (`/about?`). This also works with `Link` and keeps search parameters before the hash in the browser URL.

Because these hooks have return values similar to `useState`, it is easy and fun to build your own location hooks: `useCrossTabLocation`, `useLocalStorage`, `useMicroFrontendLocation` and whatever routing logic you want to support in the app. Give it a try!

### `useParams`: extracting matched parameters
Expand Down Expand Up @@ -441,7 +445,7 @@ const App = () => (

### `<Route path={pattern} />`

`Route` represents a piece of the app that is rendered conditionally based on a pattern `path`. Pattern has the same syntax as the argument you pass to [`useRoute`](#useroute-route-matching-and-parameters).
`Route` represents a piece of the app that is rendered conditionally based on a pattern `path`. Patterns use the matching syntax described under [`useRoute`](#useroute-route-matching-and-parameters) and are relative to the current router's base.

The library provides multiple ways to declare a route's body:

Expand Down Expand Up @@ -525,6 +529,8 @@ import { Link } from "wouter"

Link will always wrap its children in an `<a />` tag, unless `asChild` prop is provided. Use this when you need to have a custom component that renders an `<a />` under the hood.

With `asChild`, attributes such as `className`, `style`, and `aria-label` are forwarded to the child. Props supplied to `Link` override the child's corresponding props; omitted props are preserved. A `className` function receives the same active flag as a regular link. In React, `Link` also forwards its ref; with `wouter-preact`, place the ref on the child.

```jsx
// use this instead
<Link to="/" asChild>
Expand Down Expand Up @@ -638,7 +644,9 @@ available options:

- `hrefs: (href: boolean) => string` — a function for transforming `href` attribute of an `<a />` element rendered by `Link`. It is used to support hash-based routing. By default, `href` attribute is the same as the `href` or `to` prop of a `Link`. A location hook can also define a `hook.hrefs` property, in this case the `href` will be inferred.

- **`aroundNav: (navigate, to, options) => void`** — a handler that wraps all navigation calls. Use this to intercept navigation and perform custom logic before and after the navigation occurs. You can modify navigation parameters, add side effects, or prevent navigation entirely. This is particularly useful for implementing [view transitions](#how-do-i-add-view-transitions-to-my-app). By default, it simply calls `navigate(to, options)`.
- **`aroundNav: (navigate, to, options) => void`** — a handler that wraps navigation through `useLocation`, including `Link` and `Redirect`. Use it to modify navigation parameters, add side effects, or cancel a navigation by not calling `navigate`. This is particularly useful for implementing [view transitions](#how-do-i-use-wouter-with-view-transitions-api). By default, it calls `navigate(to, options)`.

Browser Back/Forward navigation, direct History API calls, and calls to the low-level `navigate` exported from `wouter/use-browser-location` bypass this handler.

```js
const aroundNav = (navigate, to, options) => {
Expand Down Expand Up @@ -741,7 +749,7 @@ return (

If a trailing slash is important for your app's routing, you could specify a custom parser. Parser is a method that takes a pattern string and returns a RegExp and an array of parsed key. It uses the signature of a [`parse`](https://github.com/lukeed/regexparam?tab=readme-ov-file#regexparamparseinput-regexp) function from `regexparam`.

Let's write a custom parser based on a popular [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) package that does support strict routes option.
The example below requires [`path-to-regexp` v6](https://github.com/pillarjs/path-to-regexp/tree/v6.3.0), which supports the `strict` option and the three-argument `pathToRegexp` API used here. Install it with `bun add path-to-regexp@6`. Version 8 uses a different API.

```js
import { pathToRegexp } from "path-to-regexp";
Expand Down Expand Up @@ -859,6 +867,8 @@ More complex examples involve using `useRoutes` hook (similar to how React Route

Wouter works seamlessly with the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API), but you'll need to manually activate it. This is because view transitions require synchronous DOM rendering and must be wrapped in `flushSync` from `react-dom`. Following wouter's philosophy of staying lightweight and avoiding unnecessary dependencies, view transitions aren't built-in. However, there's a simple escape hatch to enable them: the `aroundNav` prop.

This recipe uses the browser's `document.startViewTransition` API. React's [`<ViewTransition>` component](https://react.dev/reference/react/ViewTransition) has different requirements: the default location hook uses `useSyncExternalStore`, whose updates [cannot be marked as React transitions](https://react.dev/reference/react/useSyncExternalStore#caveats). Wrapping `navigate` in `startTransition` therefore does not enable React `<ViewTransition>` animations for these route updates.

```jsx
import { flushSync } from "react-dom";
import { Router, type AroundNavHandler } from "wouter";
Expand Down
3 changes: 2 additions & 1 deletion packages/wouter-preact/src/react-deps.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export function useSyncExternalStore(subscribe, getSnapshot, getSSRSnapshot) {

// provide forwardRef stub for preact
export function forwardRef(component) {
return component;
// Preact passes legacy context as the second argument, not a forwarded ref.
return (props) => component(props);
}

// Userland polyfill while we wait for the forthcoming
Expand Down
36 changes: 36 additions & 0 deletions packages/wouter-preact/test/preact.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,42 @@ describe("Preact support", () => {
teardown();
});

test("asChild forwards props and preserves the child's ref (#536)", async () => {
const { Link, Router } = await loadPreact();
const container = document.body.appendChild(document.createElement("div"));
const childRef = mock();
try {
act(() => {
render(
<Router base="/app">
<Link
href="/about"
asChild
className="parent"
style={{ color: "red" }}
aria-label="About us"
>
<a ref={childRef} className="child" title="Child title">
About
</a>
</Link>
</Router>,
container
);
});
const link = container.querySelector("a")!;
expect(link.getAttribute("href")).toBe("/app/about");
expect(link.className).toBe("parent");
expect(link.style.color).toBe("red");
expect(link.getAttribute("aria-label")).toBe("About us");
expect(link.title).toBe("Child title");
expect(childRef).toHaveBeenCalledWith(link);
} finally {
act(() => render(null, container));
container.remove();
}
});

describe("useRoute", () => {
test("should only accept strings", async () => {
const { useRoute } = await loadPreact();
Expand Down
5 changes: 4 additions & 1 deletion packages/wouter-preact/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ type HTMLLinkAttributes = Omit<JSX.HTMLAttributes, "className"> & {
export type LinkProps<H extends BaseLocationHook = BrowserLocationHook> =
NavigationalProps<H> &
AsChildProps<
{ children: ComponentChildren; onClick?: JSX.MouseEventHandler<Element> },
Omit<HTMLLinkAttributes, "onClick" | "ref"> & {
children: ComponentChildren;
onClick?: JSX.MouseEventHandler<Element>;
},
HTMLLinkAttributes
>;

Expand Down
34 changes: 18 additions & 16 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export const useParams = () => useContext(ParamsCtx);

// Internal location hooks avoid redundant context reads and navigation callbacks.

const usePathnameFromRouter = (router) =>
relativePath(router.base, router.hook(router)[0]);
const usePathnameFromRouter = (router, base = router.base) =>
relativePath(base, router.hook(router)[0]);

const useLocationFromRouter = (router) => {
const [location, navigate] = router.hook(router);
Expand Down Expand Up @@ -115,7 +115,12 @@ export const matchRoute = (parser, route, path, loose) => {

export const useRoute = (pattern) => {
const router = useRouter();
return matchRoute(router.parser, pattern, usePathnameFromRouter(router));
const absolute = /^~\//.test(pattern);
return matchRoute(
router.parser,
absolute ? pattern.slice(1) : pattern,
usePathnameFromRouter(router, absolute ? "" : router.base)
);
};

/*
Expand Down Expand Up @@ -262,10 +267,10 @@ export const Link = forwardRef((props, ref) => {
transition /* ignore nav props */,
/* eslint-enable no-unused-vars */

...restProps
...linkProps
} = props;

const onClick = useEvent((event) => {
linkProps.onClick = useEvent((event) => {
// ignores the navigation when clicked using right mouse button or
// by holding a special modifier key: ctrl, command, win, alt, shift
if (
Expand All @@ -285,22 +290,19 @@ export const Link = forwardRef((props, ref) => {
});

// handle nested routers and absolute paths
const href = router.hrefs(
linkProps.href = router.hrefs(
targetPath[0] === "~" ? targetPath.slice(1) : router.base + targetPath,
router // pass router as a second argument for convinience
);

// Omitted props should preserve the child's own className and ref.
if (cls !== undefined)
linkProps.className = cls?.call ? cls(currentPath === targetPath) : cls;
if (ref) linkProps.ref = ref;

return asChild && isValidElement(children)
? cloneElement(children, { onClick, href })
: h("a", {
...restProps,
onClick,
href,
// `className` can be a function to apply the class if this link is active
className: cls?.call ? cls(currentPath === targetPath) : cls,
children,
ref,
});
? cloneElement(children, linkProps)
: h("a", linkProps, children);
});

const flattenChildren = (children, result = []) => {
Expand Down
32 changes: 14 additions & 18 deletions packages/wouter/src/use-browser-location.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,21 @@ import { useSyncExternalStore } from "./react-deps.js";
/**
* History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History
*/
const eventPopstate = "popstate";
const eventPushState = "pushState";
const eventReplaceState = "replaceState";
const eventHashchange = "hashchange";
const events = [
eventPopstate,
eventPushState,
eventReplaceState,
eventHashchange,
];
const events = ["popstate", "pushState", "replaceState", "hashchange"];

let listeners = [];
const onLocationChange = () => listeners.forEach((callback) => callback());

// Native events can run microtasks between listeners. Notify all subscribers
// together so React can process parent and child updates in the same batch.
const subscribeToLocationUpdates = (callback) => {
for (const event of events) {
addEventListener(event, callback);
}
if (listeners.push(callback) === 1)
events.forEach((event) => addEventListener(event, onLocationChange));

return () => {
for (const event of events) {
removeEventListener(event, callback);
}
listeners = listeners.filter((listener) => listener !== callback);
if (!listeners.length)
events.forEach((event) => removeEventListener(event, onLocationChange));
};
};

Expand Down Expand Up @@ -55,7 +51,7 @@ export const useHistoryState = () =>
useLocationProperty(currentHistoryState, () => null);

export const navigate = (to, { replace = false, state = null } = {}) =>
history[replace ? eventReplaceState : eventPushState](state, "", to);
history[replace ? "replaceState" : "pushState"](state, "", to);

// the 2nd argument of the `useBrowserLocation` return value is a function
// that allows to perform a navigation.
Expand All @@ -69,7 +65,7 @@ const patchKey = Symbol.for("wouter_v3");
//
// See https://stackoverflow.com/a/4585031
if (typeof history !== "undefined" && typeof window[patchKey] === "undefined") {
for (const type of [eventPushState, eventReplaceState]) {
for (const type of ["pushState", "replaceState"]) {
const original = history[type];
// TODO: we should be using unstable_batchedUpdates to avoid multiple re-renders,
// however that will require an additional peer dependency on react-dom.
Expand Down
2 changes: 1 addition & 1 deletion packages/wouter/src/use-hash-location.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const navigate = (to, { state = null, replace = false } = {}) => {
// Works for ALL protocols including data:
const url = new URL(oldURL);
url.hash = `/${hash}`;
if (search) url.search = search;
if (search !== undefined) url.search = search;
const newURL = url.href;

history[replace ? "replaceState" : "pushState"](state, "", newURL);
Expand Down
58 changes: 58 additions & 0 deletions packages/wouter/test/browser-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import { act, render } from "@testing-library/react";
import { ReactNode, useEffect, useSyncExternalStore } from "react";
import { Route, Switch, useRoute } from "../src/index.js";

test("keeps Switch children consistent across native popstate listeners", () => {
const eventName = "popstate";
// Native browser events can run a microtask between listeners. Stop the
// event after the child subscribes to reproduce a render before Switch
// receives the update, which synchronous dispatchEvent otherwise hides.
const subscribe = (callback: () => void) => {
const listener = (event: Event) => {
event.stopImmediatePropagation();
callback();
};
addEventListener(eventName, listener);
return () => removeEventListener(eventName, listener);
};

const InterruptEvent = ({ children }: { children: ReactNode }) => {
useSyncExternalStore(subscribe, () => true);
return <>{children}</>;
};

const renders: string[] = [];
const effects: string[] = [];
const Detail = () => {
const [, params] = useRoute("/characters/:id");
renders.push(params!.id);
useEffect(() => {
effects.push(params!.id);
});
return null;
};

history.replaceState(null, "", "/characters/new");
history.pushState(null, "", "/characters/123");

const { container } = render(
<Switch>
<Route path="/characters/new">New character</Route>
<Route path="/characters/:id">
<InterruptEvent>
<Detail />
</InterruptEvent>
</Route>
</Switch>
);

act(() => {
history.back();
dispatchEvent(new Event(eventName));
});

expect(renders).toEqual(["123"]);
expect(effects).toEqual(["123"]);
expect(container).toHaveTextContent("New character");
});
14 changes: 10 additions & 4 deletions packages/wouter/test/link.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,11 @@ describe("<Link /> with `asChild` prop", () => {
</Link>;
});

test("does not allow other props", () => {
// @ts-expect-error
test("accepts forwarded attributes and refs", () => {
<Link to="/" asChild className="">
<a>Hello</a>
</Link>;

// @ts-expect-error
<Link to="/" asChild style={{}}>
<a>Hello</a>
</Link>;
Expand All @@ -146,10 +144,18 @@ describe("<Link /> with `asChild` prop", () => {
<a>Hello</a>
</Link>;

// @ts-expect-error
<Link to="/" asChild ref={null}>
<a>Hello</a>
</Link>;

<Link
to="/"
asChild
ref={React.createRef<HTMLButtonElement>()}
className={(active) => (active ? "active" : undefined)}
>
<button>Hello</button>
</Link>;
});

test("should support other navigation params", () => {
Expand Down
Loading
Loading