From 03d7a428b6c994b11511f3a7e6d260b2be3e3aeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 17:36:16 +0000 Subject: [PATCH] Fix #558: Make ~ paths respect Router base prop The ~ prefix is meant to escape from nested route bases and navigate to absolute paths within the app. Before this fix, ~/path would ignore all bases including the explicit Router base prop, navigating to /path on the domain root. After this fix, ~/path respects the outermost explicit Router base. With , ~/schedule navigates to /app/schedule instead of /schedule, matching the expected behavior where ~ means "absolute within the app" rather than "absolute on the domain". Implementation: - Add explicitBase property to router context that tracks only the outermost explicit Router base prop (not nested route bases) - Mark Routers created by nested Routes with internal _nested flag - Update absolutePath() and Link href generation to use explicitBase for ~ paths instead of ignoring the base entirely Tests updated to reflect new behavior and verify the fix for the reported issue scenario. Co-authored-by: Alexey Taktarov --- packages/wouter/src/index.js | 25 +++++++-- packages/wouter/src/paths.js | 6 ++- packages/wouter/test/link.test.tsx | 66 +++++++++++++++++++++-- packages/wouter/test/redirect.test.tsx | 2 +- packages/wouter/test/url-pattern.test.tsx | 2 +- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 7c8f549e..f460ea10 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -33,6 +33,8 @@ const defaultRouter = { searchHook: useBrowserSearch, parser: parsePattern, base: "", + // tracks only explicit Router base props (not nested route bases) + explicitBase: "", // this option is used to override the current location during SSR ssrPath: undefined, ssrSearch: undefined, @@ -77,7 +79,11 @@ const useLocationFromRouter = (router) => { return [ relativePath(router.base, location), useEvent((to, opts) => - router.aroundNav(navigate, absolutePath(to, router.base), opts) + router.aroundNav( + navigate, + absolutePath(to, router.base, router.explicitBase), + opts + ) ), ]; }; @@ -170,7 +176,16 @@ export const Router = ({ children, ...props }) => { k === "base" ? /* base is special case, it is appended to the parent's base */ parent[k] + (props[k] ?? "") - : props[k] ?? parent[k]; + : k === "explicitBase" + ? /* explicitBase tracks the root explicit Router base and is inherited unchanged */ + parent[k] === "" + ? /* if parent has no explicit base, use this Router's base (if not nested) */ + props.base !== undefined && !props._nested + ? props.base + : "" + : /* otherwise inherit parent's explicit base unchanged */ + parent[k] + : props[k] ?? parent[k]; if (option !== next[k]) { if (prev === next) ref.current = next = { ...next }; @@ -246,7 +261,7 @@ export const Route = ({ path, nest, match, ...renderProps }) => { return h( ParamsCtx.Provider, { value: params }, - base ? h(Router, { base }, children) : children + base ? h(Router, { base, _nested: true }, children) : children ); }; @@ -291,7 +306,9 @@ export const Link = forwardRef((props, ref) => { // handle nested routers and absolute paths linkProps.href = router.hrefs( - targetPath[0] === "~" ? targetPath.slice(1) : router.base + targetPath, + targetPath[0] === "~" + ? (router.explicitBase || "") + targetPath.slice(1) + : router.base + targetPath, router // pass router as a second argument for convinience ); diff --git a/packages/wouter/src/paths.js b/packages/wouter/src/paths.js index 5b10d7ba..bd3dfa25 100644 --- a/packages/wouter/src/paths.js +++ b/packages/wouter/src/paths.js @@ -3,8 +3,10 @@ */ const baseDefaults = (base = "") => (base === "/" ? "" : base); -export const absolutePath = (to, base) => - to[0] === "~" ? to.slice(1) : baseDefaults(base) + to; +export const absolutePath = (to, base, explicitBase) => + to[0] === "~" + ? baseDefaults(explicitBase ?? base) + to.slice(1) + : baseDefaults(base) + to; /* * Transforms `path` into its relative `base` version diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index a8acfcb0..443dfbf0 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -140,7 +140,7 @@ describe("", () => { expect(link.getAttribute("href")).toBe("/app/dashboard"); }); - test("renders `href` with absolute links", () => { + test("renders `href` with absolute links respecting base", () => { const { getByTestId } = render( @@ -148,7 +148,20 @@ describe("", () => { ); const element = getByTestId("link"); - expect(element).toHaveAttribute("href", "/home"); + expect(element).toHaveAttribute("href", "/app/home"); + }); + + test("renders `href` with absolute links in nested routes", () => { + const { getByTestId } = render( + + + + + + ); + + const element = getByTestId("link"); + expect(element.getAttribute("href")).toBe("/app/home"); }); test("supports history state", () => { @@ -180,9 +193,9 @@ describe("", () => { ); - expect(getByTestId("root")).toHaveAttribute("href", "#/"); - expect(getByTestId("home")).toHaveAttribute("href", "#/home"); - expect(getByTestId("absolute")).toHaveAttribute("href", "#/home"); + expect(getByTestId("root").getAttribute("href")).toBe("#/"); + expect(getByTestId("home").getAttribute("href")).toBe("#/home"); + expect(getByTestId("absolute").getAttribute("href")).toBe("#/app/home"); }); }); @@ -403,4 +416,47 @@ describe(" with `asChild` prop", () => { expect(link).toHaveAttribute("href", undefined); expect(link).toHaveTextContent("Click Me"); }); + + test("issue #558: ~ paths respect base in nested routes", () => { + const memLoc = memoryLocation({ + path: "/app/schedule/2026-02-11", + }); + + const ScheduleDetail = () => ( +
+

Schedule Detail

+ + Back to Overview + +
+ ); + + const { getByTestId } = render( + + + + + + ); + + const backLink = getByTestId("back-link"); + expect(backLink.getAttribute("href")).toBe("/app/schedule"); + + fireEvent.click(backLink); + + // The navigate function will trigger, and we can verify by rendering a component + // that uses the location hook + const LocationDisplay = () => { + const [location] = memLoc.hook(); + return
{location}
; + }; + + const { getByTestId: getByTestId2 } = render( + + + + ); + + expect(getByTestId2("location").textContent).toBe("/app/schedule"); + }); }); diff --git a/packages/wouter/test/redirect.test.tsx b/packages/wouter/test/redirect.test.tsx index b607ef98..d96dc721 100644 --- a/packages/wouter/test/redirect.test.tsx +++ b/packages/wouter/test/redirect.test.tsx @@ -31,7 +31,7 @@ test("supports `base` routers with absolute path", () => {
); - expect(location.pathname).toBe("/absolute"); + expect(location.pathname).toBe("/app/absolute"); }); test("supports replace navigation", () => { diff --git a/packages/wouter/test/url-pattern.test.tsx b/packages/wouter/test/url-pattern.test.tsx index 26c83e99..81850a2e 100644 --- a/packages/wouter/test/url-pattern.test.tsx +++ b/packages/wouter/test/url-pattern.test.tsx @@ -175,7 +175,7 @@ test("Switch, inherited params and links work with nested URLPattern routes", () ); const [next, outside] = Array.from(container.querySelectorAll("a")); expect(next.getAttribute("href")).toBe("/app/users/42/posts/8"); - expect(outside.getAttribute("href")).toBe("/outside"); + expect(outside.getAttribute("href")).toBe("/app/outside"); fireEvent.click(next); expect(container.querySelector("span")?.textContent).toBe( "42:8:/app/users/42:/posts/8"