From 69524b94999257be679b59d41b3da6c7a7bd1b17 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:00:14 +0300 Subject: [PATCH 1/8] fix: forward Link asChild props (#536) --- README.md | 2 + packages/wouter-preact/src/react-deps.js | 3 +- packages/wouter-preact/test/preact.test.tsx | 36 +++++++++++ packages/wouter-preact/types/index.d.ts | 5 +- packages/wouter/src/index.js | 18 +++--- packages/wouter/test/link.test-d.tsx | 14 +++-- packages/wouter/test/link.test.tsx | 70 +++++++++++++++++++++ packages/wouter/types/index.d.ts | 6 +- 8 files changed, 137 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 092133da..32f8187e 100644 --- a/README.md +++ b/README.md @@ -525,6 +525,8 @@ import { Link } from "wouter" Link will always wrap its children in an `` tag, unless `asChild` prop is provided. Use this when you need to have a custom component that renders an `` 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 diff --git a/packages/wouter-preact/src/react-deps.js b/packages/wouter-preact/src/react-deps.js index 25de4db5..2be1465f 100644 --- a/packages/wouter-preact/src/react-deps.js +++ b/packages/wouter-preact/src/react-deps.js @@ -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 diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index 2679f33a..fa0fd4d7 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -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( + + + + About + + + , + 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(); diff --git a/packages/wouter-preact/types/index.d.ts b/packages/wouter-preact/types/index.d.ts index 35a5c838..d615e7fb 100644 --- a/packages/wouter-preact/types/index.d.ts +++ b/packages/wouter-preact/types/index.d.ts @@ -112,7 +112,10 @@ type HTMLLinkAttributes = Omit & { export type LinkProps = NavigationalProps & AsChildProps< - { children: ComponentChildren; onClick?: JSX.MouseEventHandler }, + Omit & { + children: ComponentChildren; + onClick?: JSX.MouseEventHandler; + }, HTMLLinkAttributes >; diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 6be644eb..99a75bdd 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -290,17 +290,15 @@ export const Link = forwardRef((props, ref) => { router // pass router as a second argument for convinience ); + const linkProps = { ...restProps, onClick, href }; + // 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 = []) => { diff --git a/packages/wouter/test/link.test-d.tsx b/packages/wouter/test/link.test-d.tsx index b9b31d01..bc8d1162 100644 --- a/packages/wouter/test/link.test-d.tsx +++ b/packages/wouter/test/link.test-d.tsx @@ -130,13 +130,11 @@ describe(" with `asChild` prop", () => { ; }); - test("does not allow other props", () => { - // @ts-expect-error + test("accepts forwarded attributes and refs", () => { Hello ; - // @ts-expect-error Hello ; @@ -146,10 +144,18 @@ describe(" with `asChild` prop", () => { Hello ; - // @ts-expect-error Hello ; + + ()} + className={(active) => (active ? "active" : undefined)} + > + + ; }); test("should support other navigation params", () => { diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index 74adf677..a8acfcb0 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -255,6 +255,76 @@ describe("active links", () => { }); describe(" with `asChild` prop", () => { + test("forwards attributes, styles, events and ref to its child (#536)", () => { + const ref = mock<(element: HTMLAnchorElement) => void>(); + const onFocus = mock(); + const { getByRole } = render( + + + About + + + ); + const link = getByRole("link", { name: "About us" }); + expect(link).toHaveClass("parent-class"); + expect(link).not.toHaveClass("child-class"); + expect(link).toHaveStyle({ color: "red" }); + expect(link).toHaveAttribute("data-tracking", "about"); + expect(link).toHaveAttribute("title", "Child title"); + expect(link).not.toHaveAttribute("replace"); + expect(link).not.toHaveAttribute("state"); + expect(ref).toHaveBeenCalledWith(link); + fireEvent.focus(link); + expect(onFocus).toHaveBeenCalledTimes(1); + fireEvent.click(link); + expect(location.pathname).toBe("/about"); + }); + + test("preserves the child's className and ref when Link omits them", () => { + const childRef = mock<(element: HTMLAnchorElement) => void>(); + const { getByText } = render( + + + About + + + ); + const link = getByText("About"); + expect(link).toHaveClass("child-class"); + expect(childRef).toHaveBeenCalledWith(link); + }); + + test("updates the child's active className after navigation", () => { + const { hook, navigate } = memoryLocation({ path: "/about" }); + const { getByText } = render( + + (active ? "active" : undefined)} + > + About + + + ); + const link = getByText("About"); + expect(link).toHaveClass("active"); + act(() => navigate("/other")); + expect(link).not.toHaveClass("active"); + expect(link).not.toHaveClass("child-class"); + }); + test("when `asChild` is not specified, wraps the children in an ", () => { const { getByText } = render( diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index 41a4a2f9..cada6813 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -130,7 +130,11 @@ type HTMLLinkAttributes = Omit< export type LinkProps = NavigationalProps & AsChildProps< - { children: ReactElement; onClick?: MouseEventHandler }, + Omit & + RefAttributes & { + children: ReactElement; + onClick?: MouseEventHandler; + }, HTMLLinkAttributes & RefAttributes >; From 7c136053ab21f9ce7378b7ddfd9c4299898c5f0a Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:00:14 +0300 Subject: [PATCH 2/8] fix: notify browser location subscribers together (#556) --- packages/wouter/src/use-browser-location.js | 17 ++++-- .../wouter/test/browser-navigation.test.tsx | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 packages/wouter/test/browser-navigation.test.tsx diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index f496b0f2..cb734a42 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -14,14 +14,19 @@ const events = [ eventHashchange, ]; +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) + for (const event of events) addEventListener(event, onLocationChange); + return () => { - for (const event of events) { - removeEventListener(event, callback); - } + listeners = listeners.filter((listener) => listener !== callback); + if (!listeners.length) + for (const event of events) removeEventListener(event, onLocationChange); }; }; diff --git a/packages/wouter/test/browser-navigation.test.tsx b/packages/wouter/test/browser-navigation.test.tsx new file mode 100644 index 00000000..94b3db8f --- /dev/null +++ b/packages/wouter/test/browser-navigation.test.tsx @@ -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( + + New character + + + + + + + ); + + act(() => { + history.back(); + dispatchEvent(new Event(eventName)); + }); + + expect(renders).toEqual(["123"]); + expect(effects).toEqual(["123"]); + expect(container).toHaveTextContent("New character"); +}); From 29280a86b6f1b3904412776150162b396c4ecc9d Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:02:05 +0300 Subject: [PATCH 3/8] fix: allow explicit empty search in hash navigation (#473) --- README.md | 2 + packages/wouter/src/use-hash-location.js | 2 +- .../wouter/test/use-hash-location.test.tsx | 53 ++++++++++++++++--- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 32f8187e..8c1c9101 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,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 diff --git a/packages/wouter/src/use-hash-location.js b/packages/wouter/src/use-hash-location.js index 5fa959a2..130b886b 100644 --- a/packages/wouter/src/use-hash-location.js +++ b/packages/wouter/src/use-hash-location.js @@ -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); diff --git a/packages/wouter/test/use-hash-location.test.tsx b/packages/wouter/test/use-hash-location.test.tsx index d6ccfba1..59856f5a 100644 --- a/packages/wouter/test/use-hash-location.test.tsx +++ b/packages/wouter/test/use-hash-location.test.tsx @@ -1,5 +1,5 @@ import { test, expect, mock } from "bun:test"; -import { renderHook, render, act } from "@testing-library/react"; +import { renderHook, render, act, fireEvent } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { Router, Route, useLocation, Link } from "../src/index.js"; @@ -90,20 +90,57 @@ test("changes search and hash when contains ? symbol", () => { expect(location.hash).toBe("#/abc"); }); -test("preserves the search for an empty query and ignores extra query segments", () => { - history.replaceState(null, "", "/foo?original#/app"); +test.each([false, true])( + "clears the search for an explicit empty query (replace: %s)", + (replace) => { + history.replaceState(null, "", "/foo?original#/app"); + const initialLength = history.length; + const state = { source: "clear-search" }; + const { result } = renderHook(() => useHashLocation()); + const [, navigate] = result.current; + + act(() => navigate("#/empty?", { replace, state })); + + expect(location.pathname).toBe("/foo"); + expect(location.hash).toBe("#/empty"); + expect(location.search).toBe(""); + expect(result.current[0]).toBe("/empty"); + expect(history.state).toEqual(state); + expect(history.length).toBe(initialLength + (replace ? 0 : 1)); + } +); + +test("ignores extra query segments", () => { const { result } = renderHook(() => useHashLocation()); const [, navigate] = result.current; - - act(() => navigate("#/empty?")); - expect(location.hash).toBe("#/empty"); - expect(location.search).toBe("?original"); - act(() => navigate("/next?first?ignored")); expect(location.hash).toBe("#/next"); expect(location.search).toBe("?first"); }); +test("Link can explicitly clear search parameters with hash routing (#473)", () => { + history.replaceState(null, "", "/foo?original#/app"); + const initialLength = history.length; + const state = { source: "clear-search-link" }; + const { getByText } = render( + + + Clear search + + Next page + + ); + + fireEvent.click(getByText("Clear search")); + + expect(location.pathname).toBe("/foo"); + expect(location.hash).toBe("#/next"); + expect(location.search).toBe(""); + expect(history.state).toEqual(state); + expect(history.length).toBe(initialLength); + expect(getByText("Next page")).toBeInTheDocument(); +}); + test("creates a new history entry when navigating", () => { const { result } = renderHook(() => useHashLocation()); const [, navigate] = result.current; From 6826ea6ebd6a934de5adcf69cc231e72590bccf3 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:02:05 +0300 Subject: [PATCH 4/8] docs: clarify navigation interception limits (#452) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c1c9101..3d26240c 100644 --- a/README.md +++ b/README.md @@ -642,7 +642,9 @@ available options: - `hrefs: (href: boolean) => string` — a function for transforming `href` attribute of an `` 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) => { From 09e237a1376233c28ebda0c2163f129f242ca1b0 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:02:05 +0300 Subject: [PATCH 5/8] docs: explain React ViewTransition compatibility (#559) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3d26240c..d0cfbb67 100644 --- a/README.md +++ b/README.md @@ -865,6 +865,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 [`` 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 `` animations for these route updates. + ```jsx import { flushSync } from "react-dom"; import { Router, type AroundNavHandler } from "wouter"; From d5fc3cde9e88796d0a655602e0e496b40e6d4f58 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:02:05 +0300 Subject: [PATCH 6/8] docs: specify the strict parser dependency version (#563) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0cfbb67..ee1661ad 100644 --- a/README.md +++ b/README.md @@ -747,7 +747,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"; From d5a95f8bd169386fb589c86214d093d4e4e5db4b Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 03:03:52 +0300 Subject: [PATCH 7/8] fix: support absolute useRoute patterns across bases (#244) --- README.md | 4 +- packages/wouter/src/index.js | 11 +++-- packages/wouter/test/use-route.test-d.ts | 12 ++++++ packages/wouter/test/use-route.test.tsx | 52 ++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ee1661ad..16ebc563 100644 --- a/README.md +++ b/README.md @@ -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"; @@ -443,7 +445,7 @@ const App = () => ( ### `` -`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: diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 99a75bdd..457280c8 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -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); @@ -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 = typeof pattern === "string" && pattern.startsWith("~/"); + return matchRoute( + router.parser, + absolute ? pattern.slice(1) : pattern, + usePathnameFromRouter(router, absolute ? "" : router.base) + ); }; /* diff --git a/packages/wouter/test/use-route.test-d.ts b/packages/wouter/test/use-route.test-d.ts index 46cccb08..57261bca 100644 --- a/packages/wouter/test/use-route.test-d.ts +++ b/packages/wouter/test/use-route.test-d.ts @@ -51,3 +51,15 @@ test("infers parameters from the route path", () => { }>(); } }); + +test("infers parameters from absolute route patterns", () => { + const [match, params] = useRoute("~/app/users/:name?/:id"); + + if (match) { + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(params.name).toEqualTypeOf(); + expectTypeOf(params[0]).toEqualTypeOf(); + } else { + expectTypeOf(params).toEqualTypeOf(); + } +}); diff --git a/packages/wouter/test/use-route.test.tsx b/packages/wouter/test/use-route.test.tsx index fa0c842e..41a414cf 100644 --- a/packages/wouter/test/use-route.test.tsx +++ b/packages/wouter/test/use-route.test.tsx @@ -160,6 +160,58 @@ it("supports regex patterns", () => { assertRoute(/[/](?[a-z]+)/, "/123", false); }); +it("matches absolute patterns without a base (#244)", () => { + assertRoute("~/users/:id", "/users/12", { 0: "12", id: "12" }); + assertRoute("~/", "/", {}); + assertRoute("~/users/:id", "/other/12", false); + assertRoute("/~user", "/~user", {}); + assertRoute("~user", "/~user", {}); + assertRoute("~", "/~", {}); +}); + +it.each([ + ["/app/team/users/12", "~/app/team/users/:id"], + ["/app/users/12", "~/app/users/:id"], + ["/other/users/12", "~/other/users/:id"], +] as const)( + "matches absolute patterns across nested bases at %s", + (path, pattern) => { + const { result } = renderHook(() => useRoute(pattern), { + wrapper: (props) => ( + + + + ), + }); + + expect(result.current).toStrictEqual([true, { 0: "12", id: "12" }]); + } +); + +it("switches between relative, absolute and regex patterns", () => { + const { hook, navigate } = memoryLocation({ path: "/app/users/12" }); + const { result, rerender } = renderHook( + ({ pattern }: { pattern: string | RegExp }) => useRoute(pattern), + { + initialProps: { pattern: "/users/:id" as string | RegExp }, + wrapper: (props) => , + } + ); + + expect(result.current).toStrictEqual([true, { 0: "12", id: "12" }]); + rerender({ pattern: "~/app/users/:id" }); + expect(result.current).toStrictEqual([true, { 0: "12", id: "12" }]); + rerender({ pattern: /^\/users\/(?[^/]+)$/ }); + expect(result.current).toStrictEqual([true, { 0: "12", id: "12" }]); + + act(() => navigate("/outside/users/34")); + expect(result.current).toStrictEqual([false, null]); + rerender({ pattern: "~/outside/users/:id" }); + expect(result.current).toStrictEqual([true, { 0: "34", id: "34" }]); + act(() => navigate("/outside/users/56")); + expect(result.current).toStrictEqual([true, { 0: "56", id: "56" }]); +}); + it("reacts to pattern updates", () => { const { result, rerender } = renderHook( ({ pattern }: { pattern: string }) => useRoute(pattern), From d881463fad13f46b0883359ac9816897f4896558 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 04:21:45 +0300 Subject: [PATCH 8/8] perf: reduce routing fix overhead and correct size comparisons --- .github/workflows/size.yml | 5 ++--- packages/wouter/src/index.js | 11 +++++------ packages/wouter/src/use-browser-location.js | 19 +++++-------------- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 413e99e1..9222fe4f 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -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 @@ -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 diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 457280c8..7c8f549e 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -115,7 +115,7 @@ export const matchRoute = (parser, route, path, loose) => { export const useRoute = (pattern) => { const router = useRouter(); - const absolute = typeof pattern === "string" && pattern.startsWith("~/"); + const absolute = /^~\//.test(pattern); return matchRoute( router.parser, absolute ? pattern.slice(1) : pattern, @@ -267,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 ( @@ -290,12 +290,11 @@ 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 ); - const linkProps = { ...restProps, onClick, href }; // Omitted props should preserve the child's own className and ref. if (cls !== undefined) linkProps.className = cls?.call ? cls(currentPath === targetPath) : cls; @@ -303,7 +302,7 @@ export const Link = forwardRef((props, ref) => { return asChild && isValidElement(children) ? cloneElement(children, linkProps) - : h("a", { ...linkProps, children }); + : h("a", linkProps, children); }); const flattenChildren = (children, result = []) => { diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index cb734a42..4b7ab950 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -3,16 +3,7 @@ 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()); @@ -21,12 +12,12 @@ const onLocationChange = () => listeners.forEach((callback) => callback()); // together so React can process parent and child updates in the same batch. const subscribeToLocationUpdates = (callback) => { if (listeners.push(callback) === 1) - for (const event of events) addEventListener(event, onLocationChange); + events.forEach((event) => addEventListener(event, onLocationChange)); return () => { listeners = listeners.filter((listener) => listener !== callback); if (!listeners.length) - for (const event of events) removeEventListener(event, onLocationChange); + events.forEach((event) => removeEventListener(event, onLocationChange)); }; }; @@ -60,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. @@ -74,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.