From e29aeda1435f34d99a27d36482cc9c4182984e20 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 3 Nov 2026 13:24:56 -0600 Subject: [PATCH 1/4] 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(); + } + }); +}); From b23cdc72e138a32b5e1a15464a57e5fd2b815c72 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:45:12 +0000 Subject: [PATCH 2/4] fix: a leading slash is a full path under a base, everywhere navigate, useLinkState, useResolvedPath and query/server-function redirects no longer add the router base to a path that starts with "/". This matches and raw HTTP redirects, and redirect() from @solidjs/web, which is not router-aware: redirect(paths.login) now lands on /app/login instead of /app/app/login. paths nodes already hold the base and stay the portable spelling. A full-path redirect outside the base navigates the document; a navigate() to one stays a soft navigation, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- .changeset/full-path-leading-slash.md | 7 + README.md | 10 +- src/data/query.ts | 21 +- src/utils.ts | 16 +- test/base-trailing-slash.spec.tsx | 4 +- test/claims.spec.tsx | 11 +- test/data/action.spec.ts | 5 +- test/data/flight-consumer.spec.ts | 4 +- test/full-path-base.spec.tsx | 179 ++++++++++++++++++ test/paths-base.spec.tsx | 12 +- test/query-redirect.spec.tsx | 25 ++- .../ssr-redirect-response-head.spec.tsx | 9 +- test/utils.spec.ts | 12 +- 13 files changed, 262 insertions(+), 53 deletions(-) create mode 100644 .changeset/full-path-leading-slash.md create mode 100644 test/full-path-base.spec.tsx diff --git a/.changeset/full-path-leading-slash.md b/.changeset/full-path-leading-slash.md new file mode 100644 index 00000000..0bacb786 --- /dev/null +++ b/.changeset/full-path-leading-slash.md @@ -0,0 +1,7 @@ +--- +"@solidjs/router": patch +--- + +Under a `base`, a path that starts with `/` is now a full path everywhere, and the router never adds the base to it. This covers `navigate()`, `useLinkState`, `useResolvedPath`, and query and server-function redirects, and matches what `` and a raw HTTP redirect already did. `redirect()` comes from `@solidjs/web`, which doesn't know the router's base, so `redirect(paths.login)` used to land on `/app/app/login`. It now lands on `/app/login`. + +Migration: under `base: "/app"`, `navigate("/about")` now goes to `/about`. Use `paths.about` or `` `${base}/about` ``. The same applies to `useLinkState(() => "/about")`, `useResolvedPath(() => "/about")` and `redirect("/login")`. A redirect to a full path outside the base navigates the document. A `navigate()` to one navigates softly, as before. diff --git a/README.md b/README.md index fcddd0d7..23f3da7b 100644 --- a/README.md +++ b/README.md @@ -497,7 +497,7 @@ paths.about(); // zero-arg/search calls terminate to a plain string paths(); // "/" — the root ``` -Every node coerces via `toString`, so nodes drop straight into `href`, `navigate()`, and `redirect()` without explicit termination. Accessing a segment that doesn't exist in the tree, or binding a param with the wrong type, is a compile error. +Every node coerces via `toString`, so nodes drop straight into `href`, `navigate()`, and `redirect()` without explicit termination. Nodes include the router's `base`, so they are the portable way to link under one: a plain string that starts with `/` is a full path everywhere (`href`, `navigate()`, `useLinkState`, `useResolvedPath`, `redirect()`), and the base is never added to it. Accessing a segment that doesn't exist in the tree, or binding a param with the wrong type, is a compile error. ## Links @@ -670,7 +670,7 @@ getUser.keyFor(5); // "users[5]" Revalidate with the `revalidate` export or by setting `revalidate` keys on action responses — the whole key invalidates every entry for the query, `keyFor` invalidates one. -A query may also redirect: a guard read that throws or returns `redirect()` (from `@solidjs/web`) navigates instead of resolving. Local paths starting with a single `/` and same-origin server-function redirects stay relative to the router's `base`. Other targets navigate softly with `replace` only within the same origin and base, when the history integration can represent their URL; otherwise they navigate the document. On the client, a `Location` header only navigates for redirect statuses (301, 302, 303, 307, 308). For example, returning `respond({ id: 1 }, { status: 201, headers: { Location: "/items/1" } })` delivers `{ id: 1 }` without navigating. Any `revalidate` keys on a redirect response invalidate first, and the client read stays pending so nothing renders the redirect as data. This holds for `"use server"` queries too, where the transport carries the redirect to the client rather than letting `fetch` follow it. The carrier takes precedence over `Location`. +A query may also redirect: a guard read that throws or returns `redirect()` (from `@solidjs/web`) navigates instead of resolving. A path starting with a single `/` is a full path, as everywhere in the router: the `base` is never added, so `redirect(paths.login)` and `redirect("/app/login")` both land on `/app/login` under `base: "/app"`. Same-origin targets navigate softly with `replace` only within the router's base, when the history integration can represent their URL; otherwise they navigate the document. On the client, a `Location` header only navigates for redirect statuses (301, 302, 303, 307, 308). For example, returning `respond({ id: 1 }, { status: 201, headers: { Location: "/items/1" } })` delivers `{ id: 1 }` without navigating. Any `revalidate` keys on a redirect response invalidate first, and the client read stays pending so nothing renders the redirect as data. This holds for `"use server"` queries too, where the transport carries the redirect to the client rather than letting `fetch` follow it. The carrier takes precedence over `Location`. ### `liveQuery` @@ -836,7 +836,7 @@ createRouter(config); | option | type | description | | --------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------- | | `routes` | `RouteDefinition[]` | The route tree — inline arrays infer literally; wrap extracted trees in `defineRoutes` | -| `base` | `string` | Base url to use for matching routes | +| `base` | `string` | Base url to use for matching routes. A path that starts with `/` is a full path; use `paths` for base-aware links | | `preload` | `RoutePreloadFunc` | App-wide preload: once per mount/request, result reaches the root render-prop as `props.data` | | `history` | `RouterHistory` | History adapter; defaults to browser history on the client and the request URL on the server | | `singleFlight` | `boolean` | Single-flight mutations, default `true` | @@ -884,6 +884,8 @@ const navigate = useNavigate(); navigate(paths.login, { replace: true }); ``` +A string that starts with `/` is a full path, like an `href`: under `base: "/app"`, `navigate("/about")` goes to `/about`, not `/app/about`. Use `paths.about` (or `` `${base}/about` ``) for a path under the base. A full path outside the base still navigates softly, and no route matches it unless one does. + For declarative redirects on render (the old ``), call it during component setup or redirect from a preload. ### useLocation @@ -973,7 +975,7 @@ preload(paths.users(2).settings, { preloadData: true }); Reactive `active`/`current`/`pending` state for [custom link components](#links), matched by the same rule as plain anchors: - `current()` — same path and same query as the location, ignoring parameter order and the hash (what `aria-current="page"` reflects) -- `active()` — the location's path is the link's path or lives under it, query ignored (`data-active`); a root link (`/`, which resolves to the router's `base`) is exact-only +- `active()` — the location's path is the link's path or lives under it, query ignored (`data-active`); a root link (the router's `base`, or `/` without one) is exact-only - `pending()` — the link's path is the target of an in-flight navigation, back/forward excluded (`data-pending`). Works without the [`pendingLinks`](#links) plugin, which only adds the attribute to plain anchors Pass `{ end: true }` to make `active` (and `pending`) exact-path for any link. diff --git a/src/data/query.ts b/src/data/query.ts index 710be54b..97cd25e3 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -497,21 +497,18 @@ export function applyServerResponse( if (!isServer) { const target = new URL(url, window.location.href); if (navigate && target.origin === window.location.origin) { - // Carried redirects and local leading slashes keep their router-base - // meaning. Other URLs need the integration's parser (e.g. hash - // history) without applying the base again. - const relativeToBase = !!carried || (url[0] === "/" && url[1] !== "/"); + // A carried redirect or a local leading slash is a full logical path + // (the base is never added). Other URLs need the integration's + // parser (e.g. hash history), and must round-trip to the same + // document URL. Either way it navigates softly only under the base. + const local = !!carried || (url[0] === "/" && url[1] !== "/"); const resolved = target.pathname + target.search + target.hash; - const path = carried ? resolved : relativeToBase ? url : router!.parsePath(resolved); - const rendered = new URL(router!.renderPath(path), window.location.href); - // Resolved URLs must stay within the router base and round-trip to - // the same document URL, including with hash history. + const path = local ? resolved : router!.parsePath(resolved); if ( - relativeToBase || - (isUnderBase(path.split(/[?#]/, 1)[0], router!.base.path()) && - rendered.href === target.href) + isUnderBase(path.split(/[?#]/, 1)[0], router!.base.path()) && + (local || new URL(router!.renderPath(path), window.location.href).href === target.href) ) { - navigate(path, { replace: true, resolve: relativeToBase }); + navigate(path, { replace: true, resolve: false }); } else window.location.href = target.href; } else window.location.href = target.href; } else if (navigate && url.startsWith("/")) navigate(url, { replace: true }); diff --git a/src/utils.ts b/src/utils.ts index 90767652..2860c076 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -114,14 +114,14 @@ export function resolvePath(base: string, path: string, from?: string): string | } const basePath = normalizePath(base); const fromPath = from && normalizePath(from); - let result = ""; - if (!fromPath || path.startsWith("/")) { - result = basePath; - } else if (fromPath.toLowerCase().indexOf(basePath.toLowerCase()) !== 0) { - result = basePath + fromPath; - } else { - result = fromPath; - } + // A leading "/" is a full document path: the base is never added to it + const result = path.startsWith("/") + ? "" + : !fromPath + ? basePath + : fromPath.toLowerCase().indexOf(basePath.toLowerCase()) !== 0 + ? basePath + fromPath + : fromPath; return (result || "/") + normalizePath(path, !result); } diff --git a/test/base-trailing-slash.spec.tsx b/test/base-trailing-slash.spec.tsx index 1dd4360b..f972356c 100644 --- a/test/base-trailing-slash.spec.tsx +++ b/test/base-trailing-slash.spec.tsx @@ -40,7 +40,7 @@ describe("base with a trailing slash", () => { expect(() => render(() => , div)).toThrow(/not a valid base path/); }); - test("navigate('/about') lands on the about route", async () => { + test("navigate('/app/about'), a full path, lands on the about route", async () => { const div = document.createElement("div"); document.body.appendChild(div); const history = memoryHistory("/app/"); @@ -62,7 +62,7 @@ describe("base with a trailing slash", () => { const dispose = render(() => , div); try { expect(div.querySelector('[data-route="home"]')).not.toBeNull(); - navigate("/about"); + navigate("/app/about"); await settle(); expect(history.get()).toBe("/app/about"); expect(div.querySelector('[data-route="about"]')).not.toBeNull(); diff --git a/test/claims.spec.tsx b/test/claims.spec.tsx index d36f8c99..4d7d9379 100644 --- a/test/claims.spec.tsx +++ b/test/claims.spec.tsx @@ -626,12 +626,12 @@ describe("compiler-claimed anchors", () => { expect(state("application")).toEqual([false, null]); expect(state("apple")).toEqual([false, null]); - navigate("/"); + navigate("/app"); await settle(); expect(state("root")).toEqual([true, "page"]); expect(state("apple")).toEqual([false, null]); - navigate("/x"); + navigate("/app/x"); await settle(); expect(state("x")).toEqual([true, "page"]); expect(state("root")).toEqual([false, null]); @@ -920,7 +920,7 @@ describe("link state parity: claimed anchors and useLinkState", () => { type Link = { href: string; end?: boolean; active: boolean; current: boolean }; // `href` and `location` are full URL paths; under a `base`, useLinkState - // and navigate get the base-relative path, as an app would write them + // and navigate get the same full path the anchor does type Scenario = { name: string; base?: string; location: string; links: Link[] }; const scenarios: Scenario[] = [ { @@ -1046,7 +1046,6 @@ describe("link state parity: claimed anchors and useLinkState", () => { ]; const key = (link: Link) => `${link.href}|${link.end ? "end" : ""}`; - const relative = (base: string, path: string) => path.slice(base.length) || "/"; const bases = [...new Set(scenarios.map(s => s.base ?? ""))]; const scenariosUnder = (base: string) => scenarios.filter(s => (s.base ?? "") === base); @@ -1069,7 +1068,7 @@ describe("link state parity: claimed anchors and useLinkState", () => { for (const link of uniqueLinks) states.set( key(link), - useLinkState(() => relative(base, link.href), { end: link.end }) + useLinkState(() => link.href, { end: link.end }) ); return ( <> @@ -1111,7 +1110,7 @@ describe("link state parity: claimed anchors and useLinkState", () => { }; return { check, - navigate: (to: string) => navigate(relative(base, to)), + navigate: (to: string) => navigate(to), cleanup: () => { dispose(); div.remove(); diff --git a/test/data/action.spec.ts b/test/data/action.spec.ts index 1b270316..c103a019 100644 --- a/test/data/action.spec.ts +++ b/test/data/action.spec.ts @@ -21,7 +21,8 @@ vi.mock("../../src/utils.js", () => ({ configurable: false }); return obj; - } + }, + isUnderBase: () => true })); let mockRouterContext: RouterContext; @@ -245,7 +246,7 @@ describe("action", () => { const boundAction = useAction(redirectAction); await boundAction(); - expect(navigate).toHaveBeenCalledWith("/next", { replace: true, resolve: true }); + expect(navigate).toHaveBeenCalledWith("/next", { replace: true, resolve: false }); expect(onSettled).toHaveBeenCalledTimes(1); expect(mockRouterContext.submissions[0]()).toHaveLength(0); }); diff --git a/test/data/flight-consumer.spec.ts b/test/data/flight-consumer.spec.ts index 833d73af..00c8f002 100644 --- a/test/data/flight-consumer.spec.ts +++ b/test/data/flight-consumer.spec.ts @@ -79,7 +79,7 @@ describe("setupFlightDataConsumer", () => { { "notes[]": ["destination data"] }, { response: new Response(null, { headers: carrier("/notes") }) } ); - expect(navigate).toHaveBeenCalledWith("/notes", { replace: true, resolve: true }); + expect(navigate).toHaveBeenCalledWith("/notes", { replace: true, resolve: false }); expect(query.get("notes[]")).toEqual(["destination data"]); }); @@ -177,7 +177,7 @@ describe("setupFlightDataConsumer", () => { }; await formAction(save, "keyless-save").call({ r: router }); - expect(navigate).toHaveBeenCalledWith("/dash/b", { replace: true, resolve: true }); + expect(navigate).toHaveBeenCalledWith("/dash/b", { replace: true, resolve: false }); expect(await layout()).toBe("fresh-layout"); expect(fetchLayout).toHaveBeenCalledTimes(1); }); diff --git a/test/full-path-base.spec.tsx b/test/full-path-base.spec.tsx new file mode 100644 index 00000000..3222ba1c --- /dev/null +++ b/test/full-path-base.spec.tsx @@ -0,0 +1,179 @@ +import { render, redirect } from "@solidjs/web"; +import { vi } from "vitest"; +import { + createRouter, + hashHistory, + memoryHistory, + query, + useLinkState, + useNavigate, + useResolvedPath, + type LinkState, + type Navigator +} from "../src/index.js"; + +// Under a `base`, a leading "/" is a full document path: the router never +// adds the base to it. `paths` nodes already hold the base, so they are the +// portable spelling. Route-relative strings still resolve against the route. + +const settle = (ms = 30) => new Promise(r => setTimeout(r, ms)); + +const routes = [ + { path: "/", component: () =>

