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
5 changes: 5 additions & 0 deletions .changeset/plain-href-link-claims.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Server link claims no longer parse a root-relative href with `URL` when its path and query only use characters `URL` keeps unchanged and it has no `.`/`..` segments, and `comparablePath` skips its parse for such paths. Other hrefs still go through `URL`. On a 1000-link SSR page this cuts the claims' cost per request from about 1.2 ms to about 0.4 ms.
37 changes: 23 additions & 14 deletions src/claims.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { hasServerLinkState, registerElementClaim, setLinkClaim } from "@solidjs/web";
import { hasServerLinkState, isServer, registerElementClaim, setLinkClaim } from "@solidjs/web";
import { createRenderEffect, getOwner, isHydrating, onCleanup, untrack } from "solid-js";
import type { LinksPlugin, RouterContext } from "./types.js";
import { isUnderBase, linkMatcher } from "./utils.js";
import { isUnderBase, linkMatcher, plainHref } from "./utils.js";

/**
* Claimed forms are handed to this slot instead of the claims importing the
Expand Down Expand Up @@ -61,7 +61,7 @@ export function setupLinkClaims(
}

/** The anchor's resolved URL when the router manages it, else `undefined`. */
function managedUrl(a: HTMLAnchorElement | SVGAElement): URL | undefined {
function managedUrl(a: HTMLAnchorElement | SVGAElement) {
if (explicitLinks && !a.hasAttribute("link")) return;
const svg = isSvg(a);
// claims fire at creation while the element is still in the template's
Expand Down Expand Up @@ -194,31 +194,39 @@ function managedLinkUrl(
page: string | URL,
origin: string,
basePath: string
): URL | undefined {
): { pathname: string; search: string; comparable?: string } | undefined {
if ((explicitLinks && !a.link) || a.target || !a.href || a.download) return;
if (a.rel && a.rel.split(/\s+/).includes("external")) return;
let url;
try {
url = new URL(a.href, page);
} catch {
return;
// server pages resolve root-relative hrefs against `origin`, so plain ones skip `URL`
let url: { pathname: string; search: string; comparable?: string } | undefined = isServer
? plainHref(a.href)
: undefined;
if (!url) {
let parsed;
try {
parsed = new URL(a.href, page);
} catch {
return;
}
if (parsed.origin !== origin) return;
url = parsed;
}
if (url.origin !== origin || !isUnderBase(url.pathname, basePath)) return;
if (!isUnderBase(url.pathname, basePath)) return;
return url;
}

/** Matches targets against the router's location, sharing its parse until it changes. */
function locationMatcher(router: RouterContext, basePath: string) {
let matched: string | undefined;
let match: ReturnType<typeof linkMatcher>;
return (target: string | undefined) => {
return (target: string | undefined, comparable?: string) => {
const location = router.location;
const key = location.pathname + location.search;
if (key !== matched) {
matched = key;
match = linkMatcher(location, basePath);
}
return match(target);
return match(target, comparable);
};
}

Expand All @@ -232,6 +240,7 @@ export function setupServerLinkClaims(
page: URL
) {
const basePath = router.base.path();
const origin = page.origin;
const match = locationMatcher(router, basePath);
setLinkClaim(attrs => {
const url = managedLinkUrl(
Expand All @@ -244,11 +253,11 @@ export function setupServerLinkClaims(
},
explicitLinks,
page,
page.origin,
origin,
basePath
);
if (!url) return "";
const { active, current } = match(url.pathname + url.search);
const { active, current } = match(url.pathname + url.search, url.comparable);
return current ? ' data-active aria-current="page"' : active ? " data-active" : "";
});
}
48 changes: 39 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMemo, getOwner, runWithOwner } from "solid-js";
import { isServer } from "@solidjs/web";
import type {
StandardSchemaV1,
MatchFilter,
Expand All @@ -24,10 +25,33 @@ export function normalizePath(path: string, omitSlash: boolean = false) {
* link matching compares. Raw and encoded spellings (`/café`, `/caf%C3%A9`)
* meet; escapes are never decoded, so `%2F` is not a path separator.
*/
export const comparablePath = (path: string) =>
new URL(mockBase + normalizePath(path.split(/[?#]/, 1)[0])).pathname
.toLowerCase()
.replace(/\/$/, "");
export const comparablePath = (path: string) => {
const p = normalizePath(path.split(/[?#]/, 1)[0]);
return (isServer && isPlainPath(p) ? p : new URL(mockBase + p).pathname).toLowerCase().replace(/\/$/, "");
};

// characters `URL` keeps verbatim in a special-scheme path (no `%`, `\`)
const plainPathRegex = /^[\w\-.~!$&'()*+,;=:@/]*$/;
const dotSegmentRegex = /(?:^|\/)\.\.?(?:\/|$)/;
// a root-relative href whose path and query `URL` would keep verbatim
const plainHrefRegex =
/^(\/(?![/\\])[\w\-.~!$&'()*+,;=:@/]*)(?:\?([\w\-.~!$&()*+,;=:@/?]*))?(?:#|$)/;

const hasDotSegment = (path: string) => path.includes("/.") && dotSegmentRegex.test(path);

/** Whether `URL` would report this pathname unchanged, so it can skip the parse. */
const isPlainPath = (path: string) => plainPathRegex.test(path) && !hasDotSegment(path);

/** A root-relative href's pathname, search and comparable path, or `undefined` if `URL` would change them. */
export function plainHref(href: string) {
const m = plainHrefRegex.exec(href);
if (!m || hasDotSegment(m[1])) return;
const pathname = m[1];
const comparable = (
pathname.endsWith("/") ? pathname.replace(/\/+$/, "") : pathname
).toLowerCase();
return { pathname, search: m[2] ? "?" + m[2] : "", comparable };
}

/**
* Whether a URL pathname is the router's base path or under it, on a segment
Expand Down Expand Up @@ -65,15 +89,21 @@ export function linkMatcher(
) {
const loc = comparablePath(pathname);
const root = comparablePath(base);
return (target: string | undefined): { active: boolean; current: boolean } => {
return (
target: string | undefined,
path = target === undefined ? "" : comparablePath(target)
): { active: boolean; current: boolean } => {
if (target === undefined) return { active: false, current: false };
const path = comparablePath(target);
const exact = loc === path;
if (loc !== path)
return {
active: !end && path !== "" && path !== root && loc.startsWith(path + "/"),
current: false
};
const hashless = target.split("#", 1)[0];
const q = hashless.indexOf("?");
return {
active: exact || (!end && path !== "" && path !== root && loc.startsWith(path + "/")),
current: exact && comparableQuery(search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
active: true,
current: comparableQuery(search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
};
};
}
Expand Down
53 changes: 53 additions & 0 deletions test/server/plain-href-fast-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import { comparablePath, mockBase, normalizePath, plainHref } from "../../src/utils.js";

const viaUrl = (path: string) =>
new URL(mockBase + normalizePath(path.split(/[?#]/, 1)[0])).pathname
.toLowerCase()
.replace(/\/$/, "");

const alphabet = "/aZ09-._~!$&'()*+,;=:@?#%\\ \t\"<>`{}^|é→.";
function rand(seed: number) {
let s = seed;
return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
}

describe("plain path fast path", () => {
test("agrees with URL for random paths and hrefs", () => {
const r = rand(1);
let hits = 0;
for (let i = 0; i < 50000; i++) {
const len = Math.floor(r() * 12);
let s = r() < 0.8 ? "/" : "";
for (let j = 0; j < len; j++) s += alphabet[Math.floor(r() * alphabet.length)];
expect(comparablePath(s), s).toBe(viaUrl(s));
const plain = plainHref(s);
if (!plain) continue;
hits++;
const url = new URL(s, "http://sr/page/here?x=1");
expect(plain.pathname, s).toBe(url.pathname);
expect(plain.search, s).toBe(url.search);
expect(plain.comparable, s).toBe(viaUrl(url.pathname));
}
expect(hits).toBeGreaterThan(1000);
});

test("falls back for hrefs URL would rewrite", () => {
for (const href of [
"/a/../b",
"/a/./b",
"/caf%C3%A9",
"/café",
"//x.example/a",
"/a b",
"/a?q='x'",
" /a"
])
expect(plainHref(href), href).toBeUndefined();
expect(plainHref("/About//?#x")).toEqual({
pathname: "/About//",
search: "",
comparable: "/about"
});
});
});
29 changes: 29 additions & 0 deletions test/server/ssr-link-state.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ describe("SSR link state", () => {
expect(anchor(html, "About")).not.toContain("aria-current");
});

test("hrefs URL normalizes match the same as plain ones", async () => {
const Router = createRouter({
routes: [
{
path: "/",
component: () => (
<>
<a href="/About/">Upper</a>
<a href="/users/../about">Dots</a>
<a href="/about?#top">EmptyQuery</a>
<a href="/caf%C3%A9">Encoded</a>
<a href="/café">Raw</a>
</>
),
children: [
{ path: "/about", component: () => null },
{ path: "/café", component: () => null }
]
}
]
});
let html = await renderToString(() => <Router url="/about" />);
for (const text of ["Upper", "Dots", "EmptyQuery"])
expect(anchor(html, text)).toContain('data-active aria-current="page"');
html = await renderToString(() => <Router url="/caf%C3%A9" />);
for (const text of ["Encoded", "Raw"])
expect(anchor(html, text)).toContain('data-active aria-current="page"');
});

test("anchors the router does not manage stay unmarked", async () => {
const Router = createRouter({ routes });
const html = await renderToString(() => <Router url="/about" />);
Expand Down
Loading