From e29aeda1435f34d99a27d36482cc9c4182984e20 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 3 Nov 2026 13:24:56 -0600 Subject: [PATCH] fix: stop adding the base twice to paths nodes in navigate and useLinkState --- .changeset/paths-node-base.md | 5 ++ src/routing.ts | 15 +++- test/paths-base.spec.tsx | 154 ++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 .changeset/paths-node-base.md create mode 100644 test/paths-base.spec.tsx diff --git a/.changeset/paths-node-base.md b/.changeset/paths-node-base.md new file mode 100644 index 00000000..9ebcbfbd --- /dev/null +++ b/.changeset/paths-node-base.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Under a `base`, `navigate(paths.about)` and `useLinkState(paths.about)` no longer add the base twice. A paths node already includes the base, so with `base: "/app"` they gave `/app/app/about`. They now use the node's path as-is and give `/app/about`. `useLinkState(paths.about)` also works under `hashHistory`, where it never reported the link as current before. diff --git a/src/routing.ts b/src/routing.ts index 46ed03aa..8a561d19 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -405,7 +405,13 @@ export const useLinkState = ( ): LinkState => { const router = useRouter(); const location = router.location; - const to = useResolvedPath(() => String(href())); + const route = useRoute(); + // a paths node's brand already holds the base; resolving would add it again + const to = createMemo(() => { + const h = href(); + const brand = typeof h !== "string" ? ((h as any)[HREF] as string | undefined) : undefined; + return brand !== undefined ? brand : route.resolvePath(String(h)); + }); const base = router.base.path(); const state = createMemo(() => linkMatcher(location, base, options.end)(to())); return { @@ -1085,9 +1091,10 @@ export function createRouterContext( }; resolvedTo = compose(headed); } else { - if (typeof to !== "string") to = href || to.toString(); - resolvedTo = resolveTarget(to); - if (resolvedTo === undefined) throw unroutable(to); + // the brand already holds the base, so it skips resolution + if (typeof to !== "string" && href === undefined) to = to.toString(); + resolvedTo = href !== undefined ? href : resolveTarget(to as string); + if (resolvedTo === undefined) throw unroutable(to as string); } const navigationDepth = diff --git a/test/paths-base.spec.tsx b/test/paths-base.spec.tsx new file mode 100644 index 00000000..a3a27d2c --- /dev/null +++ b/test/paths-base.spec.tsx @@ -0,0 +1,154 @@ +import { render } from "@solidjs/web"; +import { vi } from "vitest"; +import { + createRouter, + hashHistory, + memoryHistory, + useLinkState, + useNavigate, + type LinkState, + type Navigator +} from "../src/index.js"; + +const settle = () => new Promise(r => setTimeout(r, 30)); + +describe("paths nodes under a base", () => { + const originalScrollTo = window.scrollTo; + beforeEach(() => { + window.scrollTo = vi.fn() as any; + }); + afterAll(() => { + window.scrollTo = originalScrollTo; + }); + + const routes = [ + { path: "/", component: () =>

home

}, + { path: "/about", component: () =>

about

} + ] as const; + + test("navigate(paths.about) lands on /app/about", async () => { + const history = memoryHistory("/app"); + const Router = createRouter({ routes, base: "/app", history }); + let navigate!: Navigator; + const div = document.createElement("div"); + const dispose = render( + () => {p => ((navigate = useNavigate()), p.children)}, + div + ); + try { + await settle(); + expect(String(Router.paths.about)).toBe("/app/about"); + navigate(Router.paths.about); + await settle(); + expect(history.get()).toBe("/app/about"); + expect(div.textContent).toBe("about"); + } finally { + dispose(); + } + }); + + test("useLinkState(paths.about) is current on /app/about, like the base-relative string", async () => { + const history = memoryHistory("/app/about"); + const Router = createRouter({ routes, base: "/app", history }); + let node!: LinkState; + let relative!: LinkState; + let root!: LinkState; + const div = document.createElement("div"); + const dispose = render( + () => ( + + {p => { + node = useLinkState(() => Router.paths.about); + relative = useLinkState(() => "/about"); + root = useLinkState(() => Router.paths); + return p.children; + }} + + ), + div + ); + try { + await settle(); + expect(div.textContent).toBe("about"); + expect([node.active(), node.current()]).toEqual([true, true]); + expect([relative.active(), relative.current()]).toEqual([true, true]); + expect([root.active(), root.current()]).toEqual([false, false]); + } finally { + dispose(); + } + }); +}); + +describe("paths nodes under hashHistory", () => { + const originalScrollTo = window.scrollTo; + beforeEach(() => { + window.scrollTo = vi.fn() as any; + window.history.replaceState(null, "", "/#/about"); + }); + afterEach(() => { + window.history.replaceState(null, "", "/"); + }); + afterAll(() => { + window.scrollTo = originalScrollTo; + }); + + test("useLinkState(paths.about) reads the node's route, not its display href", async () => { + const Router = createRouter({ + routes: [ + { path: "/", component: () =>

home

}, + { path: "/about", component: () =>

about

} + ] as const, + history: hashHistory() + }); + let link!: LinkState; + const div = document.createElement("div"); + const dispose = render( + () => {p => ((link = useLinkState(() => Router.paths.about)), p.children)}, + div + ); + try { + await settle(); + expect(String(Router.paths.about)).toBe("#/about"); + expect(div.textContent).toBe("about"); + expect([link.active(), link.current()]).toEqual([true, true]); + } finally { + dispose(); + } + }); + + test("navigate(paths.about) and useLinkState(paths.about) under a base", async () => { + const Router = createRouter({ + routes: [ + { path: "/", component: () =>

home

}, + { path: "/about", component: () =>

about

} + ] as const, + base: "/app", + history: hashHistory() + }); + window.history.replaceState(null, "", "/#/app"); + let navigate!: Navigator; + let link!: LinkState; + const div = document.createElement("div"); + const dispose = render( + () => ( + + {p => ( + (navigate = useNavigate()), (link = useLinkState(() => Router.paths.about)), p.children + )} + + ), + div + ); + try { + await settle(); + expect(div.textContent).toBe("home"); + navigate(Router.paths.about); + await settle(); + expect(window.location.hash).toBe("#/app/about"); + expect(div.textContent).toBe("about"); + expect([link.active(), link.current()]).toEqual([true, true]); + } finally { + dispose(); + } + }); +});