Skip to content
Draft
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: 21 additions & 4 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
),
];
};
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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
);
};

Expand Down Expand Up @@ -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
);

Expand Down
6 changes: 4 additions & 2 deletions packages/wouter/src/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 61 additions & 5 deletions packages/wouter/test/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,28 @@ describe("<Link />", () => {
expect(link.getAttribute("href")).toBe("/app/dashboard");
});

test("renders `href` with absolute links", () => {
test("renders `href` with absolute links respecting base", () => {
const { getByTestId } = render(
<Router base="/app">
<Link href="~/home" data-testid="link" />
</Router>
);

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(
<Router base="/app">
<Router base="/schedule">
<Link href="~/home" data-testid="link" />
</Router>
</Router>
);

const element = getByTestId("link");
expect(element.getAttribute("href")).toBe("/app/home");
});

test("supports history state", () => {
Expand Down Expand Up @@ -180,9 +193,9 @@ describe("<Link />", () => {
</>
);

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");
});
});

Expand Down Expand Up @@ -403,4 +416,47 @@ describe("<Link /> 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 = () => (
<div>
<h1>Schedule Detail</h1>
<Link href="~/schedule" data-testid="back-link">
Back to Overview
</Link>
</div>
);

const { getByTestId } = render(
<Router base="/app" hook={memLoc.hook}>
<Router base="/schedule">
<ScheduleDetail />
</Router>
</Router>
);

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 <div data-testid="location">{location}</div>;
};

const { getByTestId: getByTestId2 } = render(
<Router hook={memLoc.hook}>
<LocationDisplay />
</Router>
);

expect(getByTestId2("location").textContent).toBe("/app/schedule");
});
});
2 changes: 1 addition & 1 deletion packages/wouter/test/redirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ test("supports `base` routers with absolute path", () => {
</Router>
);

expect(location.pathname).toBe("/absolute");
expect(location.pathname).toBe("/app/absolute");
});

test("supports replace navigation", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/wouter/test/url-pattern.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading