Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/full-path-leading-slash.md
Original file line number Diff line number Diff line change
@@ -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 `<a href>` 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 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`.
5 changes: 5 additions & 0 deletions .changeset/paths-node-base.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down Expand Up @@ -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` |
Expand All @@ -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 |
Expand Down Expand Up @@ -884,6 +886,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 is another document, so `navigate()` loads it (as an anchor or a redirect to it does).

For declarative redirects on render (the old `<Navigate>`), call it during component setup or redirect from a preload.

### useLocation
Expand Down Expand Up @@ -973,7 +977,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.
Expand Down
21 changes: 9 additions & 12 deletions src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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 });
Expand Down
12 changes: 10 additions & 2 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -405,7 +406,11 @@ export const useLinkState = (
): LinkState => {
const router = useRouter();
const location = router.location;
const to = useResolvedPath(() => String(href()));
// 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()));
return {
Expand Down Expand Up @@ -1116,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
Expand Down
20 changes: 10 additions & 10 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "/");
}

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

Expand Down
4 changes: 2 additions & 2 deletions test/base-trailing-slash.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("base with a trailing slash", () => {
expect(() => render(() => <Router />, 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/");
Expand All @@ -62,7 +62,7 @@ describe("base with a trailing slash", () => {
const dispose = render(() => <Router />, 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();
Expand Down
15 changes: 5 additions & 10 deletions test/claims.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,18 +620,14 @@ 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("/");
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]);
Expand Down Expand Up @@ -920,7 +916,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[] = [
{
Expand Down Expand Up @@ -1046,7 +1042,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);

Expand All @@ -1069,7 +1064,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 (
<>
Expand Down Expand Up @@ -1111,7 +1106,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();
Expand Down
5 changes: 3 additions & 2 deletions test/data/action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ vi.mock("../../src/utils.js", () => ({
configurable: false
});
return obj;
}
},
isUnderBase: () => true
}));

let mockRouterContext: RouterContext;
Expand Down Expand Up @@ -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);
});
Expand Down
4 changes: 2 additions & 2 deletions test/data/flight-consumer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});

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