Skip to content
Open
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
16 changes: 16 additions & 0 deletions .changeset/spotty-moons-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@solidjs/router": patch
---

Speed up `<A>` server rendering by roughly 5x

`<A>` spent most of its server render time in `mergeProps` and `splitProps`, which build
accessor-backed prop objects that can never be observed during a one shot
`renderToString`. The default `activeClass` / `inactiveClass` merge is gone, the
`location.pathname` normalization is shared instead of repeated per link, `JSON.stringify`
is skipped when there is no `state`, and on the server a link that passes nothing beyond
the props `<A>` consumes itself now skips `splitProps` and the JSX spread entirely.

Rendered output is unchanged on the client, and unchanged on the server apart from the
`link` marker serializing as `link` rather than `link="true"` on that fast path. Client
bundles get slightly smaller, since the server branch is constant folded away.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
"scripts": {
"build": "rm -rf dist && tsc && rollup -c",
"prepublishOnly": "npm run build",
"test": "vitest run && npm run test:types",
"test": "vitest run && npm run test:ssr && npm run test:types",
"test:watch": "vitest",
"test:ssr": "vitest run --config vitest.ssr.config.ts",
"test:types": "tsc --project tsconfig.test.json",
"pretty": "prettier --write \"{src,test}/**/*.{ts,tsx}\"",
"release": "pnpm build && changeset publish"
Expand Down
105 changes: 81 additions & 24 deletions src/components.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
/*@refresh skip*/
import type { JSX } from "solid-js";
import { createMemo, mergeProps, splitProps } from "solid-js";
import {
useHref,
useLocation,
useNavigate,
useResolvedPath
} from "./routing.js";
import type {
Location,
Navigator
} from "./types.js";
import { createMemo, splitProps } from "solid-js";
import { isServer } from "solid-js/web";
import { useHref, useLocation, useNavigate, useResolvedPath } from "./routing.js";
import type { Location, Navigator } from "./types.js";
import { normalizePath } from "./utils.js";

declare module "solid-js" {
Expand All @@ -34,16 +27,55 @@ export interface AnchorProps extends Omit<JSX.AnchorHTMLAttributes<HTMLAnchorEle
activeClass?: string | undefined;
end?: boolean | undefined;
}
// Every <A> on a page normalizes the same `location.pathname`, so a one entry cache
// collapses that work to once per navigation instead of once per link. Pure function
// of the input, so a stale or missed entry can only cost a recompute.
let lastPathname: string | undefined;
let lastNormalizedPathname: string;
function normalizeLocationPath(pathname: string): string {
if (pathname !== lastPathname) {
lastNormalizedPathname = decodeURI(normalizePath(pathname).toLowerCase().replace(/\/$/, ""));
lastPathname = pathname;
}
return lastNormalizedPathname;
}

// Props <A> consumes itself. `children` is deliberately absent: leaving it in `rest` keeps
// the spread path byte for byte identical to before, including `innerHTML`/`textContent`
// precedence over children and the order a `ref` observes.
const SPLIT_PROPS = [
"href",
"state",
"class",
"activeClass",
"inactiveClass",
"end"
] as const satisfies readonly (keyof AnchorProps)[];

// The fast path renders children explicitly, so a link that only has children still
// qualifies for it. Anything outside this set, `innerHTML` included, takes the spread path.
const FAST_PATH_PROPS: ReadonlySet<string> = new Set([...SPLIT_PROPS, "children"]);

export function A(props: AnchorProps) {
props = mergeProps({ inactiveClass: "inactive", activeClass: "active" }, props);
const [, rest] = splitProps(props, [
"href",
"state",
"class",
"activeClass",
"inactiveClass",
"end"
]);
// `splitProps` plus the JSX spread is the bulk of the per link cost, and neither is
// needed when the caller passes nothing beyond the props <A> consumes itself. Server
// only: a render pass there is one shot, so the key set cannot grow after this check,
// which it can on the client when the caller uses a reactive spread. The branch is
// constant folded out of client builds.
//
// Own property names rather than `for...in`: a non-enumerable own prop still reaches the
// element through `splitProps`, so missing one here would silently drop it.
let fastPath = false;
if (isServer) {
fastPath = true;
for (const key of Object.getOwnPropertyNames(props)) {
if (!FAST_PATH_PROPS.has(key)) {
fastPath = false;
break;
}
}
}

const to = useResolvedPath(() => props.href);
const href = useHref(to);
const location = useLocation();
Expand All @@ -52,19 +84,44 @@ export function A(props: AnchorProps) {
if (to_ === undefined) return [false, false];
// trailing slashes are ignored so `/route` and `/route/` share active state
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
const loc = normalizeLocationPath(location.pathname);
return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
});
// One read, so a getter backed `state` is not observed twice
const state = () => {
const value = props.state;
return value === undefined ? undefined : JSON.stringify(value);
};

if (fastPath) {
return (
<a
href={href() || props.href}
state={state()}
classList={{
...(props.class && { [props.class]: true }),
[props.inactiveClass ?? "inactive"]: !isActive()[0],
[props.activeClass ?? "active"]: isActive()[0]
}}
link
aria-current={isActive()[1] ? "page" : undefined}
>
{props.children}
</a>
);
}

const [, rest] = splitProps(props, [...SPLIT_PROPS]);

return (
<a
{...rest}
href={href() || props.href}
state={JSON.stringify(props.state)}
state={state()}
classList={{
...(props.class && { [props.class]: true }),
[props.inactiveClass!]: !isActive()[0],
[props.activeClass!]: isActive()[0],
[props.inactiveClass ?? "inactive"]: !isActive()[0],
[props.activeClass ?? "active"]: isActive()[0],
...rest.classList
}}
link
Expand Down
221 changes: 221 additions & 0 deletions test/anchor.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// @vitest-environment jsdom
import { vi } from "vitest";
import { render } from "solid-js/web";
import { A, MemoryRouter, Route, createMemoryHistory, useNavigate } from "../src/index.jsx";

// jsdom has no scrollTo, and navigating triggers the router's scroll handling
window.scrollTo = vi.fn() as any;

function mount(url: string, Comp: () => any) {
const history = createMemoryHistory();
history.set({ value: url });
const root = document.createElement("div");
document.body.appendChild(root);
const dispose = render(
() => (
<MemoryRouter history={history}>
<Route path="/docs/intro" component={Comp} />
<Route path="/other" component={Comp} />
</MemoryRouter>
),
root
);
return {
anchor: () => root.querySelector("a")!,
dispose: () => {
dispose();
root.remove();
}
};
}

// Client behaviour of <A>. The server only fast path is covered in test/ssr/anchor.spec.tsx;
// everything here goes through the `splitProps` + spread path, as it does in a browser.
describe("<A>", () => {
test("resolves href and marks itself inactive", () => {
const { anchor, dispose } = mount("/docs/intro", () => <A href="/other">go</A>);
expect(anchor().getAttribute("href")).toBe("/other");
expect(anchor().className).toBe("inactive");
expect(anchor().hasAttribute("aria-current")).toBe(false);
expect(anchor().hasAttribute("link")).toBe(true);
expect(anchor().textContent).toBe("go");
dispose();
});

test("marks itself active on an exact match", () => {
const { anchor, dispose } = mount("/docs/intro", () => <A href="/docs/intro">here</A>);
expect(anchor().className).toBe("active");
expect(anchor().getAttribute("aria-current")).toBe("page");
dispose();
});

test("marks itself active for a parent path unless end is set", () => {
const parent = mount("/docs/intro", () => <A href="/docs">parent</A>);
expect(parent.anchor().className).toBe("active");
expect(parent.anchor().hasAttribute("aria-current")).toBe(false);
parent.dispose();

const end = mount("/docs/intro", () => (
<A href="/docs" end>
parent
</A>
));
expect(end.anchor().className).toBe("inactive");
end.dispose();
});

test("ignores trailing slashes and case when matching", () => {
const slash = mount("/docs/intro", () => <A href="/docs/intro/">x</A>);
expect(slash.anchor().className).toBe("active");
slash.dispose();

const upper = mount("/docs/intro", () => <A href="/DOCS/Intro">x</A>);
expect(upper.anchor().className).toBe("active");
upper.dispose();
});

test("honours activeClass and inactiveClass", () => {
const off = mount("/docs/intro", () => (
<A href="/other" activeClass="on" inactiveClass="off">
x
</A>
));
expect(off.anchor().className).toBe("off");
off.dispose();

const on = mount("/docs/intro", () => (
<A href="/docs/intro" activeClass="on" inactiveClass="off">
x
</A>
));
expect(on.anchor().className).toBe("on");
on.dispose();
});

test("keeps a user supplied class alongside the active state class", () => {
const { anchor, dispose } = mount("/docs/intro", () => (
<A href="/other" class="btn">
x
</A>
));
expect(anchor().classList.contains("btn")).toBe(true);
expect(anchor().classList.contains("inactive")).toBe(true);
dispose();
});

test("merges a user supplied classList", () => {
const { anchor, dispose } = mount("/docs/intro", () => (
<A href="/other" classList={{ extra: true, skipped: false }}>
x
</A>
));
expect(anchor().classList.contains("extra")).toBe(true);
expect(anchor().classList.contains("skipped")).toBe(false);
expect(anchor().classList.contains("inactive")).toBe(true);
dispose();
});

test("serialises state only when it is provided", () => {
const without = mount("/docs/intro", () => <A href="/other">x</A>);
expect(without.anchor().hasAttribute("state")).toBe(false);
without.dispose();

const withState = mount("/docs/intro", () => (
<A href="/other" state={{ a: 1 }}>
x
</A>
));
expect(withState.anchor().getAttribute("state")).toBe(JSON.stringify({ a: 1 }));
withState.dispose();
});

test("forwards unknown props to the anchor", () => {
const { anchor, dispose } = mount("/docs/intro", () => (
<A href="/other" id="lnk" target="_blank" rel="external" aria-label="go">
x
</A>
));
expect(anchor().id).toBe("lnk");
expect(anchor().getAttribute("target")).toBe("_blank");
expect(anchor().getAttribute("rel")).toBe("external");
expect(anchor().getAttribute("aria-label")).toBe("go");
expect(anchor().getAttribute("href")).toBe("/other");
expect(anchor().className).toBe("inactive");
dispose();
});

test("forwards the router's own passthrough attributes", () => {
const { anchor, dispose } = mount("/docs/intro", () => (
<A href="/other" replace noScroll preload={false}>
x
</A>
));
expect(anchor().hasAttribute("replace")).toBe(true);
expect(anchor().hasAttribute("noScroll")).toBe(true);
expect(anchor().getAttribute("preload")).toBe("false");
dispose();
});

test("resolves a relative href against the current route", () => {
const { anchor, dispose } = mount("/docs/intro", () => <A href="sibling">x</A>);
expect(anchor().getAttribute("href")).toBe("/docs/intro/sibling");
dispose();
});

test("leaves an external href untouched", () => {
const { anchor, dispose } = mount("/docs/intro", () => <A href="https://example.com">x</A>);
expect(anchor().getAttribute("href")).toBe("https://example.com");
dispose();
});

test("renders nested children", () => {
const { anchor, dispose } = mount("/docs/intro", () => (
<A href="/other">
<span>deep</span>
</A>
));
expect(anchor().querySelector("span")!.textContent).toBe("deep");
dispose();
});

// upstream inserts children through the spread, so a `ref` runs after they are in place
test("a ref sees the children already inserted", () => {
let seen: string | undefined;
const { dispose } = mount("/docs/intro", () => (
<A href="/other" ref={(el: HTMLAnchorElement) => (seen = el.textContent ?? undefined)}>
child
</A>
));
expect(seen).toBe("child");
dispose();
});

test.each([
["no extra props", () => <A href="/other">x</A>],
[
"with extra props",
() => (
<A href="/other" id="lnk">
x
</A>
)
]
])("updates the active class on navigation (%s)", async (_name, Link) => {
let navigate!: ReturnType<typeof useNavigate>;
const Page = () => {
navigate = useNavigate();
return Link();
};
const { anchor, dispose } = mount("/docs/intro", Page);

expect(anchor().className).toBe("inactive");
expect(anchor().hasAttribute("aria-current")).toBe(false);

navigate("/other");
await vi.waitFor(() => expect(anchor().className).toBe("active"));

expect(anchor().getAttribute("aria-current")).toBe("page");

dispose();
});
});
Loading
Loading