home

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

about

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

login

} +] as const; + +type Kind = "memory" | "hash"; + +const originalScrollTo = window.scrollTo; +beforeEach(() => { + window.scrollTo = vi.fn() as any; + window.history.replaceState(null, "", "/#/app"); + query.clear(); +}); +afterEach(() => { + window.history.replaceState(null, "", "/"); + query.clear(); +}); +afterAll(() => { + window.scrollTo = originalScrollTo; +}); + +function setup(kind: Kind, extra: readonly any[] = []) { + const history = kind === "memory" ? memoryHistory("/app") : undefined; + const Router = createRouter({ + routes: [...routes, ...extra], + base: "/app", + history: history || hashHistory() + }); + const at = () => (history ? history.get() : window.location.hash.slice(1)); + let navigate!: Navigator; + const resolved: Record string | undefined> = {}; + const links: Record = {}; + const div = document.createElement("div"); + document.body.appendChild(div); + const dispose = render( + () => ( + + {p => { + navigate = useNavigate(); + for (const to of ["/about", "/app/about", "about"]) { + resolved[to] = useResolvedPath(() => to); + links[to] = useLinkState(() => to); + } + links.node = useLinkState(() => Router.paths.about); + return ( + <> +
+ {p.children} + + ); + }} + + ), + div + ); + const state = (key: string) => [links[key].active(), links[key].current()]; + const cleanup = () => { + dispose(); + div.remove(); + }; + return { Router, div, at, navigate: (to: any) => navigate(to), resolved, state, cleanup }; +} + +describe.each(["memory", "hash"])("a leading slash is a full path (%s history)", kind => { + test("useResolvedPath: a leading slash keeps its path; a relative path gets the base", async () => { + const { resolved, cleanup } = setup(kind); + try { + await settle(); + expect(resolved["/about"]()).toBe("/about"); + expect(resolved["/app/about"]()).toBe("/app/about"); + expect(resolved["about"]()).toBe("/app/about"); + } finally { + cleanup(); + } + }); + + test("navigate: a full path and a paths node land on /app/about", async () => { + const { Router, div, at, navigate, cleanup } = setup(kind); + try { + await settle(); + expect(div.textContent).toBe("home"); + for (const to of ["/app/about", Router.paths.about]) { + navigate("/app"); + await settle(); + expect(div.textContent).toBe("home"); + navigate(to); + await settle(); + expect(at()).toBe("/app/about"); + expect(div.textContent).toBe("about"); + } + } finally { + cleanup(); + } + }); + + test("navigate('/about') does not add the base", async () => { + const { at, navigate, cleanup } = setup(kind); + try { + await settle(); + navigate("/about"); + await settle(); + expect(at()).toBe("/about"); + } finally { + cleanup(); + } + }); + + test("useLinkState: the full path and the node are current; '/about' is not", async () => { + const { navigate, state, cleanup } = setup(kind); + try { + navigate("/app/about"); + await settle(); + expect(state("/app/about")).toEqual([true, true]); + expect(state("node")).toEqual([true, true]); + expect(state("about")).toEqual([true, true]); + expect(state("/about")).toEqual([false, false]); + } finally { + cleanup(); + } + }); + + test("an anchor to the full path navigates softly", async () => { + const { div, at, cleanup } = setup(kind); + try { + await settle(); + const a = div.querySelector('[data-testid="full"]')!; + a.click(); + await settle(); + expect(at()).toBe("/app/about"); + expect(div.textContent).toBe("about"); + } finally { + cleanup(); + } + }); + + test.each([ + ["a paths node", (Router: any) => redirect(Router.paths.login)], + ["a full-path string", () => redirect("/app/login")] + ])("a query redirect to %s lands on /app/login", async (_, make) => { + let Router!: any; + const read = query(async () => make(Router), `full-path-redirect-${kind}`); + const Guard = () => { + read(); + return

guarded

; + }; + const s = setup(kind, [{ path: "/guarded", component: Guard }]); + Router = s.Router; + try { + await settle(); + s.navigate("/app/guarded"); + await settle(100); + expect(s.at()).toBe("/app/login"); + expect(s.div.textContent).toBe("login"); + } finally { + s.cleanup(); + } + }); +}); diff --git a/test/paths-base.spec.tsx b/test/paths-base.spec.tsx index a3a27d2c..a791a881 100644 --- a/test/paths-base.spec.tsx +++ b/test/paths-base.spec.tsx @@ -47,11 +47,12 @@ describe("paths nodes under a base", () => { } }); - test("useLinkState(paths.about) is current on /app/about, like the base-relative string", async () => { + test("useLinkState(paths.about) is current on /app/about, like the full-path string", async () => { const history = memoryHistory("/app/about"); const Router = createRouter({ routes, base: "/app", history }); let node!: LinkState; - let relative!: LinkState; + let full!: LinkState; + let bare!: LinkState; let root!: LinkState; const div = document.createElement("div"); const dispose = render( @@ -59,7 +60,9 @@ describe("paths nodes under a base", () => { {p => { node = useLinkState(() => Router.paths.about); - relative = useLinkState(() => "/about"); + full = useLinkState(() => "/app/about"); + // a leading "/" is a full path: the base is not added + bare = useLinkState(() => "/about"); root = useLinkState(() => Router.paths); return p.children; }} @@ -71,7 +74,8 @@ describe("paths nodes under a base", () => { await settle(); expect(div.textContent).toBe("about"); expect([node.active(), node.current()]).toEqual([true, true]); - expect([relative.active(), relative.current()]).toEqual([true, true]); + expect([full.active(), full.current()]).toEqual([true, true]); + expect([bare.active(), bare.current()]).toEqual([false, false]); expect([root.active(), root.current()]).toEqual([false, false]); } finally { dispose(); diff --git a/test/query-redirect.spec.tsx b/test/query-redirect.spec.tsx index 25bb989c..992cc954 100644 --- a/test/query-redirect.spec.tsx +++ b/test/query-redirect.spec.tsx @@ -389,7 +389,7 @@ describe.each(["returned", "thrown"])("%s query response metadata", mode => { }); test.each([ - ["/next?q=1#part", "/app/next?q=1#part"], + ["/app/next?q=1#part", "/app/next?q=1#part"], ["../next?q=1#part", "/app/next?q=1#part"], ["?saved=1", "/app/docs/chapter?saved=1"], ["#part", "/app/docs/chapter?filter=active#part"], @@ -410,12 +410,12 @@ describe.each(["returned", "thrown"])("%s query response metadata", mode => { expect(history.get()).toBe(expected); }); - test("keeps a carried redirect relative to the router base", async () => { + test("a carried redirect is a full path, navigated softly under the router base", async () => { initial = "/app/docs/chapter"; window.location.href = new URL(initial, originalLocation.href).href; const href = window.location.href; const { root, history, observed, caught } = await setup( - carriedRedirect("/sign-in?q=1#part"), + carriedRedirect("/app/sign-in?q=1#part"), "/app" ); expect(history.get()).toBe("/app/sign-in?q=1#part"); @@ -427,8 +427,25 @@ describe.each(["returned", "thrown"])("%s query response metadata", mode => { expect(history.get()).toBe("/app/sign-in?q=1#part"); }); + test("a carried redirect outside the router base navigates the document", async () => { + initial = "/app/docs/chapter"; + window.location.href = new URL(initial, originalLocation.href).href; + const expected = new URL("/sign-in?q=1#part", window.location.href).href; + const { root, history, observed, caught } = await setup( + carriedRedirect("/sign-in?q=1#part"), + "/app" + ); + expect(window.location.href).toBe(expected); + expect(history.get()).toBe(initial); + expect(root.textContent).toBe("page"); + expect(observed).toEqual([]); + expect(caught).toEqual([]); + }); + test.each([ new URL("/other?q=1#part", window.location.href).href, + // a leading "/" is a full path: the base is not added + "/next?q=1#part", "../../other?q=1#part", new URL("/apple/next", window.location.href).href, `//${window.location.host}/other` @@ -517,7 +534,7 @@ describe.each(["returned", "thrown"])("%s query redirects with hashHistory", mod ["local", new URL("/shell#/login?q=1#part", window.location.href).href, ""], ["local", `//${window.location.host}/shell#/login?q=1#part`, ""], ["carried", "/login?q=1#part", ""], - ["carried", "/login?q=1#part", "/app"] + ["carried", "/app/login?q=1#part", "/app"] ])("renders a %s redirect to %s with base '%s'", async (kind, target, base) => { window.history.replaceState(null, "", `/shell#${base}/private`); const observed: unknown[] = []; diff --git a/test/server/ssr-redirect-response-head.spec.tsx b/test/server/ssr-redirect-response-head.spec.tsx index a084b861..330075e9 100644 --- a/test/server/ssr-redirect-response-head.spec.tsx +++ b/test/server/ssr-redirect-response-head.spec.tsx @@ -99,8 +99,11 @@ describe("an SSR redirect writes into the response head", () => { expect(response.headers.get("Location")).toBe("/login"); }); - test("a query redirect resolves its Location against the router base", async () => { - const getAccount = query(async () => redirect("/login"), "account"); + test.each([ + ["/app/login", "/app/login"], + ["/login", "/login"] + ])("a query redirect to %s keeps its full path under the router base", async (to, expected) => { + const getAccount = query(async () => redirect(to), "account"); const Router = createRouter({ base: "/app", routes: [ @@ -116,6 +119,6 @@ describe("an SSR redirect writes into the response head", () => { ) ); expect(response.status).toBe(302); - expect(response.headers.get("Location")).toBe("/app/login"); + expect(response.headers.get("Location")).toBe(expected); }); }); diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 2a5dba47..bbeb8625 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -34,14 +34,14 @@ describe("resolvePath should", () => { expect(actual).toBe(expected); }); - test("resolve root path against base and ignore from", () => { - const expected = "/base"; + test("a root path is a full path: no base, and from is ignored", () => { + const expected = "/"; const actual = resolvePath("/base", "/", "/base/foo"); expect(actual).toBe(expected); }); - test("resolve rooted paths against base and ignore from", () => { - const expected = "/base/bar"; + test("a rooted path is a full path: no base, and from is ignored", () => { + const expected = "/bar"; const actual = resolvePath("/base", "/bar", "/base/foo"); expect(actual).toBe(expected); }); @@ -70,8 +70,8 @@ describe("resolvePath should", () => { expect(actual).toBe(expected); }); - test(`work with rooted search and base`, () => { - const expected = "/base?foo=bar"; + test(`a rooted search under a base is a full path`, () => { + const expected = "/?foo=bar"; const actual = resolvePath("/base", "/?foo=bar", "/base/page"); expect(actual).toBe(expected); }); From 08df79c3f8371733b5a6a4b8b17a3eb387986f79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:59:03 +0000 Subject: [PATCH 3/4] refactor: drop #717's brand special-casing, now redundant With a leading slash a full path, a paths node's brand resolves to itself, so navigate goes back to plain resolution and useLinkState only reads the brand (hash mode's toString is the display href). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- src/routing.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/routing.ts b/src/routing.ts index a6f0e190..9c74480d 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -405,12 +405,10 @@ export const useLinkState = ( ): LinkState => { const router = useRouter(); const location = router.location; - 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)); + // a paths node's brand is its logical path; toString is the display href + const to = useResolvedPath(() => { + const h: any = href(); + return h[HREF] || String(h); }); const base = router.base.path(); const state = createMemo(() => linkMatcher(location, base, options.end)(to())); @@ -1091,10 +1089,9 @@ export function createRouterContext( }; resolvedTo = compose(headed); } else { - // 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); + if (typeof to !== "string") to = href || to.toString(); + resolvedTo = resolveTarget(to); + if (resolvedTo === undefined) throw unroutable(to); } const navigationDepth = From 89fe57097ffb154efc312380de98e6aa93bfcb20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:11:30 +0000 Subject: [PATCH 4/4] fix: navigate() to a full path outside the base loads the document Follows SvelteKit's precedence, like anchors and redirects already do. isUnderBase now ignores a query and hash itself, so navigate and applyServerResponse share the check. README notes Vite's BASE_URL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- .changeset/full-path-leading-slash.md | 2 +- README.md | 4 +++- src/data/query.ts | 2 +- src/routing.ts | 6 +++++- src/utils.ts | 4 ++-- test/claims.spec.tsx | 4 ---- test/full-path-base.spec.tsx | 22 ++++++++++++++++++---- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.changeset/full-path-leading-slash.md b/.changeset/full-path-leading-slash.md index 0bacb786..b93ef482 100644 --- a/.changeset/full-path-leading-slash.md +++ b/.changeset/full-path-leading-slash.md @@ -4,4 +4,4 @@ Under a `base`, a path that starts with `/` is now a full path everywhere, and the router never adds the base to it. This covers `navigate()`, `useLinkState`, `useResolvedPath`, and query and server-function redirects, and matches what `
` and a raw HTTP redirect already did. `redirect()` comes from `@solidjs/web`, which doesn't know the router's base, so `redirect(paths.login)` used to land on `/app/app/login`. It now lands on `/app/login`. -Migration: under `base: "/app"`, `navigate("/about")` now goes to `/about`. Use `paths.about` or `` `${base}/about` ``. The same applies to `useLinkState(() => "/about")`, `useResolvedPath(() => "/about")` and `redirect("/login")`. A redirect to a full path outside the base navigates the document. A `navigate()` to one navigates softly, as before. +Migration: under `base: "/app"`, `navigate("/about")` now goes to `/about`. Use `paths.about` or `` `${base}/about` ``. The same applies to `useLinkState(() => "/about")`, `useResolvedPath(() => "/about")` and `redirect("/login")`. A full path outside the base is another document: `navigate()` to one, like an anchor or a redirect, now loads the document instead of navigating softly. Under Vite's `base`, pass `import.meta.env.BASE_URL` as the router `base`. diff --git a/README.md b/README.md index 23f3da7b..81088065 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,8 @@ createRouter(config); | `links` | `LinksPlugin` | Link claims plugin — `pendingLinks` adds `data-pending` to the in-flight navigation's target | | `transformUrl` | `(url: string) => string` | Rewrite URLs before matching | +Under Vite's `base`, pass `import.meta.env.BASE_URL` as the router `base` and link with `paths`, so links, `navigate()` and `redirect()` follow it. + The returned instance is the provider component and carries the static surface: | member | description | @@ -884,7 +886,7 @@ const navigate = useNavigate(); navigate(paths.login, { replace: true }); ``` -A string that starts with `/` is a full path, like an `href`: under `base: "/app"`, `navigate("/about")` goes to `/about`, not `/app/about`. Use `paths.about` (or `` `${base}/about` ``) for a path under the base. A full path outside the base still navigates softly, and no route matches it unless one does. +A string that starts with `/` is a full path, like an `href`: under `base: "/app"`, `navigate("/about")` goes to `/about`, not `/app/about`. Use `paths.about` (or `` `${base}/about` ``) for a path under the base. A full path outside the base is another document, so `navigate()` loads it (as an anchor or a redirect to it does). For declarative redirects on render (the old ``), call it during component setup or redirect from a preload. diff --git a/src/data/query.ts b/src/data/query.ts index 1e5c53a4..e8b0d561 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -505,7 +505,7 @@ export function applyServerResponse( const resolved = target.pathname + target.search + target.hash; const path = local ? resolved : router!.parsePath(resolved); if ( - isUnderBase(path.split(/[?#]/, 1)[0], router!.base.path()) && + isUnderBase(path, router!.base.path()) && (local || new URL(router!.renderPath(path), window.location.href).href === target.href) ) { navigate(path, { replace: true, resolve: false }); diff --git a/src/routing.ts b/src/routing.ts index 9c74480d..d76a94e3 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -56,7 +56,8 @@ import { scoreRoute, mergeSearchString, expandOptionals, - validateSearch + validateSearch, + isUnderBase } from "./utils.js"; // Flash-cookie detection/clearing are cookie utilities on the CORE entry // (they live beside the cookie codec) — routing is in every app's eager @@ -1120,6 +1121,9 @@ export function createRouterContext( head.headers.set("Location", resolvedTo); } setSource({ value: resolvedTo, replace, scroll, state: nextState }); + } else if (!isUnderBase(resolvedTo!, basePath!)) { + // a full path outside the base is another document, as for anchors and redirects + window.location.href = resolvedTo!; } else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) { // Heading there already (same value and state) writes nothing — the // integration decides that against the updater's `headed`, so a diff --git a/src/utils.ts b/src/utils.ts index 2860c076..1ec899bc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -54,13 +54,13 @@ export function plainHref(href: string) { } /** - * Whether a URL pathname is the router's base path or under it, on a segment + * Whether a URL path (query and hash ignored) is the router's base path or under it, on a segment * boundary: base `/app` covers `/app`, `/app/` and `/app/x`, not `/apple`. * Case-insensitive; no base (`""` or `/`) covers every path. */ export function isUnderBase(pathname: string, base: string) { const b = base.toLowerCase().replace(/\/+$/, ""); - const p = pathname.toLowerCase(); + const p = pathname.split(/[?#]/, 1)[0].toLowerCase(); return !b || !p || p === b || p.startsWith(b + "/"); } diff --git a/test/claims.spec.tsx b/test/claims.spec.tsx index 4d7d9379..fff1cf26 100644 --- a/test/claims.spec.tsx +++ b/test/claims.spec.tsx @@ -620,11 +620,7 @@ describe("compiler-claimed anchors", () => { ]; try { expect(state("apple")).toEqual([false, null]); - - navigate("/application/x", { resolve: false }); - await settle(); expect(state("application")).toEqual([false, null]); - expect(state("apple")).toEqual([false, null]); navigate("/app"); await settle(); diff --git a/test/full-path-base.spec.tsx b/test/full-path-base.spec.tsx index 3222ba1c..d3be9bda 100644 --- a/test/full-path-base.spec.tsx +++ b/test/full-path-base.spec.tsx @@ -114,14 +114,28 @@ describe.each(["memory", "hash"])("a leading slash is a full path (%s hist } }); - test("navigate('/about') does not add the base", async () => { - const { at, navigate, cleanup } = setup(kind); + test.each([ + ["/about", "/about"], + ["/other?q=1#part", "/other?q=1#part"], + ["/apple", "/apple"] + ])("navigate(%s), a full path outside the base, loads the document", async (to, expected) => { + const { at, div, navigate, cleanup } = setup(kind); + const originalLocation = window.location; try { await settle(); - navigate("/about"); + const before = at(); + Object.defineProperty(window, "location", { + value: { ...originalLocation }, + configurable: true + }); + navigate(to); await settle(); - expect(at()).toBe("/about"); + expect(window.location.href).toBe(expected); + Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); + expect(at()).toBe(before); + expect(div.textContent).toBe("home"); } finally { + Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); cleanup(); } });